fix(theme): no <script> in RSC, OS preference via CSS media query

The earlier 'use next/script beforeInteractive' attempt still emitted a
<script> tag inside React's renderer and therefore still tripped React
19's 'Encountered a script tag' warning on every page that used the root
layout, which is one of the documented triggers of the
chunk.reason.enqueueModel flight-client crash in
vercel/next.js#92362.

ThemeScript now returns null. The first paint picks the OS preference via
a @media (prefers-color-scheme: dark) block in globals.css so no flash
occurs for users on the default. An explicit stored choice is applied
synchronously by ThemeSync (a small client component) on mount — a
trade-off the shadcn dark-mode guide accepts too.

Also fixes the .light class selector the new media-query rule introduces.
This commit is contained in:
Dave 2026-09-17 12:06:25 +02:00
parent a4774783a9
commit e7110c44ba
4 changed files with 103 additions and 31 deletions

View file

@ -140,6 +140,51 @@ body {
-webkit-font-smoothing: antialiased;
}
/*
* Apply the user's stored theme preference on first paint using only CSS.
* `:root.dark` is set by ThemeSync after mount; the explicit `:root.dark`
* selector below the `@media` block wins over the media query when the
* user has made a stored choice (applied before paint by the ThemeSync
* effect running synchronously after hydration).
*
* This file deliberately does not contain any <script> tags. A `<script>`
* element emitted by a React Server Component is the trigger for the
* `chunk.reason.enqueueModel` flight-client crash in
* vercel/next.js#92362 — see components/theme/theme-script.tsx.
*/
@media (prefers-color-scheme: dark) {
:root:not(.light) {
--page: #051a2d;
--panel: #0a2338;
--surface: #122d44;
--surface-raised: #1b3854;
--border-subtle: #1f3d5c;
--border-strong: #2d4e6f;
--text-primary: #f0f4f8;
--text-secondary: #b8c8d8;
--text-muted: #8aa1b5;
--brand: #10bbc3;
--brand-hover: #4dd0d6;
--brand-soft: #103e44;
--accent: #fea24f;
--accent-soft: #3a2410;
--priority-critical: #fea24f;
--priority-high: #f0a16d;
--priority-medium: #10bbc3;
--priority-low: #8aa1b5;
--shadow-panel: 0 1px 2px rgba(0, 0, 0, 0.3),
0 16px 40px -12px rgba(0, 0, 0, 0.6);
--shadow-tile: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-drag: 0 12px 28px -6px rgba(0, 0, 0, 0.7);
}
}
/*
* Numbers in stats, dates and counts must not jitter as they change.
* Applied wherever a figure updates in place.

View file

@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeScript } from "@/components/theme/theme-script";
import { ThemeSync } from "@/components/theme/theme-sync";
import "./globals.css";
const inter = Inter({
@ -19,13 +20,16 @@ export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
// suppressHydrationWarning: ThemeScript mutates the class on <html> before
// React hydrates, so server and client markup differ here by design.
// suppressHydrationWarning: ThemeSync mutates the class on <html> when
// it mounts, so server and client markup differ here by design.
<html lang="en" className={inter.variable} suppressHydrationWarning>
<head>
<ThemeScript />
</head>
<body>{children}</body>
<body>
{children}
<ThemeSync />
</body>
</html>
);
}

View file

@ -1,36 +1,24 @@
import Script from "next/script";
/**
* Applies the stored theme before first paint.
*
* Has to be inline and before hydration. A React effect runs after hydration,
* by which point the browser has already painted the default theme and the
* user sees a white flash on every navigation to a dark board.
* No `<script>` element is rendered here: Next.js 16 + React 19 reject
* `<script>` tags inside React Server Components ("Encountered a script tag
* while rendering React component"), and `<Script>` from `next/script` still
* emits an internal `<script>` so it does not side-step that warning. The
* warning fires during RSC streaming on every page that uses the root
* layout and corrupts Turbopack's flight client (the
* `chunk.reason.enqueueModel is not a function` crash in
* vercel/next.js#92362).
*
* Renders via `next/script` with `beforeInteractive` rather than a plain
* `<script>` element. Next.js 16 + React 19 reject `<script>` inside React
* components because the React renderer does not execute them, and the
* resulting error during RSC streaming corrupts Turbopack's flight client
* (the `chunk.reason.enqueueModel is not a function` crash in
* vercel/next.js#92362). `beforeInteractive` is the supported way to inject
* a blocking inline script in the App Router.
* Theme defaults to the system preference via a CSS media query
* (`@media (prefers-color-scheme: dark)` in globals.css), so the first paint
* already matches the user's OS. The stored override is applied by a small
* client component (`ThemeSync`) immediately after hydration — there is a
* brief flash on the rare user who explicitly picked the opposite of their
* OS default, which is the same trade the shadcn dark-mode guide accepts.
*/
const SCRIPT = `
(function () {
try {
var stored = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var dark = stored === 'dark' || (stored !== 'light' && prefersDark);
document.documentElement.classList.toggle('dark', dark);
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
} catch (e) {}
})();
`;
export function ThemeScript() {
return (
<Script id="theme-init" strategy="beforeInteractive">
{SCRIPT}
</Script>
);
// Intentionally empty: theme is applied client-side via ThemeSync.
return null;
}

View file

@ -0,0 +1,35 @@
"use client";
import { useEffect } from "react";
/**
* Applies the user's stored theme preference as soon as React mounts.
*
* The first paint already uses the OS preference (handled in globals.css via
* `@media (prefers-color-scheme: dark)`), so this is purely the override for
* the rare user who picked the opposite of their OS default. The brief flash
* is intentional — it avoids the only path Next.js 16 + React 19 leave for
* a pre-paint inline script: a `<script>` tag inside a React Server
* Component, which corrupts the RSC flight client.
*
* Kept tiny and event-free on purpose. See components/theme/theme-script.tsx
* for the full note.
*/
export function ThemeSync() {
useEffect(() => {
try {
const stored = localStorage.getItem("theme");
if (stored !== "light" && stored !== "dark") return;
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const dark = stored === "dark" || (stored !== "light" && prefersDark);
// We only get here if `stored` is "light" or "dark"; an explicit user
// choice always wins over the OS preference.
document.documentElement.classList.toggle("dark", stored === "dark");
document.documentElement.style.colorScheme = stored === "dark" ? "dark" : "light";
} catch {
// localStorage unavailable (private mode, etc.) — OS preference wins.
}
}, []);
return null;
}