mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
The root layout shipped the active locale's catalog as `<script defer>` and counted on it executing ahead of Next's hydration scripts. It does not: Next emits its own chunks as `async` tags AHEAD of anything the layout renders into `<head>`, and an `async` script runs the moment its fetch settles. So the 370 KB catalog was racing the app bundle, and on a cold load it lost about a third of the time — `load-locale` initialised with an empty cache, every `t()` in the hydration render returned its raw key against the server's real text, and React tore the tree down with a text hydration mismatch. The user saw a frame of `nav.skipToContent` before the provider's backfill landed. Register the catalog through `next/script` at `beforeInteractive` instead. Next's own bootstrap awaits those scripts before requiring a single app module, and pairs the registration with a `<link rel=preload>` so the download still starts with the document rather than after it. The CSP nonce rides along, so the posture is unchanged. Fold the boot global into the cache lazily rather than snapshotting it at module evaluation. The snapshot made correctness depend on an ordering the module cannot see; reading on demand reduces the invariant to something local and testable — whatever the boot script assigns is visible to the first `t()` that asks, whenever it lands. Measured on the dashboard render spec, 30 repeats per project against a production build: 11 of 30 failed on desktop before, 0 of 60 after.
149 lines
6.0 KiB
TypeScript
149 lines
6.0 KiB
TypeScript
import type { Metadata, Viewport } from "next";
|
||
import { Inter } from "next/font/google";
|
||
import { headers } from "next/headers";
|
||
import Script from "next/script";
|
||
import "./globals.css";
|
||
import { Providers } from "@/components/providers";
|
||
import { AuthShell } from "@/components/layout/auth-shell";
|
||
import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
|
||
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
|
||
import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale";
|
||
|
||
const inter = Inter({
|
||
variable: "--font-sans",
|
||
subsets: ["latin"],
|
||
weight: ["400", "500", "600", "700"],
|
||
});
|
||
|
||
export const metadata: Metadata = {
|
||
title: "HealthLog",
|
||
description:
|
||
"Self-hosted health tracker — weight, blood pressure, glucose, mood, medications. Withings + Apple Health sync, transparent derived wellness metrics, AI Insights you own.",
|
||
manifest: "/manifest.json",
|
||
icons: {
|
||
icon: "/favicon.svg",
|
||
// Pre-flattened 180×180 on the app background — Safari ignores PNG
|
||
// alpha in favourites/home-screen tiles and would render the
|
||
// transparent logo on a white slab otherwise.
|
||
apple: "/apple-touch-icon.png",
|
||
},
|
||
appleWebApp: {
|
||
capable: true,
|
||
statusBarStyle: "black-translucent",
|
||
title: "HealthLog",
|
||
},
|
||
openGraph: {
|
||
title: "HealthLog",
|
||
description:
|
||
"Self-hosted health tracker. Weight, blood pressure, glucose, mood, medications. Withings + Apple Health sync. AI Insights you own.",
|
||
type: "website",
|
||
locale: "en_US",
|
||
alternateLocale: ["de_DE", "fr_FR", "es_ES", "it_IT", "pl_PL"],
|
||
siteName: "HealthLog",
|
||
// Drop-in OG asset. Replace with a 1200×630 dashboard-screenshot
|
||
// capture when an official one ships; the logo render keeps the
|
||
// unfurl from rendering a blank tile in the meantime.
|
||
images: [
|
||
{
|
||
url: "/logo-readme.png",
|
||
width: 1000,
|
||
height: 1000,
|
||
alt: "HealthLog — your health data, your server",
|
||
},
|
||
],
|
||
},
|
||
twitter: {
|
||
card: "summary_large_image",
|
||
title: "HealthLog",
|
||
description:
|
||
"Self-hosted health tracker. Weight, blood pressure, glucose, mood, medications. Withings + Apple Health sync. AI Insights you own.",
|
||
images: ["/logo-readme.png"],
|
||
},
|
||
};
|
||
|
||
export const viewport: Viewport = {
|
||
width: "device-width",
|
||
initialScale: 1,
|
||
viewportFit: "cover",
|
||
// Match the browser chrome (Android URL bar, iOS PWA status bar) to
|
||
// the active palette. The hex values are the resolved background of
|
||
// `--background` from `app/globals.css` for each theme so the bar
|
||
// edge never seams against the page on cold paint.
|
||
themeColor: [
|
||
{ media: "(prefers-color-scheme: light)", color: "#f3f2f5" },
|
||
{ media: "(prefers-color-scheme: dark)", color: "#282a36" },
|
||
],
|
||
};
|
||
|
||
// 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<{
|
||
children: React.ReactNode;
|
||
}>) {
|
||
const nonce =
|
||
process.env.NODE_ENV === "production"
|
||
? ((await headers()).get("x-nonce") ?? undefined)
|
||
: undefined;
|
||
|
||
const initialLocale = await resolveInitialLocale();
|
||
|
||
return (
|
||
<html lang={initialLocale} suppressHydrationWarning>
|
||
<head>
|
||
<script
|
||
suppressHydrationWarning
|
||
nonce={nonce}
|
||
dangerouslySetInnerHTML={{ __html: themeScript }}
|
||
/>
|
||
{/*
|
||
Locale-catalog boot script (generated into public/i18n/ by
|
||
scripts/generate-i18n-boot.mjs, a prebuild/predev step).
|
||
Replaces the former RSC-prop handoff that inlined the whole
|
||
active catalog into every document's flight payload (392 KB of a
|
||
505 KB dashboard HTML). Served as a static asset: ETag
|
||
revalidation (304 on repeat loads) + SW cache-first on the
|
||
versioned URL replace the per-document payload.
|
||
|
||
`beforeInteractive` is load-bearing, not a preference. The
|
||
catalog has to be in hand when the client bundle takes its FIRST
|
||
render, or `t()` resolves raw keys against an empty bundle while
|
||
the server HTML carries real text — React tears the tree down
|
||
with hydration error #418 and the user watches a frame of
|
||
`nav.skipToContent` before the provider's backfill lands. A plain
|
||
`<script defer>` cannot promise that: Next emits its own chunks
|
||
as `async` tags AHEAD of this element in `<head>`, and an `async`
|
||
script runs the moment its fetch settles, so the 370 KB catalog
|
||
was simply racing the app bundle — and lost about a third of the
|
||
time on a cold load. `beforeInteractive` hands the URL to Next's
|
||
own bootstrap, which awaits it before requiring a single app
|
||
module (`next/dist/client/app-bootstrap.js`), and pairs it with a
|
||
`<link rel=preload>` so the download still starts with the
|
||
document rather than after it.
|
||
*/}
|
||
<Script
|
||
id="healthlog-i18n-boot"
|
||
strategy="beforeInteractive"
|
||
nonce={nonce}
|
||
src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_APP_VERSION ?? "dev"}`}
|
||
/>
|
||
</head>
|
||
<body className={`${inter.variable} font-sans antialiased`}>
|
||
<Providers initialLocale={initialLocale}>
|
||
<MonitoringBootstrap />
|
||
<WebVitalsReporter />
|
||
{/* `DEMO_MODE` is a server-only env var; the proxy uses it to
|
||
block mutations. Resolve it here (server component) and
|
||
thread the boolean into the client shell so the demo
|
||
banner can render without a client-side detection path. */}
|
||
<AuthShell demoMode={process.env.DEMO_MODE === "true"}>
|
||
{children}
|
||
</AuthShell>
|
||
</Providers>
|
||
</body>
|
||
</html>
|
||
);
|
||
}
|