Hydration Proof

Search documentation

Find a page or section

The browser environments and identities every route is tested in.

To test signed-in pages for hydration errors, add a scenario for each kind of user. A scenario sets the browser's locale, timezone, viewport and sign-in state. Its login runs once; the resulting cookies and storage are reused for the pages in that scenario.

What a scenario is

Without scenarios, the tool uses one scenario named default. It uses your machine's locale and timezone and a light color scheme. Add more scenarios when your users see the app under different conditions.

List scenarios to test the environments your users have:

hydration-proof.config.ts
import { defineConfig } from "hydration-proof";
 
export default defineConfig({
  scenarios: [
    { name: "default" },
    { name: "dark-mobile", colorScheme: "dark", viewport: "mobile" },
    { name: "karachi", locale: "ur-PK", timezoneId: "Asia/Karachi" },
  ],
});

The report names the scenario for each page. To test just one, pass its name:

npx hydration-proof test --scenario dark-mobile

Locale, timezone, theme and viewport

OptionValues
localeA BCP 47 locale such as "de-DE". It also sets the Accept-Language header
timezoneIdAn IANA timezone such as "Asia/Karachi"
colorScheme"light" (default), "dark" or "no-preference"
reducedMotion"reduce" or "no-preference"
viewport{ width, height }, or a preset: "mobile" (390×844, touch), "tablet" (820×1180) or "desktop" (1280×800)

To cover several combinations without listing each scenario by hand, use the environment matrix.

Test signed-in pages for hydration errors

Hydration problems can hide behind a login—for example, a dashboard date or an admin table that reads localStorage. Choose one way to sign in:

  1. cookies: pass a session cookie when your test environment can issue one. No browser login is needed.
  2. storageState: reuse a Playwright session saved with context.storageState({ path }).
  3. login: sign in through the app. This runs once per scenario, not once for every page.

An authorization entry in headers works too.

This example separates public, customer and admin pages. Replace the order ID and selectors with values from your app:

hydration-proof.config.ts
import { defineConfig } from "hydration-proof";
 
function requiredEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Set ${name} before running hydration-proof`);
  return value;
}
 
export default defineConfig({
  routes: {
    dynamic: { "/account/orders/[id]": ["your-stable-test-order-id"] },
  },
 
  scenarios: [
    // Everything that is not behind a login
    { name: "guest", exclude: ["/account/**", "/admin/**"] },
 
    // A real sign-in, once, then reused for every page of this scenario
    {
      name: "customer",
      include: ["/account/**"],
      login: async ({ page, baseUrl }) => {
        await page.goto(`${baseUrl}/login`);
        await page.fill("#email", requiredEnv("TEST_USER_EMAIL"));
        await page.fill("#password", requiredEnv("TEST_USER_PASSWORD"));
        await page.click("button[type=submit]");
        await page.waitForURL("**/account");
      },
    },
 
    // A session the test environment hands out: no browser work at all
    {
      name: "admin",
      include: ["/admin/**"],
      cookies: [{ name: "session", value: requiredEnv("ADMIN_SESSION") }],
    },
  ],
});

Set the credentials in the environment before running the test:

TEST_USER_EMAIL=… TEST_USER_PASSWORD=… ADMIN_SESSION=… npx hydration-proof test

How the login runs

  • login receives { page, baseUrl }: a Playwright page in a browser context with the scenario's settings, and the URL of the tested app.
  • It runs once per run, after the app is up and after hooks.setup.
  • A login that throws stops the run with exit code 2 and the error message.
  • Cookies without a domain apply to the tested app.
  • Keep credentials in environment variables, not in the config file, which gets committed.

When a signed-in page ends on the login page

If a signed-in route ends on the login page, it gets an HP9010 warning. Check that login waits for the redirect, the cookie belongs to this domain and the session has not expired. Run with --headed --workers 1 to watch the sign-in. If the route should redirect, set expectRedirect instead; see routes.

Split public, customer and admin pages

Use include and exclude to choose each scenario's routes. * matches one path segment and ** matches any number. A route no scenario includes is not tested; keep a guest scenario for public pages.

Mock browser requests

mocks return fixed responses to browser requests. Use them when live data would make a page change between test runs:

hydration-proof.config.ts
import { defineConfig } from "hydration-proof";
 
export default defineConfig({
  scenarios: [
    {
      name: "default",
      mocks: [{ url: "**/api/metrics", body: { signups: 12, revenue: 3400 } }],
    },
  ],
});

url accepts a glob or a RegExp. Object bodies are sent as JSON. Mocks affect browser requests only; they do not replace data fetched by the server while rendering. They also disable the browser's HTTP cache.

OptionTypeDefaultDescription
url*string | RegExp—URL glob (** and *) or RegExp of browser requests to answer.
methodstring—Only answer requests with this HTTP method.
statusnumber200Response status.
headersRecord<string, string>—Response headers.
bodyunknown—Response body; objects are sent as JSON.

Seed data before the pages load

To create users or seed a database, use hooks.setup in the config. It runs once per build mode, after the app is up and before the scenario logins, and may return a teardown function. Sign-in itself belongs in a scenario's login: there is no global setup hook.

Fixed clock and random values

clock fixes the browser time for Date.now() and new Date(), and randomSeed seeds Math.random() and crypto.getRandomValues() in the browser. Both are diagnostic: they make the client values repeatable between runs, but the server keeps its real clock, so time-dependent and random output is still found. To prove that the clock or randomness causes a finding, use probes.

More scenario options

  • headers: extra request headers
  • localStorage and sessionStorage: entries written before any page script runs
  • initScripts: code run before page scripts
  • query: query parameters added to every URL, such as { currency: "EUR" }
  • browser: "chromium", "firefox" or "webkit" for this scenario
  • network: "fast-3g", "slow-3g" or a custom profile
  • cpu: slow JavaScript down by a factor (Chromium only)
  • cache: "warm" loads each page once before testing it, like a returning visitor (the default is "cold")

All scenario options

OptionTypeDefaultDescription
name*string—Unique name, shown in reports and used by --scenario.
localestringthis machine's localeBCP 47 locale for the browser (also sets Accept-Language).
timezoneIdstringthis machine's timezoneIANA timezone for the browser, e.g. Asia/Karachi.
colorSchemeColorScheme"light"Emulated prefers-color-scheme: "light", "dark" or "no-preference".
reducedMotion"reduce" | "no-preference"—Emulated prefers-reduced-motion.
viewportViewportOption—Viewport size as { width, height }, or a preset: "mobile" (390×844, touch), "tablet" (820×1180) or "desktop" (1280×800).
userAgentstring—User agent string for the browser context.
storageStatestring—Playwright storage state file (cookies and localStorage), e.g. a logged-in user.
cookiesCookieConfig[]—Cookies set before every page load, e.g. a session cookie for a signed-in user.
headersRecord<string, string>—Extra request headers.
localStorageRecord<string, string>—localStorage entries set before page scripts run.
sessionStorageRecord<string, string>—sessionStorage entries set before page scripts run.
initScriptsstring[]—Scripts run before page scripts (code strings).
login(context: LoginContext) => Promise<void>—Sign in once before this scenario's pages are tested. The cookies and storage it leaves behind are used for every page.
mocksMockConfig[]—Answers for browser requests (API fixtures). Server-side requests are not affected.
includestring[]—Only test routes matching these globs in this scenario.
excludestring[]—Skip routes matching these globs in this scenario.
queryRecord<string, string>—Query parameters added to every URL (e.g. { currency: 'EUR' }).
browserBrowserNamebrowser.nameBrowser for this scenario.
networkNetworkProfile—Throttle the network: "fast-3g", "slow-3g" or { downloadKbps, uploadKbps, latencyMs }. Chromium throttles natively; Firefox and WebKit delay subresources instead.
cpunumber—Slow down JavaScript by this factor (Chromium only).
cacheCacheStatecoldwarm loads the page once before testing it, like a returning visitor.
clockstring | number—Fixed browser time (ISO string or epoch ms) for Date.now() / new Date(). A diagnostic option: the server keeps its real clock, so time-dependent output is still found.
randomSeednumber—Seed for Math.random() and crypto.getRandomValues() in the browser (diagnostic, like clock).
OptionTypeDefaultDescription
name*string—Cookie name.
value*string—Cookie value.
domainstringthe tested originCookie domain.
pathstring"/"Cookie path.
httpOnlyboolean—Hide the cookie from page scripts.
secureboolean—Send the cookie over HTTPS only.
sameSite"Strict" | "Lax" | "None"—The SameSite attribute.