fix: the theme belongs in a cookie, and the offline page in the shell

next/script rendered the theme script with a nonce, and browsers hide a
nonce attribute from the DOM once they have parsed it, so the server sent
nonce="" and the client read undefined. Every page load logged a
hydration mismatch.

The cookie is the fix rather than a workaround for it. The server reads
it and stamps data-theme on the html element, so the first frame is
already the right colour and there is no inline script at all.

Chasing that turned up worse. /offline sat outside [locale], which made
it a sibling of the root layout, so Next gave it a generated one: no
stylesheet, no font, no theme, and a second html element. The phase 7
check asserted the text and a button and passed while the page was
plainly broken.

It is a normal screen now. The service worker keeps one offline copy per
language the reader actually visits, learned from their own successful
navigations, so no list of locales lives in the worker and a third
language stays a catalog file.

The e2e warm-up also asks for the dynamic routes, which cost a compile of
their own and were being paid for by whichever test reached one first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-09-05 23:04:05 +00:00
co-authored by Claude Opus 5
parent e4eb1617d1
commit 8c8463924f
11 changed files with 167 additions and 119 deletions
+25 -1
View File
@@ -681,13 +681,37 @@ Sending retries on an interval as well as on the `online` event: a phone walking
coverage does not reliably fire that event, and a stranded photograph is the one failure
this feature exists to prevent.
### The theme is a cookie, not an inline script
The first attempt read localStorage in an inline script before the first paint. Under a
nonce policy that script is one React and the browser disagree about: the browser hides a
nonce attribute from the DOM, so the server rendered `nonce=""` and the client saw
`undefined`, and every page load logged a hydration mismatch.
The cookie is better than a workaround for that. The server reads it and stamps
`data-theme` on the html element, so the first frame is already the right colour and there
is no inline script at all. `system` writes no attribute, so the reader's own setting keeps
being followed as it changes.
### The offline page lives under `[locale]`
It was briefly at `/offline`, outside the locale segment, so that the service worker could
cache exactly one URL. That was wrong twice over: `[locale]/layout.tsx` is the root layout,
so a sibling route got Next's generated fallback instead, with no stylesheet, no font, no
theme and a second `<html>` element; and the page guessed its language from the browser
rather than knowing it.
It is now a normal screen. The worker keeps one copy per language the reader actually
visits, learned from their own successful navigations, so the fallback matches the page
they were trying to reach. No list of locales lives in the worker: a third language stays a
catalog file and nothing else, which is exactly the sort of place a hardcoded list would be
forgotten.
### CSP by nonce, styles still inline
The proxy mints a nonce per request and Next stamps it on the scripts it renders, with
`'strict-dynamic'` for the chunks they load. `style-src` keeps `'unsafe-inline'`: React
writes inline `style` attributes for things like a dragged card and there is no way to
nonce those.
This forced one change: `/offline` is rendered per request rather than prerendered. A
This forced one change: the offline page is rendered per request rather than prerendered. A
prerendered page carries a build-time nonce that no live policy matches, so its scripts
were blocked and the page rendered without ever hydrating. The service worker caches
headers along with the body, so the copy it serves offline stays self consistent.