Hydration Proof

Search documentation

Find a page or section

Use hydration-proof with Playwright

Built on Playwright, and made to run next to the suite you already have.

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-playwright are used as they are. Nothing is downloaded twice.
  • npx hydration-proof install is 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:

e2e/home.spec.ts
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:

  1. 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.
  2. React 19 keeps wrong attributes silently. In production, a mismatched className, style or data-* attribute is never reported and never fixed.
  3. 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.
  4. 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:

hydration-proof.config.ts
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.

OptionTypeDefaultDescription
quietMsnumber400Quiet time (ms) without DOM changes or React commits before the page counts as settled.
timeoutnumber30000Maximum time (ms) per page.
hydrationTimeoutnumber15000Maximum time (ms) for hydration to finish once React is loaded.
selectorstringA selector that must exist before the final snapshot.
functionstringPage 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:

  1. The browsers. Nothing to do: see above.
  2. The login. If your Playwright global setup signs in and saves storageState (for example to playwright/.auth/user.json), point a scenario's storageState at the same file. The login then runs once, in whichever suite runs first.
  3. The server. If Playwright's webServer already starts the app, leave server out of the hydration-proof config and pass the URL.
hydration-proof.config.ts
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:3000

For 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:

run-from-node.ts
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.