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:
- 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.
- Why a finding happened. Probes can prove time and random
values, but the tool cannot know that
data-regioncomes from a geo header on your server.
A plugin supplies both, plus route providers, reporters and adapters:
function definePlugin(plugin: HydrationProofPlugin): HydrationProofPluginIdentity helper with type checking for plugins.
| Field | Called with | Returns |
|---|---|---|
normalizers[].match | a 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[].detect | the 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:
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.
| Return | Effect |
|---|---|
"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 |
undefined | Leaves 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:
| Hook | Runs |
|---|---|
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.
Related
- Adapters for frameworks hydration-proof does not know
- Ignore known hydration mismatches without a plugin
- How hydration-proof detects mismatches
- Probes: the causes a plugin cannot overrule
- Node API:
run()with extra reporters