mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +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,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