Ref Exposure and Composition Scoping
194 - Ref exposure and scoping across composition boundaries (slot model)
Written for AI agents. See Log Methodology Note below for details.
Status: Phase A implemented (slot contract tag + validation). Phases B–C pending. See Implementation Results.
Extracted from DL#193 (which grew to cover binding + Phases 1–4 + two ref refinements). This log owns the single question: when component A composes/overrides component B, which of B's refs become visible to A, and in which ViewState scope are they typed and delivered.
It replaces the earlier hasCodeFile-only "two cases + suppression" draft with a contract-declared
slot tag model. The slot tag makes override targets explicit instead of inferred, which (a)
protects coded components from having ref-type assumptions broken by arbitrary overrides, and (b) gives
the compiler a first-class signal for which scope a ref belongs to.
Central simplification (this revision): a Tier 2 composite is fully inlined into the parent scope.
A no-code component has no independent reactivity — its ViewState is a pure compile-time projection of
usage-site data. So instead of giving it a runtime boundary and bridging refs back to the parent (the
DL#193 __parentContext / parentDataChain / (vs,_p1)=>_p1 machinery), the compiler splices the
component's template into the usage site, substitutes contract-ViewState bindings with the usage-site
prop expressions, and constructs its refs in the parent's ReferencesManager. Refs are then
external-scope by construction — no forwarding, no re-basing, no __parentContext. Tier 2 slots become
plain compile-time template composition (default content and override fragment both inline in parent
scope). The parent-bound render-function injection (higher-order constructor) is needed only for
Tier 3, whose .ts is a genuine boundary you must inject across.
Related: DL#181 (override — its content-replace is subsumed by slots and remove is dropped; its
attribute/style merge is retained on any ref), DL#187 (three-tier
headfull model), DL#162 (structural components), DL#193 ($parent binding + Phase-3 ref forwarding).
Decisions for the Implementer (TL;DR)
A slot is a scopeless compile-time hole. It has no ViewState scope of its own. The jay-html
fragment the parent places into it (the override) carries the parent's scope — for both ViewState
bindings and refs. Default content (authored by the component in its own template) carries the
component's own scope. Slots are compile-time artifacts, not runtime — they carry no phase
(treated as slow), unlike interactive tags which are implicitly fast+interactive.
Ref surfaces, by contract tag type:
| What | interactive (existing) |
slot (new) |
|---|---|---|
| What it is: | the component's own ref | a declared override target |
| Scope of its refs: | Tier 3 → own; Tier 2 → external (inlined) |
override fragment → parent; default content → own |
| Overridable: | attribute/style merge only (any ref; presentation-safe) | content fill — the only content-override target |
| Exposed to consumer: | contract-declared → yes; internal (jay-html-only) → Tier 3 no, Tier 2 yes |
injected refs yes (parent scope, keyed by slot name) |
slotis a newContractTagType(contract.ts:3), alongsidedata/interactive/variant/subContract. It declares a named region a parent may override; the injected refs are surfaced on the parent, keyed under the slot name, in the parent's (external) scope. Slots may carry default content (rendered when not overridden). Slots work at all tiers, carry no phase, and (v1) take no type constraint on injected content.Two
<override>forms, split by the addressing attribute (chosen: reuse<override>, not a new tag). The form is selected byref=vsslot=, not by presence of children.- Attribute form —
<override ref="X" style=… class=… attr=… />merges attributes onto the existing element, per-key (styleper-CSS-property via cascade). Allowed on anyref, all tiers. Safe by construction: element identity and type are preserved, so a coded component'srefs.Xstill resolves to the same element of the same type. This is the only part of DL#181 retained — the efficient "restyle a composite without externalizing design props" model. - Content form —
<override slot="X">…children…</override>fills declared slotX(atype: slottag; else compile error, prevention-first). Rationale (the user's): a coded component's.tsassumes each ref's element/component type; replacing content at an arbitrary ref silently breaks that. A slot is the author's explicit "this location is safe to replace." An empty<override slot="X"></override>renders the slot with nothing — the clean replacement for DL#181remove. ref=addresses a template element (the universal anchor, DL#181 A2);slot=names a declared slot. Keepingreffor the template marking and splitting only the usage-site verb keeps one addressing mechanism in templates.
- Attribute form —
Own-ref exposure & scope, by
hasCodeFile(jay-html-parser.ts:1242):- Tier 3 (coded). The component's contract-declared
interactiverefs are its public Refs API — exposed, in the component's own scope (the component computes/emits them). Its internal (jay-html-only, not in contract) refs are encapsulated. Override-content refs are added to this Refs type (see 4). - Tier 2 (no-code design composite). No
.ts, no independent reactivity → the composite is fully inlined into the parent scope. Contract-ViewState bindings are substituted with the usage-site prop expressions at compile time; all owninteractiverefs (contract-declared and jay-html-only) are constructed in the parentReferencesManager, so they are external scope by construction — no forwarding, no__parentContext, no(vs,_p1)=>_p1re-basing. A no-code component that references itself (directly or transitively) can't be inlined → compile error: add a.ts(make it Tier 3). Inlining is the Tier 2-only concern (it replaces the earlier "forwarding" phase and subsumes the Tier-1 removal).
- Tier 3 (coded). The component's contract-declared
Tier 3 Refs type extends the original component Refs. The generated Refs for a coded component with slots extends its contract-derived Refs (declared events/functions, own scope) and adds the override-content refs keyed by slot name (parent scope). E.g.
interface _CodedCardRefs extends CardRefs { body: { cta: CounterRef<AppViewState> } }.No "suppression" heuristic, no provenance markers. The replaceable location is the slot, by declaration — its default content is replaced by the override by definition. Scope is read from the tag type, so DL#193's
OVERRIDE_INJECTED_MARKERand the earlier "suppress the overridden ref" rule are both gone.
Two tiers only (Tier 1 removed). A <jay:X> whose component has no contract is treated as Tier 2
with an empty contract — the DL#162 no-contract/empty unwrap is deleted, no structural special-case,
no warning machinery (Q5).
Parent-facing exposed set, per tier:
- Tier 2 (jay-html + contract, no
.ts) — inlined: owninteractiverefs (contract-declared + jay-html) at external scope; + slot refs at external scope, keyed by slot name. All refs are just the parent's own refs (the composite has no boundary); grouping under the usage-site key is a naming convenience, not a scope boundary. - Tier 3 (jay-html + contract +
.ts) — real boundary: contract-declared Refs (own scope) + slot-injected refs (parent scope, keyed by slot name). Internal jay-html refs encapsulated. Own refs are not forwarded — the.tsowns them.
Non-obvious constraints:
- "External scope" = the ViewState at the composite's usage site — page scope for a root instance,
forEach-item scope for a repeated instance. Tier 2 refs reach external scope by construction: the composite is inlined and its refs register in the parentReferencesManager— no re-basing selector, no__parentContext. Tier 3 slot-injected refs (§5) also get external scope structurally — their render function is built in and bound to the parentReferencesManager. Neither path uses the DL#193(vs,_p1)=>_p1re-basing selector; DL#194 no longer needs__parentContextfor ref scoping (its general handler-offset role in DL#193 is out of this log's scope). - Two independent axes — do not conflate. (1) Template data bindings (
<h3>{heading}</h3>) resolve against the component's own ViewState — for Tier 2 that ViewState is the compile-time projection substituted from the usage site; for Tier 3 it is computed by the.ts. (2) Exposed refs scope per the table above. Scope rules here govern (2) only, never (1).
Background
DL#187 defines three headfull tiers by file presence: (1) jay-html only, (2) jay-html + contract
("pure headfull", no code), (3) jay-html + contract + .ts (coded). DL#162's structural components are
Tier 2's degenerate (empty-tags) case. DL#181 let a usage site override a component's markup at any
ref anchor. DL#193 Phase 3 forwards a structural component's inner component refs to the usage
site; refinement 1 re-based override-injected refs to the outer scope while keeping owned refs at
composite scope.
Problem
Two defects in the pre-slot model:
Coded-component safety. DL#181 lets a consumer override any ref. But a coded component's
.tsis written against specific ref types (refs.ctais aCounterRef,refs.titleanHTMLElementProxy). A consumer override that swaps a ref's element/component type silently breaks those assumptions. No author opt-in, no compile-time guard.Wrong scope for a no-code component's own refs. A no-code card with its own
<jay:Counter ref="cardCounter">typed the refCounterRef<CardViewState>— the card's internal scope. But the card has no.ts; whoever wirescardCounterworks at the usage site in the external scope (AppViewState), notCardViewState.
The slot model fixes (1) by making override an explicit, author-declared extension point, and (2) by
inlining a no-code component so its own interactive refs are the parent's, at external scope, by
construction.
Prior Art / Adjacent Mechanisms
ContractTagType(contract.ts:3) — enumdata | interactive | variant | subContract. Addingslotis a clean fifth member; the parser (contract-parser.ts:119-122) maps YAMLtype:strings.interactivetag handling — requireselementType(contract-parser.ts:180), implicitlyfast+interactive(contract-phase-validator.ts:22), excluded from ViewState / routed to Refs (contract-phase-validator.ts:57).slotreuses "Refs, not ViewState" but is phaseless/slow and requires noelementType.hasCodeFile(jay-html-parser.ts:1242) — the existing discriminator for own-ref encapsulation (Tier 3) vs inlining (Tier 2).- DL#193
__parentContext/$parent/parentDataChain— delivered external scope into a composite while keeping a runtime boundary. This revision removes the Tier 2 boundary entirely, so the bridge is unnecessary for Tier 2 and the(vs,_p1)=>_p1re-basing selector is not emitted. Tier 3 slots are parent-bound via a render function (§5), not this selector. DL#194 therefore needs neither__parentContextnorparentDataChainfor ref scoping. filterToComponentRefs/hasNamedComponentRefs(jay-html-compile-refs.ts:56,68) limited forwarding to component refs. With Tier 2 inlined, element refs are parent-native — no ref-kind filtering is needed for exposure.getForwardedInnerRef(node-reference.ts:273) is ref-kind-agnostic and does no re-basing (line 270); it stays on the collection path but is no longer the DL#194 exposure mechanism.- DL#181 override — the
<override>element andrefanchoring are reused. Its attribute/style merge form is retained verbatim (safe: preserves element type;styleper-property via cascade,jay-html-overrides.tsapplyOverrides). Its content-replace form is subsumed by slots (content now must target atype: slotref); itsremoveoperation is dropped. - DL#162 empty-contract unwrap (
jay-html-parser.ts:1245-1254) — deleted under this model. A no-contract<jay:X>becomes Tier 2 with an empty contract (empty ViewState, owninteractiverefs parent-native via inlining, no slots) — no unwrap, no structural special-case. Inlining a Tier 2 is the modern form of DL#162 force-inline, now restricted to the no-code tier (Tier 3 keeps its boundary).
Design
1. The slot contract tag
Add slot to ContractTagType and the parser. A slot declares a named, overridable region — no phase,
no type constraint (v1):
# card.jay-contract
name: Card
tags:
- tag: heading
type: data
dataType: string
- tag: body # ← the override target
type: slot
required: false # true → the parent MUST override it (else compile error)
Template side reuses DL#181's ref anchoring: the element whose ref matches a slot tag is the
slot; its children are the default content. At the usage site the slot is filled with
<override slot="body">…</override> (§2) — slot=, not ref=.
<!-- card.jay-html -->
<div class="card">
<h3>{heading}</h3>
<!-- data binding: card's own scope -->
<div ref="body">
<!-- slot `body` (scopeless hole) -->
<p>Default body</p>
<!-- default content: card's own scope; replaced by an override -->
</div>
</div>
Validation (compiler, prevention-first):
<override slot="X">must resolveXto atype: slottag → else `"<override slot="body"> — no slot "body" in. Declare it as type: slot, or use
<override ref="body" …/> to restyle an existing element."`
<override ref="X" … />(attribute form) is allowed on any ref (contract-declared, slot, or override-only) — it never changes element type. Children under aref=override → compile error ("useslot=to fill a slot").required: trueslot with no<override slot=…>at a usage site → compile error.removeis no longer a keyword — it is not recognized specially and is treated as an ordinary attribute. Dropped; use an empty<override slot="X"></override>. (On the attribute form it would merge as aremoveattribute; on the slot form any attributes are already a compile error.)
2. Two <override> forms: attribute (any ref) vs content (slots only)
<override> keeps DL#181's two forms, now split by safety:
- Attribute form (
<override ref="X" style=… class=… />, no children): merges attributes onto the existing element — per-key,styleper-CSS-property via CSS cascade so the component's stylesheet stays a black box (DL#181 A5). Allowed on any ref because it preserves element identity/type. Any{binding}in a value resolves at the parent scope (DL#193 §C), like slot content. - Content form (
<override slot="X">…children…</override>): fills declared slotX(atype: slottag). The structural, type-changing operation that needs author opt-in. The form is chosen by the addressing attribute (slot=here vsref=above), not by presence of children — so an empty<override slot="X"></override>unambiguously means "render the slot with nothing."
DL#181's remove is dropped — an empty slot fill (or if/variant) replaces it.
3. Scope rules (read from the tag type)
- Slot is scopeless. The override fragment the parent places carries the parent (external)
scope for both ViewState bindings and refs. For Tier 2 this is automatic — the whole composite is
inlined into the parent, so the fragment is already parent-scope template. For Tier 3 it is achieved
structurally (§5): the fragment's render function is built in the parent scope and bound to the parent
ReferencesManager+eventWrapper, so its refs are parent-owned by construction (no re-basing selector). Default content (author-placed) carries the component's own scope (Tier 3) / the substituted projection scope (Tier 2, inlined). interactiveref scope byhasCodeFile: Tier 3 → own scope, handled by.ts; Tier 2 → external scope, parent-native via inlining (Phase B) — the consumer wires them like its own refs.
4. Default content
A slot renders its template children when the usage site gives no <override>; an override replaces
them. Default content compiles in the component's own scope; an override compiles in the parent scope. For
Tier 2 both are inlined directly (compile-time template composition); for Tier 3 the override is a
parent-bound render function passed in place of the default when present (§5).
5. How slots are filled — inline (Tier 2) vs render-function injection (Tier 3)
The two tiers fill slots by fundamentally different mechanisms, because only Tier 3 has a runtime boundary to inject across.
5a. Tier 2 — inline (no boundary, no injection)
A no-code composite is spliced into the parent at compile time. The component's template becomes parent
template; its contract-ViewState bindings are substituted with the usage-site prop expressions; the
slot's default content and any <override slot="X"> fragment are placed inline at the [ref="X"] anchor.
Everything ends up in parent scope, so refs — the component's own interactive refs and any override
refs — register in the parent ReferencesManager directly.
<!-- usage site: card is Tier 2 (no .ts); body slot filled with a Counter -->
<jay:Card heading="{item.title}">
<override slot="body"><jay:Counter ref="cta" /></override>
</jay:Card>
compiles (conceptually) to the card body inlined into the parent render, with {heading} → {item.title}:
// parent render, item scope — NO childComp for the card, NO makeHeadlessInstanceComponent
e(
'div',
{ class: 'card' },
[
e('h3', {}, [dt((vs) => vs.item.title)]), // {heading} substituted → item.title
e(
'div',
{ class: 'card-body' },
[
childComp(Counter, () => ({ initialValue: 0 }), refCta()), // refCta ∈ PARENT ref manager
],
refBody(),
),
],
refCard(),
);
- No
childComp(makeCardWithSlots(…)), no__parentContext, no(vs,_p1)=>_p1. The card's refs (refCard,refBody) and the override's (refCta) are all the parent's. - Recursion guard: a Tier 2 that references itself directly or transitively can't be inlined →
compile error asking the author to add a
.ts(promote to Tier 3, which introduces a real boundary).
Why substitution is forced (not one option among several). A ref captures its viewState and
coordinate from the single live currentConstructionContext().currData at construction
(references-manager.ts:57-68, node-reference.ts:335-342); template bindings read that same currData
(element.ts:583-602). One scope, one currData — refs and bindings can't sit in different scopes within
one fragment. The only thing that ever split them was the child boundary + __parentContext forwarding.
So the fragment must render at one scope, and since refs must be external (the DL's core
requirement), that scope is the parent's — which forces the card's own bindings to be rewritten to
parent-scope expressions. Scope-switching to the projection instead (e.g. via the existing withData
primitive, element.ts:400-410 / context.ts forAsync) would make bindings trivial but push refs into the
projection scope, re-requiring the very re-basing machinery we are removing. Dead end. Substitution is the
price of deleting the boundary, not a stylistic choice.
What "substitution" concretely is (and is not). It is not textual find-replace. The mapping
heading := item.title is exactly the getProps projection the compiler already emits for today's
childComp (element.ts:42-56) — inlining relocates it from a runtime child-prop into the binding
accessors. Bindings compile to coordinate-free (vs) => … accessors (dt/da/dp/ba, element.ts:94-97),
so the transform is: in each card binding accessor, replace every reference that resolves to a card
root ViewState field with that field's usage-site prop expression (root.heading → root.item.title;
chained access keeps the suffix — root.heading.length → root.item.title.length). Implementation seam — an alias map on Variables, resolved in resolveAccessor (no grammar change).
Every accessor in the PEG grammar funnels through the single method Variables.resolveAccessor
(expression-parser.pegjs:528,547,649,672,685,691), which already hosts two "resolve against a remapped
scope without rewriting the text" mechanisms: $parent climbing and withParentShift
(expression-compiler.ts:175-245). DL#193 already uses that seam for override content
(remapOverrideBindingsToParent + PARENT_SCOPE_PRAGMA → withParentShift(1),
jay-html-overrides.ts:84, expression-compiler.ts:464). Tier-2 substitution is the same idea generalized
from "shift up N levels" to "map each root field to an arbitrary parent accessor":
- Resolve each usage-site prop expression against the parent
Variables(parseAccessor("item.title", parentVars)) → anAccessor(itsrootVar/terms/parentLevelare already correct for the inlining site). - Build a card-root
Variablesseeded with the card contract type andaliases: Record<string, Accessor>(one per contract data field, from step 1). Seed one for every contract field; a missing usage-site prop is the existing "required prop not passed" validation. - In
resolveAccessor, before the default field walk, ifaccessor[0]matches an alias, return the aliasAccessorwith the remaining terms appended (and itsresolvedTypewalked for those terms) — exactly parallel to the$parentbranch. Compile the whole card template against this scope.
Static/literal props (refinement, found in implementation). A usage-site prop is not always a
dynamic {expr} — it is frequently a static literal (label="Live Status", status="success",
count="42", bare featured). Under childComp these were coerced to the declared dataType
(coerceStaticComponentProp: Status.success / 42 / true / quoted string) and passed as props;
inlined, the same coercion must reach the card bindings. So the alias value covers two shapes:
(a) dynamic — a single-accessor prop ({item.title}), seeded via parseAccessor against the parent
scope (DL#187 restricts a structural component to scalar/enum props, so a dynamic prop is always a single
accessor, never a template); (b) static — a literal Accessor carrying the coerced literal as a
render override plus the declared prop type as resolvedType (so a card-internal enum comparison
status == success still renders Status.success on the RHS). Accessor.render() returns the literal
directly when present, and a literal is scope-independent so a $parent climb that lands on it is returned
as-is (no _pN wrapping). This keeps a single seam (aliases on Variables) for both prop kinds.
Because it lives in resolveAccessor, it inherits the machinery DL#193 already built
(lexicallyInScope / parentDepth, expression-compiler.ts:108-149,503-517) and is unit-testable in
isolation (expression-compiler.unit.test.ts): construct a Variables with aliases, parse a binding,
assert the rendered accessor string — no element/DOM codegen needed.
- Card-internal nested scopes (a
forEach/if/withDatainside the card template) are respected for free: child scopes are built from the resolved (aliased) accessor's type viachildVariableFor, so an inner loop var resolves in the item scope (not aliased), while$parentfrom inside climbing to a card-root field still hits the alias through the preserved.parentchain. - Usage-site depth is automatic. The prop expressions (
item.title) are resolved against the parent scope, so the aliased accessor already sits at the correct depth — inlining is in-place, no extra_pNplumbing. $parentclimbing to/above the card root (a Tier 2 reaching past itself into its consumer) is the one case aliasing can't express in v1 — it needs a downward depth shift the currentwithParentShift(upward only) doesn't provide. v1 restriction (prevention-first): a Tier 2 template may not use a root-level$parent— compile error directing the author to add a.ts. (Card-internal$parentto card-root fields is fine — it resolves via the aliased parent chain.) Lifting the root-level case is future work.
5b. Tier 3 — parent-bound render-function injection
A coded component is a real runtime boundary (its .ts owns render + scope), so the slot content can't be
inlined — it must be injected as a render function created in the parent scope and handed to the
component. No new runtime primitive: it uses saveContext/restoreContext (context.ts:119-136) plus the
existing ReferencesManager eventWrapper (references-manager.ts:135).
childComp(
makeCardWithSlots({ // higher-order constructor: slot fns → child ctor
body: withParentContext(parentCtx, () =>
childComp(Counter, () => ({ initialValue: 0 }), refCta()) // refCta ∈ PARENT ref manager
),
}),
(vs) => ({ heading: 'Sign up' }), // props only — no __parentContext needed
refSignupCard(),
)
function makeCardWithSlots(slots) {
return makeJayStackComponent( // Tier 3 inner ctor: .ts-owned render
(options) => cardRender(options, slots), // render factory receives the slot fns
cardComponentDef, coordinateKey,
);
}
// inside the .ts-owned cardRender, at the slot anchor:
e('div', { class: 'card-body' }, [ slots.body ? slots.body() : /* default content, own scope */ ], refBody())
- In: the parent builds
slots.bodyas a render function bound to itself — closing over the parent ref constructors + the parent'seventWrapper, wrapped bywithParentContextso its refs register with the parent manager and capture parent data/coordinates at invocation. The child invokes it at the[ref="body"]anchor; absent → the component's own default content (own scope). - Out: the injected fragment's refs are already the parent's, surfaced keyed by slot name —
refs.card.body.cta— at parent scope, with no(vs,_p1)=>_p1re-basing.
For Tier 3 the generated Refs extends the component's contract-derived Refs and adds the slot-keyed override refs:
interface _CodedCardRefs extends CardRefs {
// CardRefs = declared events/functions, own scope
body: { cta: CounterRef<AppViewState> }; // override content, parent scope, keyed by slot
}
The coded component stays imported (.ts-owned render); the slot fns are the only thing the parent
injects. This answers Q1: the "slots prop" is a set of parent-bound render functions, and the ref-return
channel falls out for free because the refs were never the child's to begin with. The higher-order
constructor + withParentContext are Tier-3-only — Tier 2 needs none of it (5a).
6. Future: slot props (phase 2)
A slot may later declare props the component passes to the slot-content render function — e.g. a
product gallery with a productCard slot exposing a productId slot prop. This solves the repeated
(forEach) slot constraint: a repeated slot passes each item's data to the content render function
instead of the content reaching into forEach scope directly. Marked as a future extension; v1 slots
are single-region and prop-less (Q4).
Implementation Plan
Phase A — slot contract tag + validation (prevention-first, no runtime yet):
- Add
ContractTagType.slot; parsetype: slot; noelementTyperequirement; phaseless (slow); route to Refs (not ViewState). - Validation:
<override slot=…>must resolve to a slot; the<override ref=… />attribute form is allowed on any ref; children under aref=override are a compile error;requiredslot must be filled. InapplyOverrides(jay-html-overrides.ts), keep the attribute/style merge (nowref=), route content byslot=to a slot target, and drop theremovebranch entirely (removeis no longer a keyword). Fixture-based tests with clear error strings.
Phase B — Tier 2 inlining (fixes cardCounter; subsumes Tier-1 removal): 3. For a Tier 2 (no-code) composite, stop emitting a childComp boundary. Instead splice the
component template into the parent render: (a) binding substitution via an alias map — add
aliases: Record<string, Accessor> to Variables; seed it by resolving each usage-site prop expression
against the parent scope; handle it in resolveAccessor (before the default field walk) exactly like the
existing $parent/withParentShift branches, so card-root field accessors resolve to the parent
expression and card-internal scopes are respected for free (§5a). No PEG grammar change. (b) construct
the component's own interactive refs (contract-declared + jay-html-only) in the parent
ReferencesManager, so they are external scope by construction — no __parentContext, no
(vs,_p1)=>_p1, no filterToComponentRefs forwarding. Element and hydrate targets both. Slots on a
Tier 2 are inlined the same way (default content + <override slot="X"> fragment placed at the
[ref="X"] anchor, §5a).
Unit tests first (expression-compiler.unit.test.ts): alias resolution for a plain field
(heading → vs.item.title), chained access (heading.length), a card-internal forEach whose inner
var is not aliased, and a card-internal $parent to a card-root field that is aliased — asserting
rendered accessor strings, no element codegen. 4. Recursion validation (prevention-first): a no-code component that references itself directly or
transitively → compile error ("<jay:X> recurses; add an X.ts to make it Tier 3"). Detect during
the compile-time inline expansion (cycle in the inlined-component graph).
4b. Root-level $parent validation (prevention-first): a Tier 2 template using a root-level
$parent (reaching past the composite into its consumer) needs a downward depth shift aliasing can't
express in v1 → compile error directing the author to add a .ts (Tier 3). Card-internal $parent
to card-root fields is allowed. Lifting the root-level case is future work. 5. Remove Tier 1: a <jay:X> with no contract is Tier 2 with an empty contract → inlined by the same
path; delete the DL#162 empty/no-contract unwrap and its structural special-case, no warning machinery.
Update DL#162/#187/#193 tests. 6. Regenerate the override-ref-forwarding example: cardCounter → CounterRef<AppViewState> (now via
inlining, not forwarding). Fixtures: Tier 2 card with own ref + slot (default + overridden, single +
forEach) showing distinct external vs projection scopes.
Phase C — Tier 3 slot injection via a parent-bound render function + default content: 7. For a Tier 3 (coded) composite only, compile each <override slot="X"> fragment into a render
function created in the parent scope, closing over the parent ref constructors + the parent's
eventWrapper, wrapped with a compiler helper withParentContext (saveContext() at creation /
restoreContext() at invocation, context.ts:119-136) so its refs register with the parent
ReferencesManager and capture parent data/coordinates even though the child invokes it. Pass the slot
fns to a higher-order component constructor —
makeCardWithSlots(slots) → makeJayStackComponent((options) => render(options, slots), …). The child
render invokes slots.X() at the [ref="X"] anchor, falling back to the component's own default
content (own scope) when absent. Surface injected refs on the parent, keyed by slot name
(refs.card.X.cta). No (vs,_p1)=>_p1 re-basing for slot refs — they are parent-owned by
construction and on update. Validate the update path: the slot fragment updates from the parent's
reaction (parent context read live, element.ts:593-595), never fed child viewState (the child positions
the slot DOM + mount/unmount; the parent owns the slot data update). Fixtures: Tier 3 card with a slot
(default + overridden, single + forEach).
Examples
No-code card (Tier 2, inlined) — contract declares a body slot; card owns cardCounter. The card is
spliced into the page render, so both refs are the page's own refs at external scope (grouped under the
usage-site key for naming only — no runtime boundary):
// app.jay-html.d.ts (generated at the usage site)
export interface _HeadlessCard0Refs {
cardCounter: CounterRef<AppViewState>; // own interactive ref, inlined → parent-native, external scope
body: { cta: CounterRef<AppViewState> }; // slot content inlined at [ref="body"] → external scope
}
Coded card (Tier 3) — same body slot; card's .ts handles internal refs and exposes declared events:
export interface _CodedCardRefs extends CardRefs {
// CardRefs = declared events/functions (own scope)
body: { cta: CounterRef<AppViewState> }; // only slot-injected refs added, external scope
// internal (non-contract) refs are encapsulated
}
Override forms (prevention-first):
<!-- ✅ attribute form (ref=): restyle ANY element, identity/type preserved -->
<jay:Card><override ref="hero" style="border-radius: 16px" class="featured" /></jay:Card>
<!-- ❌ children under a ref= override → compile error (use slot=) -->
<jay:Card
><override ref="title"><h3>Hi</h3></override></jay:Card
>
<!-- ✅ content form (slot=): fill a declared slot -->
<jay:Card
><override slot="body"><jay:Counter ref="cta" /></override
></jay:Card>
<!-- ✅ empty slot fill = the old `remove` -->
<jay:Card><override slot="body"></override></jay:Card>
Trade-offs
- Author cooperation for content overrides (vs DL#181). Content overrides now need a declared
slot — costs "replace any content" flexibility, buys type safety and a clear, designer-facing
extension surface. Attribute/style overrides stay free on any ref (safe), preserving DL#181's
efficient restyle-without-design-props model.
removeis dropped. - New contract surface.
slotis genuinely new (null-hypothesis: no existing tag encodes "parent-scope, overridable, default-content-bearing region" —interactiveis own-scope, non-overridable, phased). Justified: it removes two inferred mechanisms (provenance markers, overridden-ref suppression) and makes Tier 3 override tractable via render-function injection. - Tier 2 inlining removes runtime surface (net subtraction). No
childCompboundary, no__parentContext, noparentDataChain, no(vs,_p1)=>_p1re-basing, no element-ref forwarding for the no-code tier. The ViewState projection becomes a compile-time binding substitution. Cost: a Tier 2 can't recurse (must add a.ts) — a rare case, caught at compile time with a clear message. - Breaking: narrows DL#181 override targets, removes the DL#162 unwrap, and drops the Tier 2 component boundary; existing outputs/tests change. Acceptable per the project's "no backward compatibility" stance.
Verification Criteria
- No-code card (inlined):
cardCounter(own interactive) andbody.cta(slot content) both external scope;onChange/find/mappayload viewState is the external item; generated output has nochildComp(makeCard…)boundary,__parentContext, or(vs,_p1)=>_p1for the card. - A no-code card that references itself → compile error asking for a
.ts(recursion guard). - A no-code card whose template uses a root-level
$parent→ compile error asking for a.ts(v1 restriction). Card-internal$parentto card-root fields resolves via the aliased parent chain. - Binding substitution (alias map in
resolveAccessor) respects card-internalforEach/ifscopes: an inner-scope var is not aliased; only card-root ViewState fields resolve to the usage-site prop expressions. Covered byexpression-compiler.unit.test.tsalias-resolution unit tests. - Coded card: Refs extends the contract-derived Refs (own scope); internal refs absent;
body.ctapresent at external scope, keyed by slot name; injected via the higher-order constructor. <override ref=…>(attribute form) merges onto any ref (per-key;styleper-property), element type unchanged.<override slot=…>fills a declared slot (empty = renders nothing).slot=on a non-slot, children under aref=override, or content on aref=override → compile error;requiredslot unfilled → error. (removeis no longer a keyword — treated as an ordinary attribute.)- Slot with no override renders default content; with an override renders (and surfaces refs of) the fragment keyed by slot name (Tier 2: inlined; Tier 3: injected render function).
- No-contract
<jay:X>compiles as Tier 2 with an empty contract (inlined, own interactive refs external scope, no slots so not content-overridable); the DL#162 unwrap is gone and no warning is emitted. - All four targets consistent (element, main-sandbox, hydrate; server = ref no-op). Fixtures use distinct external vs projection viewStates so a regression to composite scope fails.
Questions and Answers
Q1. Tier 3 slot channel — generated shape? Prop name/signature for the slot render function and the
ref-return typing that surfaces injected refs (keyed by slot name) at external scope. Requires a trace
of childComp construction + the override compile path (applyHeadfullOverrides,
parseHeadfullFSImports) and the coded-component ref boundary before committing.
Answer (finalized — see §5b): Tier 3 only. The "slots prop" is a set of parent-bound render
functions keyed by slot name, injected via a compiler-generated higher-order component constructor
(makeCardWithSlots(slots) → makeJayStackComponent((options) => render(options, slots), …)). Each slot fn
is built in the parent scope and bound (via withParentContext = saveContext/restoreContext) to the
parent ReferencesManager + eventWrapper, so the ref-return channel needs no explicit wiring — the
injected refs are the parent's by construction and surface keyed by slot name. No new runtime primitive;
the update path is validated by having the parent own the slot fragment's update (child positions the DOM
only). Tier 2 does not use this — it is inlined (§5a), so slot content is plain compile-time template
composition in parent scope.
Q2. Type constraint on a slot? Answer: not required. No elementType on slots in v1. (Future
slot props — §6 — may add typed inputs, but not a constraint on injected content.)
Q3. Keying of injected refs. Answer: slot-override refs are keyed by the slot name in the
component Refs type (refs.card.body.cta), added to (Tier 3: extending) the component's Refs. A slot
name colliding with a declared ref name → compile error.
Q4. Repeated / list slots? Answer: single region for v1. Repeated slots require slot props (§6) —
the item data is passed to the slot-content render function — so they land in phase 2, together with
slot props. This also resolves the earlier slots-under-forEach constraint.
Q5. Tier 1 (no-contract) handling. Answer (revised): Tier 1 is removed. A <jay:X> whose
component has no contract is treated as Tier 2 with an empty contract — same compile path, empty
ViewState, own interactive refs parent-native via inlining. The DL#162 empty/no-contract unwrap is
deleted; no structural special-case, no build warning. (An empty contract simply declares no slots, so
such a component still can't be content-overridden — but that falls out of the Tier 2 rules, not a
separate tier.)
Q6. Why inline Tier 2 instead of keeping a boundary + forwarding? Answer (this revision): a no-code
component has no independent reactivity — its ViewState is a pure projection of usage-site data, so the
runtime boundary buys nothing and forces a bridge (__parentContext / parentDataChain / (vs,_p1)=>_p1)
to pull refs back to external scope. Inlining makes the projection a compile-time binding substitution and
lands refs in the parent ReferencesManager directly — strictly less machinery (null-hypothesis: the
boundary was never needed for the no-code tier). Confirmed safe: (1) jay-html is never sandboxed, so
inlining can't defeat isolation; (2) there is no need to keep a usage-site ref to the Tier 2 instance;
(3) recursion is disallowed for no-code components (add a .ts → Tier 3). Tier 3 keeps its boundary
because its .ts is real code that can't be inlined.
Q7. Is the {heading} → {item.title} binding substitution tractable, or is it a fragile rewrite?
Answer (traced — see §5a): tractable, and it is forced, not a fragile textual rewrite. (a) Refs and
bindings read the same single currentConstructionContext().currData (references-manager.ts:57-68,
element.ts:583-602), so a fragment can't split "bindings at projection scope, refs at external scope"
without the boundary+forwarding we're deleting — the fragment must render at parent scope, forcing binding
substitution. (b) The mapping is exactly the getProps projection the compiler already emits for
childComp (element.ts:42-56) — inlining relocates it into the binding accessors, it is not new. (c)
Bindings are coordinate-free (vs)=>… accessors (element.ts:94-97), so substitution is done as an
alias map on Variables, resolved in resolveAccessor — the single seam every grammar accessor
funnels through (expression-parser.pegjs:528,547,649,672,685,691), which already hosts $parent and
withParentShift. DL#193 already uses that seam for override content
(remapOverrideBindingsToParent/PARENT_SCOPE_PRAGMA); Tier-2 aliasing generalizes it. This makes the
transform unit-testable in isolation (parse a binding against an aliased Variables, assert the
rendered accessor — no element codegen). Card-internal forEach/if scopes are respected automatically
via childVariableFor. (d) withData (element.ts:400-410) can render a fragment against a derived view
state without a boundary, but it scope-switches refs too → would re-require forwarding, so it is not
used here. Two v1 restrictions keep it bounded: no self-recursion and no root-level $parent in a Tier 2
template (both → "add a .ts").
Implementation Results
Phase A — slot contract tag + validation (complete)
Prevention-first, no runtime changes. All work is in compiler-jay-html; 788 tests pass
(4 pre-existing skips).
1. slot contract tag.
contract.ts— addedContractTagType.slot(5th member): scopeless, phaseless (treated as slow), noelementType, nodataType, contributes no ViewState.contract-parser.ts—parseTypemapstype: slot;parsePhaserejects an explicit phase on a slot;parseTagrejects slot mixed with other types, and rejectsdataType/elementTypeon a slot.contract-phase-validator.ts—isTagInPhasereturnsfalsefor slots (never in ViewState).contract-to-view-state-and-refs.ts—traverseTagreturns{}for a slot (no ViewState member, no ref of its own in Phase A; injected refs surface in Phase C/E).
2. Two <override> forms + validation (jay-html-overrides.ts):
OverrideSpecfields:ref,slot,attributes,content,hasContent. Addressing attribute (ref=vsslot=) selects the form.- Attribute form (
ref=): merges attributes/style onto any ref (contract, slot, or override-only); content on this form → compile error. - Content form (
slot=): fills a declaredtype: slotregion;slot=on a non-slot → compile error; attributes on this form → compile error; slot target resolved via[ref="slotName"]. - Neither/both addressing attributes → compile error.
applyOverrides/applyHeadfullOverridestake aslotNames?: Set<string>;jay-html-parser.tsbuilds it from the loaded contract's slot tags at the compile call site.
Deviations from the plan
removefully removed as a keyword (per user request — no backward compat, still in a branch). The plan said "remove→ compile error"; insteadremoveis not recognized at all and is treated as an ordinary attribute. On the attribute form it would merge as aremoveattribute; on the slot form, attributes are already a compile error, so<override slot="X" remove>still errors (via the "cannot also set attributes" path). Design-log §1/§2, Implementation Plan, and Verification Criteria updated to match.- Deleted the
page-with-override-unwrap-parent-bindingfixture + its 3 tests (element/server/ hydrate). Under the DL#194 model an<override>on an empty-contract (Tier 1) component is rejected, which is the correct new behavior; the old unwrap fixture exercised the dropped path. (No-contract → Tier 2 empty contract, inlined, is folded into Phase B.)
Fixture migrations (zero output change)
page-with-override-parent-bindingandpage-with-override-forwarded-ref: addedtype: slottags to the card contracts and switched<override ref=…>content forms to<override slot=…>. Slot content splicing (set_contenton[ref="slot"]) is byte-identical to the old ref-content path, so the generate-* outputs are unchanged.
Tests
contract-parser.test.ts— 6 new slot-tag tests (parse optional/required, reject dataType/elementType/phase/mixed).jay-html-overrides.unit.test.ts— rewritten to the two-form model (parse + apply, error strings).parse-jay-file.unit.test.ts—headercontract/jay-html gained abodyslot; 3 override tests rewritten to the slot / attribute forms.
Phase B — Tier 2 inlining (in progress)
Step 3a — alias substitution mechanism (complete). The binding-substitution seam the whole tier depends on, built first and unit-tested in isolation (no codegen yet), per the plan.
expression-compiler.ts—Variablesgained a privatealiases: Record<string, Accessor>(8th constructor param, defaults{}).resolveAccessorresolves a contract-field accessor through the alias map before the default field walk:heading→ the seeded usage-site accessoritem.title; chained access (author.name) appends the remaining terms onto the alias and walks its resolved type; the alias's ownrootVar/parentLevelare preserved so a card-internal$parentclimb that lands on the card-root scope composes automatically (climb wraps the alias result'sparentLevel). Aliases are preserved acrossasLexical/withParentShift(same-scope reconstructions) but not propagated tochildVariableFor/childVariableForWithDatachild scopes — only card-root contract fields are projected, so a card-internalforEachitem var resolves against the (aliased) array's real usage-site item type and is never itself aliased. No PEG grammar change.expression-compiler.unit.test.ts— newTier 2 alias substitution (DL#194)describe block:resolveAccessor(plain field → usage-site accessor; chained access; forEach item not aliased;$parentclimb composes with alias at parentLevel 1) + codegen ({heading}→dt(vs => vs.item?.title), chained, and a card-internal{$parent.heading}→dt((vs1, _p1) => _p1.item?.title)). 226/226 pass.
Step 3b — inline codegen across all four targets (complete). A Tier 2 composite (jay-html +
contract, no .ts; structural: true on JayHeadlessImports) no longer emits a
childComp/__headlessInstances boundary. Its template is spliced directly into the parent render:
bindings resolve through the alias overlay (step 3a), and the composite's own refs are constructed in
the parent ReferencesManager, nested under the usage-site ref name (nestRefs([camelCase(refName)], …)).
No __parentContext, no (vs,_p1)=>_p1 identity, no filterToComponentRefs forwarding.
Alias build —
buildInlineAliases(element, parentVariables, contractProps)(jay-html-compiler.ts) maps each usage-site attribute to anAccessor: dynamic{expr}→parseAccessor(stripped, parentVariables); static →coerceStaticComponentProp(DL#187 enum/number/boolean coercion) wrapped in a literalAccessor. Static enum members are widened to the base enum type ((Status.success as Status)) only in this inline path — the value is substituted directly into the composite'salias === Status.warningcomparisons, and a narrowedStatus.successliteral makestscflag every other branch as a no-overlap error (TS2367). The real-boundary child-comp prop path (coerceStaticComponentPropviarenderChildCompProps) is left unwidened.Element + hydrate —
renderInlinedStructuralInstance(jay-html-compiler.ts). Direct DOM splicing is coordinate-safe under the DL#126 flat-map model (pre-assignedjay-coordinateattributes; no re-indexing on splice).inlinedRoot(Variables) +insideInlinedComposite(RenderContext/HydrateContext) suppress DL#193 override-injected re-basing at the spliced root.Server —
renderServerInlinedStructuralInstance(jay-html-compiler-server.ts), added this step to reach the four-target consistency the design requires. Same alias/coordinate model; static text emitted literally;if=on the composite tag wrapped viaparseServerCondition. Import collection filters out the composite's own root/refs type names (!headless.structural), keeping only enum types actually referenced by inlined bindings.Sandbox/bridge —
emittedForwardedRefHelpers: new Set()added to both context literals in jay-html-compiler-bridge.ts (RenderContext gained the field this branch).Fixture note: the inlined
?jay-mainSandboxcomponent import is unresolvable totsc; the// @ts-expect-error Cannot find moduleline above it is added manually to fixtures (matching the existing component-in-component sandbox fixtures) and is stripped byprettify'sremoveCommentson fixture read, so comparison tests are unaffected whilebuild:check-typescompiles the raw file.Fixtures — 6 contract fixtures cover element/hydrate/server (+ main-sandbox where applicable): page-with-structural-badge (static props incl. widened enum), page-with-override-parent-binding, page-with-forwarded-ref, page-with-forwarded-ref-foreach, page-with-forwarded-ref-multi, page-with-override-forwarded-ref.
Verification — full package suite green (798 passed, 4 skipped) and
yarn build:check-typesclean.
Step 4 — recursion validation (complete). A no-code (Tier 2) composite that references itself
directly or transitively via <jay:X> is a cycle in the inlined-component graph, caught during the
compile-time inline expansion in parseHeadfullFSImports (jay-html-parser.ts).
- Ancestry, not global-visited. The prior DL#123 guard used one global
visitedset and reported any re-encounter as "circular", which conflates a true cycle with a diamond (a shared no-code dependency reached via two independent paths). Split into two sets:ancestry(the sources on the current DFS path — a hit is a real cycle →headfullRecursionError(name)) andvisited(cross-path dedup — a hit is a diamond → silently skip re-expansion, no error).ancestryis extended (new Set(ancestry).add(resolvedSrc)) only on the nested-expansion call, so it unwinds per branch. - Tier-scoped by construction. Tier 3 recursion uses the
<recurse>tag, never a<jay:X>self-import, so a<jay:X>cycle is unambiguously a Tier 2 mistake. The message (jay-html-helpers.tsheadfullRecursionError) directs the author to add anX.ts(promote to Tier 3, a real boundary that can recurse at runtime).
Step 4b — root-level $parent validation (complete). This was already a compile error by
construction — Variables.forInlinedComponent sets parent: undefined, so any $parent climb from an
inlined composite's own template fails regardless of where the composite is used (page root or nested).
The only gap was the message. resolveAccessor (expression-compiler.ts) now emits
rootParentInInlinedCompositeError() (directing the author to add a .ts) instead of the generic
"no parent scope" text when the exhausted scope is an inlinedRoot; non-composite pages keep the
generic diagnostic. Because a Tier 2 composite has no card-internal nested scopes (forEach is banned,
conditionals reuse the current scope), every $parent in its template is root-level — so this catches
all of them.
- Fixtures —
page-tier2-recursion(card self-imports card) andpage-tier2-root-parent(card template uses{$parent.pageTitle}). - Tests —
generate-element.test.ts: recursion assertsreadAndParseJayFile(...).validationsequals[headfullRecursionError('card')](parse-time); root-parent asserts the element file.validationsequals[rootParentInInlinedCompositeError()](codegen-time). - Verification — full package suite green (800 passed, 4 skipped) and
yarn build:check-typesclean.
Phase C — Tier 3 slot injection (compiler + runtime complete; runtime-DOM verification via smoke tests #9/#10)
Mechanism refinement — "Fork C" (deviates from §5b; §5b left intact as the original design record).
§5b proposed filling a Tier 3 slot with a parent-bound render function passed to a higher-order
component constructor, where the child invokes slots.X() at the anchor (with a default-content
fallback inside the .ts-owned render), and the fragment is bound to the parent via a new compiler
helper withParentContext (saveContext/restoreContext). During the runtime trace two problems
surfaced:
- Update ownership. If the child invokes the slot fn, the fragment is constructed inside the
child's
ConstructContext, so its refs and bindings capture the child'scurrData— exactly the scope split §5a proves is impossible within one fragment. Re-basing the slot's bindings back to the parent would re-require the(vs,_p1)=>_p1machinery the DL set out to remove. - No new primitive available.
withParentContext(saveContext-at-create / restoreContext-at-invoke) would be genuinely new runtime surface, contradicting the null-hypothesis-first rule.
Fork C keeps the same observable contract (§5b's refs.card.X.cta external-scope keying, no
(vs,_p1)=>_p1) but changes the mechanism to use only existing primitives:
- The parent builds each
<override slot="X">fragment inline in its own render (inside the page'sConstructContext.withRootContext), with ordinary(vs) => vs.fieldbindings and its refs registered in a parent-owned nested slot ref manager — so refs and bindings capture the parentcurrDataby construction. No_p1, no re-basing, nowithParentContext. - The fragment is passed to the child two ways: (a) into the child's inline render, which mounts it at the
[ref="X"]anchor via a new tiny runtime primitiveforeignChild(fragment)— DOM + mount/unmount passthrough, no-opupdate— so the child positions the DOM but never feeds it child viewState; and (b) as a new 5thslotsargument tochildComp, whose update loop drives each slot fragment's.updatefrom the parent's reaction. The child cascade excludes the fragment (foreignChild's no-op update); the parent cascade owns it. - Ref surfacing: the instance ref name (
richCard) collides — it is both a component ref (the child) and a pre-seeded nested slot ref manager.ReferencesManager.mkRefsOfTypenow attaches the slot manager to theComponentRefsImpl(setSlotRefManager), and a newDELEGATE_SLOT_REF_TRAPsurfacesrefs.richCard.body.ctaalongside the child's ownrefs.richCard.cardAction(via the existingDELEGATE_REFS_TO_COMP_TRAP).
Net surface vs §5b: foreignChild (4-line passthrough) + a slots param on childComp +
setSlotRefManager/DELEGATE_SLOT_REF_TRAP on the ref system, and no withParentContext, no
higher-order makeCardWithSlots invoking-with-default at runtime (the default-vs-override choice is made
at compile time: an un-overridden slot emits its default content inline in the child render; an
overridden slot emits foreignChild(slots.X)).
Step (runtime) — complete. element.ts (childComp 5th slots param + update loop; foreignChild),
node-reference.ts (setSlotRefManager/getSlotRef/DELEGATE_SLOT_REF_TRAP),
references-manager.ts (collision merge in mkRefsOfType). Runtime unit test
packages/runtime/runtime/test/lib/slot-content.test.ts (5 tests) asserts: slot DOM mounts inside the
child at the anchor; slot updates from the parent view state; child data binding unaffected;
refs.richCard.cardAction (child ref) and refs.richCard.body.cta (slot ref) both resolve. Full runtime
suite green (291 passed, 3 skipped).
Step (compiler, element target) — complete. renderHeadlessInstance (jay-html-compiler.ts) splits
the injected body into template nodes (compiled in CHILD scope, filled slot anchors emit
foreignChild(slots.X)) and <override slot="X"> fragments (compiled in PARENT scope into a shared
<ref>Slots const, materialized in the page render root context). The instance ref carries the slot
refs as a nested manager (refs.<instance>.<slot>.<ref>). assign-coordinates.ts excludes <override>
from the multi-child display:contents wrap. Element fixture page-with-tier3-slot + test green.
Step (parser) — complete. The Tier 3 branch (jay-html-parser.ts) injects the default body via
applyHeadfullOverrides (so ATTRIBUTE-form overrides <override ref="X" class="…"/> merge onto child
elements), then re-clears each FILLED slot anchor: removes its ref, marks it jay-foreign-slot="X",
empties its content, and appends the <override slot="X"> elements verbatim as siblings so codegen can
split them out and compile in PARENT scope.
Design — SSR / hydrate coordinate model for Fork C slots (derived, forced by Fork C + DL#126).
The browser reference DOM (element target) is: the filled slot anchor is replaced by the parent-owned
fragment — the card <div class="card"> directly contains [h2, cardAction-button, fragment-button],
with no anchor <div> and no <override> tag. SSR and hydrate must produce/adopt that same DOM.
- Coordinates. Bindings are coordinate-free in the browser (element.ts), but SSR/hydrate are driven
entirely by
jay-coordinate-baseattributes fromassign-coordinates.ts(hydrate readsCOORD_ATTRat jay-html-compiler-hydrate.ts:1229; server at renderServerOpenTag). The slot fragment is authored in<override slot="X">(a child of<jay:card>) but owned and built by the PAGE render, so its content gets page-scope coordinates rooted at${instanceCoord}/${slotName}(e.g.S0/0/card:richCard/body/0). This key is globally unique (instanceCoord unique × slot name) and lives in the page's flat coordinate map (buildCoordinateMapqueries the whole subtree, context.ts:469, so it finds the fragment even though it physically nests inside the child'sS2DOM). - assign-coordinates.ts.
assignHeadlessInstancewalks template (non-override) children in the CHILD scope as before;walkChildrenskips<override>nodes; each override's content is walked viawalkChildren(overrideNode,${instanceCoord}/${slotName}, parentScopeId, …)→ page-scope coords. - Server.
renderServerHeadlessInstancesplits overrides out; renders template children in child scope; when it reaches ajay-foreign-slot="X"anchor it renders the matching override's CONTENT in the PAGE context (pagevs, page coords) in place of the anchor element (no anchor<div>), and never renders the<override>sibling. - Hydrate.
renderHydrateHeadlessInstancemirrors the element target: the child adopt render emitsforeignChild(slots.X)at the anchor (child does NOT adopt the fragment); the page hydrate render builds the<ref>Slotsconst viaadoptElementon the page coordinate map (${instanceCoord}/${slot}) and passes it to_makeHeadless…(slots)+childCompHydrate(…, slots). - Runtime-verification (SSR done; DOM-hydration via build). The smoke-test
/combinedroute (tasks #9/#10) exercises a Tier 3 slotted component (richCard, real.tsboundary) and a Tier 2 slotted component (promoCard) on one page, both with page-scope{pageTitle}slot bindings. Real SSR (HTTP fetch, dev + production self-hosted modes) confirms the Tier 3bodyslot renders the parent-scope override (Body for Combined Page), suppresses default content (Default body), and the Tier 2ctaslot resolves page scope (CTA for Combined Page). The hydrate/client/server targets all build for the page (codegen compiles + bundles). Still not covered by an executed test: the browser-level hydration hand-off itself (adoptElementon the page map →foreignChildforwarding the adopted dom/mount at the anchor) — the smoke suite has no headless-browser step, so DOM hydration is verified only insofar as the generated hydrate bundle builds.
Step (compiler, server target) — complete. renderServerHeadlessInstance (jay-html-compiler-server.ts)
splits <override> nodes out of the instance's children, builds a slotOverrides map keyed by slot name,
and threads it (plus the PAGE ServerContext as slotPageContext) into the instance context. When
renderServerElement reaches a jay-foreign-slot="X" anchor it renders the matching override's CONTENT
using the page context (page vs, page coordinates) IN PLACE of the anchor element, and never emits the
<override> sibling. Verified output: the override <button> replaces the anchor <div> at page
coordinate S0/0/card:richCard/body/0, reads vs.pageTitle (page scope). Fixture
generated-server-element.ts + test green.
Step (compiler, hydrate target) — complete. renderHydrateHeadlessInstance (jay-html-compiler-hydrate.ts)
mirrors the element target: splits template vs <override slot> nodes; the child adopt render emits
foreignChild(slots.X) at the anchor (child does NOT adopt the fragment); each override fragment compiles
in the PAGE HydrateContext (so its adoptElement/adoptText calls use the page coordinate map key
${instanceCoord}/${slot}/…); the slotted render fn takes (options, slots) and is wrapped in a
higher-order _makeHeadless${idx}(slots) constructor; the page hydrate() render materializes the
<ref>Slots const in its root context (via a new slotPreambles accumulator on HydrateContext) and
passes it to both _makeHeadless${idx}(slots) and childCompHydrate(…, slots). The instance ref carries
the slot refs nested (refs.richCard.body.cta). The synthetic _Headless${idx}Refs/Slots types come
from the shared refs section (element pass), reused by the hydrate file. Fixture
generated-element-hydrate.ts + test green.
Bridge/sandbox target — out of scope. Headless component instances are unsupported in sandbox mode
(jay-html-compiler-bridge.ts:131–133 returns an empty fragment). No headless-instance page has a bridge
fixture and the bridge test suite covers only component-based (page-using-counter) pages, so a Tier 3
slot page needs no bridge fixture. (A pre-existing generateElementBridgeFile-on-headless-instance
undefined failure is unrelated to this work and stays out of scope.)
Slots type index signature. The generated _Headless${idx}Slots interface carries both the named
per-slot members (body: BaseJayElement<PageVS>) and an index signature
([slot: string]: BaseJayElement<PageVS>). The index signature is required so the const typed as
_Headless${idx}Slots is assignable to the runtime slots?: Record<string, BaseJayElement<ParentVS>>
param on childComp/childCompHydrate — a named interface without an index signature is not assignable
to Record. The named members preserve author-facing per-slot type checks. (build:check-types caught
this; vitest does not type-check.)
Verification (Phase C compiler). compiler-jay-html full suite green (803 passed, 4 pre-existing
skips) and build:check-types clean after rebuilding the runtime dist (so foreignChild + the 5th
slots param are visible to the fixtures). Element / hydrate / server targets each have a
page-with-tier3-slot fixture + test asserting the full generated file via toEqual(prettify(...)).
Two hydrate-target regressions found + fixed when running full yarn confirm (both surfaced only
in the dev-server Playwright hydration suite, which vitest fixture tests do not exercise):
jcprop dropped from headless-instance props (26 failures, all headfull FS pages 8a–8m). Phase C had addedattrCanonical === 'jc'to the skip list inrenderChildCompProps(jay-html-compiler.ts). Butjc: '<contractName>'is an established DL#123 prop thatmakeHeadlessInstanceComponentconsumes — onmainit flows throughrenderChildCompPropsunfiltered. Removing that one skip restored it for every real childComp boundary (element + hydrate sharerenderChildCompProps, so the element target already carriedjc; only the skip was wrong).buildInlineAliaseskeeps itsjcskip — Tier 2 inlining passes no props. Tier 3 fixtures (page-with-tier3-slot) updated to includejc: 'card'.Non-interactive conditional guard dropped the inline-alias overlay in the hydrate target (test 8n,
if="featured"wherefeaturedis a static Tier 2 prop, 4 failures). The hydrate guard rebuiltguardVariablesasnew Variables(currentType, undefined, 0, 'viewState')to root the guard at the render-fnviewStateparam — but that fresh scope has emptyaliases, so an inlined composite's contract field (featured→ literaltrue) failed to resolve ("data field [featured] not found").status(fast+interactive) resolved because interactive conditionals take a different path that keepscontext.variables. Fix: newVariables.withRootVarName(name)reconstructs the scope with a custom root var preservingaliases+inlinedRoot; the guard uses it wheninsideInlinedComposite. Non-inlined path unchanged. (8n was committed on this branch with the message "yarn confirm still fails" and had noexpected-hydrate.ts— its hydration was never green until this fix.)
Verification (full repo). yarn confirm exits 0 — rebuild (72 packages) + build:check-types +
all tests + format. dev-server suite 741/741 (8n now green), compiler-jay-html 803/803, smoke-test
66/66.
Post-Phase-C review feedback (3 issues)
User feedback on the smoke-test examples after Phase C. Decisions confirmed with the user.
Issue 3 (correctness) — dev-server pre-render splices Tier 3 into Tier 2 shape
Symptom. The combined page (/combined: a Tier 3 richCard with <override slot="body"> + a
Tier 2 promoCard) fails hydration in the dev server:
[jay hydration] adoptText coordinate "S0/0/richcard:richCard/body/0" not found in DOM. The SSR DOM
carries physical coords (…S1/0/2/0) while the client hydrate adopts at the Fork C parent-scoped
S0/0/richcard:richCard/body/0.
Root cause (traced through the runtime). Direct-compiling the original page.jay-html through
both the server-element and hydrate targets produces identical Fork C coords — the compiler is
correct and the two targets agree (proven; not a cache). The divergence is a second, older, non
tier-aware copy of the template-injection logic that only the dev server runs:
injectHeadfullFSTemplates/injectHeadfullFSTemplatesRecursive(jay-html-parser.ts:862,:887) is the dev-server pre-render inliner. Its per-<jay:X>loop (was:936-950) always calledapplyHeadfullOverrides(Tier-2 splice) for any component with overrides — including Tier 3 coded ones — splicing<override slot="body">inline into<div ref="body">…</div>(ref kept, nojay-foreign-slotanchor).dev-server.tssendResponse(:963) runsinjectHeadfullFSTemplateson the original, then compiles the SSR server-element from that flattened content → physicalS1/0/2/0. The?jay-hydratemodule compiles from the raw original via the parser's tier-aware branch (jay-html-parser.ts:1251,hasCodeFile → Fork C) →S0/0/richcard:richCard/body/0. DOM ≠ hydrate.
The parser's real compile path (parseHeadfullFSImports, loop at :1248-1326) already branches by
hasCodeFile (Tier 3 Fork C vs Tier 2 splice). The pre-render helper was a duplicate that never got
the DL#194 tier-awareness.
Fix (confirmed: shared helper — subtract-first). Extract the per-<jay:X> tier-aware injection
(Tier 3: default body + FOREIGN_SLOT_MARKER anchors on filled slots + verbatim <override slot>
tail; Tier 2: applyHeadfullOverrides) into one helper used by both parseHeadfullFSImports and
injectHeadfullFSTemplatesRecursive. The pre-render caller loads the contract (for slotNames) and
computes hasCodeFile the same way, then defers to the shared helper (with display: contents still
applied). One source of truth removes the drift.
Issue 1 (prevention) — component instance with overrides must declare ref
An override/slot-bearing instance with no ref= gets an auto-name (refs.ar0.cta). Confirmed:
add a validation error requiring an explicit ref="…" on a <jay:X> that carries <override>
children, so the ref path is author-controlled and stable. Prevention-first (no fragile auto-names).
Issue 2 (examples) — combine /override + /promo
Confirmed: merge the two overlapping example pages into one that exercises both override types and
binding-in-overrides, converting one card to a coded (Tier 3) component. (/combined already
covers Tier2+Tier3; consolidate the demo surface accordingly.)
Implementation results (all three issues)
Issue 3 — shared helper (done). Extracted injectComposableTemplateIntoTag
(jay-html-parser.ts) as the single tier-aware per-<jay:X> injector (Tier 3 Fork C:
FOREIGN_SLOT_MARKER anchors + verbatim <override slot> tail; Tier 2: applyHeadfullOverrides),
with a setDisplayContents flag. Both the compile path (parseHeadfullFSImports) and the
dev-server pre-render path (injectHeadfullFSTemplatesRecursive) now call it — the pre-render caller
loads slotNames from the contract and computes hasCodeFile the same way. Verified: /combined
SSR now emits Fork C coords (S0/0/richcard:richCard/body/0) matching the hydrate; the physical
S1/0/2/0 divergence is gone. Smoke test (62) passes SSR + hydrate.
Issue 1 — require explicit ref (done). New validation
overrideRequiresExplicitRefError (jay-html-helpers.ts), wired into the parseHeadfullFSImports
loop: a <jay:X> carrying <override> children with no trimmed ref is a compile error. New
fixture contracts/page-override-no-ref + a generate-element.test.ts case assert the exact
message. Affected inputs updated to comply: the page-with-override-parent-binding fixture (ref
card, expected outputs regenerated), the combined page (promoCard/overrideCard refs), and the
four <jay:header> override cases in parse-jay-file.unit.test.ts. The /combined d.ts now surfaces
refs.promoCard.cta / refs.overrideCard.cta instead of the auto-named refs.ar0.cta.
Issue 2 — combined example (done). /override and /promo pages removed; their coverage folded
into /combined, which now composes a Tier 3 richCard (Fork C slot with a nested ref +
page-scope binding), a Tier 2 promoCard (binding-in-override + own prop), and a Tier 2
overrideCard (full override vocabulary: content replace, attribute/style merge on an element ref,
slot removal, container replace). combined/page.ts wires interactive handlers through the stable
refs (refs.richCard.body.cta, refs.promoCard.cta, refs.overrideCard.cta). Both smoke-test
blocks (SSR + hydrate) updated; all 62 smoke tests pass.
Additional coverage — composition under repetition and deep nesting
Two more smoke-test pages were added to stress coordinates + view state + scoping across the two-tier model in the two situations the earlier pages did not cover: repetition and deep alternating nesting.
/foreach-composite (works; exposed + fixed a client-runtime ref bug). A Tier 3 richCard and a
Tier 2 promoCard placed under a parent forEach="cards" trackBy="id", each with a per-item override
(Body for {title}, CTA for {title}). Verified: every repeated instance resolves its own item —
Tier 3 body-slot override (Body for Alpha/Body for Beta), Tier 2 cta-slot override
(CTA for Alpha/CTA for Beta), and each component's own heading="{title}"
(<h2>Alpha</h2> / <h2>Beta</h2>). The refs surface is collection-typed under the item scope:
refs.cards.richCards.body.cta and refs.cards.promoCards.cta are
HTMLElementCollectionProxy<CardOfPageViewState, HTMLButtonElement>. Repeated coordinates
(S2/0/0, S3/0/0) + per-item view state + item scoping are all correct across both tiers.
Client-runtime bug — Fork C slot refs were dropped when the Tier 3 instance is repeated (fixed).
refs.cards.richCards.body.cta.onclick(...)type-checked but threw at runtime (TypeError: Cannot read properties of undefined (reading 'cta')—.bodywasundefined). Root cause: the Fork C slot ref manager is pre-seeded into the parent manager'srefs[instanceName]viachildRefManagersand then merged onto the component ref inBaseReferencesManager.mkRefsOfType(references-manager.ts). That merge was gated onManagedRefType.component+ComponentRefsImplonly — the non-repeated case. Under a parentforEachthe instance ref is a component collection (ManagedRefType.componentCollection/ComponentCollectionRefImpl), so the merge was skipped and the freshly-built collection overwrote the slot ref manager, discarding thebodygroup. (Tier 2promoCardswas unaffected — it is never in any of the four ref lists, so its pre-seeded manager is never overwritten.) Fix mirrors the static path for the collection:ComponentCollectionRefImplgainssetSlotRefManager/getSlotRef, aDELEGATE_COLLECTION_SLOT_REF_TRAPexposes the slot group on the collection proxy (before the DL#193 forwarded-inner-ref trap), and themkRefsOfTypemerge now also coverscomponentCollection+ComponentCollectionRefImpl. This completes the DL#194 ref contract under repetition — the compiler already emitted the correct type; only the runtime was incomplete. Covered byruntime/test/lib/slot-content.test.ts("Tier 3 slot content under a parent forEach":.bodyresolves, onclick fans out to every item with its item viewState, and the child's owncardActionstill resolves across the collection).
Codegen bug — the per-item slot fragment was hoisted, so only the first repeated item wired its slot ref (fixed). After the ref-resolution fix above,
refs.cards.richCards.body.cta.onclick(...)resolved, but on the client only the first repeated item'sBody for {}button actually received the DOM event handler — Beta/Gamma got none. Root cause: the compiler materializes a Tier 3 instance's<override slot>content as a shared const (richCardsSlots = { body: adoptElement(...refCta()) }) and passes it both to the child constructor closure and tochildCompHydrate(..., slots). Both targets collect these const declarations in acontext.slotPreamblesside-channel that escapes anyforEachwrapping and is flushed once, hoisted at the top of the render body. That is correct for a non-repeated instance, but under a parentforEachthe parent repeats the child-with-slot — so the slot fragment (its ownadoptElement/refCta()) must be built per item. A single hoisted const callsrefCta()once, wiring only the first item; the coordinateS1/card:richCards/body/0is identical across items but resolves per-item within each item's local scope map, so moving the const into the item callback is sufficient — no coordinate reassignment. Fix (jay-html-compiler-hydrate.ts): theforEachbranch gives itsitemContext(adopt path) andcreateRenderContext(create/new-item path) each their ownslotPreambles: []sink, and flushes it into the corresponding per-item callback block — the adopt callback becomes(item) => { const richCardsSlots = {…refCta()…}; return […]; }and the create callback likewise. The root-context flush is untouched (still handles the non-repeated case). The standard element target (client-only interactive) had the identical hoist — its forEachnewContextspread the parent'sslotPreamblesarray, so the const flushed at the root. Same fix injay-html-compiler.ts: the forEachnewContextgets its ownslotPreambles: []andrenderForEachemits them inside the item callback beforereturn(empty-preamble output is byte-identical to before, so no other forEach fixture changed). The server (SSR) target already emits the override inside the itemfor..ofloop, so it needed no change. Covered by fixturecontracts/page-with-tier3-slot-foreachacross all three targets (hydrate/element/server) and the runtimeslot-content.test.tsonclick-fan-out test.
Limitation confirmed (not new):
$parentinside an override is unsupported on SSR. The page was first authored withBody for {title} on {$parent.pageTitle}. Two validations fire: (1)$parentat the root of a Tier 2 inlined override reaches past the composite into its consumer (the documented v1 Tier 2 limitation, §4b); (2)[SSR] $parent bindings are not yet supported in the server target. The page binds only item scope ({title}) as a result.
/nested-composition (exposed an SSR bug). A realistic three-level nest with alternating tiers:
Tier 3 section > Tier 2 card > Tier 3 button (section internally composes <jay:card>, which
internally composes <jay:button>). section.title threads down as the card heading and then as the
button label. Renders Section: Featured and Card: Featured correctly, but the innermost button
renders Button: (empty label).
Root cause — the Tier 2 alias substitution is not applied to a nested child's prop bindings on the
SSR target. The substitution is not a template rewrite; it is an expression-parse-time overlay:
buildInlineAliases (jay-html-compiler.ts:523-580) → Variables.forInlinedComponent
(expression-compiler.ts:272-287) → the alias branch of Variables.resolveAccessor
(expression-compiler.ts:355-382). An alias only fires for an expression that is (a) fed through the
expression compiler and (b) parsed against the alias-carrying Variables.
- Parser splices the Tier 2 body as raw HTML —
injectComposableTemplateIntoTag(jay-html-parser.ts:919-929,set_content(childBody.innerHTML)). A nested<jay:button label="{heading}" />is inserted verbatim;{heading}is never rewritten. - Client + hydrate targets recover it by threading the alias-carrying
Variablesinto the nested child's prop expressions:renderInlinedStructuralInstance(jay-html-compiler.ts:1000) →renderNestedComponent→renderChildCompProps→parseComponentPropExpressionagainst alias-carrying variables. So the interactive path is correct. - SSR target never parses the nested child's prop bindings.
renderServerInlinedStructuralInstance(jay-html-compiler-server.ts:241) renders the card's own text with aliases (henceCard: Featured), but the nested<jay:button>routes torenderServerHeadlessInstance(jay-html-compiler-server.ts:320,454-471), which reads props fromvs.__headlessInstances— there is norenderChildCompPropson the server target.__headlessInstancesis populated bydiscoverHeadlessInstances(slow-render-transform.ts:693-700, copies"{heading}"verbatim) andresolveHeadlessInstances(:833-858, skips any<jay:>whose attributes still contain{…}and otherwise resolves only against the child's own contract) — no Tier 2 alias overlay. So{heading}is dropped and the child receives an empty prop. Static props survive (copied as literal strings by both paths); only dynamic{contractField}bindings need the projection the SSR path omits.
This is a deviation from the stated design (§TL;DR lines 19-20 / axis-1 note: Tier 2 inlining "substitutes contract-ViewState bindings with the usage-site prop expressions" — all of them, including those inside nested child usages). It is SSR-only; client/hydrate are correct.
Candidate fixes (decision pending — not implemented).
- (a) Parser-level splice rewrite — in
injectComposableTemplateIntoTag(jay-html-parser.ts:919-929), rewrite nested<jay:child>prop bindings to their aliased consumer expressions at splice time, so the inlined HTML is self-consistent for all targets. Uniform, but introduces a new rewrite pass at the parser — which contradicts the design note that substitution is expression-parse-time, not a tree-walk — and must be traced for double-application against the client/hydrate alias overlay and for multi-level nesting (Tier 3 > Tier 2 > Tier 3). - (b) Extend the existing alias overlay to the SSR discovery path — carry the enclosing composite's
usage-site prop projection into
discoverHeadlessInstances/resolveHeadlessInstances(slow-render-transform.ts:693-700/:833-858) so a nested child's{contractField}resolves through the parent's alias→consumer-expression chain before hitting runtime ViewState. Reuses the existing mechanism (subtract-first) rather than adding a rewrite pass, but threads a compile-time alias concept into the slow-render runtime pipeline — needs a runtime-path trace before it is declared sound.
The /nested-composition page is kept as the regression repro; its SSR button-label assertion is marked
it.fails (self-flips green when the bug is fixed). Section/card rendering and the page structure are
asserted normally.
Runtime-path trace (completed) — ground truth from the built artifacts. Traced one concrete
{heading}/{label} through the real SSR pipeline using the smoke-test build output
(examples/jay-stack/smoke-test/build/dev/pre-rendered/nested-composition/):
page.jay-html(pre-rendered, feeds both consumers): the Tier 2 bodies are spliced in with the<jay:card>/<jay:button>wrapper tags kept (jc/styleadded), and every binding copied verbatim —<jay:card heading="{title}">,<jay:button label="{heading}">, textCard: {heading},Button: {label}.page.cache.jsondiscovered instances:section {title:"Featured"}✓, butcard {heading:""}andbutton {label:""}— both empty.page.server-element.ts(the codegen consumer): emitsSection: ${vs_section0.title}andCard: ${vs_section0.title}— i.e. the codegen alias overlay does resolve the card's own text through the enclosingsectionViewState. The button routes tovs_button1 = vs.__headlessInstances['S2/0/button:action']→Button: ${vs_button1.label}, andlabelis"".
Correction to the root-cause note above: Card: Featured is correct because the codegen overlay
resolves it to vs_section0.title, not because of the discovered card instance (whose heading is in
fact empty and unused). The only broken output is the nested Tier 3 button's prop binding. The
pre-rendered HTML has two independent consumers: (i) discoverHeadlessInstances (a string-only walk)
→ __headlessInstances; (ii) the server-element codegen (has the overlay). Text bindings ride (ii) and
are correct; the nested instance's label prop rides (i) and is dropped.
Candidate (b) — rejected after the trace. discoverHeadlessInstances(preRenderedJayHtml: string)
(slow-render-transform.ts:658-660) takes only a string — no importResolver, no contract. It copies
prop attributes verbatim (:693-700 / :743-749) and recurses into <jay:card> treating the nested
<jay:button> as a top-level instance. The value is later resolved by
resolvePropBinding(binding, scope) (resolve-instance-props.ts:32-42) against
buildInstanceBindingScope (:88-101) — a flat page scope with no notion of card's fields, so
{heading} → '' (confirmed at fast-changing-runner.ts:154-160: matchProp is against the button
contract, and resolvePropBinding against the item/page scope). Making the alias fire here would require
re-implementing Tier 2 alias building (contract load + buildInlineAliases + rewrite) inside the
runtime discovery path — a second home for the compile-time logic. That is more surface, not less;
it fails the subtract-first test.
Candidate (a) — recommended, but scoped. Extend the existing splice
injectComposableTemplateIntoTag (jay-html-parser.ts:865-931) to rewrite only nested <jay:child>
prop attributes (skip ref/jay*; do not touch text or regular bindings), using the parent
jayTag's own usage attributes as the projection map (heading="{title}" ⇒ {heading} → {title}).
Rationale from the trace:
- Not a new pass.
injectComposableTemplateIntoTagalready rewrites the subtree (set_content,applyHeadfullOverrides, slot-markerremoveAttribute/set_content). This is one more targeted rewrite at a site that already holds both the child body and the usage attributes. - Chains across levels for free. Injection is bottom-up recursive
(
injectHeadfullFSTemplatesRecursive,:1031-1044):card→sectionrewriteslabel="{heading}"→label="{title}", thensection→page (title="Featured") rewrites{title}→Featured. Ground truth confirms every value in this example is page-rootable, so the discoveredbuttonwould carrylabel="Featured"→ resolves →Button: Featured. - No double-application. The compiler targets parse their own source and apply the overlay
themselves; (a) only rewrites the pre-render string that feeds discovery. The server-element reads
the button
labelfrom__headlessInstances(not from the attribute), so rewriting the attribute changes only what discovery records — the codegen overlay output is untouched. - Must preserve bindings, not bake literals. Map
{field}→ the usage RHS verbatim (binding RHS stays a binding for fast/interactive re-resolution; literal RHS becomes a literal;$parent/chained RHS copied unchanged). Baking a literal for a fast/interactive-phase prop would break per-request re-resolution.
Second trace (completed) — (a)/(b) both superseded by an enclosing-instance-scope fix. The
"residual limitation" I first flagged for (a) — an enclosing component's prop being computed in its own
.ts rather than threaded from the page — is not an edge case; it is the mainstream composition
pattern (a component fetches/derives data and hands it to its children). Re-tracing the runtime with that
case in mind found (a) is insufficient and the true fix is smaller.
The value is not lost — it is stored per-coordinate, just out of scope. section's computed ViewState
ships in slowViewStates[sectionCoord] / __headlessInstances['S0/0/section:section']
(slowly-changing-runner.ts:122, instance-slow-render.ts:118, fast-changing-runner.ts:113). What is
wrong is only the resolution scope: buildInstanceBindingScope (resolve-instance-props.ts:88-101)
assembles the page scope for every instance, at all three call sites
(slowly-changing-runner.ts:99-103, instance-slow-render.ts:89-93, fast-changing-runner.ts:99-107),
so a nested child's {title}/{heading} looks for a page field, misses, and collapses to ''
(resolve-instance-props.ts:38-40).
Tier 2 intermediates already carry the alias as runtime data flow. A Tier 2 pure headfull component is
resolved through a synthesized passthrough (passthrough-component.ts:42-58): its ViewState is exactly
its props, echoed per phase. So card's passthrough emits heading = <resolved {heading} prop> — which
is the heading ← title alias projection, performed as data flow, not expression rewriting. This is
why the cache shows card {heading:""} (the passthrough ran; only its input scope was wrong). No
compile-time alias rewrite (candidate a) is needed to "cross" a Tier 2 layer — the passthrough already
does it, if it resolves against the enclosing ViewState.
Fix — resolve each discovered instance's prop bindings against its enclosing instance's resolved ViewState (page ViewState for top-level instances). Trace of the full chain under this scope:
section(Tier 3, top-level) → scope = page VS →title="Featured"(or{pageField}) resolves →slowViewStates[S0] = {title:"Featured"}.card(Tier 2 passthrough) → scope =slowViewStates[S0]→{title}→"Featured"→slowViewStates[S1] = {heading:"Featured"}.button(Tier 3) → scope =slowViewStates[S1]→{heading}→"Featured"→__headlessInstances[S2] = {label:"Featured"}→ server-element emitsButton: Featured. ✓
This resolves both user questions: an all-Tier-3 stack works (each child resolves against its parent's
VS), and a page-bound title="{expr}" flows all the way down (section resolves it at page scope; card/
button resolve against the parent VS above them) — including when title is computed in section.ts,
because the scope is the parent's resolved VS regardless of how the field was produced.
Feasibility (verified against the runtime).
- Ordering is parent-first already. Discovery pushes an instance before recursing into its children
(
slow-render-transform.ts:724then:755), sodiscoveredis depth-first parent-first (cache order:section, card, button). All three resolution loops iterate that order, so a parent's VS is stored before its children resolve. - Parentage must be recorded (only genuinely new surface). Discovery pushes
{contractName, props, coordinate}(slow-render-transform.ts:724-728) with no parent link. AddparentCoordinate?: Coordinate, threaded throughwalk(set to the current instance's coordinate when recursing into ajay:tag's children at:755; passed through unchanged for non-jay:elements at:770-778).parentCoordinate.join('/')then keys straight into the storedslowViewStates/instanceViewStates. - Scope swap at the three call sites. When an instance has a
parentCoordinate, passpageViewState: <parent's resolved VS>(merged slow+fast at the fast site) instead of the page VS; keeppageParams/pagePropssojay.*built-ins still resolve. Top-level instances (no parent) are unchanged → no regression for the single-level case that covers nearly all existing fixtures.
Why this beats (a) and (b). It reuses ViewStates already computed and stored by coordinate (subtract-first); it needs no parser rewrite, no compile-time alias in the runtime, and no second home for the alias logic. It is SSR-runtime-only — the client/hydrate paths already propagate nested props (childComp wiring / alias overlay). Candidates (a) and (b) above are superseded and kept only as rejected alternatives.
Constraints / follow-ups. (1) Phase ordering (DL#189) still governs: a child reading a field its
parent only produces at a later phase correctly collapses to ''. (2) forEach-nested instances resolve
against the forEach item today (fast-changing-runner.ts:138-160); a nested instance inside a forEach
needs the item as its enclosing scope — verify when implementing, not required for /nested-composition.
(3) resolveHeadlessInstances Pass 2 (slow-render-transform.ts:808+) is orthogonal here — the button
label flows through __headlessInstances → server-element codegen (confirmed in the generated
page.server-element.ts), which the runner scope fix populates correctly.
Decision: proceed with the enclosing-instance-scope fix (record parentCoordinate in discovery;
resolve each instance against its enclosing instance's resolved ViewState at the three runner call sites).
Not implemented yet.
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.