Hydration Proof

Search documentation

Find a page or section

Teach the tool what it cannot know about your app.

hydration-proof plugins add knowledge the tool cannot have on its own: markup that is meant to differ, the reason a finding happened, routes only a running system knows, and where results should go. A plugin is a plain object made with definePlugin, so it can live next to your config or be published as a package your apps share.

What can hydration-proof plugins do?

Two kinds of knowledge are out of the tool's reach:

  1. Markup that is meant to differ. A support-chat widget, an analytics attribute, a CDN that rewrites HTML. Left alone, these are reported on every page, which trains people to ignore the report.
  2. Why a finding happened. Probes can prove time and random values, but the tool cannot know that data-region comes from a geo header on your server.

A plugin supplies both, plus route providers, reporters and adapters:

function definePlugin(plugin: HydrationProofPlugin): HydrationProofPlugin

Identity helper with type checking for plugins.

FieldCalled withReturns
normalizers[].matcha serialized DOM node (k 1 element, 3 text, 8 comment; elements have tag, attrs and children) and its parent"drop", "opaque" or undefined
ignoreAttributes(a list, not a function)Attribute names or patterns that are never compared
detectors[].detectthe finding (read only) and { source, scenario }{ id, title, confidence, reason?, fixes?, docsUrl? } or undefined
routes[].routes{ rootDir, baseUrl } (the app is running)paths or route objects
reporters[]onBegin(context), onPage(page, issues, context), onEnd(report, context)onEnd may return the files it wrote
adapters[]see adapters

Plugins run in the order they are listed, and plugin names must be unique.

A complete plugin

This plugin, for a made-up company called Acme, uses every kind of extension:

hydration-proof.config.ts
import { defineConfig, definePlugin } from "hydration-proof";
 
const acme = definePlugin({
  name: "acme",
 
  normalizers: [
    // The support widget injects itself after load: never compare it.
    {
      name: "support-chat",
      match: (node) =>
        node.k === 1 && node.tag === "acme-chat" ? "drop" : undefined,
    },
    // The experiment script's body changes per request, but the tag has to be
    // in the right place, so keep the element and ignore its contents.
    {
      name: "experiment-script",
      match: (node) =>
        node.k === 1 &&
        node.tag === "script" &&
        node.attrs.some(
          ([name, value]) => name === "data-acme" && value === "experiments"
        )
          ? "opaque"
          : undefined,
    },
  ],
  // Analytics writes these in an effect; they are never a mismatch.
  ignoreAttributes: [/^data-track-/],
 
  detectors: [
    {
      name: "geo-header",
      detect: (issue) =>
        issue.selector?.includes("[data-region]") &&
        issue.server !== issue.client
          ? {
              id: "geo-header",
              title: "Region resolved from a header on the server",
              confidence: 0.9,
              reason:
                "The server reads x-acme-region; the browser falls back to the default region.",
              fixes: [
                "Pass the region from the server as a prop instead of resolving it again on the client.",
              ],
              docsUrl: "https://wiki.acme.test/geo",
            }
          : undefined,
    },
  ],
 
  routes: [
    {
      name: "cms",
      // The app is already running, so ask it which pages exist.
      routes: async ({ baseUrl }) => {
        const response = await fetch(`${baseUrl}/api/pages`);
        if (!response.ok) {
          throw new Error(`The CMS route list failed: ${response.status}`);
        }
        const pages = (await response.json()) as { path: string }[];
        return pages.map((page) => page.path);
      },
    },
  ],
 
  reporters: [
    {
      name: "slack",
      onEnd: async (report) => {
        if (report.summary.failed === 0 || !process.env.SLACK_WEBHOOK) return;
        await fetch(process.env.SLACK_WEBHOOK, {
          method: "POST",
          headers: { "content-type": "application/json" },
          body: JSON.stringify({
            text: `${report.summary.failed} pages have hydration problems on ${report.run.branch ?? "unknown"}`,
          }),
        });
      },
    },
  ],
});
 
export default defineConfig({ plugins: [acme] });

Normalizers: drop or opaque

A normalizer decides, node by node, what is left out of the comparison between the server HTML and the hydrated DOM. It receives a serialized node: k is 1 for an element, 3 for text and 8 for a comment, and elements have tag, attrs (a list of [name, value] pairs) and children.

ReturnEffect
"drop"Removes the node, as if it were not there
"opaque"Keeps the element in the tree, so its position is still compared, but never compares its content
undefinedLeaves the node alone

"opaque" is the right answer for framework script tags and anything whose presence matters but whose body does not.

A normalizer is finer than ignore.selectors, which drops a whole subtree. For one-off exceptions, ignoring findings with a selector or an ignore rule is simpler; reach for a normalizer when the same markup shows up across apps.

Detectors: explain a finding

A detector runs on each finding and may return a cause. The cause with the highest confidence wins, with one exception: a cause proven by a differential probe is always kept. A plugin cannot overrule evidence.

Keep confidence honest, on a scale from 0 to 1:

  • below about 0.5 for a guess,
  • 0.8 or more only when the evidence is specific.

The number decides which explanation the reader sees first. reason is shown as evidence, and fixes are shown before the generic suggestions.

The second argument has the source around the element (file, line, content) when it was found, and the scenario's locale, timezoneId and colorScheme.

A detector that throws is recorded as evidence on the finding, not as a failure of the run. A broken plugin degrades the report instead of breaking the pipeline.

Route providers: routes from anywhere

Some routes are only known to a running system: pages from a CMS, tenants from a database, a list behind an internal endpoint. A route provider runs with the app already started, so it can ask the app.

It returns paths ("/pricing") or route objects, the same shapes as routes.paths in the configuration. Routes it adds have source: "plugin" in the report. A provider that fails adds a note, and the run continues with the routes it does have.

Reporters: send results somewhere

A reporter has up to three hooks:

HookRuns
onBegin(context)Before the first page
onPage(page, issues, context)After each page, with its findings
onEnd(report, context)Once, with the finished report, the exit code and the failures

Post to Slack, write a custom format, push a metric. When onEnd returns the files it wrote, they are listed with the built-in reports.