Hydration Proof

Search documentation

Find a page or section

Test interactions and navigation

What happens when a user acts before the page is interactive, or arrives through the router.

hydration-proof finds lost clicks before hydration by loading each page with its scripts held back, like a user on a slow connection, and acting on it: it types, clicks, focuses and scrolls, then checks that nothing was lost once React took over. Navigation checks compare a route reached through the app's router with the same route loaded directly.

Why interactions before hydration matter

The server HTML shows buttons and fields long before React attaches its event handlers. In that window the page looks ready, but a click does nothing, and text typed into a field can be wiped when React hydrates it. On a fast development machine the window is too short to notice; on a slow phone it is not.

These checks are off by default because each one costs extra page loads.

Check interactions while the page loads

npx hydration-proof test --interactions

Or set checks.interactions in the config:

hydration-proof.config.ts
import { defineConfig } from "hydration-proof";
 
export default defineConfig({
  checks: { interactions: true },
});

For each page, with the page's scripts held back, hydration-proof:

  1. types into the first text field, checks the first checkbox, selects text and scrolls, then lets the page hydrate and checks that the text and the checkbox (HP5002), the focus and the selection (HP5003) and the scroll position (HP5007) survived;
  2. clicks the first button outside forms and links, and compares the result with the same click after hydration. A click that only works after hydration is reported as lost (HP5001), a warning: the page looks ready before it is.

Pages without fields, buttons or scrollable content are skipped. Each checked page costs about six extra page loads.

How to fix lost clicks before hydration

Pick the fix that matches the finding:

  • Lost click (HP5001). Keep controls disabled, or show them as loading, until the page is interactive. Or make them work without JavaScript: links, and forms with Server Actions. Shipping less JavaScript for the first view helps too, and Suspense boundaries hydrate in order of interaction.
  • Input reset (HP5002). Use uncontrolled inputs (defaultValue, defaultChecked), or read the current DOM value when the component mounts, so text typed before hydration is kept. Also fix any hydration mismatch around the form: a re-rendered branch creates new, empty inputs.
  • Focus lost (HP5003). Do not re-create the focused element during hydration (fix mismatches around it), and do not move focus in effects that run on load.
  • Scroll reset (HP5007). Do not change the scroll position during hydration (scrollTo in effects, focus() on load); let the browser restore it.

A button that stays disabled until its component has hydrated renders the same HTML on the server and in the first client render, so it causes no mismatch:

buy-button.tsx
"use client";
 
import { useEffect, useState } from "react";
 
export function BuyButton({ onBuy }: { onBuy: () => void }) {
  const [hydrated, setHydrated] = useState(false);
 
  // Effects only run in the browser, after hydration.
  useEffect(() => setHydrated(true), []);
 
  return (
    <button type="button" disabled={!hydrated} onClick={onBuy}>
      Buy
    </button>
  );
}

Interaction issue codes

CodeTitleSeverity
HP5001An interaction before hydration was lostWarning
HP5002User input was reset during hydrationError
HP5003Focus was lost during hydrationWarning
HP5004The page differs after client-side navigationWarning
HP5005Client-side navigation failedError
HP5006An element handles the same event twiceWarning
HP5007Scroll position was reset during hydrationWarning
HP5008A custom interaction failedError

Write custom interactions

The built-in check only touches the first field and the first button. For the flows that matter, write the steps yourself with Playwright:

hydration-proof.config.ts
import { defineConfig } from "hydration-proof";
 
export default defineConfig({
  interactions: [
    {
      route: "/checkout",
      name: "continue to payment",
      steps: async ({ page }) => {
        await page.getByLabel("Email").fill("test@example.com");
        await page.getByRole("button", { name: "Continue" }).click();
        await page.getByText("Payment").waitFor();
      },
    },
    {
      route: "/search",
      name: "type before the page is ready",
      when: "before-hydration",
      steps: async ({ page }) => {
        await page.fill("#query", "shoes");
      },
    },
  ],
});
  • route is a glob, matched against the path and the route pattern.
  • when: 'after-hydration' (the default) runs once the page is interactive; 'before-hydration' runs while the page's scripts are still held back.
  • steps receives { page, baseUrl, url }, with a Playwright page.
  • scenarios limits the interaction to some scenarios.

Custom interactions run on every test run, without --interactions, on the pages that hydrated. An interaction that throws, or that causes an uncaught error on the page, is reported as HP5008. Run with --headed to watch where it fails.

OptionTypeDefaultDescription
route*stringRoute glob the interaction runs on, e.g. /checkout or /products/**.
namestringShown in reports.
when"before-hydration" | "after-hydration"after-hydration (default): run once the page is interactive. before-hydration: run while the page's scripts are still held back, like a user on a slow connection.
scenariosstring[]Only in these scenarios.
steps*(context: InteractionContext) => Promise<void>The interaction, written with Playwright: receives { page, baseUrl, url }. A thrown error is reported as HP5008.

Check client-side navigation

A route can render one thing when you load its URL and another when you reach it through a link. The navigation check finds that:

npx hydration-proof test --navigation

It opens a page, navigates to each route with the app's router (router.push in the Next.js App Router and Pages Router) and compares the result with loading the route directly. Both loads use the same fixed browser clock and random seed, and numbers are ignored in the comparison.

  • Content that differs after navigation is reported as HP5004, a warning. Routes that render parallel routes (@slot folders), or that an intercepting route can replace, are reported as info, since the difference is usually intended.
  • An error thrown during navigation, a failed RSC request, or a URL that never changes is reported as HP5005.
  • Navigations the framework turns into a full page load (for example to another root layout) are noted in the page's timeline, not reported.

The check needs an adapter with a client router, which today means Next.js. With another framework it is skipped with a note. Each checked route costs two page loads, or three with prefetch.

Configure it with checks.navigation:

hydration-proof.config.ts
import { defineConfig } from "hydration-proof";
 
export default defineConfig({
  checks: {
    navigation: { from: "/", prefetch: true, maxRoutes: 20 },
  },
});

A route can name its own starting page with navigateFrom in routes.paths, for example to test a modal that an intercepting route shows.

OptionTypeDefaultDescription
fromstring/ (another tested route when the target is /)Page to navigate from.
prefetchbooleantrueAlso navigate after the router prefetched the route.
maxRoutesnumber20Most routes checked per scenario.

Events handled twice (HP5006)

HP5006 is found on every run, without extra flags: a script or an inline on* attribute handles an event on an element that React also handles, so one click can run the action twice. Handle the event in one place: remove the inline attribute or the script that adds the listener, or remove the React handler.