A Playwright hydration error test needs more than page.goto() and a console
listener. hydration-proof is built on Playwright: it waits for React to finish
hydrating, compares the server HTML with the hydrated DOM, and reports the
differences React never logs. Run it next to your e2e suite, and reuse that
suite's browsers, login and server.
Built on Playwright
hydration-proof drives Chromium, Firefox and WebKit through playwright-core
1.63 or newer, its only runtime dependency. playwright-core downloads nothing
when you install it, and at run time hydration-proof prefers your project's own
copy when it is new enough:
- The browsers you already have in
~/.cache/ms-playwrightare used as they are. Nothing is downloaded twice. npx hydration-proof installis only needed when the project has no Playwright browsers at all.
Why does a Playwright hydration error go unnoticed?
A common way to catch hydration errors in Playwright tests is a console listener:
import { expect, test } from "@playwright/test";
test("home page has no hydration errors", async ({ page }) => {
const errors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
await page.goto("/");
expect(errors).toEqual([]);
});It misses most problems and explains none of them. Playwright hydration issues slip through for four reasons:
- Production builds say almost nothing. At best the console shows a
minified error code such as
Minified React error #418, with no element and no values, and nothing at all for attribute mismatches. - React 19 keeps wrong attributes silently. In production, a mismatched
className,styleordata-*attribute is never reported and never fixed. - React recovers. When the text or structure differs, React 19 throws the server HTML away and renders the page again on the client. The page works, so assertions about its content still pass, while users get a slower page and lose state.
- The server HTML looks ready too early. Buttons and fields are visible before React attaches its handlers, so a test that clicks as soon as an element appears can act before hydration, or wait for the wrong thing.
hydration-proof checks for all of this directly. It compares each stage of the page, from the server HTML to the hydrated DOM, and audits every attribute against the props React renders on the client. See how it works.
How to make Playwright wait for hydration
Waiting for hydration is the hard part. The page's load event does not mean React has hydrated, and a visible element proves nothing, because the server HTML rendered it.
hydration-proof connects to React through __REACT_DEVTOOLS_GLOBAL_HOOK__,
the hook React looks for when it loads, in development and production builds.
It provides the hook, or wraps an existing one so React DevTools and Fast
Refresh keep working, and React then reports every renderer and every commit
to it. So hydration-proof knows when the root and each Suspense boundary have
hydrated.
After hydration, it waits until the page is quiet. It never waits for
"network idle". The ready option sets the rules:
import { defineConfig } from "hydration-proof";
export default defineConfig({
ready: {
// Wait for the same thing the e2e suite waits for.
selector: "[data-app-ready]",
// No DOM changes or React commits for this long.
quietMs: 400,
// Hydration must finish within this time once React has loaded.
hydrationTimeout: 15_000,
// The most time one page may take.
timeout: 30_000,
},
});ready.function is a page function, written as a string, that must return a
truthy value before the final snapshot. A route can have its own ready
settings in routes.paths.
A page whose hydration does not finish in time is reported as
HP9001. A page that never becomes quiet (animations,
polling, live data) is reported as HP9009: set
ready.selector or ready.function, or lower ready.quietMs.
| Option | Type | Default | Description |
|---|---|---|---|
| quietMs | number | 400 | Quiet time (ms) without DOM changes or React commits before the page counts as settled. |
| timeout | number | 30000 | Maximum time (ms) per page. |
| hydrationTimeout | number | 15000 | Maximum time (ms) for hydration to finish once React is loaded. |
| selector | string | — | A selector that must exist before the final snapshot. |
| function | string | — | Page function source that must return truthy before the final snapshot. |
Use it with an existing Playwright setup
Three things from your Playwright setup are worth reusing:
- The browsers. Nothing to do: see above.
- The login. If your Playwright global setup signs in and saves
storageState(for example toplaywright/.auth/user.json), point a scenario'sstorageStateat the same file. The login then runs once, in whichever suite runs first. - The server. If Playwright's
webServeralready starts the app, leaveserverout of the hydration-proof config and pass the URL.
import { defineConfig } from "hydration-proof";
export default defineConfig({
// The app is already running: CI starts it, or Playwright's webServer does.
server: {
url: process.env.BASE_URL ?? "http://localhost:3000",
reuseExisting: true,
},
routes: { discover: true },
scenarios: [
{
name: "signed-in",
// The file Playwright's global setup already wrote.
storageState: "playwright/.auth/user.json",
},
],
ready: {
selector: "[data-app-ready]",
quietMs: 200,
timeout: 15_000,
},
});Or skip the config and pass the URL on the command line:
npx hydration-proof test --url http://localhost:3000For signed-in pages with a login function instead of a saved state, see
scenarios and sign-in.
Run it next to your tests, not inside them
Do not run hydration-proof inside a Playwright test
hydration-proof launches and drives its own browser contexts, takes six DOM snapshots per page and needs each page loaded without interference. Nested in a Playwright worker, it fights your tests for the same browser and slows both down.
Run it as its own command, next to the e2e suite. When a script needs to do
something around the run, such as start a server, seed a database or publish
the report, call run() from Node:
import { run } from "hydration-proof";
const { exitCode, report } = await run({
overrides: { url: "http://localhost:3000", reporters: ["json"] },
write: (text) => process.stdout.write(text),
});
// The report is the same object the JSON reporter writes.
for (const issue of report.issues) {
if (issue.severity !== "error") continue;
console.log(
`${issue.route.pattern} ${issue.code} ${issue.source?.file ?? "(no source)"}`
);
}
process.exit(exitCode);The Node API covers run() and its options.
Related
- How hydration-proof works: the stages it compares
- The Node API for scripts around a run
- Scenarios and sign-in
- Lost clicks and input before hydration
- Minified React error #418