# Test interactions and navigation

> Find lost clicks before hydration, input that React resets, lost focus and scroll, and pages that differ after client-side navigation, on every route.

Source: https://hydration.jscrate.dev/docs/interactions
Last updated: 2026-09-18

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

```bash
npx hydration-proof test --interactions
```

Or set `checks.interactions` in the config:

```ts title="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](https://hydration.jscrate.dev/docs/issues/hp5002)), the focus and the selection
   ([HP5003](https://hydration.jscrate.dev/docs/issues/hp5003)) and the scroll position
   ([HP5007](https://hydration.jscrate.dev/docs/issues/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](https://hydration.jscrate.dev/docs/issues/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:

```tsx title="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

| Code | Title | Severity |
| --- | --- | --- |
| [HP5001](https://hydration.jscrate.dev/docs/issues/hp5001) | An interaction before hydration was lost | Warning |
| [HP5002](https://hydration.jscrate.dev/docs/issues/hp5002) | User input was reset during hydration | Error |
| [HP5003](https://hydration.jscrate.dev/docs/issues/hp5003) | Focus was lost during hydration | Warning |
| [HP5004](https://hydration.jscrate.dev/docs/issues/hp5004) | The page differs after client-side navigation | Warning |
| [HP5005](https://hydration.jscrate.dev/docs/issues/hp5005) | Client-side navigation failed | Error |
| [HP5006](https://hydration.jscrate.dev/docs/issues/hp5006) | An element handles the same event twice | Warning |
| [HP5007](https://hydration.jscrate.dev/docs/issues/hp5007) | Scroll position was reset during hydration | Warning |
| [HP5008](https://hydration.jscrate.dev/docs/issues/hp5008) | A custom interaction failed | Error |

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

```ts title="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](https://hydration.jscrate.dev/docs/issues/hp5008). Run with
`--headed` to watch where it fails.

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `route` (required) | `string` | — | Route glob the interaction runs on, e.g. `/checkout` or `/products/**`. |
| `name` | `string` | — | Shown 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. |
| `scenarios` | `string[]` | — | Only in these scenarios. |
| `steps` (required) | `(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:

```bash
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](https://hydration.jscrate.dev/docs/issues/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](https://hydration.jscrate.dev/docs/issues/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](https://hydration.jscrate.dev/docs/frameworks/nextjs). 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`:

```ts title="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`](https://hydration.jscrate.dev/docs/configuration#routes), for example to test a modal that
an intercepting route shows.

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `from` | `string` | `/` (another tested route when the target is `/`) | Page to navigate from. |
| `prefetch` | `boolean` | true | Also navigate after the router prefetched the route. |
| `maxRoutes` | `number` | 20 | Most routes checked per scenario. |

## Events handled twice (HP5006)

[HP5006](https://hydration.jscrate.dev/docs/issues/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.

## Related

- [HP5001: an interaction before hydration was lost](https://hydration.jscrate.dev/docs/issues/hp5001)
- [The `checks` options](https://hydration.jscrate.dev/docs/configuration#checks)
- [Hydration errors in Next.js](https://hydration.jscrate.dev/docs/frameworks/nextjs)
- [useEffect and two-pass rendering](https://hydration.jscrate.dev/docs/guides/useeffect-two-pass-rendering)
- [Use hydration-proof with Playwright](https://hydration.jscrate.dev/docs/playwright)
