Skip to content

🐛 Substitute <Content /> at every position in a body - #402

Merged
taras merged 2 commits into
mainfrom
agent/issue-328-nested-content
Aug 9, 2026
Merged

🐛 Substitute <Content /> at every position in a body#402
taras merged 2 commits into
mainfrom
agent/issue-328-nested-content

Conversation

@taras

@taras taras commented Aug 9, 2026

Copy link
Copy Markdown
Owner

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:

<!-- Host.md -->
<Echo>prefix <Content /> suffix</Echo>
<Content /> renders the content its invocation was given, so it means something
only inside a component's body. <Content> is reserved: it never resolves a
component, so a repository file named Content cannot supply it.

After: <Host>MATERIAL</Host> renders ECHO(prefix MATERIAL suffix). The same
holds inside a structural construct (<Capture>, <Each>), several levels
deep, and inside an <Output> region. Slot partitioning, the once-only slot
errors of §6.3.3, and the content-scope rule of §6.3.4 are unchanged.

How it works

expandBody → buildBody → substituteSegmentList (now recursive) → claimed <Content /> → expandClaimed in the invocation's content scope

substituteSegmentList mapped a body's segments and returned any non-Content
component element untouched, so it never looked at segment.children. It now
descends 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 /> the
engine has resolved is told apart from one an author wrote where nothing
projects by object identity, in a WeakSet owned by the invocation. project()
cloned every projected component element to attach projectedEnv, so passing a
resolved <Content /> into a nested invocation destroyed its claim and it
reached component resolution anyway. Projection now leaves Content elements
alone — 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. slot on <Content /> names which of this
invocation'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 my
header 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-H

Then review:

  1. specs/executable-mdx-spec.md §6.3 and §6.3.4 — the resolved rule and the
    substitution algorithm
  2. substituteSegmentList in packages/core/src/expand.ts — the recursion
  3. makeProjectFn in the same file — the Content exemption
  4. packages/core/tests/invocation-scope.test.ts O41 and
    expansion-identity.test.ts XP26 — lifetime and identity

Look carefully at:

  • The Content exemption in makeProjectFn. It is load-bearing for claim
    identity, and the alternative reading — that a projected <Content /> should
    pick 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

  • A nested projection runs in the projecting invocation's content scope, not the
    wrapper's — enforced by expandClaimed resolving through state.enclosing,
    checked by O41, which fails if the projection moves into the wrapper.
  • A nested projection is a distinct expansion per structural position, including
    per iteration — enforced by deriving the projection path from the element's
    own expansion path, checked by XP26.
  • Slot validation errors are emitted once per invocation however deep the first
    projection point is — enforced by the shared SubstitutionState, checked by
    NS-H6.
  • Projected caller content is never traversed for projection points, so an
    unclaimed <Content /> stays the reserved-name failure — enforced by walking
    only the body, checked by NS-H9.
  • <Content /> outside a component body is still the reserved-name failure —
    unchanged; the exemption in makeProjectFn skips 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.ts
  • NS-H1NS-H5, NS-H7, NS-H8 prove substitution at a nested invocation, at
    depth, at a nested named slot, at two different depths at once, inside
    <Output>, inside <Capture>, and inside <Each>, by rendered output. All
    fail on the unmodified engine with the reserved-name error from the issue.
  • XP26 proves identity directly, by reading Expansion.id from a probe
    projected into a <Content /> written inside <Each>: the two iterations
    carry different identifiers and re-expansion reproduces the ordered pair.
    Rendered output cannot show this; NS-H8 makes no identity claim.
  • NS-H9 proves an unclaimed caller-authored <Content /> passed through a
    nested wrapper stays the reserved-name failure instead of being consumed by
    the enclosing component.
  • NS-H10 exercises the specification's
    <Section slot="header"><Content slot="header" /></Section> form and proves
    the selected outer slot reaches the nested invocation's named slot.
  • NS-H6 proves slot errors stay once-only when the first projection point is
    nested; it would catch a per-position SubstitutionState.
  • O41 proves the content-scope rule. The wrapper completes before the provider
    retains its own resource, so the timeline start:projected, start:own, stop:projected, stop:own fails if the projection moved into the wrapper's
    scope — stop:projected would land before start:own.

