render-invariant
"Rendered output must carry no [object Object], bare null/undefined/NaN leaf, src-less `<img>`, or dev-media URL naming no file in the pack."
Venue: check — Rendered output must carry no [object Object], bare null/undefined/NaN leaf, src-less
<img>, or dev-media URL naming no file in the pack.
Every section is server-rendered against every fixture in its invariant corpus, and the output is checked for four 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". - A
/__dev-media/…URL that names no file in the dev media pack — in asrc, asrcsetrung, aposter, or anhref. Any other URL (a page link, an off-site embed, adata:URI) is not this check’s business and passes untouched.
The corpus is your base fixture, each of your states fixtures, and one synthesized
fixture per declared 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; nothing
else is skipped, and no declaration buys a slot an exemption.
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, 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.
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 strings.” It is: an absent slot is routine, not exceptional — and your markup has to say what happens then.
The fourth defect is a different animal, and the reason is shorter. Every media reference you write as an asset id is resolved for you, so a bad id collapses to an empty value the other three defects already catch. A media URL you type by hand skips that resolution entirely: it is just a string, so a typo in it renders perfectly, passes every shape test above, and shows up as a 404 the first time somebody loads the page. Checking that the URL names real bytes is the only thing that can tell the two apart.
Where the first three defects actually come from
Section titled “Where the first three defects actually come from”Worth being precise, because the obvious guesses are wrong. Most of the ways a blank
value could reach the page as text are already closed at the binding, which normalizes
every value to its declared shape: a string-valued slot is "" rather than null when
unfilled, a collection is always an array so .map cannot throw, and ?? "" / ?? []
have nothing to do (values and
emptiness). Rendering a binding’s value as a
child is safe on top of that — React renders nothing for an empty string.
What remains are the places where a value is turned into something else before it
reaches the DOM, and the number kinds whose canonical blank really is null:
-
String coercion. A template literal,
String(x),"" + x,.join(), or any JSX string attribute (alt,title,aria-label,src) turnsnullinto"null", an absent number into"null", and an object into"[object Object]". Coercion is what React’s own child handling was protecting you from — and interpolating a whole record row, rather than one of its fields, is the shortest route to[object Object]. -
Arithmetic on an absent number.
null * 1.05isNaN, and React rendersNaNas text. -
An
<img>reaching the page with nothing in itssrc. Two routes get you there.A hand-written
<img>bound to a slot value.src={value?.url}on an absent value drops the attribute entirely, and React emits<img alt="…">with nosrcat all — which the check treats exactly likesrc="". Interpolating instead (src={`${value?.url}`}) produces the literalsrc="undefined", which it also catches.A
Slot.Urlrenderedas="img"on a blank value.imgis one of the embed-like elementsSlot.Urlputs the value insrcrather thanhref, and a blank value omits the URL attribute entirely — deliberately, so a link never gets a self-referentialhref="". On an<img>that same omission is a broken image. Branch on the binding’spresentso a blank value renders nothing there, or keep the element out of theimg/sourcefamily.Slot.ImageandSlot.Videocannot produce this defect. On an absent value each returns a marked<div role="img">placeholder — framed and unframed are two distinct branches reaching the same shape — never an<img>carrying the missing value.Slot.Video’s framed placeholder is the one that holds an<img>at all: a value with a poster but no rendition yet is a pending transcode, and the poster renders inside the box. That<img>is emitted only when there is a poster to put in itssrc, so thesrcis never empty.
- 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 a record row’s fields — an unfilled
numberfield isnull(a string-valued one is the blank""), so check before you format or interpolate it; never interpolate the row itself. ASlot.*formatcallback is that check: it is typed(value: NonNullable<…>) => ReactNodeand only ever runs on a non-blank value. - Render an image slot through
Slot.Image, not a hand-written<img>. The primitive owns each branch, and no absent-value branch has an<img>in it. A raw<img>is still legal markup — it just makes the empty state your problem, and the empty state is exactly what the synthesized fixtures render. - Inside a list, which image primitive you reach for follows from the declared item.
A record row’s image field binding carries its own
def, so<Slot.Image slot={unit.fields.photo} />is the form — and it is the one this rule cares about, because a row normalizes in place and an unfilled image field really isnull. A single-slot list’sItemBindingcarries nodef, soSlot.Imagedoes not typecheck there; render the item throughImagewithframepassed explicitly from a constsection.tsexports (<Image value={photo.value} frame={PHOTO_FRAME} />). That list cannot produce this defect either way — a member with no served url is dropped at bind time — but a hand-written<img>there still forfeits thesrcset, the mobile-crop<picture>and the aspect box. See Slot primitives: a list of images. - Branch a
Slot.Urlrenderedas="img"onpresent(or any othersrc-valued element) —{slot.x.present && <Slot.Url … as="img" />}— so a blank url slot renders nothing rather than a source-less<img>. - Reference pack media by its asset id, never by a hand-written URL. State the id in
fixtures.ts(photo_id,video_id,document_id, or a floor plan’sid) and let resolution build the/__dev-media/…path — then a wrong id is caught as a wrong id rather than surviving as a plausible-looking string. The dev server’s media library lists every id the pack carries. Adocumentslot is the likeliest place to slip, since it links its bytes through anhrefrather than embedding them. - Declare the slot’s
framein the schema for a slot that owns its own layout box. This is a layout fix, not an invariant fix — both branches are already safe. An undeclared frame’s placeholder is aw-fulldiv with no height, so an unfilled slot collapses to nothing and the page reflows when it fills; a declared frame’s placeholder carries the aspect box and holds its space. Leave the frame undeclared only where the parent already establishes the box (a logo, a slide inside its own aspect parent).
Before
Section titled “Before”import { bindSlots, Section, Slot } from "@homepages/template-kit";
import { schema } from "./section.js";import type { Props } from "./section.js";
export function Renderer({ slots }: Props) { const slot = bindSlots(schema, slots);
return ( <Section> {/* coerced: an absent year_built lands in the heading as the text "null" */} <h2>{`${slot.headline.value} — built ${slot.year_built.value}`}</h2>
<Slot.List slot={slot.units} as="ul"> {bindItems(slot.units).map((unit) => ( <SlotGroup slot={unit} as="li" key={unit.index}> {/* coerced: a unit with no price renders "2 Bed · null" */} <h3>{`${unit.fields.label.value} · ${unit.fields.price.value}`}</h3> {/* arithmetic on an absent sqft: NaN, rendered as text */} <p>{Number(unit.fields.sqft.value) * 1.05} sq ft heated</p> </SlotGroup> ))} </Slot.List>
{/* hand-written: on a listing with no floor plan, `src` is dropped entirely */} <img src={slot.floorplan.value?.url} alt="Floor plan" className="w-full" /> </Section> );}// sections/units/section.ts — floorplan declares no frame, so it owns no layout boxfloorplan: image({ label: "Floorplan" }),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 · null, NaN sq ft heated, and an
<img> with no src at all.
That hand-written <img> also carries no marker, so this section fails
missing-slot-marker on floorplan at the same time. Two rules,
one cause: bypassing the primitive gives up both the empty-state branch and the marker.
return ( <Section> <h2> <Slot.Text slot={slot.headline} as="span" /> {slot.year_built.value ? ` — built ${slot.year_built.value}` : null} </h2>
<Slot.List slot={slot.units} as="ul"> {bindItems(slot.units).map((unit) => ( <SlotGroup slot={unit} as="li" key={unit.index}> <h3> <Slot.Text slot={unit.fields.label} as="span" /> <Slot.Number slot={unit.fields.price} as="span" format={(price) => ` · ${price}`} /> </h3> <Slot.Number slot={unit.fields.sqft} as="p" format={(sqft) => `${Math.round(sqft * 1.05)} sq ft heated`} /> </SlotGroup> ))} </Slot.List>
<Slot.Image slot={slot.floorplan} alt="Floor plan" /> </Section>);// sections/units/section.ts — the frame declaration is the whole deltafloorplan: image({ label: "Floorplan", frame: { breakpoint: 768, desktop: { aspect: 4 / 3 } } }),Every coercion now sits behind a check — format runs only on a non-blank value, so nothing
inside one can be handed a null to interpolate.
The image changed twice, for two different reasons. Moving to Slot.Image is what fixes
broken-image: the primitive’s absent-value branch renders a marked <div role="img"> and
no <img> at all, so there is no element left for the check to find. Declaring frame is
the layout half — the placeholder now carries a 4:3 box instead of collapsing to zero
height, so an unfilled floor plan holds its space and stays selectable rather than
disappearing until someone fills it.
See also
Section titled “See also”- Slot primitives:
Slot.Image— where the frame comes from, and what each branch renders for a missing source. - Slot primitives:
Slot.Url— which elements take the value insrcrather thanhref, and what a blank value does to each. 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.