From ae775b383c12b2b0b965875b682c5bfd628545c1 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 3 Nov 2026 23:32:40 +0100 Subject: [PATCH 1/2] fix(boot): a stylesheet lost on the way is asked for again A lost stylesheet left the page unstyled until a reload, and every e2e step that finds elements by testid still passed on it. Only axe noticed: the one-off target-size failure on eight scattered controls. Losing the main stylesheet on purpose gives those same eight. --- frontend/e2e/stylesheet.spec.ts | 60 +++++++++++ frontend/src/lib/stylesheetRetry.dom.test.ts | 105 +++++++++++++++++++ frontend/src/lib/stylesheetRetry.ts | 50 +++++++++ frontend/src/main.tsx | 3 + frontend/src/watchMain.tsx | 3 + 5 files changed, 221 insertions(+) create mode 100644 frontend/e2e/stylesheet.spec.ts create mode 100644 frontend/src/lib/stylesheetRetry.dom.test.ts create mode 100644 frontend/src/lib/stylesheetRetry.ts diff --git a/frontend/e2e/stylesheet.spec.ts b/frontend/e2e/stylesheet.spec.ts new file mode 100644 index 00000000..de2995b7 --- /dev/null +++ b/frontend/e2e/stylesheet.spec.ts @@ -0,0 +1,60 @@ +import { test, expect, type Page } from "@playwright/test"; +import { openApp } from "./helpers"; + +// A request can be lost on the way on a loaded machine or a poor line. A lost stylesheet leaves the +// whole page unstyled, and every test that finds its elements by testid still passes on it: only an +// axe run ever noticed (8 controls under the target size). So this looks at the styles themselves. + +/** Lose the first request for every stylesheet of the page; the ones after it go through. */ +async function loseEachStylesheetOnce(page: Page): Promise { + const lost: string[] = []; + await page.route(/\/assets\/[^/]+\.css/, async (route) => { + const url = route.request().url(); + if (lost.includes(url)) return route.continue(); + lost.push(url); + return route.abort("failed"); + }); + return lost; +} + +/** Every stylesheet link of the page holds rules (a lost one still has a `sheet`, whose rules throw + * on reading), and the page's own tokens are in force: `--bg` comes from the stylesheet alone, while + * `--font-scale` would prove nothing, the app also sets it from script. */ +async function expectStyled(page: Page): Promise { + await expect + .poll(() => + page.evaluate(() => { + const hasRules = (l: HTMLLinkElement) => { + try { + return (l.sheet?.cssRules.length ?? 0) > 0; + } catch { + return false; + } + }; + const links = [...document.querySelectorAll('link[rel="stylesheet"]')]; + const root = getComputedStyle(document.documentElement); + return { + links: links.length > 0, + rules: links.every(hasRules), + tokens: root.getPropertyValue("--bg").trim() !== "", + }; + }), + ) + .toEqual({ links: true, rules: true, tokens: true }); +} + +test("the app gets back a stylesheet lost on the way", async ({ page }) => { + const lost = await loseEachStylesheetOnce(page); + await openApp(page); + expect(lost.length).toBeGreaterThan(0); + await expectStyled(page); +}); + +test("a public /watch link gets back a stylesheet lost on the way", async ({ page }) => { + const lost = await loseEachStylesheetOnce(page); + // No such link: the page says so, and it is the same entry with the same stylesheet. + await page.goto("/watch/no-such-token"); + await expect(page.locator("#root")).not.toBeEmpty(); + expect(lost.length).toBeGreaterThan(0); + await expectStyled(page); +}); diff --git a/frontend/src/lib/stylesheetRetry.dom.test.ts b/frontend/src/lib/stylesheetRetry.dom.test.ts new file mode 100644 index 00000000..c90511dc --- /dev/null +++ b/frontend/src/lib/stylesheetRetry.dom.test.ts @@ -0,0 +1,105 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { clear, events } from "./log"; +import { retryLostStylesheets } from "./stylesheetRetry"; + +const LOADED = { cssRules: [{}] }; +// A lost stylesheet still has a `sheet`, and reading its rules throws (measured: Chromium, WebKit). +const LOST = { + get cssRules(): never { + throw new DOMException("Cannot access rules", "SecurityError"); + }, +}; + +function link(href: string, loaded: boolean, sheet: unknown = loaded ? LOADED : LOST) { + const el = document.createElement("link"); + el.rel = "stylesheet"; + el.href = href; + el.setAttribute("crossorigin", ""); + Object.defineProperty(el, "sheet", { value: sheet, configurable: true }); + document.head.append(el); + return el; +} + +const links = () => [...document.head.querySelectorAll('link[rel="stylesheet"]')]; +const logged = () => events().map((e) => e.event); + +describe("retryLostStylesheets", () => { + beforeEach(() => { + vi.useFakeTimers(); + clear(); + }); + afterEach(() => { + vi.useRealTimers(); + document.head.innerHTML = ""; + }); + + it("leaves a loaded stylesheet alone", () => { + const fine = link("/a.css", true); + retryLostStylesheets(); + vi.runAllTimers(); + expect(links()).toEqual([fine]); + expect(logged()).toEqual([]); + }); + + it("asks again for a lost one, in its place, keeping its attributes", () => { + const first = link("/a.css", true); + const lost = link("/b.css", false); + const last = link("/c.css", true); + retryLostStylesheets(); + vi.advanceTimersByTime(0); + const [a, b, c] = links(); + expect(a).toBe(first); + expect(b).not.toBe(lost); + expect(b!.getAttribute("href")).toBe("/b.css"); + expect(b!.hasAttribute("crossorigin")).toBe(true); + expect(c).toBe(last); + expect(logged()).toEqual(["app.stylesheet_lost"]); + }); + + it.each([ + ["no sheet at all", null], + ["an empty sheet", { cssRules: [] }], + ])("takes a link with %s for lost too", (_, sheet) => { + const lost = link("/a.css", false, sheet); + retryLostStylesheets(); + vi.advanceTimersByTime(0); + expect(links()[0]).not.toBe(lost); + }); + + it("says so once the new request has loaded", () => { + link("/a.css", false); + retryLostStylesheets(); + vi.advanceTimersByTime(0); + links()[0]!.dispatchEvent(new Event("load")); + vi.runAllTimers(); + expect(logged()).toEqual(["app.stylesheet_lost", "app.stylesheet_recovered"]); + }); + + it("waits longer each time and then gives up, reporting it", () => { + link("/a.css", false); + retryLostStylesheets(); + const fail = () => links()[0]!.dispatchEvent(new Event("error")); + + vi.advanceTimersByTime(0); + const second = links()[0]; + fail(); + vi.advanceTimersByTime(999); + expect(links()[0]).toBe(second); + vi.advanceTimersByTime(1); + const third = links()[0]; + expect(third).not.toBe(second); + + fail(); + vi.advanceTimersByTime(2999); + expect(links()[0]).toBe(third); + vi.advanceTimersByTime(1); + const fourth = links()[0]; + expect(fourth).not.toBe(third); + + fail(); + vi.runAllTimers(); + expect(links()[0]).toBe(fourth); + expect(logged()).toEqual(["app.stylesheet_lost", "app.stylesheet_gave_up"]); + }); +}); diff --git a/frontend/src/lib/stylesheetRetry.ts b/frontend/src/lib/stylesheetRetry.ts new file mode 100644 index 00000000..3c431000 --- /dev/null +++ b/frontend/src/lib/stylesheetRetry.ts @@ -0,0 +1,50 @@ +// A stylesheet can be lost on the way (a poor line, a loaded machine), and a browser never asks again +// for one by itself: the page then stays unstyled until a reload, every control a bare browser button. +// A module script waits for the document's stylesheets (measured: a sheet held 3 s held the module +// too), so by the time this runs each link has either loaded or failed, and no event is left to wait +// for. A lost one is asked for again in its own place, so the cascade's order stays as it was built. +// +// All of these sheets are this site's own: reading the rules of a loaded one is allowed, so a throw +// says it failed (measured: Chromium, WebKit). Three tries, then the page stays as it is. A reload here +// could loop, and the unstyled page still works. +import { log } from "./log"; + +const RETRY_AFTER_MS = [0, 1000, 3000]; + +function holdsRules(link: HTMLLinkElement): boolean { + try { + return (link.sheet?.cssRules.length ?? 0) > 0; + } catch { + return false; + } +} + +function askAgain(link: HTMLLinkElement, attempt: number): void { + const wait = RETRY_AFTER_MS[attempt]; + if (wait === undefined) { + log.error("app.stylesheet_gave_up", "a stylesheet could not be loaded", { href: link.href }); + return; + } + window.setTimeout(() => { + const next = link.cloneNode(false) as HTMLLinkElement; + next.addEventListener("load", () => + log.info("app.stylesheet_recovered", "a lost stylesheet loaded on a new request", { + href: next.href, + attempt: attempt + 1, + }), + ); + next.addEventListener("error", () => askAgain(next, attempt + 1)); + link.replaceWith(next); + }, wait); +} + +/** Run once, from a page's entry module. */ +export function retryLostStylesheets(): void { + for (const link of document.querySelectorAll('link[rel="stylesheet"]')) { + if (holdsRules(link)) continue; + log.warn("app.stylesheet_lost", "a stylesheet was lost on the way; asking again", { + href: link.href, + }); + askAgain(link, 0); + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index ec1d0d50..66176a38 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -17,12 +17,15 @@ import "flag-icons/css/flag-icons.min.css"; import { lazyWithPreload } from "./lib/lazyWithPreload"; import { installClientLog } from "./lib/logHooks"; import { installStaleBuildReload } from "./lib/staleBuild"; +import { retryLostStylesheets } from "./lib/stylesheetRetry"; import { appCopyOf } from "./i18n/appCopy"; // First, before any render: a crash or a navigation during boot belongs in the log too. installClientLog(appCopyOf, typedUrlKeys()); // A tab from before the last release reloads onto the new build instead of crashing on a lazy chunk. installStaleBuildReload(); +// A stylesheet lost on the way is asked for again rather than leaving the page unstyled. +retryLostStylesheets(); // Split by top-level route so each entry point is its own chunk: a legal page never downloads the // authenticated app bundle, and the app never carries it either. The legal pages live outside the diff --git a/frontend/src/watchMain.tsx b/frontend/src/watchMain.tsx index 33821e9c..2456dfa1 100644 --- a/frontend/src/watchMain.tsx +++ b/frontend/src/watchMain.tsx @@ -5,6 +5,7 @@ import WatchPage from "./watch/WatchPage"; import { T } from "./watch/watchStrings"; import "./index.css"; import { installStaleBuildReload } from "./lib/staleBuild"; +import { retryLostStylesheets } from "./lib/stylesheetRetry"; // The entry of a public /watch/ link (watch.html). Whoever opens it got the link from a // friend: signed out, often on a phone, often on mobile data — so it imports the player page and @@ -14,6 +15,8 @@ import { installStaleBuildReload } from "./lib/staleBuild"; // A tab left open across a release reloads onto the new build instead of failing on a chunk. installStaleBuildReload(); +// A stylesheet lost on the way is asked for again rather than leaving the page unstyled. +retryLostStylesheets(); const crashCopy = () => ({ title: T.crashTitle, subtitle: T.crashHint, reload: T.reload }); From cbe1b3bd631c1f5d56bb86f875a8b74884410007 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 4 Nov 2026 00:05:30 +0100 Subject: [PATCH 2/2] chore(release): 0.116.3 --- CHANGELOG.md | 6 ++++++ VERSION | 2 +- frontend/src/content/release-notes/en.json | 8 ++++++++ frontend/src/content/release-notes/hu.json | 8 ++++++++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c553c0d..4a8cd3f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ edit the release notes and re-run the generator. Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). +## 0.116.3 — 2026-11-04 + +### Fixed + +- If a stylesheet is lost on the way, the app now asks for it again, so a poor connection no longer leaves the page unstyled until a reload. + ## 0.116.2 — 2026-11-03 ### Fixed diff --git a/VERSION b/VERSION index fdf3a3e5..ed3a5734 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.116.2 +0.116.3 diff --git a/frontend/src/content/release-notes/en.json b/frontend/src/content/release-notes/en.json index 3711573b..92cbfc71 100644 --- a/frontend/src/content/release-notes/en.json +++ b/frontend/src/content/release-notes/en.json @@ -1,4 +1,12 @@ [ + { + "version": "0.116.3", + "date": "2026-11-04", + "new": [], + "fixed": [ + "If a stylesheet is lost on the way, the app now asks for it again, so a poor connection no longer leaves the page unstyled until a reload." + ] + }, { "version": "0.116.2", "date": "2026-11-03", diff --git a/frontend/src/content/release-notes/hu.json b/frontend/src/content/release-notes/hu.json index 91085221..5b3fa82c 100644 --- a/frontend/src/content/release-notes/hu.json +++ b/frontend/src/content/release-notes/hu.json @@ -1,4 +1,12 @@ [ + { + "version": "0.116.3", + "date": "2026-11-04", + "new": [], + "fixed": [ + "Ha egy stíluslap elvész útközben, az app most újra lekéri, így a rossz kapcsolat miatt nem marad formázatlan az oldal újratöltésig." + ] + }, { "version": "0.116.2", "date": "2026-11-03",