fix(pwa): set the status-bar colour with the theme before first paint

The theme-color tags were rewritten to the app's theme only after
hydration, so with a light system and the app in dark mode the status
bar stayed light on a cold start. The existing boot script now sets
them along with the theme class, and once more after parsing in case
the tags stream in after it.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 22:57:47 +02:00
1 parent ad620bcf80
commit 6b41c11f30
3 files changed
+67 -6

No files matched your search

+4 -5
View File
@@ -9,7 +9,7 @@ import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale";
import { isKeyMismatch } from "@/lib/boot/key-mismatch-state";
import { THEME_COLOR } from "@/lib/pwa/theme-color";
import { THEME_BOOT_SCRIPT, THEME_COLOR } from "@/lib/pwa/theme-color";
import { KeyMismatchPage } from "./key-mismatch-page";
const inter = Inter({
@@ -80,9 +80,6 @@ export const viewport: Viewport = {
],
};
// Inline script to apply theme before first paint (prevents FOUC)
const themeScript = `(function(){try{var t=localStorage.getItem("healthlog-theme");var c=(t==="light"||t==="dark")?t:(t==="system"?(window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light"):"dark");document.documentElement.classList.add(c)}catch(e){document.documentElement.classList.add("dark")}})()`;
export default async function RootLayout({
children,
}: Readonly<{
@@ -101,7 +98,9 @@ export default async function RootLayout({
<script
suppressHydrationWarning
nonce={nonce}
dangerouslySetInnerHTML={{ __html: themeScript }}
// Applies the theme (and its status-bar colour) before
// first paint, so neither flashes the wrong theme.
dangerouslySetInnerHTML={{ __html: THEME_BOOT_SCRIPT }}
/>
{/*
Locale-catalog boot script (generated into public/i18n/ by
+52 -1
View File
@@ -3,7 +3,11 @@ import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { applyThemeColor, THEME_COLOR } from "@/lib/pwa/theme-color";
import {
applyThemeColor,
THEME_BOOT_SCRIPT,
THEME_COLOR,
} from "@/lib/pwa/theme-color";
function fakeMeta(content: string) {
const attrs = new Map<string, string>([["content", content]]);
@@ -46,3 +50,50 @@ describe("applyThemeColor", () => {
);
});
});
describe("THEME_BOOT_SCRIPT", () => {
function boot(stored: string | null, systemDark: boolean) {
const metas = [fakeMeta(THEME_COLOR.light), fakeMeta(THEME_COLOR.dark)];
const classes = new Set<string>();
const listeners = new Map<string, () => void>();
const document = {
documentElement: { classList: { add: (c: string) => classes.add(c) } },
querySelectorAll: () => metas,
addEventListener: (type: string, fn: () => void) =>
listeners.set(type, fn),
};
const window = {
matchMedia: () => ({ matches: systemDark }),
};
const localStorage = { getItem: () => stored };
new Function("window", "document", "localStorage", THEME_BOOT_SCRIPT)(
window,
document,
localStorage,
);
return { metas, classes, listeners };
}
it("paints the status bar in the app's theme before hydration", () => {
// A light system, the app in its dark default: the light-keyed tag is
// the one the browser applies, so it must carry the dark colour already.
const { metas, classes } = boot(null, false);
expect([...classes]).toEqual(["dark"]);
for (const meta of metas) {
expect(meta.attrs.get("content")).toBe(THEME_COLOR.dark);
}
});
it("follows a stored light choice on a dark system", () => {
const { metas, classes } = boot("light", true);
expect([...classes]).toEqual(["light"]);
for (const meta of metas) {
expect(meta.attrs.get("content")).toBe(THEME_COLOR.light);
}
});
it("repeats the rewrite once the document is parsed", () => {
const { listeners } = boot("system", true);
expect(listeners.has("DOMContentLoaded")).toBe(true);
});
});
+11
View File
@@ -28,3 +28,14 @@ export function applyThemeColor(
meta.setAttribute("content", THEME_COLOR[resolved]);
}
}
/**
* The inline boot script the root layout runs before first paint. It stamps
* the theme class (no flash of the wrong theme) and rewrites the theme-color
* tags to that theme, which until now waited for hydration: a phone in light
* mode running the app in dark mode showed a light status bar until React
* caught up. The tags may stream in after the script, so it repeats the
* rewrite once the document is parsed. Built from constants only — the
* layout's one `dangerouslySetInnerHTML` stays file-literal content.
*/
export const THEME_BOOT_SCRIPT = `(function(){var c="dark";try{var t=localStorage.getItem("healthlog-theme");c=(t==="light"||t==="dark")?t:(t==="system"?(window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light"):"dark")}catch(e){}document.documentElement.classList.add(c);var k=c==="light"?${JSON.stringify(THEME_COLOR.light)}:${JSON.stringify(THEME_COLOR.dark)};function m(){var a=document.querySelectorAll('meta[name="theme-color"]');for(var i=0;i<a.length;i++)a[i].setAttribute("content",k)}m();document.addEventListener("DOMContentLoaded",m)})()`;