Keep the dashboard the server streamed

`/` runs its reads server-side and hands them to TanStack through a
HydrationBoundary, so the first HTML already carries real tiles. React threw
that away on every cold load. The app shell hydrates first and its mount
answers `/api/auth/me`; the dashboard sits below `loading.tsx`, hydrates later,
and its first client render therefore read an account the server never had.
That disagreed with the streamed HTML, React reported #418 and re-rendered the
whole page. The prefetch paid for its queries and delivered none of its paint.

The account payload is now withheld from the hydration render wherever it is
only rendered. `useAccountOnceMounted()` answers null until the component has
mounted, which is what the server rendered, and hands the payload over on the
next render. The dashboard page, the caregiver card, the getting-started
checklist, the achievements tile and the unit labels read it that way. `useAuth`
itself still reads the cache, because the document vault redirects off
`user.modules` and a first render claiming "no account" bounces it to `/`.

Two tiles disagreed without any account involved. A query reports `isLoading`
true on the client's first render and false on the server, so both now gate on
whether the cell holds an answer.

Nothing in CI ran the shipped configuration: the suite's server turns the
prefetch off so route mocks keep governing the dashboard. A second server runs
it on, and one spec asserts that no hydration error is reported and that the
tile node the server streamed is still the node in the DOM once the page
settles.

Measured over nine cold loads with the CPU throttled 4x: seven bailouts before
and none after, median LCP 869 ms before and 145 ms after. FCP and the time to
the first tile in the DOM are unchanged, because the streamed paint was never
the problem. Keeping it was.
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 17:28:48 +02:00
1 parent 03877a0971
commit 675c70eba8
16 files changed
+468 -60

No files matched your search

