Skip to content
HomePagesHomePages template kit

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:

  1. The text [object Object], anywhere in the rendered HTML.
  2. A text leaf that is exactly null, undefined, or NaN. The match is on the whole leaf, so prose that merely contains the word (“null and void”) is safe.
  3. An <img> whose src is absent, empty, "undefined", or "null".
  4. A /__dev-media/… URL that names no file in the dev media pack — in a src, a srcset rung, a poster, or an href. Any other URL (a page link, an off-site embed, a data: 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.

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) turns null into "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.05 is NaN, and React renders NaN as text.

  • An <img> reaching the page with nothing in its src. 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 no src at all — which the check treats exactly like src="". Interpolating instead (src={`${value?.url}`}) produces the literal src="undefined", which it also catches.

    A Slot.Url rendered as="img" on a blank value. img is one of the embed-like elements Slot.Url puts the value in src rather than href, and a blank value omits the URL attribute entirely — deliberately, so a link never gets a self-referential href="". On an <img> that same omission is a broken image. Branch on the binding’s present so a blank value renders nothing there, or keep the element out of the img/source family.

    Slot.Image and Slot.Video cannot 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 its src, so the src is 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 number field is null (a string-valued one is the blank ""), so check before you format or interpolate it; never interpolate the row itself. A Slot.* format callback is that check: it is typed (value: NonNullable<…>) => ReactNode and 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 is null. A single-slot list’s ItemBinding carries no def, so Slot.Image does not typecheck there; render the item through Image with frame passed explicitly from a const section.ts exports (<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 the srcset, the mobile-crop <picture> and the aspect box. See Slot primitives: a list of images.
  • Branch a Slot.Url rendered as="img" on present (or any other src-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’s id) 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. A document slot is the likeliest place to slip, since it links its bytes through an href rather than embedding them.
  • Declare the slot’s frame in 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 a w-full div 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).
sections/units/Renderer.tsx
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 box
floorplan: 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.

sections/units/Renderer.tsx
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 delta
floorplan: 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.