Skip to content

Decide the theme before the first paint, not after it - #204

Merged
davidmckayv merged 2 commits into
CopilotKit:mainfrom
zopeVaibhav:fix/theme-flash-before-paint
Aug 24, 2026
Merged

Decide the theme before the first paint, not after it#204
davidmckayv merged 2 commits into
CopilotKit:mainfrom
zopeVaibhav:fix/theme-flash-before-paint

Conversation

@zopeVaibhav

@zopeVaibhav zopeVaibhav commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Closes #203.

What this changes

A person with the dark theme selected saw a white frame on every reload. ThemeProvider reads the stored preference synchronously in its useState initialiser, 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.html carried no class, no inline style and no script), the dev build's missing stylesheet (styles.css is imported from main.tsx, so Vite injects it only once the module graph has loaded), and body'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-scheme too, 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 follows color-scheme on its own. Those surfaces were light for the whole session, not just a frame.

That inline style outranks the color-scheme the palette declares, so applyDarkTheme gained a setRootColorScheme effect. 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 reads index.html back and asserts the copy stays in step.

Where it runs

  • New state that outlives a request? None. The theme is a per-browser localStorage value, as it already was; nothing reaches the server.
  • What happens on the second replica? Nothing differs. The change is a static <script> in index.html and two CSS declarations — every replica serves the same bundle, and no request participates in the decision.
  • Anything serialised? None.
  • Anything fanned out to a browser? None.
  • New listener, port, or schedule? None.

Boundary and audit

  • Every acting call still goes through the gateway: resolve, decide, audit, then act. No acting call is touched — this is browser-side theming only.
  • New refusals and new failures each write a row. No new refusals or failures exist.
  • Nothing new is trusted from the client that the server can resolve itself. Nothing new is read from the client at all.

Changelog

  • A line in CHANGELOG.md under Unreleased — "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 reads index.html back: the storage-key copy stays in step, the script is neither defer nor type="module" (either would run after parsing and restore the flash), it applies the class itself, it sets colorScheme, and both palettes declare a colour scheme. One existing test extended to assert the toggle moves color-scheme as well as the class.

App suite 121 passing, tsc --noEmit clean, biome lint and format clean. vite build succeeds and the built dist/index.html keeps the boot script above both the module script and the stylesheet link.

@davidmckayv
davidmckayv force-pushed the fix/theme-flash-before-paint branch from 458a0bd to 0e25387 Compare August 24, 2026 16:12

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@zopeVaibhav

Copy link
Copy Markdown
Contributor Author

The CSP sentence is up as #225 — one paragraph in docs/deployment.md under Known costs, since
that section is already where the deployment traps that are invisible from the code live.

One correction while writing it: it is milder than a blank page. script-src blocks the inline
script, the pre-paint decision never runs, and the flash this PR removed comes back on the first
frame — the app still boots. The paragraph says that, and names the 'sha256-' hash as the form
that survives a rebuild without needing a per-request nonce.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The theme is applied after the first paint, so every refresh flashes white

2 participants