@@ -0,0 +1,100 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { SSR_PREFETCH_BASE_URL } from "./setup/ssr-prefetch-server";
/**
* The dashboard survives hydration in the configuration that ships.
*
* `/` is an RSC wrapper that runs the dashboard's reads server-side and hands
* them to TanStack through a `HydrationBoundary` (`src/app/page.tsx`), so the
* first HTML already carries real tiles. It only does that when
* `DASHBOARD_SSR_PREFETCH` is not `"false"` — which is the default, and
* therefore what every self-hoster runs.
*
* The rest of the suite runs against a server with that flag OFF, because the
* dashboard specs drive their fixtures through `page.route` and a route mock
* only sees client fetches. So nothing here ever exercised the shipped path,
* and a React #418 hydration bailout sat on `/` for a signed-in account on
* every cold load: the app shell hydrates first and its queries answer before
* the streamed dashboard boundary hydrates, so the dashboard's first CLIENT
* render read an account payload (and an achievements payload, and its own
* in-flight tiles) the server never had. React threw the streamed tree away
* and rebuilt it — paying for the prefetch and delivering none of it.
*
* This spec talks to the second server (`SSR_PREFETCH_BASE_URL`, prefetch ON,
* no route mocks, the real seeded account) and asserts two things a mocked
* spec cannot:
*
* 1. the browser reports no hydration error at all;
* 2. the tile node the SERVER streamed is still the same DOM node once the
* page settles. A bailout re-creates it, so node identity is the honest
* witness that the server's work survived instead of being re-done. The
* probe only reads identity — writing a marker attribute onto server HTML
* would be a hydration mismatch of its own making.
*
* Runs on both viewport projects, because the defect reproduced on both.
*/
declare global {
interface Window {
__ssrTileWitness?: { seen: boolean; replaced: number };
}
}
test.describe("dashboard hydration with the SSR prefetch on", () => {
test.use({
storageState: STORAGE_STATE_PATH,
baseURL: SSR_PREFETCH_BASE_URL,
});
test("streams the dashboard and keeps it through hydration", async ({
page,
}) => {
const clientErrors: string[] = [];
page.on("pageerror", (error) => clientErrors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") clientErrors.push(message.text());
});
// `addInitScript` runs before any page script, so the poll is armed for
// the very first frame and latches the node React hydrated into.
await page.addInitScript(() => {
const witness = { seen: false, replaced: 0 };
window.__ssrTileWitness = witness;
let node: Element | null = null;
const poll = () => {
const el = document.querySelector('[data-slot="dashboard-tile-strip"]');
if (el) {
if (!node) witness.seen = true;
else if (node !== el) witness.replaced += 1;
node = el;
}
requestAnimationFrame(poll);
};
requestAnimationFrame(poll);
});
await page.goto("/");
const strip = page.locator('[data-slot="dashboard-tile-strip"]');
await expect(strip).toBeVisible();
// The seeded account owns one weight reading, so the strip carries a real
// tile — proof the prefetch populated this render rather than the client
// having filled it in after mount.
await expect(
strip.locator('[data-slot="dashboard-tile-link"]').first(),
).toBeVisible();
// Let every boundary hydrate and every post-mount refetch land before the
// verdict; a bailout that happens late is still a bailout.
await page.waitForLoadState("networkidle");
expect(
clientErrors.filter((message) => /418|hydrat/i.test(message)),
).toEqual([]);
const witness = await page.evaluate(() => window.__ssrTileWitness);
expect(witness?.seen).toBe(true);
expect(witness?.replaced).toBe(0);
});
});
+20
View File
@@ -0,0 +1,20 @@
/**
* The second E2E server: the app as self-hosters actually run it.
*
* `playwright.config.ts` starts the main server with
* `DASHBOARD_SSR_PREFETCH=false`, because the dashboard specs mock `/api/*`
* through `page.route` and a route mock only sees CLIENT fetches — an
* SSR-embedded snapshot would bypass every fixture. That trade is sound for
* those specs and wrong for the product: the flag ships ON, so the whole suite
* was proving a configuration nobody runs.
*
* This module names the second server so the config and the one spec that
* uses it cannot drift on a port number. With `E2E_SKIP_WEB_SERVER=1` neither
* server is started for you and both have to be running — the same contract
* that already applies to the main one.
*/
export const SSR_PREFETCH_PORT = 3100;
export const SSR_PREFETCH_BASE_URL =
process.env.E2E_SSR_PREFETCH_BASE_URL ??
`http://localhost:${SSR_PREFETCH_PORT}`;
+57 -23
View File
@@ -1,5 +1,10 @@
import { defineConfig, devices } from "@playwright/test";
import {
SSR_PREFETCH_BASE_URL,
SSR_PREFETCH_PORT,
} from "./e2e/setup/ssr-prefetch-server";
/**
* Playwright configuration for the HealthLog E2E suite.
*
@@ -94,28 +99,57 @@ export default defineConfig({
// to point at an already-running dev server.
webServer: process.env.E2E_SKIP_WEB_SERVER
? undefined
: {
command: `mkdir -p .next/standalone/.next/static .next/standalone/public && cp -R .next/static/. .next/standalone/.next/static && cp -R public/. .next/standalone/public && PORT=3000 HOSTNAME=127.0.0.1 ${JSON.stringify(process.execPath)} .next/standalone/server.js`,
url: "http://localhost:3000/api/version",
timeout: 60_000,
reuseExistingServer: !process.env.CI,
stdout: "ignore",
stderr: "pipe",
env: {
...process.env,
// Native document rendering is fail-soft and outside the browser
// suite. Disable it here so queued local thumbnail jobs cannot
// crash the shared server inside Skia during unrelated scenarios.
NATIVE_CANVAS: "off",
// The dashboard RSC wrapper server-prefetches the snapshot into
// the first HTML (HydrationBoundary). Playwright's route mocks
// only see CLIENT fetches, so an SSR-embedded snapshot would
// bypass `mockDashboardSnapshot` and every dashboard spec would
// assert against the seeded account instead of its fixture.
// Disable the prefetch for the e2e server — the suite keeps the
// deterministic client-fetch path; the SSR fast path is
// verified by Lighthouse/manual passes.
DASHBOARD_SSR_PREFETCH: "false",
: [
{
command: `mkdir -p .next/standalone/.next/static .next/standalone/public && cp -R .next/static/. .next/standalone/.next/static && cp -R public/. .next/standalone/public && PORT=3000 HOSTNAME=127.0.0.1 ${JSON.stringify(process.execPath)} .next/standalone/server.js`,
url: "http://localhost:3000/api/version",
timeout: 60_000,
reuseExistingServer: !process.env.CI,
stdout: "ignore",
stderr: "pipe",
env: {
...process.env,
// Native document rendering is fail-soft and outside the browser
// suite. Disable it here so queued local thumbnail jobs cannot
// crash the shared server inside Skia during unrelated scenarios.
NATIVE_CANVAS: "off",
// The dashboard RSC wrapper server-prefetches the snapshot into
// the first HTML (HydrationBoundary). Playwright's route mocks
// only see CLIENT fetches, so an SSR-embedded snapshot would
// bypass `mockDashboardSnapshot` and every dashboard spec would
// assert against the seeded account instead of its fixture.
// Disable the prefetch for the e2e server — the suite keeps the
// deterministic client-fetch path.
DASHBOARD_SSR_PREFETCH: "false",
},
},
},
// The SHIPPED configuration, on its own port.
//
// Every spec above runs against a server with the dashboard's RSC
// prefetch disabled, which is the one thing self-hosters never run:
// the flag defaults ON. That gap let a React #418 hydration bailout
// reach production on `/` for a signed-in account, on both viewports,
// on every cold load — the client's first render read query cells the
// server never had, so React discarded the streamed dashboard and
// rebuilt it. The suite could not see it, because with the prefetch
// off both sides paint the same skeleton.
//
// One extra server on 3100 closes that. Only
// `dashboard-ssr-prefetch-hydration.spec.ts` talks to it, and it
// asserts the one thing the mocked specs cannot: that the page the
// server streamed is the page the browser keeps.
{
command: `PORT=${SSR_PREFETCH_PORT} HOSTNAME=127.0.0.1 ${JSON.stringify(process.execPath)} .next/standalone/server.js`,
url: `${SSR_PREFETCH_BASE_URL}/api/version`,
timeout: 60_000,
reuseExistingServer: !process.env.CI,
stdout: "ignore",
stderr: "pipe",
env: {
...process.env,
NATIVE_CANVAS: "off",
DASHBOARD_SSR_PREFETCH: "true",
},
},
],
});
+25 -7
View File
@@ -45,13 +45,31 @@ describe("Playwright CI determinism", () => {
expect(config.retries).toBe(2);
expect(config.failOnFlakyTests).toBe(true);
expect(config.reporter).toEqual([["github"], ["html", { open: "never" }]]);
expect(config.webServer).toMatchObject({
command: expect.stringContaining(
`${JSON.stringify(process.execPath)} .next/standalone/server.js`,
),
env: expect.objectContaining({
NATIVE_CANVAS: "off",
}),
// Two servers, and which is which matters. The suite's own server runs
// with the dashboard's RSC prefetch OFF so `page.route` fixtures govern
// what the dashboard paints; the second runs it ON, which is the default
// and therefore the only configuration self-hosters ever see. A React #418
// hydration bailout shipped on `/` precisely because nothing in CI ran the
// shipped one. Pin both so that gap cannot reopen by deletion.
const servers = config.webServer;
expect(Array.isArray(servers)).toBe(true);
const webServers = servers as Array<Record<string, unknown>>;
expect(webServers).toHaveLength(2);
for (const server of webServers) {
expect(server).toMatchObject({
command: expect.stringContaining(
`${JSON.stringify(process.execPath)} .next/standalone/server.js`,
),
env: expect.objectContaining({
NATIVE_CANVAS: "off",
}),
});
}
expect(webServers[0]!.env).toMatchObject({
DASHBOARD_SSR_PREFETCH: "false",
});
expect(webServers[1]!.env).toMatchObject({
DASHBOARD_SSR_PREFETCH: "true",
});
});
@@ -200,9 +200,12 @@ describe("display-transform rounding guard — the bypass freeze", () => {
it("every allowlisted surface still converts its rendered values", () => {
// The chart formats its own series; anything these files render outside
// the chart must come back through the preference hook.
// the chart must come back through the preference hook. Either reading of
// it counts: a surface that paints inside a streamed page boundary takes
// `useUnitDisplayOnceMounted()`, which is the same sugar with the
// preference withheld from the hydration render.
const offenders = RAW_SCALE_SURFACES.filter(
(rel) => !read(rel).includes("useUnitDisplay()"),
(rel) => !/\buseUnitDisplay(OnceMounted)?\(\)/.test(read(rel)),
);
expect(offenders).toEqual([]);
});
+22 -6
View File
@@ -4,8 +4,8 @@ import { LookingAfterCard } from "@/components/dashboard/looking-after-card";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import React, { Suspense, useCallback, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { useAccountOnceMounted, useAuth } from "@/hooks/use-auth";
import { useUnitDisplayOnceMounted } from "@/hooks/use-unit-display";
import { useMounted } from "@/hooks/use-mounted";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
@@ -226,8 +226,18 @@ export default function DashboardPageClient({
*/
batchWindow?: BatchWindow;
} = {}) {
const { isAuthenticated, user } = useAuth();
const { isAuthenticated } = useAuth();
const mounted = useMounted();
// The account payload is withheld until this boundary has mounted, so the
// hydration render paints exactly what the server streamed. The dashboard
// hydrates after the app shell, whose mount already fetched `/api/auth/me`,
// and every account-derived branch below — the BMI chart's `heightCm` gate,
// the personalised weight bands, the module gates, the unit labels — would
// otherwise render one thing on the server and another here. With the RSC
// prefetch on, that costs the whole streamed dashboard: React #418, tree
// discarded, the prefetch paid for and thrown away. See
// `useAccountOnceMounted`.
const user = useAccountOnceMounted();
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
@@ -235,7 +245,7 @@ export default function DashboardPageClient({
// weight chart. `td` converts an ABSOLUTE reading (affine, so a temperature
// picks up the °F offset); `tdd` converts a DELTA / slope (factor only, so a
// Δ never inherits the offset). Metric users take the identity path.
const unitDisplay = useUnitDisplay();
const unitDisplay = useUnitDisplayOnceMounted();
const td = (type: string, value: number | null | undefined): number | null =>
value == null ? null : unitDisplay.toDisplay(type, value);
const tdd = (
@@ -302,8 +312,14 @@ export default function DashboardPageClient({
// its own cached route. Gated on the `insights` module (the digest is
// the AI-narrative daily layer; the route 403s when insights is off)
// and the auth state so a logged-out / gated account never fires it.
const insightsEnabled = useModuleEnabled("insights");
const digestQuery = useDailyDigest(isAuthenticated && insightsEnabled);
// Two readings of one flag, on purpose. The FETCH gate takes the live
// module map, so an account with insights off never fires the request. The
// RENDER gate below takes the mount-pinned account, so the hydration render
// agrees with the server (which had no account, and therefore the default-on
// answer). See `useAccountOnceMounted`.
const insightsModuleEnabled = useModuleEnabled("insights");
const insightsEnabled = user?.modules?.insights !== false;
const digestQuery = useDailyDigest(isAuthenticated && insightsModuleEnabled);
// v1.29.1 — the v1.29.0 selected-score-ring cluster is removed from the web
// hero (Marc, live-use: uneven, wasted tile space). The snapshot still
@@ -39,21 +39,28 @@ const mockAccessRef: { value: AccountAccess } = {
value: { accounts: [], active: null, canSwitch: false },
};
const mockAccount = () => ({
id: "delegate",
username: "delegate",
email: null,
role: "USER",
avatarUrl: null,
modules: {},
accountAccess: mockAccessRef.value,
});
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "delegate",
username: "delegate",
email: null,
role: "USER",
avatarUrl: null,
modules: {},
accountAccess: mockAccessRef.value,
},
user: mockAccount(),
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
// The card reads the mount-pinned accessor: it paints inside the dashboard's
// streamed boundary, where reading the grants on the hydration render would
// disagree with the server and cost the whole streamed tree. These cases are
// about the MOUNTED render, so the accessor answers the account.
useAccountOnceMounted: () => mockAccount(),
}));
vi.mock("@/hooks/use-account-switch", () => ({
@@ -5,7 +5,7 @@ import { HeartHandshake, Loader2 } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useAccountSwitch } from "@/hooks/use-account-switch";
import { useAuth } from "@/hooks/use-auth";
import { useAccountOnceMounted } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import {
accountLabel,
@@ -49,7 +49,12 @@ import {
*/
export function LookingAfterCard() {
const { t } = useTranslations();
const { user } = useAuth();
// Withheld until mounted: this card sits inside the dashboard's streamed
// boundary and renders nothing without grants. The shell has usually answered
// `/api/auth/me` before the boundary hydrates, so reading the grants on the
// hydration render paints a card where the server painted none — a mismatch
// that discards the streamed dashboard. See `useAccountOnceMounted`.
const user = useAccountOnceMounted();
const switchAccount = useAccountSwitch();
const access = user?.accountAccess;
@@ -66,7 +66,15 @@ function renderRow(workout: WorkoutListEntry, sportName: string) {
export function RecentWorkoutsTile() {
const { t, locale } = useTranslations();
const { data, isLoading, isError, refetch } = useWorkouts({ limit: 3 });
const { data, isError, refetch } = useWorkouts({ limit: 3 });
// A query's `isLoading` is not a hydration-safe branch: TanStack reports the
// optimistic mount fetch on the client's very first render and cannot on the
// server, so an `isLoading` gate renders one thing in the streamed HTML and
// another in the hydration render — React #418, and the whole streamed
// dashboard is discarded. Gate on what both sides can see instead: whether
// the cell holds an answer yet.
const showLoading = !data && !isError;
const workouts = data?.workouts ?? [];
return (
@@ -95,7 +103,7 @@ export function RecentWorkoutsTile() {
}
/>
{isLoading ? (
{showLoading ? (
// v1.4.43 W11-L6 — reserve roughly the loaded tile height
// (3 workouts × ~3 rem rows + spacing) so the surrounding
// dashboard layout doesn't reflow once the data lands.
@@ -18,8 +18,18 @@ let mockUser: { modules?: Record<string, boolean> } | null = {};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, user: mockUser }),
useAccountOnceMounted: () => mockUser,
}));
// The card paints its skeleton until it is mounted, because the achievements
// cell is shared with `<AchievementUnlockNotifier>` in the app shell: on a
// cold load of the dashboard that cell is often already full by the time this
// card hydrates, where the server had nothing, and a content branch there is a
// hydration mismatch that costs the whole streamed page. These cases are about
// the MOUNTED render, so the flag is stubbed to what it answers from the first
// client re-render on.
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
let mockData:
| {
summary: unknown;
@@ -19,7 +19,8 @@ import {
type LucideIcon,
} from "lucide-react";
import Link from "next/link";
import { useAuth } from "@/hooks/use-auth";
import { useAccountOnceMounted, useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/format";
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
@@ -83,7 +84,12 @@ const RECENT_LIMIT = 3;
*/
export function RecentAchievementsCard() {
const { t } = useTranslations();
const { user, isAuthenticated } = useAuth();
const { isAuthenticated } = useAuth();
// Withheld until mounted for the same reason the skeleton gate below is:
// this card paints inside the dashboard's streamed boundary, and a module
// gate that reads the account on the hydration render disagrees with the
// server, which had none. See `useAccountOnceMounted`.
const user = useAccountOnceMounted();
// v1.18.0 — the achievements module gate. When the account has the
// module turned off the dashboard tile disappears entirely (the data
@@ -102,6 +108,16 @@ export function RecentAchievementsCard() {
enabled: isAuthenticated && achievementsEnabled,
});
// The dashboard is a streamed Suspense boundary and hydrates after the app
// shell. `<AchievementUnlockNotifier>` mounts with the shell and shares this
// exact cache cell, so by the time this card replays its first render on the
// client the payload is often already there — where the server had nothing
// and painted the skeleton. Pinning the loading branch to the mount snapshot
// keeps the hydration render identical to the server's; the unlocks land on
// the re-render right after. See `useMounted`.
const mounted = useMounted();
const showSkeleton = !mounted || isPending;
const recent = pickRecentUnlocks(data?.achievements ?? [], RECENT_LIMIT);
// Module off ⇒ render nothing so the tile vanishes from the dashboard.
@@ -132,7 +148,7 @@ export function RecentAchievementsCard() {
}
/>
{isPending ? (
{showSkeleton ? (
<ul
data-slot="recent-achievements-skeleton"
aria-hidden="true"
@@ -61,7 +61,15 @@ export function VorsorgeDashboardCard() {
// the dashboard summary offering what the dedicated page withholds was the
// tell that this row had never been asked.
const { canManage } = useRecordCapabilities();
const { data: reminders, isLoading } = useMeasurementReminders();
const { data: reminders, isError } = useMeasurementReminders();
// A query's `isLoading` is not a hydration-safe branch: TanStack reports the
// optimistic mount fetch on the client's very first render and cannot on the
// server, so an `isLoading` gate renders one thing in the streamed HTML and
// another in the hydration render — React #418, and the whole streamed
// dashboard is discarded. Gate on what both sides can see instead: whether
// the cell holds an answer yet.
const showLoading = !reminders && !isError;
const { satisfy } = useMeasurementReminderMutations();
const [now] = useState(() => Date.now());
const [capturing, setCapturing] = useState<MeasurementReminder | null>(null);
@@ -102,7 +110,7 @@ export function VorsorgeDashboardCard() {
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{isLoading ? (
{showLoading ? (
<div className="space-y-2">
{Array.from({ length: 2 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full" />
@@ -17,7 +17,7 @@ import {
import type { LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { useAccountOnceMounted } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
import { queryKeys } from "@/lib/query-keys";
@@ -149,7 +149,13 @@ function readExpanded(): boolean {
* with status icon + label + CTA + dismiss-x.
*/
export function GettingStartedChecklist() {
const { user } = useAuth();
// Withheld until mounted: this card lives inside the dashboard's streamed
// boundary and its whole render turns on `if (!user) return null` below. The
// shell has usually answered `/api/auth/me` before the boundary hydrates, so
// reading the payload on the hydration render would paint a card where the
// server painted nothing — a mismatch that discards the streamed dashboard.
// See `useAccountOnceMounted`.
const user = useAccountOnceMounted();
const { t } = useTranslations();
const [dismissedIds, setDismissedIds] = useState<Set<ChecklistItemId>>(() =>
@@ -0,0 +1,81 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { useAccountOnceMounted, useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
/**
* `useAccountOnceMounted()` withholds the account payload from the server
* render, and by the same mechanism from the hydration render.
*
* ## What broke
*
* The app shell hydrates from the streamed HTML shell and its mount fires
* `/api/auth/me`. A page below a `loading.tsx` is a streamed Suspense boundary
* that hydrates LATER — routinely after that response has landed. Its first
* CLIENT render then reads an account payload the server never had (an RSC
* renders with no query cache), so every account-derived branch renders
* something else, React reports #418 and discards the whole streamed tree. On
* `/` that was the dashboard's entire SSR prefetch, thrown away on every cold
* load.
*
* ## What this pins, and what it cannot
*
* `useMounted()` answers with its SERVER snapshot during SSR *and* during
* hydration, so one gate covers both. The unit suite has no DOM, so it cannot
* run a hydration pass — but it can assert the half that is observable here,
* and the half that says a server render may not consult the cache: given a
* client whose `authMe` cell is ALREADY populated, the hook still has to answer
* null. Drop the mount gate and this goes red, because `query.data` is right
* there to be read. The contrast case pins that plain `useAuth()` does read it,
* which is what the redirect gates depend on.
*
* That the hydration render then agrees with the server is covered end to end
* by `e2e/dashboard-ssr-prefetch-hydration.spec.ts`, which runs against a
* server with the prefetch flag in its shipped state.
*/
function PinnedProbe() {
const user = useAccountOnceMounted();
return <span>{user ? user.username : "no-account"}</span>;
}
function LiveProbe() {
const { user } = useAuth();
return <span>{user ? user.username : "no-account"}</span>;
}
function clientWithAccount(): QueryClient {
const client = new QueryClient();
// Exactly the state the browser is in when a streamed page boundary
// hydrates: the shell already asked, and the answer is in the cache.
client.setQueryData(queryKeys.authMe(), {
id: "account-1",
username: "someone",
role: "USER",
timezone: "Europe/Berlin",
});
return client;
}
function render(client: QueryClient, node: React.ReactElement): string {
return renderToStaticMarkup(
<QueryClientProvider client={client}>{node}</QueryClientProvider>,
);
}
describe("useAccountOnceMounted", () => {
it("answers null even when the authMe cell already carries a payload", () => {
const html = render(clientWithAccount(), <PinnedProbe />);
expect(html).toContain("no-account");
expect(html).not.toContain("someone");
});
it("answers null with an empty cache too", () => {
expect(render(new QueryClient(), <PinnedProbe />)).toContain("no-account");
});
it("leaves plain useAuth reading the cache, which the redirect gates need", () => {
expect(render(clientWithAccount(), <LiveProbe />)).toContain("someone");
});
});
+49
View File
@@ -11,6 +11,7 @@ import { toast } from "sonner";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiFetchRaw, ApiError } from "@/lib/api/api-fetch";
import { retryOnceOnTransientError } from "@/lib/queries/retry-transient";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import type {
TimeFormatPreference,
@@ -394,6 +395,54 @@ export function useAuth() {
};
}
/**
* The account payload, withheld from the SSR pass and the hydration render.
*
* ## The problem it solves
*
* The app shell sits in the streamed HTML shell and hydrates first; mounting it
* fires `/api/auth/me`. A page below a `loading.tsx` is a streamed Suspense
* boundary and hydrates LATER — routinely after that response has landed. React
* replays the boundary's first render as a hydration render, and at that moment
* `useAuth().user` holds a payload the server never had, because an RSC renders
* with no query cache at all. Any branch that consults the account then renders
* one thing in the streamed HTML and another in the browser, React reports #418
* and discards the whole streamed tree.
*
* That stayed invisible while every page kept its own data behind a
* `useMounted()` skeleton gate: both sides painted the same silhouette. The
* dashboard's RSC prefetch (`src/app/page.tsx`) removed the silhouette — real
* tiles render on both sides now — and the divergence surfaced as a bailout on
* every cold load of `/`, throwing away exactly the paint the prefetch buys.
*
* `useMounted()` answers with its SERVER snapshot during SSR *and* during
* hydration, then with the client one from the first re-render, so this pins the
* hydration render to what the server rendered and hands the payload over one
* render later. A component mounted by a client-side navigation never sees the
* pin (`useSyncExternalStore` consults the server snapshot only while
* hydrating), and `user === null` beside a settled query is not a new state: it
* is what every consumer already renders on a cold load, before `/me` answers.
*
* ## Why `useAuth` itself must NOT do this
*
* Tried, and it breaks the app. Several surfaces drive a REDIRECT off the
* payload — the document vault sends you to `/` when
* `user.modules.inboundDocuments` is not `true`, and its effect only waits on
* `isLoading`, which is already false by then. A hydration render that answers
* "no account" makes that effect fire for real, and the vault bounces to the
* dashboard on every load. The same shape would latch into any `useState`
* initializer seeded from the account.
*
* So the withholding is opt-in, and belongs to components that RENDER the
* account rather than navigate on it. Reach for it in anything that paints
* inside a streamed page boundary; leave the redirect gates on `useAuth`.
*/
export function useAccountOnceMounted(): AuthUser | null {
const { user } = useAuth();
const mounted = useMounted();
return mounted ? user : null;
}
/**
* Wipe every client-side cache at a session END so no session ever inherits
* another account's data on a shared browser.
+29 -2
View File
@@ -16,7 +16,7 @@
*/
import { useMemo } from "react";
import { useAuth } from "@/hooks/use-auth";
import { useAccountOnceMounted, useAuth } from "@/hooks/use-auth";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
@@ -48,8 +48,35 @@ export interface UnitDisplay {
export function useUnitDisplay(): UnitDisplay {
const { user } = useAuth();
return useDisplayForPreference(user?.unitPreference);
}
/**
* The same sugar, with the preference withheld from the SSR pass and the
* hydration render.
*
* A surface that paints inside a streamed page boundary hydrates after the app
* shell has already answered `/api/auth/me`, so an imperial account renders
* "lb" on the hydration render where the server — which has no account payload
* at all — rendered "kg". React calls that a mismatch and discards the streamed
* tree; on `/` that is the whole RSC prefetch. Reading the preference one
* render later costs nothing the server had anyway. See
* `useAccountOnceMounted`.
*
* Kept separate from `useUnitDisplay` rather than folded into it: most callers
* render only after their own data lands, well past mount, and would pay a
* needless indirection for a hazard they do not have.
*/
export function useUnitDisplayOnceMounted(): UnitDisplay {
const user = useAccountOnceMounted();
return useDisplayForPreference(user?.unitPreference);
}
function useDisplayForPreference(
rawPreference: string | null | undefined,
): UnitDisplay {
const preference: UnitPreference =
user?.unitPreference === "imperial" ? "imperial" : "metric";
rawPreference === "imperial" ? "imperial" : "metric";
return useMemo<UnitDisplay>(() => {
const transformFor = (type: string) =>