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:
parent
a4774783a9
commit
e7110c44ba
4 changed files with 103 additions and 31 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
35
src/components/theme/theme-sync.tsx
Normal file
35
src/components/theme/theme-sync.tsx
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue