mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +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}`;
|
||||
Reference in new issue
Block a user