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
basefixture; - each of your
statesfixtures; - one synthesized fixture per optional slot your
basepopulates, with that slot set to its empty value (nullfor 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.
Reason
Section titled “Reason”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.
Before
Section titled “Before”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:
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> );}See also
Section titled “See also”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.