mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix: complete dashboard hydration
This commit is contained in:
1 parent
03877a0971
commit
d07b9249c8
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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user