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
+35
View File
@@ -0,0 +1,35 @@
import { setRequestLocale } from 'next-intl/server';
import { Card } from '@/components/ui/card';
import { getT } from '@/i18n/t';
import { RetryButton } from './retry-button';
/**
* What the service worker serves when a navigation cannot reach the network.
*
* It lives under `[locale]` like every other screen, so it inherits the shell: the
* stylesheet, the font and the theme the reader chose. The worker caches one copy per
* language the reader actually visits, so this is already in their language when it is
* served (see src/lib/service-worker.js).
*
* Rendered per request so it carries a live CSP nonce. Prerendered, its scripts would be
* stamped at build time and the policy would block them, leaving a page that renders but
* never becomes interactive. The worker caches the response headers along with the body,
* so the copy it serves offline stays self consistent.
*/
export const dynamic = 'force-dynamic';
export default async function OfflinePage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getT(locale);
return (
<main className="mx-auto flex min-h-dvh w-full max-w-md items-center px-5">
<Card className="w-full space-y-3">
<h1 className="text-xl font-semibold tracking-tight">{t('offline.title')}</h1>
<p className="text-sm text-[var(--text-muted)] text-pretty">{t('offline.body')}</p>
<RetryButton label={t('common.retry')} />
</Card>
</main>
);
}
@@ -0,0 +1,12 @@
'use client';
import { Button } from '@/components/ui/button';
/** Reloads whatever the reader was trying to reach, which is the only action here. */
export function RetryButton({ label }: { label: string }) {
return (
<Button type="button" block onClick={() => window.location.reload()}>
{label}
</Button>
);
}