Decide the theme before the first paint, not after it - #204
Conversation
45d3a6c to
458a0bd
Compare
458a0bd to
0e25387
Compare
davidmckayv
left a comment
There was a problem hiding this comment.
Approving, and the title undersells half of what this fixes.
The flash is real and the shape of the fix is right. main.tsx is a plain Vite SPA — createRoot into an empty #root, no SSR — so there is no hydration to mismatch, and ThemeProvider reads storage synchronously but applies it in an effect, i.e. after commit. index.html on main carries no class and no script and the stylesheet is imported from the module, so a dark-theme person gets a white frame on every single reload. An inline classic script before paint is the correct answer for this app shape, and the comment saying why defer and type="module" are both too late is the part somebody would otherwise undo.
The half the title hides is the better half. color-scheme was never declared, so the user-agent surfaces — scrollbars, form controls, overscroll — stayed light under a dark app for the whole session, not for one frame. That is a real bug rather than polish.
The tests bite. I stripped the boot script out of index.html and four of them go red, including the ordering one that checks it sits above the stylesheet. They are not decorative.
Good that the try/catch is there: localStorage throws in some privacy modes and a theme is not worth taking the app down for.
One line for a follow-up. No content-security policy ships in this repo today, so the inline script is fine — but a fork that adds a strict CSP will need a hash or a nonce for it, and that is worth a sentence in the docs so somebody does not spend an afternoon on a blank page.
Rebased past today's merges; only the changelog clashed. Checks: typecheck, lint, format clean; app suite 158 pass; CI green.
|
The CSP sentence is up as #225 — one paragraph in One correction while writing it: it is milder than a blank page. |
Closes #203.
What this changes
A person with the dark theme selected saw a white frame on every reload.
ThemeProviderreads the stored preference synchronously in itsuseStateinitialiser, so the answer exists at the first render — but it applies it in an effect, which React runs after commit, one paint too late. Three surfaces were light in that frame: the browser's own canvas (index.htmlcarried no class, no inline style and no script), the dev build's missing stylesheet (styles.cssis imported frommain.tsx, so Vite injects it only once the module graph has loaded), andbody's background still resolving against:root.An inline classic script in the head now reads the same storage key and sets the class before anything paints. It sets
color-schemetoo, which is what darkens the browser's own surfaces — scrollbars, form controls, the overscroll area — and means no colour literal has to be copied out of the palette, since the UA canvas followscolor-schemeon its own. Those surfaces were light for the whole session, not just a frame.That inline style outranks the
color-schemethe palette declares, soapplyDarkThemegained asetRootColorSchemeeffect. Without it, toggling the theme in-app left the browser's surfaces on whichever theme the page booted in.The storage key is spelled out in the HTML because a pre-paint script cannot be a module and cannot import
THEME_STORAGE_KEY. A test readsindex.htmlback and asserts the copy stays in step.Where it runs
localStoragevalue, as it already was; nothing reaches the server.<script>inindex.htmland two CSS declarations — every replica serves the same bundle, and no request participates in the decision.Boundary and audit
Changelog
CHANGELOG.mdunderUnreleased— "Refreshing no longer flashes white before the theme arrives", with the note that a deployment already on the light theme sees no difference.Proof
Fix.mp4
Five tests added to
app/tests/theme-preference.test.ts, which readsindex.htmlback: the storage-key copy stays in step, the script is neitherdefernortype="module"(either would run after parsing and restore the flash), it applies the class itself, it setscolorScheme, and both palettes declare a colour scheme. One existing test extended to assert the toggle movescolor-schemeas well as the class.App suite 121 passing,
tsc --noEmitclean, biome lint and format clean.vite buildsucceeds and the builtdist/index.htmlkeeps the boot script above both the module script and the stylesheet link.