fix: complete dashboard hydration

This commit is contained in:
Marc-André Bombeck committed 2026-08-05 23:03:17 +02:00
1 parent 03877a0971
commit d07b9249c8
16 files changed
+468 -60

No files matched your search

@@ -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) =>