implemented progressive form wrapper (for offline WebKit support)

main
Inga 🏳‍🌈 3 weeks ago
parent ffbd80d30e
commit 5d9e136dcb
  1. 77
      README.md
  2. 2
      src/backend/components/boardgame.tsx
  3. 5
      src/backend/main/index.tsx
  4. 6
      src/frontend/components/board-game.ts
  5. 22
      src/frontend/components/index.ts
  6. 65
      src/frontend/components/progressive-form.ts
  7. 6
      src/frontend/components/reactive-button.ts
  8. 6
      src/frontend/components/reactive-span.ts
  9. 1
      src/jsx-augmentations.ts

@ -287,32 +287,49 @@ None.
## Supported browser engines ## Supported browser engines
There are three main fully functional modes:
1. Main simple offline-based JS-only mode, using modern web standards;
2. More elaborate (almost) offline-based (almost) JS-only mode (game works offline,
but demo counters on the top of the page (separate from the game) only work online
because I don't have the motivation to redesign `ReactiveButton` and `ReactiveSpan`),
using modern web standards _without_ custom built-in elements
(which means old, but not too old Blink-based browsers like Chromium 54-66,
and all modern WebKit-based browsers like Safari 10.1+);
3. Online-based (plain old client-server) mode available in most browsers even with JS disabled.
The main required features are: The main required features are:
* Custom elements: https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define * ES modules: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type,
(this demo will fall back to client-server mode if `window.customElements.define` is not available). without them no JS will load, and this demo will fall back to `mode 3`
* Custom build-in elements: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/is (can be worked around by using bundlers, but this will only affect Chromium-based browsers from 54 (2016) to 60 (2017),
(again, this demo will fall back to client-server mode if this is not supported). so it's not worth it to change the entire build setup just for better UX in these browsers).
* ES modules: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type. * Custom elements: https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define,
* Also CSS grid for nice presentation: https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template without them this demo will fall back to `mode 3`.
(presentation is less nice on small screens, should be easy to make layout responsive, but I already spent too much time on this project). * Custom built-in elements: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/is,
* And `:not` CSS selector for critical UI features, without them this demo will fall back to `mode 2`.
* `:scope` CSS selector, needed in `mode 2` only:
https://developer.mozilla.org/en-US/docs/Web/CSS/:scope
* `MutationObserver`, needed in `mode 2` only:
https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
* `:not` CSS selector for critical UI features, needed in all modes
(hiding and showing different messages and buttons depending on the game state): (hiding and showing different messages and buttons depending on the game state):
https://developer.mozilla.org/en-US/docs/Web/CSS/:not. https://developer.mozilla.org/en-US/docs/Web/CSS/:not.
* Also CSS grid for nice presentation: https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template
(presentation is less nice on small screens, should be easy to make layout responsive, but I already spent too much time on this project).
Therefore, for desktop browsers: Therefore, for desktop browsers:
* Gecko (Firefox): checked and works in v132, both with JS enabled (offline mode) and with JS disabled (plain old client-server mode). * Gecko (Firefox): checked and works in v132, both with JS enabled (`mode 1`) and with JS disabled (`mode 3`).
Should work in offline mode starting with Firefox 63 (2018); should fall back plain old client-server mode in all versions, Should work in `mode 1` starting with Firefox 63 (2018); should fall back plain old client-server mode in all versions,
although the design in Firefox 51 (2017) and older will probably not look very good due to the lack of the recent CSS features. although the design in Firefox 51 (2017) and older will probably not look very good due to the lack of the recent CSS features.
* Blink (e.g. Chromium and Chromium-based browsers): superficially checked in Chromium 130, basic functionality seems to work. * Blink (e.g. Chromium and Chromium-based browsers): superficially checked in Chromium 130, basic functionality seems to work in `mode 1`.
Should work in offline mode starting with Chrome 67 (2018); should fall back to plain old client-server mode in all versions, Should work in `mode 1` starting with Chrome 67 (2018); should fall back to `mode 2` in Chrome plain old client-server mode in all versions,
although the design in Chrome 56 (2017) and older will probably not look very good due to the lack of the recent CSS features. although the design in Chrome 56 (2017) and older will probably not look very good due to the lack of the recent CSS features.
* WebKit (e.g. qutebrowser, Konqueror, Epiphany and notably Safari): not checked; should **not** work in offline mode, * WebKit (e.g. qutebrowser, Konqueror, Epiphany and notably Safari): superficialy checked in [BadWolf](https://hacktivis.me/projects/badwolf) 1.3 / webkit2gtk-4.1 2.46.3,
because WebKit does not fully implement decade-old standard which Gecko and Blink supported since 2018: basic functionality seems to work both with JS enabled (in `mode 2`) and with JS disabled (in `mode 3`).
https://github.com/WebKit/standards-positions/issues/97. Should work with JS enabled in `mode 2` in all browsers supporting custom elements (e.g. in Safari 10.1 (2017) and newer);
Everything should still work in online mode, falling back on client-server communication, should fall back to `mode 3` in older browsers, except that the design in Safari before 10.1 (2017) will not look very good (but will still be functional),
except that the design in Safari before 10.1 (2017) will not look very good (but will still be functional),
and will probably become disfunctional in Safari before 3.1 (2008). and will probably become disfunctional in Safari before 3.1 (2008).
* Servo: checked, does **not** work in nightly as of 2024-11-20 and as of 2024-11-25 (even progressive form doesn't work), * Servo: checked, does **not** work in nightly as of 2024-11-20 and as of 2024-11-25 (even progressive form doesn't work),
and its developer tools host seemingly crashes on this page and its developer tools host seemingly crashes on this page
@ -323,9 +340,9 @@ Therefore, for desktop browsers:
3. Gating offline JS mode for non-servo browsers only will mean that in the future, when servo is fixed, 3. Gating offline JS mode for non-servo browsers only will mean that in the future, when servo is fixed,
this demo will only work in client-server mode, just because servo once had a bug. Not ideal. this demo will only work in client-server mode, just because servo once had a bug. Not ideal.
(Also not even attempting to use dynamic features in servo will make it more difficult to debug the problem in it.) (Also not even attempting to use dynamic features in servo will make it more difficult to debug the problem in it.)
* EdgeHTML (classic MS Edge before they migrated to Chromium): not checked, should fall back to client-server mode in all versions, * EdgeHTML (classic MS Edge before they migrated to Chromium): not checked, should fall back to `mode 3` in all versions,
but the page will not look very pretty in Edge before 16 (2017). but the page will not look very pretty in Edge before 16 (2017).
* Trident (Internet Explorer): not checked, should fall back to client-server mode, * Trident (Internet Explorer): not checked, should fall back to `mode 3`,
UI should be functional (not pretty) in IE8+ (2008) and dysfunctional in IE7 and lower UI should be functional (not pretty) in IE8+ (2008) and dysfunctional in IE7 and lower
(fixable by refactoring the approach to displaying/hiding elements to avoid the usage of `:not` selector, (fixable by refactoring the approach to displaying/hiding elements to avoid the usage of `:not` selector,
but this is outside the scope of the task). but this is outside the scope of the task).
@ -350,22 +367,15 @@ Therefore, for desktop browsers:
So presumably, according to caniuse.com, So presumably, according to caniuse.com,
* Offline JS mode should work in 88% desktop browsers and 80% total browsers * `mode 1` should work in 88% desktop browsers and 80% total browsers
(with Safari being the notable exception contributing 9% on desktop and 18% overall); (with Safari being the notable exception contributing 9% on desktop and 18% overall);
* The demo should at least fall back to functional client-server model with the same UI * Fallback to `mode 2` should bring the total to at least 96% overall (98% on desktop; for some less popular mobile browsers support status is unknown);
in at least 98% desktop and at least 96% overall (and likely in at least 98% overall) * The demo should at least fall back to functional `mode 3` (client-server) with the functional UI
(with IE lacking support for grids contributing ~1% on desktop and ~0.5% overall, in 99.9% desktop and presumably 99.9% overall browsers,
and UC Browser and QQ Browser with unknown status of support for grids contributing further 2% overall); with Internet Explorer being the main offender.
* This demo should at least fall back to functional client-server model with at least functional (but not neccessary pretty) UI
in 99.9% desktop and at least 97% overall browsers (and likely at least 99% overall)
(with older versions of IE lacking support for `:not` selectors contributing 0.1% on desktop,
and UC Browser and QQ Browser with unknown status contributing 2% overall;
it is highly likely that they do actually support `:not` selectors).
## Known issues and quirks ## Known issues and quirks
* The game does not work offline fully on client (i.e. without falling back on client-server communication)
in WebKit-based browsers (such as qutebrowser or Safari), see above.
* Playing against the computer opponent is only supported for boards under 12 squares (i.e. 4x3 or 3x4 max). * Playing against the computer opponent is only supported for boards under 12 squares (i.e. 4x3 or 3x4 max).
* The computer player moves might be counterintuitive at times, * The computer player moves might be counterintuitive at times,
and it might be difficult for a human player to get to lose the game, and it might be difficult for a human player to get to lose the game,
@ -380,11 +390,6 @@ So presumably, according to caniuse.com,
* Use HTML `<template>`s for rendering state messages, * Use HTML `<template>`s for rendering state messages,
instead of having them all in HTML and then hiding irrelevant with CSS, instead of having them all in HTML and then hiding irrelevant with CSS,
for better graceful degradation in browsers that don't support complex CSS queries with `:not`. for better graceful degradation in browsers that don't support complex CSS queries with `:not`.
* Implement custom progressive form wrapper element (use `MutationObserver` to find when forms are added/removed),
use it instead of custom built-in element in browsers not supporting custom built-in elements
(i.e. all modern WebKit browsers including Safari 10.1+, and Chrome 54 to 66),
so that these browsers will get offline client-only support too,
instead of falling back to client-server mode.
* Implement error handling and handling of incorrect / malformed game states (since they come from the client). * Implement error handling and handling of incorrect / malformed game states (since they come from the client).
* Improve UI for large boards (disable autoplayers when board is too large, * Improve UI for large boards (disable autoplayers when board is too large,
hide "enable autoplayer" buttons, provide clear indication to the user instead). hide "enable autoplayer" buttons, provide clear indication to the user instead).

@ -53,6 +53,7 @@ export const getBoardgameHtml = (key: string, gameState: BoardgameStateType, gam
return ( return (
<board-game track={key} className={gameActiveClassNames.join(" ")} game-variant={gameVariant}> <board-game track={key} className={gameActiveClassNames.join(" ")} game-variant={gameVariant}>
<progressive-form-wrapper>
<form method="post" is="progressive-form"> <form method="post" is="progressive-form">
<p class="when-outcome-undecided"> <p class="when-outcome-undecided">
Current player: <span class="current-player-name">{gameState.currentPlayerName}</span> Current player: <span class="current-player-name">{gameState.currentPlayerName}</span>
@ -121,6 +122,7 @@ export const getBoardgameHtml = (key: string, gameState: BoardgameStateType, gam
<button {...getGenericButtonAttributes("remove-column", { key, buttonValues })}>Remove column</button> <button {...getGenericButtonAttributes("remove-column", { key, buttonValues })}>Remove column</button>
</p> </p>
</form> </form>
</progressive-form-wrapper>
</board-game> </board-game>
); );
}; };

@ -4,6 +4,8 @@ import { handleBoardgame } from "./boardgame-handler.ts";
import { getCounterHtml } from "../components/counter.tsx"; import { getCounterHtml } from "../components/counter.tsx";
export const mainPageHandler: RequestHandler = (req, res) => { export const mainPageHandler: RequestHandler = (req, res) => {
console.log(`Handling main page: ${req.url}`);
const boardThree = handleBoardgame(req, res, "tictactoe-three-1", "tictactoe-three"); const boardThree = handleBoardgame(req, res, "tictactoe-three-1", "tictactoe-three");
if (!boardThree) { if (!boardThree) {
// No return value from handleBoardgame means that it redirected user to another URL, // No return value from handleBoardgame means that it redirected user to another URL,
@ -36,6 +38,9 @@ export const mainPageHandler: RequestHandler = (req, res) => {
<br /> <br />
It works both with JS and without JS; it does not require network connection when JS is used; and it It works both with JS and without JS; it does not require network connection when JS is used; and it
supports history navigation (you can go back & forward and see how the state changes). supports history navigation (you can go back & forward and see how the state changes).
<br />
Note that, for simplicity reasons, this falls back to client-server mode in WebKit-based browsers (e.g.
Safari).
</p> </p>
<ul> <ul>
<li>{getCounterHtml(req, "a")}</li> <li>{getCounterHtml(req, "a")}</li>

@ -8,7 +8,7 @@ import { replaceLocation } from "../utils/navigation-utils.ts";
import { TrackingTools } from "../utils/query-tracking-utils.ts"; import { TrackingTools } from "../utils/query-tracking-utils.ts";
import { updateWithQueryParams } from "../utils/url-utils.ts"; import { updateWithQueryParams } from "../utils/url-utils.ts";
export class BoardGameComponent extends HTMLElement { class BoardGameComponent extends HTMLElement {
private readonly trackingTools = new TrackingTools(this); private readonly trackingTools = new TrackingTools(this);
handleTrackedValueUpdate(newValue: string | null, key: string) { handleTrackedValueUpdate(newValue: string | null, key: string) {
@ -118,3 +118,7 @@ export class BoardGameComponent extends HTMLElement {
this.trackingTools.disconnectedCallback(); this.trackingTools.disconnectedCallback();
} }
} }
export const registerBoardGame = () => {
window.customElements.define("board-game", BoardGameComponent);
};

@ -1,19 +1,13 @@
import { BoardGameComponent } from "./board-game.ts"; import { registerBoardGame } from "./board-game.ts";
import { ProgressiveForm } from "./progressive-form.ts"; import { registerProgressiveForm } from "./progressive-form.ts";
import { ReactiveButton } from "./reactive-button.ts"; import { registerReactiveButton } from "./reactive-button.ts";
import { ReactiveSpan } from "./reactive-span.ts"; import { registerReactiveSpan } from "./reactive-span.ts";
export const initializeWebComponents = () => { export const initializeWebComponents = () => {
if ((window as Partial<typeof window>).customElements?.define) { if ((window as Partial<typeof window>).customElements?.define) {
// We need to define customized built-in elements first, registerBoardGame();
// because WebKit (Safari) is not standard-compliant[1] and doesn't support them, registerProgressiveForm();
// so hopefully these calls will throw, leaving us with all custom elements or none, registerReactiveButton();
// instead of defining autonomous custom elements and skipping customized built-in elements. registerReactiveSpan();
// [1]: https://github.com/WebKit/standards-positions/issues/97
customElements.define("progressive-form", ProgressiveForm, { extends: "form" });
customElements.define("reactive-button", ReactiveButton, { extends: "button" });
customElements.define("reactive-span", ReactiveSpan, { extends: "span" });
customElements.define("board-game", BoardGameComponent);
} }
}; };

@ -16,7 +16,7 @@ const submitListener = function (this: HTMLFormElement, e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
}; };
export class ProgressiveForm extends HTMLFormElement { class ProgressiveForm extends HTMLFormElement {
connectedCallback() { connectedCallback() {
if (this.method.toUpperCase() !== "POST") { if (this.method.toUpperCase() !== "POST") {
throw new Error( throw new Error(
@ -26,4 +26,67 @@ export class ProgressiveForm extends HTMLFormElement {
this.addEventListener("submit", submitListener); this.addEventListener("submit", submitListener);
} }
disconnectedCallback() {
this.removeEventListener("submit", submitListener);
}
} }
// Only needed in browsers that support custom elements but not custom built-in elements
class ProgressiveFormWrapper extends HTMLElement {
private readonly watchedForms: Set<HTMLFormElement> = new Set<HTMLFormElement>();
private observer: MutationObserver | null = null;
private handleChanges() {
const currentForms = new Set(Array.from(this.querySelectorAll(':scope > form[is="progressive-form"]')));
// we need to dereference the set here because we're going to modify it
for (const watchedForm of [...this.watchedForms]) {
if (!currentForms.has(watchedForm)) {
watchedForm.removeEventListener("submit", submitListener);
this.watchedForms.delete(watchedForm);
}
}
for (const currentForm of currentForms) {
if (!this.watchedForms.has(currentForm)) {
this.watchedForms.add(currentForm);
currentForm.addEventListener("submit", submitListener);
}
}
}
connectedCallback() {
this.handleChanges();
const observer = new MutationObserver(this.handleChanges.bind(this));
observer.observe(this, {
childList: true,
});
this.observer = observer;
}
disconnectedCallback() {
this.observer?.disconnect();
for (const watchedForm of this.watchedForms) {
watchedForm.removeEventListener("submit", submitListener);
}
this.watchedForms.clear();
}
}
export const registerProgressiveForm = () => {
window.customElements.define("progressive-form", ProgressiveForm, { extends: "form" });
const form = document.createElement("form", { is: "progressive-form" });
if (!(form instanceof ProgressiveForm)) {
// We're probably on WebKit based browsers (e.g. Safari 10.1+), or somewhat old Blink-based (e.g. Chromium 54-66),
// see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/is for more details.
// Fall back to using ProgressiveFormWrapper.
console.log("Progressive form is unsupported");
window.customElements.define("progressive-form-wrapper", ProgressiveFormWrapper);
}
};

@ -1,6 +1,6 @@
import { TrackingTools } from "../utils/query-tracking-utils.ts"; import { TrackingTools } from "../utils/query-tracking-utils.ts";
export class ReactiveButton extends HTMLButtonElement { class ReactiveButton extends HTMLButtonElement {
private readonly trackingTools = new TrackingTools(this); private readonly trackingTools = new TrackingTools(this);
handleTrackedValueUpdate(newValue: string | null) { handleTrackedValueUpdate(newValue: string | null) {
@ -20,3 +20,7 @@ export class ReactiveButton extends HTMLButtonElement {
this.trackingTools.disconnectedCallback(); this.trackingTools.disconnectedCallback();
} }
} }
export const registerReactiveButton = () => {
window.customElements.define("reactive-button", ReactiveButton, { extends: "button" });
};

@ -1,6 +1,6 @@
import { TrackingTools } from "../utils/query-tracking-utils.ts"; import { TrackingTools } from "../utils/query-tracking-utils.ts";
export class ReactiveSpan extends HTMLSpanElement { class ReactiveSpan extends HTMLSpanElement {
private readonly trackingTools = new TrackingTools(this); private readonly trackingTools = new TrackingTools(this);
handleTrackedValueUpdate(newValue: string | null) { handleTrackedValueUpdate(newValue: string | null) {
@ -19,3 +19,7 @@ export class ReactiveSpan extends HTMLSpanElement {
this.trackingTools.disconnectedCallback(); this.trackingTools.disconnectedCallback();
} }
} }
export const registerReactiveSpan = () => {
window.customElements.define("reactive-span", ReactiveSpan, { extends: "span" });
};

@ -11,6 +11,7 @@ declare module "preact/jsx-runtime" {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- this is how declaration merging is done // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- this is how declaration merging is done
interface IntrinsicElements { interface IntrinsicElements {
"board-game": HTMLAttributes; "board-game": HTMLAttributes;
"progressive-form-wrapper": HTMLAttributes;
} }
} }
} }

Loading…
Cancel
Save