template-kit/render-invariant
The template-kit/render-invariant authoring rule — what it enforces, why, and how to fix it.
Three things must never reach a published page:
[object Object], a barenull/undefined/NaN, and an<img>with no source. Every one of them is garbage a real buyer would see.
Every section is server-rendered against every fixture in its invariant corpus, and the output is checked for three defects:
- The text
[object Object], anywhere in the rendered HTML. - A text leaf that is exactly
null,undefined, orNaN. The match is on the whole leaf, so prose that merely contains the word (“null and void”) is safe. - An
<img>whosesrcis absent, empty,"undefined", or"null".
The corpus is your base fixture, each of your states fixtures, and 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) — derived from the schema, not declared
by you. A slot already empty in base is skipped, having no filled branch to null.
The check is deliberately biased toward false negatives: it would rather miss a bug than invent one. Everything it reports is a real defect, so there is nothing here to argue with or configure around.
Reason
Section titled “Reason”Your fixtures are richer than the content a real listing gets filled with. That is the whole story, and almost every violation of this rule is a version of it.
You wrote fixtures.ts from a property you had in front of you: twelve units, a year
built, a hero photo, an agent headshot. The fill engine, on a thin listing, bakes in what
actually exists — which may be no units array at all, no year, no photo. Your preview looks
perfect, your states look perfect, and the section still renders Built undefined on a
real customer’s page, or throws on slots.units.map because units is null.
This is precisely why the corpus adds the synthesized nullability fixtures. They are adversarial inputs, not designed states: they render your section against exactly the thin content your own fixtures never showed you.
So the rule to internalize is not “avoid these three strings.” It is: an absent slot is routine, not exceptional — and your markup has to say what happens then.
Where the three defects actually come from
Section titled “Where the three defects actually come from”Worth being precise, because the obvious guesses are wrong. Rendering a nullish slot as a
child is safe: React renders null and undefined as nothing, so <p>{slots.year_built}</p>
on a thin listing is an empty <p>, not the text undefined. And a list slot is never
null — list, object_list, poi_list, image_collection and video_collection
always arrive as an array
(empty when absent), so .map cannot throw and ?? [] buys you nothing.
The defects come from three narrower places:
- String coercion. A template literal,
String(x),"" + x,.join(), or any JSX string attribute (alt,title,aria-label,src) turnsnullinto"null", an absent field into"undefined", and an object into"[object Object]". Coercion is what React’s own child handling was protecting you from. - Arithmetic on an absent number.
null * 1.05isNaN, and React rendersNaNas text. <Image bare>with no source. Framed<Image>emits no<img>at all whensrcis empty — it renders its fallback rect.bareforfeits that and ships<img src="">.
- Never coerce a slot into a string. Branch on it and render the surrounding text as markup, or guard it before it reaches a template literal or an attribute.
- Guard arithmetic behind a
typeof x === "number"check. - Format an
object_listrow’s fields — they are typedunknown, so pick, check, and format each one; never interpolate the row. - Let framed
<Image>handle a missing image, and reach forbareonly where the source is guaranteed.
Before
Section titled “Before”import { Image, Section, Slot, SlotItem } from "@homepages/template-kit";
import type { Props } from "./schema.js";
export function Renderer({ slots }: Props) { return ( <Section> {/* coerced: an absent year_built lands in the heading as the text "null" */} <h2>{`${slots.headline} — built ${slots.year_built}`}</h2>
<Slot id="units"> {slots.units.map((unit, i) => ( <SlotItem key={String(unit.id)} index={i} as="li"> {/* coerced: a unit with no price renders "2 Bed · undefined" */} <h3>{`${unit.label} · ${unit.price}`}</h3> {/* arithmetic on an absent sqft: NaN, rendered as text */} <p>{Number(unit.sqft) * 1.05} sq ft heated</p> </SlotItem> ))} </Slot>
{/* bare: the card grid already sizes the thumbnail. */} <Image bare src={slots.floorplan?.url ?? ""} alt="Floor plan" /> </Section> );}On the richest golden scenario this renders perfectly. On a listing with no year, an unpriced unit,
and no floor plan it ships built null, 2 Bed · undefined, NaN sq ft heated, and
<img src="">.
import { Image, Section, Slot, SlotItem } from "@homepages/template-kit";
import type { Props } from "./schema.js";
export function Renderer({ slots }: Props) { return ( <Section> <h2> {slots.headline} {slots.year_built ? ` — built ${slots.year_built}` : null} </h2>
<Slot id="units"> {slots.units.map((unit, i) => ( <SlotItem key={typeof unit.id === "string" ? unit.id : i} index={i} as="li"> <h3> {typeof unit.label === "string" ? unit.label : null} {typeof unit.price === "number" ? ` · ${unit.price}` : null} </h3> {typeof unit.sqft === "number" ? ( <p>{Math.round(unit.sqft * 1.05)} sq ft heated</p> ) : null} </SlotItem> ))} </Slot>
<Image slotId="floorplan" src={slots.floorplan?.url ?? null} alt={slots.floorplan?.alt ?? ""} aspectRatio="4 / 3" /> </Section> );}Every coercion now sits behind a check, and the image is framed: a null src renders the
fallback rect, holding the layout box and keeping the slot selectable instead of shipping a
source-less <img>.
See also
Section titled “See also”- Contract primitives:
Image— what the frame does with a missing source, and whybareforfeits it. typecheck— the same class of bug, caught earlier, for the cases the types can see.determinism-drift— the other check that runs over this corpus.