Themenskript aus dem Hydrierungsvergleich nehmen

Der Browser leert das Nonce-Attribut, sobald er das Element geparst hat — so
schreibt es die CSP-Spezifikation vor, damit kein Skript den Wert auslesen und
sich damit selbst nachladen kann. React vergleicht beim Hydrieren trotzdem das
Attribut, liest clientseitig den leeren Wert und meldet einen Unterschied, den
es nicht gibt.

suppressHydrationWarning an diesem einen Element sagt React, dass hier nichts
zu vergleichen ist. Das Nonce selbst bleibt: ohne es blockiert die Richtlinie
das Skript, und ohne das Skript blitzt beim Laden das falsche Thema auf.

Nachgewiesen gegen einen Entwicklungsserver: ohne das Attribut erscheint genau
die gemeldete Konsolenmeldung, mit ihm nicht.

Ein erster Versuch, das per Konsolenprüfung im e2e abzusichern, war auch ohne
den Fix grün — React meldet den Unterschied nur im Entwicklungsmodus, der
Testlauf nutzt den Produktionsbau. Die Prüfung ist wieder entfernt; eine, die
nicht anschlägt, ist schlimmer als keine. Stattdessen prüft
tests/thema-vorab.test.ts die drei Dinge am Skript, die zusammenpassen müssen:
Nonce, Hydrierungsausnahme und derselbe Speicherschlüssel wie im Umschalter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harlekin
2026-09-03 17:26:40 +02:00
co-authored by Claude Opus 5
parent 73b6dc820e
commit 2fb120dd77
3 changed files with 92 additions and 1 deletions
+26
View File
@@ -1081,3 +1081,29 @@ und keiner ist.
Sekunden. Der Aufwand, so einen Fehlschlag einmal falsch zu deuten, ist grösser.
`KEEP_TEST_DB=1` behält den Bestand, für das schnelle Nacheinander an einer einzelnen
Testdatei. Eine Sicherung im Skript besteht darauf, dass der Name auf `_test` endet.
### Das Nonce am Themenskript wird nicht hydriert
Das Skript, das das Thema vor dem ersten Malen setzt, braucht ein Nonce, sonst blockiert die
Inhaltsrichtlinie es und beim Laden blitzt das falsche Thema auf. Genau dieses Nonce erzeugte
aber bei jedem Seitenaufruf eine Hydrierungsmeldung in der Konsole.
Der Grund liegt nicht bei uns: **der Browser leert das Nonce-Attribut, sobald er das Element
geparst hat.** Das schreibt die CSP-Spezifikation so vor, damit kein Skript den Wert über
`getAttribute` auslesen und sich damit selbst nachladen kann. React vergleicht beim Hydrieren
trotzdem das Attribut, liest clientseitig den leeren Wert und meldet einen Unterschied, den es
nicht gibt — unser Markup ist auf beiden Seiten dasselbe.
Deshalb `suppressHydrationWarning` an diesem einen Element. Kein Verstecken eines Problems: es
sagt React, dass hier nichts zu vergleichen ist, und das stimmt. Die Alternative wäre, das
Skript statt über das Nonce über einen `sha256`-Hash in der Richtlinie zu erlauben. Das würde
die Ursache beseitigen, verlangt aber einen Eingriff in die Inhaltsrichtlinie für etwas, das
nur eine Meldung in der Entwicklungskonsole ist — ein schlechter Tausch.
**Geprüft wird das auf der Ebene der Quelle, nicht im Browser.** Ein e2e-Lauf kann es nicht
auffangen: React meldet den Unterschied nur im Entwicklungsmodus, der Testlauf nutzt den
Produktionsbau. Ein erster Versuch mit einer Konsolenprüfung im e2e war deshalb auch ohne den
Fix grün und wurde wieder entfernt — eine Prüfung, die nicht anschlägt, ist schlimmer als
keine. `web/tests/thema-vorab.test.ts` prüft stattdessen die drei Dinge am Skript, die
zusammenpassen müssen: Nonce, Hydrierungsausnahme und derselbe Speicherschlüssel wie im
Umschalter.
+20 -1
View File
@@ -35,7 +35,26 @@ export default async function RootLayout({ children }: { children: React.ReactNo
return (
<html lang="de">
<head>
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
{/*
`suppressHydrationWarning` wegen des Nonce, nicht wegen des Inhalts.
Der Browser **leert das Nonce-Attribut**, sobald er das Element
geparst hat — so schreibt es die CSP-Spezifikation vor, damit kein
Skript den Wert über `getAttribute` auslesen und sich selbst
nachladen kann. React vergleicht beim Hydrieren aber genau dieses
Attribut, liest clientseitig den leeren Wert und meldet einen
Unterschied, den es nicht gibt: unser Markup ist auf beiden Seiten
dasselbe.
Es bleibt bei einem einzigen Element mit einer Konstante als Inhalt,
deshalb ist die Unterdrückung hier eng genug. Der Nonce selbst
entfällt nicht: ohne ihn blockiert die Richtlinie das Skript, und
ohne das Skript blitzt beim Laden das falsche Thema auf.
*/}
<script
nonce={nonce}
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }}
/>
{/* Nur die beiden Schnitte vorladen, die auf jeder Seite vorkommen. */}
<link
rel="preload"
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
/**
* Das Skript, das das Thema vor dem ersten Malen setzt, hängt an drei Dingen,
* die zusammenpassen müssen. Fällt eines weg, merkt es niemand sofort:
*
* 1. **Der Nonce.** Ohne ihn blockiert die Inhaltsrichtlinie das Skript, und
* beim Laden blitzt das falsche Thema auf. Sichtbar, aber leicht als
* Kleinigkeit abgetan.
* 2. **`suppressHydrationWarning`.** Der Browser leert das Nonce-Attribut
* nach dem Parsen, React vergleicht es beim Hydrieren trotzdem und meldet
* einen Unterschied, den es nicht gibt. Die Meldung steht nur in der
* Konsole des Entwicklungsservers — im Produktionsbau meldet React sie
* nicht, ein e2e-Lauf kann sie also nicht auffangen. Deshalb hier auf der
* Ebene der Quelle.
* 3. **Der Schlüssel im Speicher**, derselbe wie im Umschalter. Weichen sie
* ab, wird die Wahl bei jedem Laden vergessen.
*/
const layout = readFileSync(join(process.cwd(), 'app/layout.tsx'), 'utf8')
describe('Thema vor dem ersten Malen', () => {
it('gibt dem Skript einen Nonce und nimmt es aus dem Hydrierungsvergleich', () => {
const tag = layout.slice(layout.indexOf('<script'), layout.indexOf('THEME_SCRIPT }} />'))
expect(tag).toContain('nonce={nonce}')
expect(tag).toContain('suppressHydrationWarning')
})
it('benutzt denselben Speicherschlüssel wie der Umschalter', () => {
const toggle = readFileSync(join(process.cwd(), 'components/ThemeToggle.tsx'), 'utf8')
// Der Schlüssel wird importiert, nicht abgeschrieben — genau das prüft das
// hier: eine zweite, abgeschriebene Zeichenkette wäre der Fehler.
expect(toggle).toMatch(/export const THEME_KEY = '([^']+)'/)
expect(layout).toContain("import { THEME_KEY } from '@/components/ThemeToggle'")
expect(layout).toContain('JSON.stringify(THEME_KEY)')
})
it('setzt nur die beiden ausdrücklichen Themen, nie „wie das System"', () => {
// "system" darf kein data-theme setzen, sonst gewinnt es über die
// Medienabfrage und der Nutzer sitzt im falschen Thema.
expect(layout).toContain("t==='dark'||t==='light'")
})
})