mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
ad620bcf80
commit
6b41c11f30
3 files changed
+67
-6
No files matched your search
+4
-5
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)})()`;
|
||||
Reference in new issue
Block a user