Each of the three settled behaviors was checked against a mutation of the
implementation, each applied to the real source and reverted afterwards:

Mutation Caught by
expandClaimed derives its path from the invocation instead of the element XP26, XP13
substitution walks the selected children as well as the body NS-H9, NS-H10 and six more
the claimed element keeps its consumed slot prop NS-H10, NS-H3

O41 and all of Tier NS-H were separately confirmed failing against the
unmodified engine.

Full run on this branch: deno task lint 0 errors and format clean,
deno task check clean, deno task check:jsr Success,
deno task test --changed=origin/main green, git diff --check clean.

Scope

Included

  • Recursive <Content /> substitution through a component body.
  • Claim identity preserved across re-projection.
  • slot consumed by the projection that reads it.
  • Spec §6.3, §6.3.4, §6.3.7 and conformance rows C50, O41 and XP26.

Intentionally unchanged

  • The reserved-name diagnostic for <Content /> written outside a component
    body. The rule it states is now true everywhere in a body, so it needs no
    rewording.
  • The two Synchronize the #181 living target with settled XMD contracts #292 authored components that worked around this by taking the
    material as a prop. Reverting them belongs to that target, not to this fix.
  • renderChildren(), useContent() and content() — programmatic projection
    never had the restriction.

Generated or mechanical changes

  • named-slots.test.ts loses its eight // ═══ section-divider blocks per
    AGENTS.md rule 10. No behavior change; every tier is already named by its
    describe. Worth knowing: local/no-section-divider-comments matches
    [-=_*]{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.
  • §6.3.4's substituteContent pseudocode is replaced with the algorithm in
    prose. 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

  • A nested <Content slot="x" /> now lands in the enclosing invocation's default
    slot. Nothing could have depended on the prior behavior, because the element
    never resolved at all in that position.
  • Substitution allocates a new element for each ancestor of a projection point.
    Elements no projection reached are returned by identity, so a body without
    <Content /> is unchanged.

`<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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 2 redundant comments. Inline suggestions to remove them below.

Comment thread specs/executable-mdx-spec.md Outdated
if (segment.name === "Content") {
const targetSlot = segment.props.slot as string | undefined;
if (targetSlot !== undefined) {
// Named slot projection — strip slot prop from each child

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// Named slot projection — strip slot prop from each child

Comment thread specs/executable-mdx-spec.md Outdated
// Named slot projection — strip slot prop from each child
return (slots.named.get(targetSlot) ?? []).map(stripSlotProp);
}
// Default slot projection

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// Default slot projection

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

PR #402: 🐛 Substitute at every position in a body

5 files, +270 / -57

Scope

✅ PR scope looks good.

Structural

Oxlint structural signals:

  • no-unused-vars ×4: packages/core/src/expand.ts
  • no-unnecessary-type-assertion ×1: packages/core/src/expand.ts

Slop

Oxlint slop signals:

  • no-inferrable-types ×6: packages/core/src/expand.ts

Static Analysis

Oxlint: 22 diagnostics across 1 file (9 rules)
Density: 0.081 violations/added-line

no-inferrable-types (6): packages/core/src/expand.ts
no-unused-vars (4): packages/core/src/expand.ts
no-base-to-string (4): packages/core/src/expand.ts
no-unsafe-type-assertion (3): packages/core/src/expand.ts
no-shadow (1): packages/core/src/expand.ts
restrict-template-expressions (1): packages/core/src/expand.ts
no-unnecessary-type-assertion (1): packages/core/src/expand.ts
unbound-method (1): packages/core/src/expand.ts
no-implied-eval (1): packages/core/src/expand.ts

Correctness

FILE: 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?

@taras
taras marked this pull request as ready for review August 9, 2026 03:23
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.
@taras
taras enabled auto-merge (squash) August 9, 2026 03:39
@taras
taras merged commit d9057ab into main Aug 9, 2026
11 checks passed
@taras
taras deleted the agent/issue-328-nested-content branch August 9, 2026 03:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

<Content /> is not substituted when nested inside another element in a component body

1 participant