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.
This commit is contained in:
2026-11-03 23:32:40 +01:00
parent 358d5af70b
commit ae775b383c
5 changed files with 221 additions and 0 deletions
@@ -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<HTMLLinkElement>('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"]);
});
});
+50
View File
@@ -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<HTMLLinkElement>('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);
}
}