🐛 Substitute <Content /> at every position in a body - #402
Conversation
`<Content />` was replaced only where a body's segments were mapped directly: a top-level segment, or a direct child of a top-level `<Output>`. Written anywhere else — inside another invocation, inside a structural construct, or several levels down — it survived substitution, reached component resolution, and failed as a reserved name. Substitution now descends through the body. Two identity rules the top-level path never exercised come with it: projection leaves a resolved `<Content />` alone, because a copy loses the claim that tells it apart from one an author wrote where nothing projects; and reading a slot consumes its `slot` prop, so a resolved projection nested inside another invocation partitions there by position rather than being read a second time as that invocation's named slot. Closes #328
| if (segment.name === "Content") { | ||
| const targetSlot = segment.props.slot as string | undefined; | ||
| if (targetSlot !== undefined) { | ||
| // Named slot projection — strip slot prop from each child |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // Named slot projection — strip slot prop from each child |
| // Named slot projection — strip slot prop from each child | ||
| return (slots.named.get(targetSlot) ?? []).map(stripSlotProp); | ||
| } | ||
| // Default slot projection |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // Default slot projection |
PR #402: 🐛 Substitute at every position in a body5 files, +270 / -57 Scope✅ PR scope looks good. StructuralOxlint structural signals:
SlopOxlint slop signals:
Static AnalysisOxlint: 22 diagnostics across 1 file (9 rules) no-inferrable-types (6): packages/core/src/expand.ts CorrectnessFILE: packages/core/src/expand.ts, PATTERN: multiple Oxlint violations (no-inferrable-types, no-unused-vars, etc.), CONCERN: signal cluster, QUESTION: Are these violations due to unreviewed generated code or missed type annotations? |
Rendered output cannot tell two expansions apart, so a nested projection under `<Each>` now reads `Expansion.id` from a probe: the iterations carry different identifiers and re-expansion reproduces the pair. Two behaviors the recursion settles get their own coverage. An unclaimed `<Content />` passed through a nested wrapper stays the reserved-name failure rather than being consumed by the enclosing component. The specification's `<Section slot="header">` wrap reaches a nested invocation's named slot. §6.3.4 states the substitution algorithm in prose. Its pseudocode spliced the selected children into the body, which the normative lifecycle in the same section already contradicted: the claimed element is what lets expansion run them in the invocation's content scope. The section-divider comments in named-slots.test.ts go with it; every tier is already named by its describe.
Why
<Content />is how a component renders the material its caller passed it, and§6.3 says it works "inside a component body". It did not: the engine substituted
it only where a body's segments were mapped directly — a top-level segment, or a
direct child of a top-level
<Output>. Anywhere else it survived substitution,reached component resolution, and failed as a reserved name, with a message that
described a rule the author had not broken.
That made the most natural use unavailable — passing caller material into a
prompt, a wrapper, or a capture — and the only way to learn the restriction was
to hit it. Found while synchronizing the #181 end-goal target (#292), where two
authored components had to take the material as a prop instead.
Closes #328.
What changes
Before:
After:
<Host>MATERIAL</Host>rendersECHO(prefix MATERIAL suffix). The sameholds inside a structural construct (
<Capture>,<Each>), several levelsdeep, and inside an
<Output>region. Slot partitioning, the once-only sloterrors of §6.3.3, and the content-scope rule of §6.3.4 are unchanged.
How it works
substituteSegmentListmapped a body's segments and returned any non-Contentcomponent element untouched, so it never looked at
segment.children. It nowdescends through every authored element. Only the body is walked: content that
arrives through a projection was written by the caller, whose own body resolved
its projections already.
Recursing exposed two identity rules the top-level path never exercised.
A resolved projection must survive being re-projected. A
<Content />theengine has resolved is told apart from one an author wrote where nothing
projects by object identity, in a
WeakSetowned by the invocation.project()cloned every projected component element to attach
projectedEnv, so passing aresolved
<Content />into a nested invocation destroyed its claim and itreached component resolution anyway. Projection now leaves
Contentelementsalone — their content already carries the env of the invocation that resolved
them, and the element itself is consumed as a projection, never resolved.
Reading a slot consumes it.
sloton<Content />names which of thisinvocation's slots to read (§6.3.5). Left on the resolved element, a nested
<Content slot="header" />was read a second time by the wrapper as "fill myheader slot" and silently discarded. The resolved element is now built without
it and partitions by position, like any other content written there. To place
caller content in a nested invocation's named slot, wrap it — the spec says so
and shows the form.
A body element no projection reached keeps its identity rather than being
rebuilt.
Review guide
Start with:
packages/core/tests/named-slots.test.ts, Tier NS-HThen review:
specs/executable-mdx-spec.md§6.3 and §6.3.4 — the resolved rule and thesubstitution algorithm
substituteSegmentListinpackages/core/src/expand.ts— the recursionmakeProjectFnin the same file — theContentexemptionpackages/core/tests/invocation-scope.test.tsO41 andexpansion-identity.test.tsXP26 — lifetime and identityLook carefully at:
Contentexemption inmakeProjectFn. It is load-bearing for claimidentity, and the alternative reading — that a projected
<Content />shouldpick up the wrapper's caller env — would be wrong: its children already carry
the env of the invocation that resolved them.
What must stay true
wrapper's — enforced by
expandClaimedresolving throughstate.enclosing,checked by
O41, which fails if the projection moves into the wrapper.per iteration — enforced by deriving the projection path from the element's
own expansion path, checked by
XP26.projection point is — enforced by the shared
SubstitutionState, checked byNS-H6.unclaimed
<Content />stays the reserved-name failure — enforced by walkingonly the body, checked by
NS-H9.<Content />outside a component body is still the reserved-name failure —unchanged; the exemption in
makeProjectFnskips the copy, not the diagnosis.How to verify it
deno task test packages/core/tests/named-slots.test.ts packages/core/tests/invocation-scope.test.ts packages/core/tests/expansion-identity.test.tsNS-H1–NS-H5,NS-H7,NS-H8prove substitution at a nested invocation, atdepth, at a nested named slot, at two different depths at once, inside
<Output>, inside<Capture>, and inside<Each>, by rendered output. Allfail on the unmodified engine with the reserved-name error from the issue.
XP26proves identity directly, by readingExpansion.idfrom a probeprojected into a
<Content />written inside<Each>: the two iterationscarry different identifiers and re-expansion reproduces the ordered pair.
Rendered output cannot show this;
NS-H8makes no identity claim.NS-H9proves an unclaimed caller-authored<Content />passed through anested wrapper stays the reserved-name failure instead of being consumed by
the enclosing component.
NS-H10exercises the specification's<Section slot="header"><Content slot="header" /></Section>form and provesthe selected outer slot reaches the nested invocation's named slot.
NS-H6proves slot errors stay once-only when the first projection point isnested; it would catch a per-position
SubstitutionState.O41proves the content-scope rule. The wrapper completes before the providerretains its own resource, so the timeline
start:projected, start:own, stop:projected, stop:ownfails if the projection moved into the wrapper'sscope —
stop:projectedwould land beforestart:own.Each of the three settled behaviors was checked against a mutation of the
implementation, each applied to the real source and reverted afterwards:
expandClaimedderives its path from the invocation instead of the elementXP26,XP13NS-H9,NS-H10and six moreslotpropNS-H10,NS-H3O41and all of Tier NS-H were separately confirmed failing against theunmodified engine.
Full run on this branch:
deno task lint0 errors and format clean,deno task checkclean,deno task check:jsrSuccess,deno task test --changed=origin/maingreen,git diff --checkclean.Scope
Included
<Content />substitution through a component body.slotconsumed by the projection that reads it.C50,O41andXP26.Intentionally unchanged
<Content />written outside a componentbody. The rule it states is now true everywhere in a body, so it needs no
rewording.
material as a prop. Reverting them belongs to that target, not to this fix.
renderChildren(),useContent()andcontent()— programmatic projectionnever had the restriction.
Generated or mechanical changes
named-slots.test.tsloses its eight// ═══section-divider blocks perAGENTS.md rule 10. No behavior change; every tier is already named by its
describe. Worth knowing:local/no-section-divider-commentsmatches[-=_*]{20,}in ASCII, so a divider drawn with U+2550═is invisible to it.Two other files still carry that form; sweeping them is not this PR's job.
substituteContentpseudocode is replaced with the algorithm inprose. The code spliced the selected children directly into the body, which
the normative lifecycle in the same section already contradicted — the claimed
element is what lets expansion run them in the invocation's content scope.
Risks and limitations
<Content slot="x" />now lands in the enclosing invocation's defaultslot. Nothing could have depended on the prior behavior, because the element
never resolved at all in that position.
Elements no projection reached are returned by identity, so a body without
<Content />is unchanged.