diff --git a/docs/13-entscheidungen.md b/docs/13-entscheidungen.md index 6c31abb..db6222a 100644 --- a/docs/13-entscheidungen.md +++ b/docs/13-entscheidungen.md @@ -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. diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 3d25c85..0b36e3d 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -35,7 +35,26 @@ export default async function RootLayout({ children }: { children: React.ReactNo return (
- + {/* + `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. + */} + {/* Nur die beiden Schnitte vorladen, die auf jeder Seite vorkommen. */} { + it('gibt dem Skript einen Nonce und nimmt es aus dem Hydrierungsvergleich', () => { + const tag = layout.slice(layout.indexOf('')) + + 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'") + }) +})