mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
placeholderData keeps the previous range painted while a health-chart range switch fetches, paired with its truth condition: the container dims and goes inert while isPlaceholderData is true, so the old range cannot masquerade as the new one. The mood chart needs no pairing — its range is a client-side slice over one static-key query and never drops to a skeleton. /checkups and /mood gain the dashboard-template RSC prefetch: the primary list read moves into a shared lib read (route + wrapper consume one query, so filter/order/wire shape cannot drift), the wrapper honours the DASHBOARD_SSR_PREFETCH kill-switch, resolves identity through getUnswitchedSession, JSON-round-trips the dehydrated state, and fails soft to the bare client on every error path. Page-prefetch unit tests pin the seeded keys and the fail-soft arms; the SSR hydration spec gains an assertion per page. The achievement-unlock poll drops to five minutes (in-app writes already invalidate the cell; the poll only covers out-of-band ingest), and the plan-proposal poll gains the advisor-style bounded attempt ceiling so an open thread stops polling after its budget.
142 lines
5.6 KiB
TypeScript
142 lines
5.6 KiB
TypeScript
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);
|
|
});
|
|
|
|
// The two list-page prefetch wrappers (`/checkups`, `/mood`) hand TanStack a
|
|
// server-dehydrated state through the same HydrationBoundary mechanism the
|
|
// dashboard uses. Their page-clients render behind a `useMounted` gate, so
|
|
// the streamed HTML is the auth-gate shell by design — the node-identity
|
|
// witness has nothing to latch. What CAN break, and what these assert, is
|
|
// the hydration itself: a dehydrated state that disagrees with the client
|
|
// render surfaces as a React #418 bailout on exactly this server (the
|
|
// mocked suite runs with the prefetch OFF and can never see it).
|
|
for (const target of [
|
|
{
|
|
path: "/checkups",
|
|
name: "checkups",
|
|
readySelector: '[data-testid="checkups-tab-vorsorge"]',
|
|
},
|
|
{
|
|
path: "/mood",
|
|
name: "mood",
|
|
readySelector: '[data-tour-id="mood-hero"]',
|
|
},
|
|
]) {
|
|
test(`hydrates ${target.name} with its prefetched list state`, 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());
|
|
});
|
|
|
|
await page.goto(target.path);
|
|
await expect(page.locator(target.readySelector)).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([]);
|
|
});
|
|
}
|
|
});
|