commit d7b3026ee81cbb8f0eeea09d8fda846861bf223d
parent eaf0cfaef447c1f95890edf14d984c1d36d31ca8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 8 Sep 2026 08:36:51 -0400
common: the pre-paint theme script marks itself done, and the FOUC tests wait for the mark
`export/e2e/theme.spec.ts` and `theme-family.spec.ts` reload with
`waitUntil: "commit"` and then read `data-theme` off `<html>`. "commit" resolves
as soon as the navigation commits, which is not a promise that the inline
`<head>` script has run, so on a loaded box the read lands first and sees null.
Nothing was wrong with the product; the tests were reading a value before the
statement that sets it.
The script's last statement inside its existing synchronous `try` now sets
`data-theme-ready="1"`, and each of the three post-reload reads (two in
theme.spec.ts, one in theme-family.spec.ts) waits for that marker. The marker
and the theme attributes are set by the same uninterrupted statement sequence,
and a `<head>` script still runs before any React, so the tests prove exactly
what they proved before: the theme is on `<html>` before hydration.
Measured from a worktree at this tree, one worker, behind the e2e queue:
`playwright test theme.spec.ts theme-family.spec.ts ask-chat.spec.ts --repeat-each 10`
— **250 passed, 0 failed, 8.4 min**; the two "no FOUC" tests are 20 of those, 10
each. (`pnpm e2e --` inserts a literal `--` that Playwright reads as
end-of-flags, so `--repeat-each` becomes a filename filter; the run went through
`node ../scripts/queue-lock.mjs --ports EXPORT_E2E_PORT:3020 -- pnpm exec
playwright test …`, the same lock and port the script takes. `e2e:2origin` is
known-broken and was not used.)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 17 insertions(+), 0 deletions(-)
diff --git a/common/components/ThemeScript.tsx b/common/components/ThemeScript.tsx
@@ -45,6 +45,14 @@ export function ThemeScript({
JSON.stringify(ACCENT_KEY) +
");" +
"if(a){d.style.setProperty('--brand',a);}" +
+ // Last statement of the same synchronous try block, so the marker can only
+ // be present once every attribute above is already set. e2e's "no FOUC"
+ // tests reload with waitUntil:"commit", which resolves as soon as the
+ // navigation commits — possibly before this head script has run at all —
+ // and then read data-theme. They wait for this marker instead of racing.
+ // It proves nothing weaker than before: a <head> script still runs before
+ // any React, so "set before hydration" is exactly what is observed.
+ "d.setAttribute('data-theme-ready','1');" +
"}catch(e){}})();";
return (
diff --git a/export/e2e/theme-family.spec.ts b/export/e2e/theme-family.spec.ts
@@ -32,6 +32,9 @@ test("theme-family picker switches + persists data-theme with no FOUC", async ({
// Reload: the pre-paint script re-applies data-theme before React hydrates.
await page.reload({ waitUntil: "commit" });
+ await page.waitForFunction(
+ () => document.documentElement.dataset.themeReady === "1",
+ );
expect(
await page.evaluate(() =>
document.documentElement.getAttribute("data-theme"),
diff --git a/export/e2e/theme.spec.ts b/export/e2e/theme.spec.ts
@@ -38,6 +38,9 @@ test("base family default; mode toggle applies + persists with no FOUC", async (
expect(s.theme).toBe(null);
await page.reload({ waitUntil: "commit" });
+ await page.waitForFunction(
+ () => document.documentElement.dataset.themeReady === "1",
+ );
let onCommit = await page.evaluate(() => ({
dark: document.documentElement.classList.contains("dark"),
theme: document.documentElement.getAttribute("data-theme"),
@@ -54,6 +57,9 @@ test("base family default; mode toggle applies + persists with no FOUC", async (
expect(s.dark).toBe(false);
await page.reload({ waitUntil: "commit" });
+ await page.waitForFunction(
+ () => document.documentElement.dataset.themeReady === "1",
+ );
onCommit = await page.evaluate(() => ({
dark: document.documentElement.classList.contains("dark"),
theme: document.documentElement.getAttribute("data-theme"),