5. A container re-rendering slotted content is not detected, and that is accepted
Date: 2026-08-26
Status
Accepted. No code change attached: the boundary is what the framework already
does, and this records the decision to keep doing it. Raised by #158, on the
criterion #67 left open. packages/islands/src/slot.ts is the mechanism,
docs/research/2026-08-26-slot-rerender-detection.md is the evidence, and
packages/islands/src/slot.test.tsx pins the behaviour.
Context
Spec §14, as the 2026-08-24 review amended it, asks that the operations
production forbids — React.Children/cloneElement over slotted content, and
re-rendering it — "assert loudly in preview rather than working silently".
#67 turned that into the criterion this row is measured against: "A component
attempting to re-render slotted content fails loudly with a message naming the
constraint, rather than silently rendering stale DOM". Every other row of
slot.ts's table is met — injected props, one child mounted twice, and the
build's own duplicate refusal all name the container and the child — and this
row is not. A container that re-renders around a slotted child has whatever it
was trying to say dropped, with nothing on either error channel.
#67 left the row reading "not detectable" on an argument. #158 asked whether the
memo comparator on SlotContent could be the guard: it is already the thing
that reports "equal" on every render, so a comparator that threw instead would
be a one-line fix if it could tell a container's re-render from React's own
re-invocations.
The spike measured it, on React 19.2.8 across four configurations — plain,
StrictMode, @fw/core's compileIslands() React Compiler pass, and both
together — over seven container shapes. Three findings decide this, and the
research document carries the numbers:
Four of the seven shapes never reach the comparator. Passing children
through, reordering them under one parent, Children.map into a wrapper, and
holding them in useState each produce zero comparator calls. React bails out
one level above, at AdoptedSlot, on props-object identity: adoptSlots builds
one element per slot for the container's lifetime, so oldProps === newProps on
every render and React skips the subtree. The memo is not what swallows the
container's data; the bailout above it is, and no comparator can observe a
subtree React declined to enter.
Of the three that do reach it, the legal shape and the faulty one carry the
same signal. cloneElement(child) with no new props gets past the identity
bailout and arrives with prev.html !== next.html — 344 bytes against 433 —
while the container changed nothing, because AdoptedSlot swaps the DOM it
adopted for the pre-stash snapshot once its first effect has run. A container
that renders SlotContent itself with new markup arrives carrying the same kind
of difference, and there it is the fault. One signal, two meanings.
The shape that carries the fault is one the framework never emits.
adoptSlots on the client and render.tsx's single build pass are the only two
places that construct SlotContent, and a container fed by adoptSlots has no
channel through which to express new data at all: the markup is read off the DOM
once, at mount, and a slotted child is opaque by construction. The fault is
therefore vacuous on every path the framework produces.
Decision
The framework accepts that a container's re-render of slotted content is
silent, and states it rather than guarding it. #67's "fails loudly" half stays
unmet for this one row. This file says why, and slot.test.tsx pins both the
silence and the shapes that are legal, so the boundary is measured rather than
claimed.
The comparator stays () => true. Not because a throw is expensive, but
because on every path the framework produces there is nothing to throw on, and
on the one path where a difference is visible the difference belongs to
AdoptedSlot rather than to the container. The ranking that decides the rest is
the one #158 opened with, under "Why it is not just a docblock fix": "A guard
that false-fires in a live page is worse than the gap it closes." That is the
maintainer's framing of the problem this ADR answers, not a repo-wide standard.
Preview does not close it either, and must not. packages/preview's parity
module is bounded by what production does (spec §14, the 2026-08-24 review), and
being more capable than production is the direction that masks a production
failure from the editor preview exists for. Preview drops the container's data
silently too, which is parity.
This does not accept the gap as unclosable by anything. Two directions stay
open and neither is small: refusing at AdoptedSlot, which is unmemoised and
does re-render but would need a state machine rather than a predicate to tell a
container's second render from its own source flip; and giving containers real
elements rather than opaque nodes, which means the flight payload.
docs/research/2026-08-23-flight-free-rsc.md §4 is what that direction would
cost and buy: across the flight a slotted child arrives as an ordinary React
element a container can re-render, and the opaque-HTML downgrade the framework
runs instead is what turns "re-render it with new data" into a no in §4's
table. That document decides nothing — its status is "Decision input — no
decision taken" — it measures the downgrade. Either direction is its own piece
of work. Neither is opened here.
Owner: the slot mechanism in @fw/islands. Whoever changes slot.ts owns
this row. It was #67's criterion and #67 is closed, so a reader looking for who
holds the gap finds this file.
Consequences
A container island that re-renders around a slotted child renders the DOM that
is already there. The data it meant to push is dropped, and nothing is logged.
That is the cost, and it is paid against a guard that would fire on
cloneElement(child) with no new props — a shape slot.test.tsx pins as legal
and silent — in a browser, on a page a visitor is looking at.
The boundary is pinned at the point of use rather than only stated here:
slot.test.tsx holds the silence and the legal shapes, so a change that
crosses the boundary fails there.
The research document is the evidence and this is the decision, which is why
both exist. Most of the measurement can be re-run, and one part cannot. The
harness is packages/islands/src/slot-rerender.harness.tsx, excluded from
pnpm test by the root config's *.test.tsx include and run by
pnpm test:slot-harness through packages/islands/vitest.harness.config.ts. It
covers the research document's §1–§3 — the call pattern, StrictMode and the
compiler — over a replica of slot.ts's two components. It does not cover
§4, which is the only measurement against the real module and the source of the
"344 bytes against 433" this ADR's Context states.
§4 has no harness and cannot have one: it is a temporary edit replacing
slot.ts's shipped comparator with a logging one, plus the whole workspace
suite, and committing it would mean committing a comparator that is not the one
that ships. Re-checking that figure is a manual re-run of that edit, as the
research document's method note says.
A future React invalidating the props-identity bailout reopens this. The bailout is an optimisation React does not document as a guarantee, and the first four rows of the spike's table are zeroes only because React takes it. A release that stops taking it would start delivering a container's re-renders to the comparator, at which point the question #158 asked has a different answer and this decision should be re-argued rather than inherited. The harness asserts those four zeroes, so the change surfaces as a failing run instead of as a document that quietly stopped being true.
Alternatives considered
Throw from the comparator on prev.html !== next.html. Rejected on
measurement. It fires on none of the four shapes a container normally uses,
because it is never called on them, and it fires on cloneElement(child) with
no new props, which is legal. That is a guard with no true positives and one
false positive.
Rewrite AdoptedSlot to hand SlotContent a stable string, then throw.
Rejected as out of proportion. It would remove the false-fire and leave a guard
that catches exactly one shape: a site component rendering the exported
SlotContent by hand with markup of its own — already past the content boundary
#109 draws, and not a shape the framework emits. The cost is the two-string
mechanism in AdoptedSlot, which exists because hydrating a slot and rebuilding
one genuinely need different markup: with one string shared, a container
nested two deep came back with every unrendered slot gone. Recorded rather than
dismissed, because it is the one way
the row could partly move.
Leave the reason in a docblock alone. Rejected, and it is why this file exists. #158's criterion asked for an ADR specifically, so that the boundary has an owner and a date rather than a paragraph. A docblock says what the code does; it does not record that the project looked at the alternative and declined it.
Count renders inside SlotContent instead of comparing props. Rejected on
the same measurement as the comparator. SlotContent's body does not run on a
container's re-render either — React never reconciles it — so a counter there
sees exactly what the comparator sees, which is nothing.