Skip to content
HomePagesHomePages template kit
Menu

template-kit/determinism-drift

The template-kit/determinism-drift authoring rule — what it enforces, why, and how to fix it.

The same fixture, rendered twice, must produce byte-identical HTML. This is the runtime back-stop to no-nondeterminism — it catches what a linter cannot see.

Every fixture in the section’s invariant corpus is server-rendered twice, and the two outputs are compared byte for byte. Any difference is a violation.

The corpus is broader than the fixtures you wrote:

  • your base fixture;
  • each of your states fixtures;
  • one synthesized fixture per optional slot your base populates, with that slot set to its empty value (null for a scalar or an image, [] for a list).

You do not declare that last set. Nullability coverage is derived from the schema — from (type, required) — so a nullable branch cannot be forgotten by forgetting to write a fixture for it. It only runs one way, though: a slot your base leaves empty is skipped, so base should fill every slot it can.

no-nondeterminism bans the clock and randomness in your code. A linter reads one file at a time and can only report on what it can see, so it stops at your source. This check runs the render.

That gap is where the real bugs live, and they arrive through a package. A helper that mints a fresh id per call. A library that iterates a hash and hands back a different order each time. Nothing in your Renderer looks nondeterministic — you never wrote Math.random() — but the output moves.

It matters because the publisher and the editor both render the section and must agree. Byte-identical output is what makes a section’s HTML content-addressable: the same content must hash to the same page. A section whose HTML changes on every render re-publishes forever and never converges.

If the nondeterminism is your own: compute the value at fill time and pass it in as a slot value, or move the markup into a "use client" island — an island renders in the browser after hydration, and its output is nobody’s content hash.

If the nondeterminism came from a package: replace it, or give it a deterministic input. An id generator gets a stable id derived from props. A collection gets sorted before you map it.

sections/facts/Renderer.tsx
import { Section, Slot } from "@homepages/template-kit";
import { nanoid } from "nanoid";
import type { Props } from "./schema.js";
export function Renderer({ slots, nav }: Props) {
const descriptionId = nanoid(); // ← a new id on every render; the HTML differs each time
return (
<Section id={nav.selfAnchor}>
<Slot id="headline" as="h1" textLeaf>
<span aria-describedby={descriptionId}>{slots.headline}</span>
</Slot>
<p id={descriptionId}>{slots.summary}</p>
</Section>
);
}

Nothing here reads the clock or a random source, so no lint rule fires. The section still renders differently every single time.

The id is derived from something the section was given, so it is the same on every render:

sections/facts/Renderer.tsx
import { Section, Slot } from "@homepages/template-kit";
import type { Props } from "./schema.js";
export function Renderer({ slots, nav }: Props) {
const descriptionId = `${nav.selfAnchor}-summary`;
return (
<Section id={nav.selfAnchor}>
<Slot id="headline" as="h1" textLeaf>
<span aria-describedby={descriptionId}>{slots.headline}</span>
</Slot>
<p id={descriptionId}>{slots.summary}</p>
</Section>
);
}
  • no-nondeterminism — the same ban, enforced in your editor, on the code a linter can see.
  • Server vs client — why a server-rendered file must be a pure function of its props, and why an island is not.
  • render-invariant — the other check that runs over this same fixture corpus.