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:
co-authored by
Claude Opus 5
parent
e4eb1617d1
commit
8c8463924f
@@ -6,9 +6,10 @@ import { setRequestLocale } from 'next-intl/server';
|
||||
import { getT } from '@/i18n/t';
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cookies } from 'next/headers';
|
||||
import { Providers } from '@/components/providers';
|
||||
import { ThemeScript } from '@/components/theme-script';
|
||||
import { routing } from '@/i18n/routing';
|
||||
import { THEME_COOKIE } from '@/lib/theme';
|
||||
import '../globals.css';
|
||||
|
||||
// Self hosted by next/font: no third party font request at runtime, which keeps the
|
||||
@@ -55,11 +56,18 @@ export default async function LocaleLayout({
|
||||
if (!isLocale(locale)) notFound();
|
||||
setRequestLocale(locale);
|
||||
|
||||
// Read here rather than in a script the browser runs: the first frame is already the
|
||||
// right colour, and there is no inline script for a nonce policy to argue with.
|
||||
const stored = (await cookies()).get(THEME_COOKIE)?.value;
|
||||
const theme = stored === 'light' || stored === 'dark' ? stored : undefined;
|
||||
|
||||
return (
|
||||
<html lang={locale} className={inter.variable} suppressHydrationWarning>
|
||||
<head>
|
||||
<ThemeScript />
|
||||
</head>
|
||||
<html
|
||||
lang={locale}
|
||||
className={inter.variable}
|
||||
{...(theme ? { 'data-theme': theme } : {})}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-dvh font-sans antialiased">
|
||||
<NextIntlClientProvider>
|
||||
<Providers>{children}</Providers>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { DEFAULT_LOCALE, localeFromAcceptLanguage, t } from '@impuestos/i18n';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { useBrowserValue } from '@/lib/browser';
|
||||
|
||||
export function OfflineNotice() {
|
||||
// This page has no request locale to read, so it takes the browser's own preference.
|
||||
const locale = useBrowserValue(
|
||||
() => localeFromAcceptLanguage(navigator.language),
|
||||
DEFAULT_LOCALE,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="w-full space-y-3">
|
||||
<h1 className="text-xl font-semibold tracking-tight">{t(locale, 'offline.title')}</h1>
|
||||
<p className="text-sm text-[var(--text-muted)] text-pretty">{t(locale, 'offline.body')}</p>
|
||||
<Button type="button" block onClick={() => window.location.reload()}>
|
||||
{t(locale, 'common.retry')}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { OfflineNotice } from './offline-notice';
|
||||
|
||||
export const metadata: Metadata = { title: 'Impuestos' };
|
||||
|
||||
/**
|
||||
* Rendered per request so it carries a live CSP nonce like every other page. Prerendered,
|
||||
* its scripts would be stamped at build time and the policy would block them, leaving a
|
||||
* page that renders but does not work. The service worker caches the response headers
|
||||
* along with the body, so the copy it serves offline stays self consistent.
|
||||
*/
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* What the service worker serves when a navigation cannot reach the network.
|
||||
*
|
||||
* It lives outside `[locale]` on purpose: the worker caches exactly one URL, and a page
|
||||
* that only exists per locale would mean caching one and showing it to everyone. The
|
||||
* notice picks its language in the browser instead, from the two catalogs we ship.
|
||||
*/
|
||||
export default function OfflinePage() {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh w-full max-w-md items-center px-5">
|
||||
<OfflineNotice />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+2
-9
@@ -1,5 +1,5 @@
|
||||
import createMiddleware from 'next-intl/middleware';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { routing } from './src/i18n/routing';
|
||||
|
||||
const intl = createMiddleware(routing);
|
||||
@@ -53,14 +53,7 @@ export default function proxy(request: NextRequest) {
|
||||
request.headers.set('x-nonce', nonce);
|
||||
request.headers.set('content-security-policy', csp);
|
||||
|
||||
/**
|
||||
* `/offline` is one page for every language: the service worker caches exactly one URL,
|
||||
* so locale routing must leave it alone. It still gets the headers.
|
||||
*/
|
||||
const response =
|
||||
request.nextUrl.pathname === '/offline'
|
||||
? NextResponse.next({ request: { headers: request.headers } })
|
||||
: intl(request);
|
||||
const response = intl(request);
|
||||
|
||||
response.headers.set('content-security-policy', csp);
|
||||
for (const [key, value] of Object.entries(STATIC_HEADERS)) response.headers.set(key, value);
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import Script from 'next/script';
|
||||
|
||||
/**
|
||||
* Applies the stored theme before the first paint.
|
||||
*
|
||||
* It has to be inline and it has to run before anything renders: otherwise a reader who
|
||||
* chose dark gets a white flash on every navigation. `beforeInteractive` is how the App
|
||||
* Router says exactly that. A failure to read storage leaves the system preference in
|
||||
* charge, which is the right default anyway.
|
||||
*/
|
||||
const SCRIPT = `try{var t=localStorage.getItem('impuestos-theme');if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t)}catch(e){}`;
|
||||
|
||||
export function ThemeScript() {
|
||||
return (
|
||||
<Script id="theme" strategy="beforeInteractive">
|
||||
{SCRIPT}
|
||||
</Script>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* The service worker. Three jobs and no more:
|
||||
*
|
||||
* 1. show a push notification and open the screen it points at,
|
||||
* 2. keep the app shell reachable when the network is not,
|
||||
* 2. keep an explanation reachable when the network is not,
|
||||
* 3. get out of the way of everything else.
|
||||
*
|
||||
* There is no caching of API responses. Tax figures that are quietly out of date are worse
|
||||
@@ -10,20 +10,15 @@
|
||||
* says so.
|
||||
*/
|
||||
|
||||
const CACHE = 'impuestos-shell-v1';
|
||||
const OFFLINE_URL = '/offline';
|
||||
|
||||
const PRECACHE = [OFFLINE_URL, '/icon-192.png'];
|
||||
const CACHE = 'impuestos-shell-v2';
|
||||
const ICON = '/icon-192.png';
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(CACHE)
|
||||
// One at a time rather than addAll: a single miss must not throw away the install
|
||||
// and leave the offline page uncached along with it.
|
||||
.then((cache) =>
|
||||
Promise.all(PRECACHE.map((url) => cache.add(url).catch(() => undefined))),
|
||||
)
|
||||
// A miss must not throw away the install along with it.
|
||||
.then((cache) => cache.add(ICON).catch(() => undefined))
|
||||
.then(() => self.skipWaiting()),
|
||||
);
|
||||
});
|
||||
@@ -37,6 +32,45 @@ self.addEventListener('activate', (event) => {
|
||||
);
|
||||
});
|
||||
|
||||
/** The locale is the first path segment, which is where the router always puts it. */
|
||||
function localeOf(url) {
|
||||
const segment = new URL(url).pathname.split('/')[1];
|
||||
return segment && /^[a-z]{2}$/.test(segment) ? segment : null;
|
||||
}
|
||||
|
||||
function offlineUrlFor(locale) {
|
||||
return `/${locale}/offline`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps one offline page per language the reader actually visits.
|
||||
*
|
||||
* No list of locales lives here on purpose: adding a third language is a catalog file and
|
||||
* nothing else, and a hardcoded list in a service worker is exactly the sort of place a new
|
||||
* locale would be forgotten.
|
||||
*/
|
||||
async function rememberOfflinePage(locale) {
|
||||
const url = offlineUrlFor(locale);
|
||||
const cache = await caches.open(CACHE);
|
||||
if (await cache.match(url)) return;
|
||||
await cache.add(url).catch(() => undefined);
|
||||
}
|
||||
|
||||
async function offlineResponse(requestUrl) {
|
||||
const cache = await caches.open(CACHE);
|
||||
const locale = localeOf(requestUrl);
|
||||
|
||||
if (locale) {
|
||||
const exact = await cache.match(offlineUrlFor(locale));
|
||||
if (exact) return exact;
|
||||
}
|
||||
|
||||
// Some language is better than a browser error page.
|
||||
const keys = await cache.keys();
|
||||
const anyOffline = keys.find((request) => request.url.endsWith('/offline'));
|
||||
return anyOffline ? cache.match(anyOffline) : Response.error();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigations only. A page the network cannot deliver falls back to the offline screen,
|
||||
* which explains itself and offers a retry. Everything else goes straight to the network.
|
||||
@@ -44,11 +78,13 @@ self.addEventListener('activate', (event) => {
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (event.request.mode !== 'navigate') return;
|
||||
|
||||
// Scheduled before responding, not inside the response's promise chain: waitUntil has to
|
||||
// be called while the event is still being dispatched or the work is never kept alive.
|
||||
const locale = localeOf(event.request.url);
|
||||
if (locale) event.waitUntil(rememberOfflinePage(locale));
|
||||
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(async () => {
|
||||
const cached = await caches.match(OFFLINE_URL);
|
||||
return cached ?? Response.error();
|
||||
}),
|
||||
fetch(event.request).catch(() => offlineResponse(event.request.url)),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -65,8 +101,8 @@ self.addEventListener('push', (event) => {
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(payload.title ?? 'Impuestos', {
|
||||
body: payload.body ?? '',
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png',
|
||||
icon: ICON,
|
||||
badge: ICON,
|
||||
// One number and one action (FLOWS.md section 9): the tag collapses a repeat of the
|
||||
// same notification rather than stacking it.
|
||||
tag: payload.tag ?? 'impuestos',
|
||||
|
||||
+23
-18
@@ -1,31 +1,36 @@
|
||||
'use client';
|
||||
|
||||
/*
|
||||
* No 'use client' directive: this module holds constants and two functions that touch
|
||||
* `document` when called. The cookie name is read by the server layout, and marking the
|
||||
* module client-only would drag it across the boundary for the sake of a string.
|
||||
*/
|
||||
export const THEMES = ['system', 'light', 'dark'] as const;
|
||||
export type Theme = (typeof THEMES)[number];
|
||||
|
||||
export const THEME_KEY = 'impuestos-theme';
|
||||
export const THEME_COOKIE = 'impuestos-theme';
|
||||
const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365;
|
||||
|
||||
/**
|
||||
* Applied by writing `data-theme` on the document element, which the CSS in globals.css
|
||||
* treats as beating the system preference in both directions. `system` removes the
|
||||
* attribute rather than picking a side, so the reader's own setting is followed as it
|
||||
* changes.
|
||||
* The choice lives in a cookie, not in localStorage, so the server can read it and stamp
|
||||
* `data-theme` on the html element before anything is painted. localStorage would mean an
|
||||
* inline script that reads it, which is a white flash on the first frame at best and, under
|
||||
* a nonce policy, a script React and the browser disagree about at worst.
|
||||
*
|
||||
* `system` clears the attribute rather than picking a side, so the reader's own setting is
|
||||
* followed as it changes.
|
||||
*/
|
||||
export function applyTheme(theme: Theme): void {
|
||||
if (theme === 'system') document.documentElement.removeAttribute('data-theme');
|
||||
else document.documentElement.setAttribute('data-theme', theme);
|
||||
try {
|
||||
window.localStorage.setItem(THEME_KEY, theme);
|
||||
} catch {
|
||||
// A browser with storage blocked keeps the choice for this page and no longer.
|
||||
}
|
||||
|
||||
document.cookie = `${THEME_COOKIE}=${theme}; path=/; max-age=${ONE_YEAR_SECONDS}; samesite=lax`;
|
||||
}
|
||||
|
||||
export function isTheme(value: string | undefined): value is Theme {
|
||||
return THEMES.includes(value as Theme);
|
||||
}
|
||||
|
||||
export function storedTheme(): Theme {
|
||||
try {
|
||||
const value = window.localStorage.getItem(THEME_KEY);
|
||||
return THEMES.includes(value as Theme) ? (value as Theme) : 'system';
|
||||
} catch {
|
||||
return 'system';
|
||||
}
|
||||
const match = document.cookie.match(new RegExp(`(?:^|; )${THEME_COOKIE}=([^;]*)`));
|
||||
const value = match?.[1];
|
||||
return isTheme(value) ? value : 'system';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user