Take Rastrillo's shell.js and busy.js fixes, each held by its own test
shell.js swallows a skipped view transition's rejection at startup, on pagereveal and on pageswap, and lets every other error through (Rastrillo d9c9ed13, 023740c5, e5e35a3e, 6b1c5478). busy.js arms a data-live-form form only once its server's script has left it to the browser, so it no longer wipes the busy state of a form the script sends itself (Rastrillo 8548e455). Both files are Rastrillo's bytes. TestOneOhScriptsAreUnchanged lets each through by a changedOneOhScripts entry naming the SHA-256 of the fixed file, why, and the test that holds it, so a further change fails again. The photographs serve one js/ to both sides and cannot see a script change, so each fix has its own test: check/node/transition_node.mjs, ported from Rastrillo's shell_transition_node.mjs, for shell.js, and TestBusyLeavesALiveFormItsScriptClaimed, a browser drive with a stand-in for the server's script in either load order, for busy.js.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10 files changed,
+387
−31
check/liveform_browser_test.go+120 −0check/node/transition_node.mjs+96 −0check/scripts_test.go+16 −4check/shell_test.go+26 −0js/busy.js+18 −1js/shell.js+27 −16releases/1.2.0/SHA256SUMS+2 −2releases/1.2.0/busy.js+6 −1releases/1.2.0/shell.js+17 −1releases_test.go+59 −6
diff --git a/check/liveform_browser_test.go b/check/liveform_browser_test.go| new file mode 100644 |
| index 0000000..c05707a |
| --- /dev/null |
| +++ b/check/liveform_browser_test.go |
| @@ -0,0 +1,120 @@ |
| +//go:build browser |
| + |
| +package check |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// A server that swaps forms in place marks them data-live-form, and its |
| +// own script decides on each submit whether to send the form itself or |
| +// leave it to the browser. 1.2.0's busy.js (Rastrillo 8548e455) arms |
| +// such a form only once that script has let it go: one the script |
| +// claimed is the script's to show busy, and arming it as well would hand |
| +// it back a tick after the script cancelled the submit, wiping the |
| +// script's busy state mid-request. One left to the browser keeps the |
| +// busy rule, whichever script loaded first. |
| + |
| +// liveStub stands in for the server's script, as Rastrillo's live.js |
| +// behaves: a capture listener marks the submit of a form it would send |
| +// (e.liveForm), and a later listener cancels it and shows its own busy |
| +// state, with no spinner, so the state is told apart from busy.js's. A |
| +// form whose data-live-form is "native" is left to the browser. |
| +const liveStub = `(function () { |
| + "use strict"; |
| + document.addEventListener("submit", function (e) { |
| + if (e.target.getAttribute("data-live-form") === "claimed") e.liveForm = true; |
| + }, true); |
| + document.addEventListener("submit", function (e) { |
| + if (!e.liveForm || e.defaultPrevented) return; |
| + e.preventDefault(); |
| + e.target.setAttribute("aria-busy", "true"); |
| + if (e.submitter) e.submitter.setAttribute("aria-busy", "true"); |
| + }); |
| +})();` |
| + |
| +// liveFormPage is the two forms under busy.js and the stub, loaded in |
| +// the order given. |
| +func liveFormPage(scripts ...string) string { |
| + var head strings.Builder |
| + for _, name := range scripts { |
| + head.WriteString(`<script defer src="/` + name + `"></script>`) |
| + } |
| + return `<!doctype html><html lang="en" dir="ltr"><head><meta charset="utf-8">` + |
| + `<title>live forms</title><link rel="stylesheet" href="/tokens.css">` + |
| + `<link rel="stylesheet" href="/theme.css">` + head.String() + `</head><body>` + |
| + `<form method="post" action="/claimed" data-live-form="claimed">` + |
| + `<button rst-btn id="claimed">Save</button></form>` + |
| + `<form method="post" action="/native" data-live-form="native">` + |
| + `<input type="hidden" name="form" value="native">` + |
| + `<button rst-btn id="native">Save</button></form>` + |
| + `</body></html>` |
| +} |
| + |
| +// liveFormState is a button's busy state: its aria-busy and whether |
| +// busy.js's spinner is in it. |
| +func liveFormState(id string) string { |
| + return `(() => { const button = document.getElementById(` + jsStr(id) + `);` + |
| + ` return "busy:" + (button.getAttribute("aria-busy") || "-") +` + |
| + ` " spin:" + (button.querySelector("[rst-spin]") ? "yes" : "no"); })()` |
| +} |
| + |
| +func TestBusyLeavesALiveFormItsScriptClaimed(t *testing.T) { |
| + pages := map[string]string{ |
| + "/busy-first": liveFormPage("busy.js", "live-stub.js"), |
| + "/stub-first": liveFormPage("live-stub.js", "busy.js"), |
| + } |
| + scripts := func(name string) ([]byte, bool) { |
| + if name == "live-stub.js" { |
| + return []byte(liveStub), true |
| + } |
| + return fiesta.Script(name) |
| + } |
| + payloads := make(chan string, 8) |
| + rig := harness.New(t, func(string) http.Handler { |
| + mux := sizingMuxWith(t, pages, scripts) |
| + received(mux, "/claimed", "", payloads, http.StatusNoContent) |
| + received(mux, "/native", "", payloads, http.StatusNoContent) |
| + return mux |
| + }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| + defer cancel() |
| + |
| + for _, order := range []string{"/busy-first", "/stub-first"} { |
| + // Claimed: the stub's busy state is still there after busy.js's |
| + // hand-back tick would have run, with no spinner of busy.js's, |
| + // and nothing reached the server. |
| + var claimed string |
| + mustRun(t, ctx, chromedp.Navigate(rig.Origin+order), chromedp.WaitVisible("#claimed", chromedp.ByQuery), |
| + chromedp.Click("#claimed", chromedp.ByQuery), chromedp.Sleep(300*time.Millisecond), |
| + chromedp.Evaluate(liveFormState("claimed"), &claimed)) |
| + if want := "busy:true spin:no"; claimed != want { |
| + t.Errorf("%s: a form the live script claimed reads %q after the submit, want %q: busy.js armed it and handed it back, wiping the script's busy state", order, claimed, want) |
| + } |
| + tookNothing(t, payloads, order+", the claimed form") |
| + |
| + // Native: busy.js arms it, late, and the browser sends it. |
| + var native string |
| + mustRun(t, ctx, chromedp.Navigate(rig.Origin+order), chromedp.WaitVisible("#native", chromedp.ByQuery), |
| + chromedp.Click("#native", chromedp.ByQuery), chromedp.Sleep(150*time.Millisecond), |
| + chromedp.Evaluate(liveFormState("native"), &native)) |
| + if want := "busy:true spin:yes"; native != want { |
| + t.Errorf("%s: a form the live script left to the browser reads %q after the submit, want %q: busy.js did not arm it", order, native, want) |
| + } |
| + if got := took(t, payloads, order+", the native form"); got != "form=native" { |
| + t.Errorf("%s: the native form sent %q, want form=native", order, got) |
| + } |
| + // The server has the form before the browser has its 204, and |
| + // a navigation started meanwhile is aborted by the answer. |
| + mustRun(t, ctx, chromedp.Sleep(500*time.Millisecond)) |
| + } |
| +} |
diff --git a/check/node/transition_node.mjs b/check/node/transition_node.mjs| new file mode 100644 |
| index 0000000..f8489dc |
| --- /dev/null |
| +++ b/check/node/transition_node.mjs |
| @@ -0,0 +1,96 @@ |
| +// transition_node — runs shell.js or transition.js, the file named on |
| +// the command line, against a stub page in plain Node, and hands it view |
| +// transitions whose promises reject. A transition the browser skips |
| +// rejects ready, finished and updateCallbackDone with a DOMException |
| +// named AbortError, InvalidStateError or TimeoutError even though the |
| +// navigation succeeds, and nothing else handles it, so it reached the |
| +// console as an uncaught error. The script must swallow exactly those |
| +// three, at startup (document.activeViewTransition), on pagereveal and |
| +// on pageswap, and let every other rejection through: an Error, a |
| +// TypeError, a DOMException of another name, an Error merely named |
| +// AbortError, and null. |
| +// |
| +// Ported from Rastrillo's ui/shell_transition_node.mjs (d9c9ed13, |
| +// 023740c5, e5e35a3e, 6b1c5478). check/shell_test.go is the only |
| +// caller. A failed assertion exits non-zero with its message; a pass |
| +// prints one JSON object, the count of rejections checked, so the caller |
| +// can tell a run that checked them all from one that checked none. |
| +import assert from "node:assert/strict"; |
| +import { readFileSync } from "node:fs"; |
| +import vm from "node:vm"; |
| + |
| +const script = process.argv[2]; |
| +if (script !== "shell.js" && script !== "transition.js") { |
| + throw new Error(`name shell.js or transition.js, not ${script}`); |
| +} |
| +const source = readFileSync(new URL(`../../js/${script}`, import.meta.url), "utf8"); |
| +const listeners = {}; |
| +const document = { |
| + querySelector: () => null, |
| + addEventListener() {}, |
| +}; |
| +const window = { |
| + onpagereveal: null, |
| + onpageswap: null, |
| + addEventListener: (event, listener) => { listeners[event] = listener; }, |
| +}; |
| +const start = () => vm.runInNewContext(source, { |
| + window, document, URL, DOMException, |
| + location: new URL("https://app.test/"), |
| +}); |
| + |
| +start(); |
| +assert.equal(typeof listeners.pageswap, "function", |
| + `${script} observes outgoing transitions, for a page that opts in with its own CSS`); |
| + |
| +const unhandled = []; |
| +process.on("unhandledRejection", (reason) => { unhandled.push(reason); }); |
| +const turn = () => new Promise((resolve) => setImmediate(resolve)); |
| +const skips = ["AbortError", "InvalidStateError", "TimeoutError"]; |
| +let checked = 0; |
| +for (const event of ["startup", "pagereveal", "pageswap"]) { |
| + for (const promise of ["ready", "finished", "updateCallbackDone"]) { |
| + const reasons = skips.map((name) => new DOMException("No transition", name)); |
| + reasons.push(new Error("application failure"), |
| + new TypeError("broken callback"), new DOMException("denied", "SecurityError"), |
| + Object.assign(new Error("application abort"), { name: "AbortError" }), null); |
| + for (const reason of reasons) { |
| + unhandled.length = 0; |
| + const transition = { |
| + types: new Set(), |
| + ready: Promise.resolve(), |
| + finished: Promise.resolve(), |
| + updateCallbackDone: Promise.resolve(), |
| + }; |
| + transition[promise] = Promise.reject(reason); |
| + if (event === "startup") { |
| + // The same transition at startup and again on pagereveal is |
| + // observed once, and the types are left as the page set them. |
| + document.activeViewTransition = transition; |
| + start(); |
| + assert.equal(transition.types.size, 0); |
| + await turn(); |
| + listeners.pagereveal({ viewTransition: transition }); |
| + } else { |
| + listeners[event]({ viewTransition: transition }); |
| + } |
| + await turn(); |
| + await turn(); |
| + const skipped = reason instanceof DOMException && skips.includes(reason.name); |
| + assert.deepEqual(unhandled, skipped ? [] : [reason], `${script}, ${event}.${promise}: ${reason}`); |
| + checked++; |
| + } |
| + } |
| +} |
| + |
| +// A filter on every rejection would hide this unrelated application |
| +// failure, so the script must answer only the transitions it is given. |
| +unhandled.length = 0; |
| +const unrelated = new DOMException("application abort", "AbortError"); |
| +Promise.reject(unrelated); |
| +await turn(); |
| +await turn(); |
| +assert.deepEqual(unhandled, [unrelated], `${script} swallowed a rejection that was not a transition's`); |
| +checked++; |
| + |
| +process.stdout.write(JSON.stringify({ checked }) + "\n"); |
diff --git a/check/scripts_test.go b/check/scripts_test.go| index 9e36305..dc08e34 100644 |
| --- a/check/scripts_test.go |
| +++ b/check/scripts_test.go |
| @@ -340,10 +340,16 @@ func TestBusyRuleIsTheDefault(t *testing.T) { |
| if !strings.Contains(js, `document.addEventListener("submit", busySubmit, true)`) { |
| t.Error("the busy rule is not one delegated capture-phase submit listener on the document") |
| } |
| - // Two mentions and no more: the definition and the one listener. A |
| - // third would mean someone started binding it per form again. |
| - if n := strings.Count(js, "busySubmit"); n != 2 { |
| - t.Errorf("busySubmit appears %d times, want 2 (one definition, one document listener)", n) |
| + // Three mentions and no more: the definition, the one listener, and |
| + // the window listener's late call for a data-live-form form its |
| + // server's script left to the browser (armed there because only then |
| + // is it known whether the script took it). A fourth would mean |
| + // someone started binding it per form again. |
| + if n := strings.Count(js, "busySubmit"); n != 3 { |
| + t.Errorf("busySubmit appears %d times, want 3 (one definition, one document listener, one late call for a data-live-form)", n) |
| + } |
| + if !strings.Contains(js, "busySubmit(e, true)") { |
| + t.Error("busy.js no longer arms a data-live-form form its server's script left to the browser") |
| } |
| // The opt-in scan is gone. Any querySelectorAll over a data-busy |
| // selector is the old shape coming back. |
| @@ -629,6 +635,12 @@ func TestBusyContract(t *testing.T) { |
| "btn.disabled = wasDisabled", "btn.form !== form", `getAttribute("formtarget")`, |
| // Only a submit with a spinner showing is held. |
| `btn.getAttribute("aria-busy") !== "true") return;`, |
| + // A data-live-form form is its server's script's when the script |
| + // sends it, so it is armed late, and only if the script did not |
| + // claim it (e.liveForm) in either script order |
| + // (TestBusyLeavesALiveFormItsScriptClaimed drives it). |
| + `form.hasAttribute("data-live-form") && !late`, |
| + `e.defaultPrevented || e.liveForm) return;`, |
| } { |
| if !strings.Contains(js, want) { |
| t.Errorf("busy.js does not mention %q", want) |
diff --git a/check/shell_test.go b/check/shell_test.go| index c4978f9..c34b1e2 100644 |
| --- a/check/shell_test.go |
| +++ b/check/shell_test.go |
| @@ -26,3 +26,29 @@ func TestACancelledBackDoesNotSlideTheNextPageBack(t *testing.T) { |
| t.Errorf("Back tapped, the prompt cancelled, then a link to another page followed: it slid %q, want forward", got.Cancelled) |
| } |
| } |
| + |
| +// A view transition the browser skips rejects its promises though the |
| +// navigation succeeds, and with nothing to handle them the page logged |
| +// an uncaught AbortError, which also failed any browser test that |
| +// watches the console. 1.2.0's shell.js (Rastrillo d9c9ed13, 023740c5, |
| +// e5e35a3e and 6b1c5478) swallows exactly a skipped transition's |
| +// rejection, at startup, on pagereveal and on pageswap, and lets every |
| +// other error through. node/transition_node.mjs hands it each kind. |
| +func TestShellJSSwallowsOnlyASkippedTransition(t *testing.T) { |
| + skippedTransitionsAreSwallowed(t, "shell.js") |
| +} |
| + |
| +// skippedTransitionsAreSwallowed runs node/transition_node.mjs against |
| +// one script and holds it to having checked every rejection: three |
| +// moments, three promises and eight reasons each, and one unrelated |
| +// rejection. |
| +func skippedTransitionsAreSwallowed(t *testing.T, script string) { |
| + t.Helper() |
| + var got struct{ Checked int } |
| + if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"node/transition_node.mjs", script}}), &got); err != nil { |
| + t.Fatal(err) |
| + } |
| + if want := 3*3*8 + 1; got.Checked != want { |
| + t.Errorf("node/transition_node.mjs checked %d rejections against %s, want %d", got.Checked, script, want) |
| + } |
| +} |
diff --git a/js/busy.js b/js/busy.js| index bd359df..5f7228a 100644 |
| --- a/js/busy.js |
| +++ b/js/busy.js |
| @@ -25,6 +25,10 @@ |
| included — changes nothing |
| data-busy-label="…" on the form or on the button: replacement |
| button text while it works |
| + data-live-form on a <form>: live.js's to send, and to show |
| + busy, whenever it claims the submit (marking |
| + the event e.liveForm); this file arms it only |
| + when live.js lets it go natively |
| A script that handles a submit itself (a fetch, say) cancels it in a |
| listener on the form or the document, as it would anyway: those run |
| @@ -88,9 +92,18 @@ |
| }); |
| } |
| - function busySubmit(e) { |
| + function busySubmit(e, late) { |
| var form = e.target; |
| if (!form || form.tagName !== "FORM") return; |
| + // A swapping form is live.js's whenever live.js claims it: arming it |
| + // here too would hand it back a tick after live.js cancels the |
| + // submit, clearing live.js's busy state mid-request. Whether this |
| + // capture listener runs before live.js's is down to script order, so |
| + // it is armed late, from the window listener, and only if live.js |
| + // did not claim it (e.liveForm, set in live.js's capture listener) |
| + // and nobody cancelled it. One live.js leaves to the browser (a GET, |
| + // another origin, a page live.js is not running on) keeps the rule. |
| + if (form.hasAttribute("data-live-form") && !late) return; |
| // pow.js owns every submit of a form it protects: it cancels it, |
| // shows its own busy state (this file's vocabulary) while it waits, |
| // and re-sends it marked data-pow-released. Arming here before that |
| @@ -184,6 +197,10 @@ |
| var form = e.target; |
| if (!form || form.tagName !== "FORM") return; |
| if (form === releasing) { releasing = null; return; } |
| + if (form.hasAttribute("data-live-form")) { |
| + if (e.defaultPrevented || e.liveForm) return; |
| + busySubmit(e, true); |
| + } |
| // Never hold a pow form: pow has already held it while it solved, |
| // and a second hold is a second wait for nothing. Worse, holding it |
| // here and re-sending later would send it back through pow, which |
diff --git a/js/shell.js b/js/shell.js| index 93a5e1b..72cde24 100644 |
| --- a/js/shell.js |
| +++ b/js/shell.js |
| @@ -1,4 +1,4 @@ |
| -/* shell.js — phone navigation for the sidebar and console shells. |
| +/* shell.js — transition observation and phone shell navigation. |
| First-party, dependency-free, CSP-clean, and optional: delete it (and |
| its <script> tag) and every page still works, because the index and |
| the back control are ordinary server-rendered pages and links. It is |
| @@ -26,10 +26,9 @@ |
| width focuses the nav link to that path, or else the one the URL's |
| #fragment names. |
| - The layouts load it with blocking="render": the direction is set in |
| - pagereveal, at the first render, and a deferred script can run after |
| - it. An engine that ignores blocking gets an untyped transition, which |
| - shell.css draws as a cross-fade. */ |
| + Sidebar and console use defer blocking="render" for focus and |
| + direction after parsing. Other layouts use transition.js before |
| + styles so incoming transitions skipped before pagereveal are observed. */ |
| (function () { |
| "use strict"; |
| @@ -87,17 +86,28 @@ |
| if (hit) hit.focus(); |
| } |
| - // 1. Direction. Back when the page being left recorded that its back |
| - // control was used AND this page is where it pointed, or when the |
| - // Navigation API reports a traverse to an earlier entry (the browser's |
| - // own Back); forward otherwise. The record is written on the click, |
| - // before anything commits, so a Back whose leave-page prompt was |
| - // cancelled leaves it behind: scoped to its destination, the next link |
| - // followed still slides forward. Taken on every reveal, matched or not, |
| - // so a stale one never waits for its URL to come round. A URL pattern |
| - // cannot decide it: the new page cannot know which URL is the index, |
| - // and the pattern is the app's. |
| + // A skipped browser transition rejects even though navigation succeeds. |
| + // Startup and pagereveal can expose the same object; observe it once. |
| + var observed = new WeakSet(); |
| + function observeTransition(transition) { |
| + if (!transition || observed.has(transition)) return; |
| + observed.add(transition); |
| + ["ready", "finished", "updateCallbackDone"].forEach(function (key) { |
| + if (transition[key]) transition[key].catch(function (err) { |
| + if (!(err instanceof DOMException) || |
| + ["AbortError", "InvalidStateError", "TimeoutError"].indexOf(err.name) < 0) throw err; |
| + }); |
| + }); |
| + } |
| + |
| + observeTransition(document.activeViewTransition); |
| + |
| + // 1. Direction. The back record is scoped to its destination because |
| + // a cancelled leave-page prompt can leave it behind. Consume it on |
| + // every reveal so it cannot affect a later visit. Browser Back uses |
| + // the Navigation API's entry order; URL patterns belong to the app. |
| function reveal(e) { |
| + observeTransition(e.viewTransition); |
| var back = take(WENT_BACK) === location.pathname + location.search, a = window.navigation && navigation.activation; |
| if (!back && a && a.navigationType === "traverse" && a.from && a.entry) back = a.entry.index < a.from.index; |
| // Focus first, and the type only where there is a set to add it to: |
| @@ -116,7 +126,8 @@ |
| // revealed; pagehide is the fallback where there is no pageswap. |
| // Never both: a pagehide landing after the index took the record |
| // would leave a stale one behind. |
| - function leave() { |
| + function leave(e) { |
| + observeTransition(e.viewTransition); |
| if (view(PAGE)) store(function (s) { s.setItem(RETURN, location.pathname + location.search); }); |
| } |
| window.addEventListener("onpageswap" in window ? "pageswap" : "pagehide", leave); |
diff --git a/releases/1.2.0/SHA256SUMS b/releases/1.2.0/SHA256SUMS| index 8bfaff5..ef40fa9 100644 |
| --- a/releases/1.2.0/SHA256SUMS |
| +++ b/releases/1.2.0/SHA256SUMS |
| @@ -1,7 +1,7 @@ |
| 3f3d9e0024b1921b067d6f7f88deb4a60cbe7a78e76c64e3f1d7fc3b779b9d04 LICENSE |
| b495047bd93a9b06913511076f504daba17d5bbeb3e0650f3bb53a4220329c57 NOTICE |
| 05a60a7d6c5f1441995652083e7135d11142eb414b48c021d05c3d9b52b422bd anchor.js |
| -ed41c1fedaea05b21530141671eff062c259cce971f2229b2ab51c8768ea9558 busy.js |
| +af57823e17475ed7cfa715c3986dc9093f126d8f23b1b1cdaa2af39a1aae0dd8 busy.js |
| a085f3eabd8556baebcfee2032f57464282549c65ac49ff66f3f65b2f8bc6f49 calendar.js |
| 92e7423f7275d543784ccd9529438b35800b965ba9b64737d0be6322b1c267ab copy.js |
| f21d680c67d9a2b874109824826f8ea2c4449be556fcf458bf250c955a96bc51 datetime.js |
| @@ -51,6 +51,6 @@ d9f6b21fe977c603adf81e7723c22686d36cb6161e5b4538d6c8e9248f3dedb7 icons/x.svg |
| afc01911332dc6407112214fab82e513838f7252293970e2e21f9d84bb9ae28b plain.css |
| f9da2afe8f29dd30217e209e850487123906dc73190584587c7d28dc08652545 select.js |
| 2a6c6a0284d9faddb68d1a3b79377c6a1c6144c1e07e7fde2f81dcf6e32e9e2e shell.css |
| -4e1e4ba193a1e5f08901b998b5501918a91775ba572a2cc115a35d9556cac653 shell.js |
| +0c379643d755c59cf1655c169342ab26af27c31a63c8d7c91aa77e199a30c178 shell.js |
| f75da06dfc8fff7d59eeed29050506e1e2fd5ecf60e99c25ab45260abe31076d signal.css |
| 064412f4dc68300359d122eef587bab1be650edcccf158892e170867407c7214 tokens.css |
diff --git a/releases/1.2.0/busy.js b/releases/1.2.0/busy.js| index 21e1bca..53f370d 100644 |
| --- a/releases/1.2.0/busy.js |
| +++ b/releases/1.2.0/busy.js |
| @@ -19,9 +19,10 @@ |
| }); |
| } |
| - function busySubmit(e) { |
| + function busySubmit(e, late) { |
| var form = e.target; |
| if (!form || form.tagName !== "FORM") return; |
| + if (form.hasAttribute("data-live-form") && !late) return; |
| if (form.hasAttribute("data-pow-form") && !form.hasAttribute("data-pow-released")) return; |
| if (form.getAttribute("data-busy") === "false") return; |
| var btn = e.submitter; |
| @@ -68,6 +69,10 @@ |
| var form = e.target; |
| if (!form || form.tagName !== "FORM") return; |
| if (form === releasing) { releasing = null; return; } |
| + if (form.hasAttribute("data-live-form")) { |
| + if (e.defaultPrevented || e.liveForm) return; |
| + busySubmit(e, true); |
| + } |
| if (form.hasAttribute("data-pow-form")) return; |
| if (e.defaultPrevented || form.getAttribute("aria-busy") !== "true") return; |
| var btn = e.submitter || null; |
diff --git a/releases/1.2.0/shell.js b/releases/1.2.0/shell.js| index 7045417..8d1293d 100644 |
| --- a/releases/1.2.0/shell.js |
| +++ b/releases/1.2.0/shell.js |
| @@ -41,7 +41,22 @@ |
| if (hit) hit.focus(); |
| } |
| + var observed = new WeakSet(); |
| + function observeTransition(transition) { |
| + if (!transition || observed.has(transition)) return; |
| + observed.add(transition); |
| + ["ready", "finished", "updateCallbackDone"].forEach(function (key) { |
| + if (transition[key]) transition[key].catch(function (err) { |
| + if (!(err instanceof DOMException) || |
| + ["AbortError", "InvalidStateError", "TimeoutError"].indexOf(err.name) < 0) throw err; |
| + }); |
| + }); |
| + } |
| + |
| + observeTransition(document.activeViewTransition); |
| + |
| function reveal(e) { |
| + observeTransition(e.viewTransition); |
| var back = take(WENT_BACK) === location.pathname + location.search, a = window.navigation && navigation.activation; |
| if (!back && a && a.navigationType === "traverse" && a.from && a.entry) back = a.entry.index < a.from.index; |
| returnFocus(); |
| @@ -50,7 +65,8 @@ |
| if ("onpagereveal" in window) window.addEventListener("pagereveal", reveal); |
| else window.addEventListener("pageshow", returnFocus); |
| - function leave() { |
| + function leave(e) { |
| + observeTransition(e.viewTransition); |
| if (view(PAGE)) store(function (s) { s.setItem(RETURN, location.pathname + location.search); }); |
| } |
| window.addEventListener("onpageswap" in window ? "pageswap" : "pagehide", leave); |
diff --git a/releases_test.go b/releases_test.go| index a4f5d51..6ec2b04 100644 |
| --- a/releases_test.go |
| +++ b/releases_test.go |
| @@ -2,6 +2,8 @@ package fiesta |
| import ( |
| "bytes" |
| + "crypto/sha256" |
| + "fmt" |
| "io/fs" |
| "os" |
| "os/exec" |
| @@ -349,21 +351,72 @@ func TestTheBaselineIsOneOhOh(t *testing.T) { |
| } |
| } |
| +// changedScript is a 1.0 script a 1.x release changes on purpose, to |
| +// fix it: the SHA-256 of the bytes the fix leaves, so the next change |
| +// to the file fails here again and is named in its own entry; why; and |
| +// the test that holds the fixed behaviour, since the photographs serve |
| +// the one js/ to both sides and cannot see a script change. |
| +type changedScript struct { |
| + Sum, Why, Test string |
| +} |
| + |
| +// changedOneOhScripts is keyed by the script's path. Every entry must |
| +// be used: a script with an entry that is 1.0.0's again is an exception |
| +// for nothing. |
| +var changedOneOhScripts = map[string]changedScript{ |
| + "js/shell.js": { |
| + Sum: "734771a617f39a340a1ce2ad11e08607aef191434631fbe913fc1d829b149fcd", |
| + Why: "1.2.0 takes Rastrillo d9c9ed13, 023740c5, e5e35a3e and " + |
| + "6b1c5478: a view transition the browser skips rejects its " + |
| + "promises, which logged an uncaught AbortError, so shell.js " + |
| + "swallows exactly that rejection and no other", |
| + Test: "check/shell_test.go TestShellJSSwallowsOnlyASkippedTransition", |
| + }, |
| + "js/busy.js": { |
| + Sum: "eb7210110c5ba6b23552407953bc97374f834787fcec411e643e7513524f69b7", |
| + Why: "1.2.0 takes Rastrillo 8548e455: a data-live-form form is " + |
| + "armed only once its server's script has left it to the " + |
| + "browser, so busy.js no longer wipes the busy state of a " + |
| + "form the script sends itself", |
| + Test: "check/liveform_browser_test.go TestBusyLeavesALiveFormItsScriptClaimed", |
| + }, |
| +} |
| + |
| // TestOneOhScriptsAreUnchanged: every script 1.0.0 shipped is in js/ |
| -// byte for byte. 1.1 adds a script and changes none, and the 1.0 |
| -// photographs serve one js/ to both sides on the strength of that: a |
| -// changed script would be photographed on both sides at once and |
| -// differ from neither. |
| +// byte for byte, or is exactly the bytes its changedOneOhScripts entry |
| +// names. 1.1 adds a script and changes none, and the 1.0 photographs |
| +// serve one js/ to both sides on the strength of that: a changed script |
| +// would be photographed on both sides at once and differ from neither, |
| +// which is why each fix names the test that holds it. |
| func TestOneOhScriptsAreUnchanged(t *testing.T) { |
| requireOneOhTag(t) |
| + used := map[string]bool{} |
| for _, path := range taggedFiles(t, oneOhTag, "js") { |
| b, err := os.ReadFile(filepath.FromSlash(path)) |
| if err != nil { |
| t.Errorf("%s: %v; %s shipped it, and a 1.0 page may load it", path, err, oneOhTag) |
| continue |
| } |
| - if !bytes.Equal(b, gitOut(t, "show", oneOhTag+":"+path)) { |
| - t.Errorf("%s differs from %s:%s; a 1.x release changes no 1.0 script", path, oneOhTag, path) |
| + if bytes.Equal(b, gitOut(t, "show", oneOhTag+":"+path)) { |
| + continue |
| + } |
| + change, ok := changedOneOhScripts[path] |
| + switch sum := fmt.Sprintf("%x", sha256.Sum256(b)); { |
| + case !ok: |
| + t.Errorf("%s differs from %s:%s; a 1.x release changes no 1.0 script but by a changedOneOhScripts entry", path, oneOhTag, path) |
| + case sum != change.Sum: |
| + t.Errorf("%s differs from %s:%s and is not the fix its changedOneOhScripts entry names (SHA-256 %s, want %s); a further change is a new entry, with its reason and its test", path, oneOhTag, path, sum, change.Sum) |
| + default: |
| + used[path] = true |
| + } |
| + } |
| + for _, path := range sortedNames(changedOneOhScripts) { |
| + if !used[path] { |
| + t.Errorf("changedOneOhScripts[%q] is used by no changed script; an exception for nothing is a hole waiting to be used", path) |
| + } |
| + file, name, _ := strings.Cut(changedOneOhScripts[path].Test, " ") |
| + if source, err := os.ReadFile(filepath.FromSlash(file)); err != nil || !bytes.Contains(source, []byte("func "+name+"(t *testing.T)")) { |
| + t.Errorf("changedOneOhScripts[%q] names %s, which is not a test in %s; the fix must be held by a test of its own", path, name, file) |
| } |
| } |
| } |