Binding Across Component Composition
DL#193 — Binding across component composition boundaries
Written for AI agents. See Log Methodology Note below for details.
Status: DESIGN — for review, not yet implemented.
Background
Three separate feature requests keep bumping into the same wall: an expression or a ref declared in one component scope cannot reach an element/component that lives in another scope. Jay's compiler is deliberately scope-isolated — every binding resolves against the current scope's ViewState type, and every ref is derived from the current component's contract. That isolation is the right default (DL#84), but it blocks three composition patterns:
Pure (Tier 2) components — forwarding inner child refs. A
Buttoncomponent has anonClick. We wrap it inside a pure composite/section component. At the composite's usage site, how do we reach the inner button to attachonClick? The collection variant is the composite itself being repeated at the usage site (<jay:Card forEach=…>) → collection refs. (AforEachinside the composite is not possible — a pure component takes no array prop to iterate; see §B.)Component overrides — binding to override-introduced content. A container component exposes an overridable containment area. The override jay-html injects elements/components. How do we (a) bind data from the outer (usage-site) scope into those injected elements, and (b) reach their refs/events?
forEachparent-scope binding (low priority). From inside aforEach, how do we bind to a field in the parent scope?
All three are, at heart, "cross-boundary binding" problems. This design log maps what each requires from two perspectives — the compiler (type generation + code emission) and the runtime — and proposes a unified direction.
Relevant prior art: DL#84 (headless props & scope isolation), DL#187 (pure/Tier-2 headfull), DL#181 (headfull component override), DL#162 (structural components), DL#46/#47 (recursive templates & context switching), DL#14/#24 (References API).
Current architecture (as mapped by research)
How refs are produced
- A ref lands in a component's
RefsTreeonly forContractTagType.interactivetags (contract-to-view-state-and-refs.tstraverseTag), or forref="..."on an element/child-instance in a jay-html template (jay-html-compile-refs.ts). - Ref category is decided by
Ref.elementType(component vs element) andRef.repeated(single vs collection). Four runtime buckets:element,elementCollection,component,componentCollection(ManagedRefType). - Type generation (
renderRefsType) emitsXxxRef<ParentVS>for single component refs andXxxRefs<ParentVS>(ComponentCollectionProxy & OnlyEventEmitters) for collections.forEachrefs nest under a child manager keyed by the forEach scope. - Runtime:
ComponentRefImpl/ComponentRefsImpl/ComponentCollectionRefImplwith proxy trapsEVENT_TRAP(onXxx → addEventListener) andDELEGATE_REFS_TO_COMP_TRAP(forward member access to the mounted instance's public API).
Refs carry a live, updated scope viewState (key for this design). Unlike dt/da — which
receive viewState only as the argument of their update(newData) closure — a RefImpl holds
its scope's viewState as a field (node-reference.ts:240) that is kept current by
update(newData) { this.viewState = newData } (line 293-295). formatEvent then injects that
live viewState into every JayEvent delivered to handlers:
formatEvent(event) { return { event, viewState: this.viewState, coordinate: this.coordinate }; }
So an event handler refs.x.onclick(({ event, viewState, coordinate }) => …) already gets the
current scope viewState for that ref — for a ref inside a forEach, the correct per-item
viewState (via the collection's per-ref update), not a stale capture. Refs are therefore a
second viewState-threading channel that is already live, and the type side mirrors it:
${name}Ref<ParentVS> = MapEventEmitterViewState<ParentVS, …> re-bases the event viewState to
the parent scope. Ref forwarding (issue 1) rides this live channel as-is — the forwarded ref
keeps the inner component's scope viewState; we deliberately do NOT re-base it to the usage-site
scope (see §B).
Event handlers do NOT need parent viewState threaded in (decided). An earlier draft proposed
adding parentViewState to JayEvent. That is unnecessary: the handler is written in the
component .ts that already owns the full top-level ViewState (the same component declares
both the forEach list and the parent fields), and every ref delivers a coordinate (the
trackBy id chain) identifying which item fired. So refs.removeBtn.onclick(({ viewState, coordinate }) => remove(viewState.id)) can read any parent field directly from the component's
own state — no formatEvent/JayEvent change. Parent-data reachability (Capability A) is
therefore needed only for reactive text/attribute bindings ({$parent.field} rendered in the
DOM), which have no component code to reach up. Overrides might be the one exception (handler in
the outer component, ref in the inner one), but the coordinate system covers that too — so we do
not extend the event API for them either.
How expression scope works
VariablestrackscurrentVar(vs,vs1,vs2by depth),currentType, and aparentlink.forEachcreates a child scope viachildVariableFor(accessor)(itemType, depth+1, parent=this).Accessor.render()always roots the emitted path atthis.rootVar— the current scope's variable.resolveAccessorwalkscurrentTypeonly; it never consultsthis.parent. The only special root token isjay→__jay.- Generated
forEachisforEach((vs) => vs.items, (vs1: Item) => { return <child> }, 'key'). The parent var (vs) is lexically in JS scope inside thevs1callback for the client/trusted target — but the expression language offers no syntax to reach it.
How overrides work (DL#181)
- Overrides are entirely compile-time template splicing.
jay-html-overrides.ts(parseOverrides/applyOverrides/applyHeadfullOverrides) injects the override fragment into the inner component's body, and it is compiled against the inner component's ViewState/Refs. - Override
{bindings}therefore resolve to the inner scope. There is no mechanism to bind outer data, and override provenance is not persisted on the AST node — so today the compiler cannot even tell "this element came from the outer scope's override."
Runtime scope plumbing
ConstructContext.forItem/forAsync/forScopecreate a child context holding only the child'sdata. There is no back-pointer to the parent context or parent data. Only_dataIds(the trackBy id chain) accumulates. So for the bridge/server (data-id) targets, the parent view state is genuinely unreachable at runtime; for the client/trusted target it survives only as a lexical JS closure variable.
The unifying insight
The three problems reduce to two runtime capabilities:
- (A) Parent-data reachability — reactive text/attribute bindings only. A declarative
{$parent.field}in a child scope must read live parent data. This is NOT free on any target:dt/daclosures only ever see the current scope's viewState, and keyed list reuse makes any lexically-captured or snapshot parent stale (Examples §A). The carrier is the existingConstructContextmade live — add aparentpointer + in-placeupdateso leaf helpers (which already retain their context) readcontext.parent.currData(Q7; resolves the standing TODO at element.ts:413). The sandbox/bridge target re-attaches parent on the receiving side. Event handlers are out of scope for (A) — the owning component already holds parent data +coordinate(see architecture note), so noJayEventchange. - (B) Forwarded refs. Issues 1 & 2 are a different mechanism: the inner/override RefsTree
must be surfaced to the outer scope and the passthrough/override component must forward the
child refs through its public API (the existing
DELEGATE_REFS_TO_COMP_TRAPalready forwards member access at runtime). Forwarding is pure passthrough — no event re-basing: a surfaced ref keeps the scope it was compiled in (innerCardViewStatefor §B; the specialization'sPanel$1ViewStatefor overrides, §C), so there is nothing to map across the boundary and noformatEventchange (§B, §C).
The three features draw on these in different combinations:
| Feature | Parent-data (A) | Forwarded refs (B) |
|---|---|---|
| 1. Pure-component inner refs | no | yes |
| 2. Overrides — bind outer data | only inside inner forEach |
via inheritance (§C) |
| 2. Overrides — reach injected refs | — | yes |
| 3. forEach parent binding | yes | no |
Examples & interface snippets
These ground the two ideas in concrete jay-html + generated TypeScript, so the trade-offs in Q1–Q5 are decidable from real shapes rather than prose.
A. Parent-scope binding ($parent) — issue 3
jay-html (a list where each row's remove button references a page-level title):
<div forEach="items" trackBy="id">
<span>{name}</span>
<button ref="removeBtn">Remove from {$parent.listTitle}</button>
</div>
Generated element today (from a real fixture pattern) — note the item element's text updates are driven by the item viewState passed as the closure argument, not by any parent:
forEach(
(vs: PageViewState) => vs.items,
(vs1: Item) => {
return e('div', {}, [
e('span', {}, [dt((vs1) => vs1.name)]),
e('button', {}, [`Remove from ` /* {$parent.listTitle} goes here */], refRemoveBtn()),
]);
},
'id',
);
⚠️ Correction to the initial map — $parent is NOT free on the client. The runtime
dynamicText.update(newData) (element.ts:546) calls the binding closure with only the
current-scope viewState:
update: (newData: ViewState) => { // newData === the item, never the parent
let newContent = textContent(newData);
...
}
Keyed list reuse (listCompare by trackBy) reuses an item element across parent-data
changes, so a lexically captured parent var would be stale. Several carriers were weighed
(Q7) and rejected: variadic update(vs, parentVs, …) (too invasive) and vs[SymbolParent]
(mutates user-provided array members → spread/freeze/shared-ref footguns).
Chosen carrier — reuse ConstructContext (Q7): add a parent pointer + in-place update.
Today ConstructContext supplies only initial data at element construction and is never
consulted again; making it updatable turns it into a live per-scope state carrier. The
scope-switch update writes its own context in place each cascade — mkUpdateCollection already
has the TODO for this (element.ts:413). Leaf binding helpers already capture their context
(dynamicText, line 540), so a $parent binding reads context.parent.currData — live, because
the captured context object is mutated in place; reused keyed items keep their childContext
whose .parent is that same object. No user-data mutation, no parallel structure, one mechanism
for both binding kinds.
// runtime — mkUpdateCollection.update resolves the line-413 TODO:
const update = (newData) => {
parentContext.update(newData); // NEW: make the captured context live
... existing item diff/update ...
};
// generated element — text/attribute binding for {$parent.listTitle}.
// dt passes parent data into the closure (read from its retained context); the compiler emits
// the $parent accessor as the extra param `p`:
forEach(
(vs: PageViewState) => vs.items,
(vs1: Item) => {
return e('div', {}, [
e('span', {}, [dt((vs1) => vs1.name)]),
e('button', {}, [`Remove from `, dt((vs1, p: PageViewState) => p.listTitle)], refRemoveBtn()),
]); // ^ p === context.parent.currData, kept live
},
'id',
);
Event handlers need NO change and NO parent carrier. The handler is written in the component
that owns the whole ViewState, so it just reads the parent field directly and uses coordinate
(or viewState.id) to know which item fired:
// no parentViewState needed — listTitle is the component's own state, coordinate identifies the row:
refs.removeBtn.onclick(({ event, viewState /* item */, coordinate }) => remove(viewState.id));
So issue 3 is a compiler + runtime change scoped to reactive text/attribute bindings only.
Runtime change: ConstructContext gains parent + update; the scope-switch updates
(mkUpdateCollection, mkUpdateWithData, forAsync/resolved) call parentContext.update.
Cost is O(scopes) writes per cascade, not O(items), and no user data is mutated. Secure/bridge
re-attaches parent on the receiving side. This also serves the override sub-case (Q3-option-c):
an override mounted inside the inner component's forEach reaches its hoisted member via this same
carrier.
B. Pure-component inner-ref forwarding — issue 1
Inner button (button.jay-html + button.jay-contract, Tier 1/3 — has onClick):
// generated for Button today
export type ButtonRef<ParentVS> = MapEventEmitterViewState<ParentVS, ReturnType<typeof Button>>;
// its public API exposes: onClick(handler), plus any methods/props
Pure composite card.jay-html (+ card.jay-contract, no .ts → Tier 2) wraps it:
<!-- card.jay-html -->
<div class="card">
<jay:Button ref="cta">{ctaLabel}</jay:Button>
</div>
Usage site page.jay-html:
<jay:Card ref="signupCard" ctaLabel="Sign up" />
What forwarding must generate at the usage site — the composite becomes a ref surface that re-exports its named inner refs:
// generated for the page — the forwarded inner ref surfaces here:
export interface PageElementRefs {
signupCard: CardRefs; // the composite instance
}
export interface CardRefs {
cta: ButtonRef<CardViewState>; // forwarded AS-IS, keeps Card's scope — no re-basing
}
// page.ts usage:
refs.signupCard.cta.onClick(() => navigate('/signup'));
No event re-basing (decided — the simpler model). The forwarded cta ref lives inside Card
and its RefImpl.viewState is already Card's scope. We surface it as-is, typed
ButtonRef<CardViewState> — we do not re-base its event to PageViewState. Two reasons:
- It's less code.
RefImplalready carries its own scope viewState andformatEventalready delivers it; forwarding is then pure passthrough (the existingDELEGATE_REFS_TO_COMP_TRAP), with nothing to map across the boundary. CardViewStateis the correct data, not a lossy substitute. Everything the button was rendered against is, by construction, part ofCardViewState(a pure component's only data is its own ViewState = its contract tags, DL#187). Socta.onClick(({ viewState }) => …)receives exactly the data the button displayed. Re-basing toPageViewStatewould hand the handler data the button was never bound against — more work for a worse result.
No forEach inside a pure composite. A pure (Tier 2) component gets data only through
declared props, and props mirror tags as scalar/enum values (DL#187 Q9/Q3, DL#84) — there is
no composite/array prop to drive an internal forEach. So the "button under a forEach
inside the composite" case does not arise. Collections come from the other direction: placing
the pure composite itself under a forEach at the usage site:
<!-- page.jay-html — the composite is repeated, not its innards -->
<jay:Card forEach="cards" trackBy="id" ref="cards" ctaLabel="{label}" />
// repeated=true on the composite → the forwarded ref rides the usage-site collection:
export interface PageElementRefs {
cards: CardRefs; // collection of Card instances (nested under the forEach)
}
export interface CardRefs {
cta: ButtonRefs<CardViewState>; // one cta per card — still Card's own scope, no re-basing
}
// page.ts usage — same API as a locally-declared collection ref:
refs.cards.cta.onClick((e) => navigate(e.viewState.ctaLabel)); // event across all cards
refs.cards.cta.find((r, vs) => vs.ctaLabel === 'Sign up'); // reach one card
The single-vs-collection distinction is decided by the usage-site forEach, not by anything
inside the pure component — so the existing collection machinery covers it once the forwarded ref
is in the tree.
C. Override-introduced refs & data — issue 2
Revised during implementation (2026-09-16) — read this first. The data/binding half below (capture → hoist into
Panel$1ViewState, "no runtime back-pointer", theforEachsub-case) was reworked to a merge-time$parent-remap that reuses Capability A, andforEachoverrides are now unsupported (validated against). See "Phase 2a design revision — merge-time$parent-remap (2026-09-16)" near the end of this file. The ref-forwarding half (Phase 2b) is unchanged.
Container panel.jay-html exposes an overridable area; override at the usage site
injects a button and binds outer data into it:
Syntax clarification (2026-09-16, during Phase 2 implementation): Phase 2 layers the capture /
Panel$1specialization model onto the existing DL#181<override ref="x">content-replace splice — not a new named-area (<jay:override name>) slot mechanism. The<jay:override name="body">in the example below is illustrative prose; the real, implemented syntax is the ref-targeted override shown immediately after. This is the null-hypothesis-first choice (reuse the existing mechanism; no new surface) and matches §C's own mechanism prose ("How overrides work (DL#181)", "override provenance is dropped from the AST").
<!-- usage: page.jay-html — actual implemented syntax (DL#181 override, DL#193 §C scope) -->
<jay:Panel>
<override ref="body">
<button ref="save">Save {documentName}</button>
<!-- documentName is OUTER (page) data -->
</override>
</jay:Panel>
What must be generated — an override specialization by inheritance (recommended, Q3-option-c).
The override turns Panel into a per-usage-site specialization Panel$1 whose ViewState and Refs
inherit the base component's and add the override's members:
// generated for the Panel$1 specialization (one per override site):
interface Panel$1ViewState extends PanelViewState {
documentName: string; // hoisted from the override's outer-data references
}
interface Panel$1Refs extends PanelRefs {
save: HTMLElementProxy<Panel$1ViewState, HTMLButtonElement>; // override ref, Panel$1 scope
}
// page.ts usage — the specialization instance's forwarded ref surfaces at the page:
refs.panel.save.onclick(({ viewState }) => store.save(viewState.documentName));
Three properties, all consistent with §B:
- Events carry
Panel$1ViewState(no re-basing).Panel$1ViewState extends PanelViewState, so the event is aPanelViewStateplus the override's added members — the same keep the inner scope rule as forwarded pure-component refs. (Correction to the first sketch: the ref is typedHTMLElementProxy<Panel$1ViewState, …>, not<PageViewState, …>— events carry the specialized Panel's view state, per the "no re-basing" rule.) - Bindings pass through, like a pure component.
{documentName}resolves againstPanel$1ViewState.documentName, exactly as a pure component's template reads its own ViewState. - Outer data flows down the normal channel. The override's free outer references (here
documentName) are hoisted intoPanel$1ViewStateas added members, typed by resolving them against the outer (page) scope where the override is authored, and supplied by the outer scope as extra data — no runtime back-pointer.Panel$1ViewState extends PanelViewStateis a compile-time composition for typing the override subtree's bindings and forwarded ref; whether the captured data literally becomes a member of the component's runtime ViewState depends on the tier (Q8): trivially yes for Tier 2 (props = tags = ViewState), but for a Tier 3 (coded) Panel it rides theConstructContextcarrier instead — Panel's own ViewState stays exactly what its code produces.
Today none of this is generated: override provenance is dropped from the AST, so the compiler
neither emits a Panel$1 specialization, grafts save into the forwarded Refs, nor hoists
documentName into Panel$1ViewState (it currently resolves {documentName} against the base
Panel scope and would error/miscompile).
Where the hoisted members come from — automatic capture, not author ceremony. This is the
crux, and the answer is that there is no ceremony: it is ordinary lexical scoping plus
free-variable capture. The override fragment is authored in page.jay-html, so its bindings
belong to the outer (page) scope — exactly like every other binding in that file. So:
{documentName}resolves against the page scope because that is where it is written — no$parent, no declaration on<jay:Panel>.- The compiler scans the override fragment for the outer identifiers it references (a free-variable
scan) and that set is the added members of
Panel$1ViewState. The author never lists them — it is capture, not declaration. Mental model: the override is a closure over the outer scope, andPanel$1ViewStateis its automatically-computed capture record. $parentstays compiler-internal. The author never types it in an override; the compiler only emits a parent-access when it splices the override insidePanel's ownforEachto reach the hoisted top-level member from the item scope (the sub-case below). This is why option-b's author-facing$parent.(noisy) is rejected for overrides — the same primitive is used, but under the hood.
This keeps DL#84 intact: DL#84 bans a component's own template from reaching up into its parent.
The override fragment is not Panel's own template — it is page-authored content injected into
Panel, so resolving its bindings against the page scope is lexically correct, not an isolation
breach. (Edge case to validate: an outer reference whose name collides with an existing
PanelViewState member of a different type makes Panel$1ViewState extends PanelViewState
ill-typed — report it. Reaching Panel's own internal data from an override is a separate
slot-props/render-props concern, out of scope for this DL.)
Sub-case — override region inside Panel's own forEach. If the override mounts inside an
internal Panel forEach, the added member documentName lives at Panel$1 top level but the
spliced override elements render in the item scope — reading it there is exactly Capability A
($parent). So the inheritance model is the type/surfacing story; Capability A remains the
runtime primitive for this sub-case. They compose (this is the strongest reason to build A first).
Questions (for the user)
Q1. Syntax for parent-scope access (issues 2-data & 3). Preferred sigil? Options:
$parent.field / ../field / ^field. $ is already a legal IdentifierStart, and jay
already demonstrates the "special root token" pattern, so $parent is the cheapest to slot
in. Multi-level ($parent.$parent.x vs ../../x)? Recommendation: $parent. chainable.
Answer (2026-09-14): Explicit
$parent., not implicit search-up. Implicit ancestor lookup ("bindlistTitlehere, else walk up") was considered and rejected: it reintroduces the coupling DL#84 deliberately removed, creates shadowing fragility (adding/removing an inner field silently re-targets an existing binding), and makes type-gen/validation ambiguous when multiple ancestors expose the field. Explicit is unambiguous for the compiler, reads as documentation at the call site, and keeps isolation the default. Chainable$parent.$parent..
Q2. Scope of issue 1 (pure-component ref forwarding). DL#187 Q6 / criterion 9 explicitly
say Tier 2 components have no refs. Forwarding inner child refs makes a Tier 2 component a
ref surface — crossing that line (without adding code to it). Do we:
(a) implicitly forward all inner child-component refs (auto), or
(b) require the composite to declare which inner refs are exposed (new contract concept /
new ContractTagType)?
Recommendation: (a) implicit for named (ref="...") inner instances only — auto-refs stay
private. Keeps the contract vocabulary unchanged; matches "pure = no code, but structure is
visible." The no-re-basing decision (§B) makes (a) cheaper still: forwarding named inner refs is
pure passthrough of ButtonRef<CardViewState>, no per-ref event mapping to generate. Note also
that a pure component cannot forEach internally (no composite/array prop — DL#187/DL#84), so
"forward all inner refs" has a bounded, statically-known shape; collections only appear when the
composite itself is repeated at the usage site.
Answer: (a) implicit. Auto-forward all named (
ref="...") inner child-component refs; no declaration required.
Q3. Overrides — how is override-introduced data/refs modeled? (issue 2-data). Three designs:
(a) Splice-at-outer-scope: persist override provenance, compile the override fragment against
the outer ViewState/Refs. Bindings and refs resolve to the outer (page) scope; events carry
PageViewState. Superseded by (c) — it re-bases events to the outer scope, breaking the uniform
"surfaced ref keeps its compiled scope" rule from §B.
(b) Parent-pointer: compile override against the inner scope, add $parent to reach outer
data (reuses Capability A directly). Verbose for the author (every outer reference needs
$parent.).
(c) Inheritance / specialization — RECOMMENDED. The override produces a per-site subtype
Panel$1ViewState extends PanelViewState / Panel$1Refs extends PanelRefs (Examples §C): the
override's added data members and refs are added to the base via inheritance; the override's
free outer references are hoisted into the extended ViewState (typed against the outer scope)
and supplied by the outer scope as extra data down the normal update channel — the same
props→ViewState passthrough as a Tier 2 pure component. Events carry Panel$1ViewState (no
re-basing), bindings pass through ({documentName} reads Panel$1ViewState.documentName), and
the ref forwards up like any pure-component ref.
Recommendation: (c) — it unifies overrides with the §B pure-component model (one "surfaced
ref keeps its compiled scope, data passes through as ViewState" rule for both features), keeps
events un-re-based, and routes outer data through the existing top-down update channel rather than
a runtime back-pointer. The one sub-case still needing Capability A is an override mounted inside
Panel's own internal forEach (the hoisted member sits at Panel$1 top level, read from an
item scope → $parent); the type/surfacing model and the runtime $parent primitive compose.
How the hoist is computed (resolved): no author ceremony — the override is authored in the
outer file, so its bindings resolve against the outer scope by ordinary lexical scoping, and the
compiler's free-variable scan of the fragment is the capture list that becomes
Panel$1ViewState's added members (see §C "automatic capture"). Author never declares them and
never writes $parent (that stays compiler-internal for the inner-forEach sub-case). Still
open: the Panel$1 naming/uniquing convention, and validating outer/inner name collisions.
Answer: (c) inheritance/specialization, confirmed. Hoist by automatic capture (§C).
Q4. Reactive parent bindings need runtime work on all targets (revised). The
"free on client" assumption was wrong (Examples §A): dynamicText.update gets only the item, so
parent data must be carried down by making ConstructContext live (Q7). Client/hydrate share
that carrier; the sandbox/bridge target needs its own re-attach on the receiving side. Do we
phase secure/bridge behind client, or land both together?
Recommendation: make ConstructContext live once, enable client + hydrate first, follow with
secure/bridge (which needs the extra receiving-side re-attach).
Answer: ok — client + hydrate first, secure/bridge after. But a blocking runtime detail must be solved first (below): the update gates skip the very leaves a
$parentbinding needs.
Q4a. The update-gate problem (raised in review — must be solved). Jay's update path
short-circuits on reference checks so an unchanged subtree is not re-touched. Three gates exist
on the way to a {$parent.field} leaf inside a forEach (verified in source):
- Collection gate —
element.ts:418let isModified = items !== lastItems; if (isModified){…}. If the items array reference is unchanged, noelem.updateruns for any item. - Per-item gate —
wrapWithModifiedCheck(context.ts:148-151), applied to each item element atelement.ts:429:if (newData !== current) update(current). If the item's own data reference is unchanged, the item element's update is skipped. - Leaf DOM-write gate —
dynamicText.update(element.ts:546-549) recomputestextContent(newData)every call and gates only the DOM write (if (newContent !== content)). This one is correct for us: given the chance to run, it recomputes and, reading the now-livecontext.parent.currData, produces the new content.
The problem: when the parent changes but the item array/item references do not (the common
keyed-reuse case), gates 1 and 2 both short-circuit before gate 3 ever runs — so calling
parentContext.update(newData) alone (the base carrier design) makes the parent context live but
the leaf never gets called to read it. Bindings stay stale.
Fix — a compile-time dependsOnParent flag that weakens gates 1 & 2 only where $parent is
used. The compiler already knows a scope's body references $parent; it marks that
forEach/conditional/withData with dependsOnParent: true. Then, at runtime, per cascade:
- Update the parent context first (
parentContext.update(newData)), socontext.parent.currDatais fresh before any child update reads it. - Gate 1: add
parentModified = newData !== lastParentData; when the bodydependsOnParentandparentModified, still runitemsList.forEach((v, elem) => elem.update(v))even thoughitems === lastItems(anelse ifbranch beside the existingisModifiedpath). - Gate 2: for
dependsOnParentscopes the per-itemwrapWithModifiedCheckmust not veto on an unchanged item — either skip wrapping those items, or make its check parent-aware (fire when the captured parent context changed). Gate 3 then recomputes correctly.
Cost is opt-in and localized: a plain forEach keeps all three fast gates untouched; a
$parent-using forEach re-runs its item leaves only when the parent actually changed
(O(items) recompute for that scope, with gate 3 still suppressing no-op DOM writes). This refines
the earlier "O(scopes) writes per cascade" claim: parent-context updates stay O(scopes), but a
parent change forces O(items) leaf recomputation for each parent-dependent scope — inherent
and unavoidable, since each item's $parent binding genuinely may now differ.
Precedent in the reactive core — createDerivedArray (checked per review request). The same
"re-map an item even though its own reference is unchanged" problem is already solved one layer
down, in packages/runtime/component/lib/hooks.ts. createDerivedArray maps an array through a
per-item cache (WeakMap<T, MappedItemTracking>) and decides re-mapping with an explicit predicate
(mapItem, hooks.ts:98-127):
const needToMap =
force || // <- MeasureOfChange.FULL propagated in
!cached ||
item !== cached.item || // item reference changed (our gate 2)
(index !== cached.index && cached.usedIndex) || // index changed AND the mapper read index
(length !== cached.length && cached.usedLength); // length changed AND the mapper read length
Two ideas transfer directly and validate the Q4a design rather than replacing it:
force= ourdependsOnParent+parentModified.forcecomes fromMeasureOfChange.FULL(reactive.ts:1-6:NO_CHANGE | PARTIAL | FULL) propagating down a signal cascade. It is exactly the "an outer thing changed, so re-evaluate this item even though its own reference didn't" escape hatch we need — the reactive layer already treats "recompute despite unchanged item reference" as a first-class, cascade-propagated condition. OurdependsOnParentgate branch is the DOM-runtime analogue offorce; this is the established idiom, not a new hack.usedIndex/usedLengthdependency tracking is a sharper gate than a static flag. NotemapItemdoes not blindly re-map on any index/length change — it re-maps only when the value changed and the mapper actually read it (trackableGetter, hooks.ts:87-96, recordswasUsed). The parallel for us: re-run an item's leaves only when the parent changed and that item's subtree actually reads$parent. Our compile-timedependsOnParentflag is the coarse (per-scope) version of this same discrimination — and coarse is the right call here: the compiler already knows statically whether a scope reads$parent, so there is no need for runtimewasUsedtracking. We gettrackableGetter's precision for free, at compile time, without the per-item bookkeeping cost.
Conclusion: createDerivedArray is a confirming precedent, not a cleaner replacement.
It shows (a) the codebase already models "recompute despite unchanged reference" as a propagated
signal (force/MeasureOfChange.FULL), so dependsOnParent is consistent with existing design;
and (b) the right gate is "changed AND actually used," which our static $parent analysis captures
at compile time — strictly cheaper than the runtime WeakMap+trackableGetter machinery, which
exists only because the reactive layer cannot see the mapper body. No change to the Q4a fix; we
keep the compile-time dependsOnParent flag, now with a documented precedent.
Answer: ok — keep the compile-time
dependsOnParentgate-weakening.createDerivedArrayconfirms the idiom (force/MeasureOfChange.FULL= "recompute despite unchanged reference") and that a static "reads$parent" flag is the cheaper compile-time analogue of its runtimeusedIndex/usedLengthtracking. No design change.
Q5. Priority / sequencing. Issue 1 (pure refs) and issue 2 (overrides) are the user-driving features; issue 3 (forEach parent) is explicitly low priority but is the simplest and shares runtime-A with issue 2a. Suggested order: 3 → 2 → 1 (build the cheap parent-data primitive first, then overrides on top, then the refs-forwarding work).
Answer: ok — 3 → 2 → 1.
Q6. Do we ship both binding kinds together? — RESOLVED, moot. The two "binding kinds"
(event-handler parent access vs text/attribute parent access) are no longer both in scope: event
handlers reach parent data directly from the owning component + coordinate (architecture note),
so only reactive text/attribute bindings use the live-ConstructContext carrier. There is one
binding kind and one mechanism; nothing to phase apart.
Answer: resolved — only text/attr uses the carrier; event handlers need no change.
Q7. How is parent viewState carried down? (update-flow mechanism.) Three carriers
considered (Jay's data flow is always top-down through update, so all are viable):
- (a) Variadic
update(vs, parentVs, parentParentVs, …). Conceptually cleanest (no data mutation, explicit types) but most invasive: every generic single-argupdatein the runtime (dynamicText,dynamicAttribute, allmkUpdate*, refs) becomes variadic, every intermediate site must forward the whole chain, and arg-count grows with nesting. Fights the single-arg update design. Not recommended. - (b)
vs[SymbolParent]— REJECTED (mutates user data). A hidden symbol on the viewState set fresh each update. Fine for the top-level viewState (the component render constructs it fresh each invocation), but$parentinside aforEachneeds the symbol on array members, which are user-provided objects. Mutating those has real footguns: object spread{...item}copies enumerable own symbols,Object.freezed data throws, and a shared object reference reused across scopes carries one scope's parent. Not worth it even though the symbol conveniently auto-drops onJSON.stringify. - (b')
WeakMap<childObj, parentObj>. Side table instead of mutation; walk the chain viamap.get(map.get(item)). ✅ no data mutation, GC-friendly, target-uniform, minimal compiler work. ⚠️ O(items) sets per cascade (same order as the symbol); ⚠️ identity collision — keyed by object identity, so the same object reference used as an item in two scopes gets last-writer-wins and one scope reads the wrong parent. (A strongMapis worse — it leaks; if going this route it must beWeakMap.) Perf is acceptable (V8WeakMapis ~O(1); the "slow" reputation is largely myth at UI volumes). - (b'') Captured holder cell (per scope-switch). The generated
forEach/withDatabody creates onelet parentHolder = { current }captured by all item closures; the scope-switch update setsparentHolder.current = newDataeach cascade. ✅ no data mutation, no identity collision, O(scopes) writes. ⚠️ but it's a parallel structure that duplicates whatConstructContextalready is (a per-scope object the leaf helpers already capture), plus extra codegen. Superseded by (c-live). - (c-live) Reuse
ConstructContext: addparentpointer + in-placeupdate— RECOMMENDED. My earlier rejection of (c) was wrong: it conflated "context isn't updated today" with "context can't be updated." The context does not need to be reconstructed on the cascade (infeasible) — it needs to be mutated in place at the scope-switch update points that already hold it.mkUpdateCollectioneven has a standing TODO for this (element.ts:413:// todo handle data updates of the parent contexts). Design:ConstructContextgainsparent?: ConstructContext(set inforItem/forAsync/forScope) andupdate(newData)writingthis.data(dropreadonly).- Each scope-switch update writes its own context live —
parentContext.update(newData)at the top ofmkUpdateCollection.update(resolves the TODO), same inmkUpdateWithData/condition. The cascade already flows through these, so the whole parent chain stays current. - Leaf helpers already retain their context (
dynamicTextcapturescontext = currentConstructionContext()at line 540); a parent binding readscontext.parent.currData. Reused keyed items keep theirchildContext, whose.parentis the same captured object we mutate — so no staleness. - ✅ no data mutation; ✅ no identity collision; ✅ O(scopes) writes; ✅ no new structure and
simpler codegen (no generated holder to thread) — the helper supplies parent to the closure
from its retained context. ⚠️ makes
ConstructContext.datamutable (wasreadonly); the sandbox/bridge still needs a receiving-side re-attach (unavoidable for any carrier).
- (c-snapshot)
ConstructContextread without in-place update — REJECTED. Readingcontext.parent.currDatawhen the context is a construction-time snapshot returns stale data. This is the trap; (c-live) fixes it precisely by adding the in-placeupdate.
Recommendation — (c-live): reuse ConstructContext (parent pointer + in-place update) for
the one location that needs it — reactive text/attribute bindings. It rides the existing
top-down cascade without touching user data and without a parallel structure:
- text/attr: the helper passes parent data into the closure; binding compiles to
dt((vs, p) => p.listTitle)wherep = context.parent?.currData. - event handlers: no carrier needed — the owning component already has parent data +
coordinate(see architecture note), soformatEvent/JayEventare unchanged.
WeakMap (b') / holder (b'') remain as fallbacks if we'd rather not make ConstructContext.data
mutable. Reject (a) (variadic/invasive), (b) (mutates user data), and (c-snapshot)
(stale). Injection sites for the in-place parentContext.update(newData): mkUpdateCollection
(element.ts, the existing TODO at line 413), mkUpdateWithData, and forAsync/resolved.
Secure/bridge re-attaches parent on the receiving side regardless of carrier.
Making the carrier live is necessary but NOT sufficient — see Q4a. parentContext.update keeps
context.parent.currData fresh, but the collection/per-item reference gates (element.ts:418;
wrapWithModifiedCheck) skip the leaves before they can read it. The carrier must ship together
with the dependsOnParent gate-weakening from Q4a, or $parent bindings render stale.
Note — this expands ConstructContext's role. Today ConstructContext is a
construction-time-only object: it supplies initial data when new elements are created
(currData read by dt/da/refs at construction) and is never consulted again. Adding
update makes it a persistent, live per-scope state carrier used at both construction and
update. That is a deliberate, reasonable promotion (it becomes "more complete" — the natural home
for scope state), but worth stating explicitly since it changes the object's lifecycle contract
and means its data is now mutable state, not an immutable snapshot.
Answer: ok — reuse
ConstructContext(parent pointer + in-placeupdate). Ship with the Q4a gate-weakening so the live parent context actually reaches the leaves.
Q8. How does pass-through data reach a component with code (Tier 3)? For Tier 2 (pure),
pass-through is trivial: props mirror tags = ViewState (DL#187 Q3/Q9), so a captured override
member is already a ViewState member. Tier 3 breaks that — the .ts computes ViewState from props,
and DL#187 Q3 / DL#84 deliberately keep props ≠ ViewState. Two routes for getting the override's
captured outer data to the override subtree of a coded component:
(a) Props default to ViewState members — auto-merge every prop into the ViewState unless a
declared ViewState field of the same name shadows it. Makes pass-through uniform across tiers, but
widens ViewState type-gen for every coded component, invents implicit members + shadowing
rules, and erases the props/ViewState separation Tier 3 exists to provide. Broad change for a
narrow need.
(b) Targeted supply via the live-context carrier — RECOMMENDED. Keep the component's ViewState
exactly what its code declares. Panel$1ViewState extends PanelViewState is a compile-time
composition for typing the override subtree's bindings and forwarded ref — not a runtime
member added to Panel's ViewState object. At runtime the captured data rides the same
ConstructContext carrier introduced for Capability A (Q7), injected at the override mount point;
the override subtree reads its captured members from that context. No props→ViewState projection,
no widened coded-component type-gen, separation intact.
Recommendation: (b). The narrow need (make captured outer data visible to the override
subtree) is served by the carrier we already need; Panel's own code and template never see it, so
it should never enter Panel's runtime ViewState. Route (a) is a large, surprising semantic change
to Tier 3 for something the carrier already covers. (Tier 2 stays trivial by its own props=tags
rule — no carrier needed there.)
Answer: (b) — targeted supply via the carrier; do not make props default to ViewState members.
Panel$1ViewStatestays a compile-time composition.
Design
Capability A — parent-scope data access (issues 3, and 2a option-b)
Compiler (type generation):
- Grammar (
expression-parser.pegjs): recognize a parent sigil inaccessor/propertyAccessor, dispatched like the existingjay→__jayspecial case. Applies to the slow/dotted accessor rules too, for parity. Variables.resolveAccessor(expression-compiler.ts): on a parent token, walkthis.parentN levels and resolve remaining terms againstparent.currentType. Type-safety flows automatically becauseresolvedTypecomes from the parent chain.Accessor.render()must NOT root a parent access at the parent'scurrentVar— that var is not lexically in scope inside the child callback (Examples §A). A parent access renders to an extra closure param the binding helper supplies from its retained context, e.g.dt((vs, p) => p.foo)(orp1/p2for grandparent).Accessorrecords the climbed level sorender()picks the right param.
Runtime (revised — reuse ConstructContext; see Examples §A and Q7): the initial map assumed
the client needs no runtime change because the parent var is lexically in scope. That is wrong
for reactive bindings: dynamicText.update(newData) receives only the current item, and keyed
list reuse makes any lexically-captured parent var stale. The chosen carrier makes the existing
ConstructContext live (variadic update and vs[SymbolParent] were rejected — Q7):
ConstructContext(context.ts): addparent?: ConstructContext(set inforItem/forAsync/forScope) and anupdate(newData)that writesthis.data(dropreadonly). This promotes the context from a construction-time snapshot to a live per-scope carrier (see the role-change note in Q7).- Scope-switch updates (
element.ts): each writes its own context live —parentContext.update(newData)at the top ofmkUpdateCollection.update(resolves the standing TODO at line 413), same inmkUpdateWithDataand the conditional/async paths. The cascade already runs these, so the whole parent chain stays current. - Gate-weakening for
$parentscopes (Q4a — required, not optional). The reference-check gates short-circuit before the leaves: the collection gate (element.ts:418items !== lastItems) and the per-itemwrapWithModifiedCheck(context.ts:148) both skip on unchanged item references, so a parent-only change never reaches the leaf. A compile-timedependsOnParentflag on theforEach/conditional/withDatamakes the runtime, onnewData !== lastParentData, still propagate item updates (extraelse ifbranch besideisModified) and bypass/parent-arm the per-item modified check. Plain scopes keep all fast gates. Leaf gate 3 (dynamicTextDOM-write) is unchanged and correct — it recomputes on every call. - Leaf helpers already retain their context (
dynamicTextline 540).dynamicText/dynamicAttributepass parent data (context.parent?.currData, chained for deeper levels) into the binding closure as extra params →dt((vs, p) => p.foo). - event handlers: unchanged. No
formatEvent/JayEventchange — the owning component reads parent fields directly from its own ViewState and usescoordinateto identify the item. - secure/bridge: context is runtime structure (not serialized), so the main side works uniformly; the bridge target re-attaches parent on the receiving side.
Files: expression-parser.pegjs (+ .cjs rebuild + prettier), expression-compiler.ts
(resolveAccessor, Accessor), context.ts (parent + update; wrapWithModifiedCheck
parent-aware/bypass per Q4a), element.ts (mkUpdateCollection/mkUpdateWithData/async — set
parent context live and the dependsOnParent gate branch; dynamicText/dynamicAttribute —
pass parent to closures), the forEach/conditional/withData descriptor (dependsOnParent
flag) and its emission in the jay/hydrate codegen. No change to node-reference.ts/formatEvent
(event handlers reach parent data via the owning component + coordinate). Deferred to Phase 4:
jay-html-compiler-bridge.ts, jay-html-compiler-server.ts.
Capability B — forwarded refs (issues 1, 2-refs)
Issue 1 — pure-component inner refs. The refs pipeline is
contract tags → RefsTree → optimize/merge → type-gen + manager-gen → runtime. Today a Tier 2
component contributes no refs. To forward:
- Parser (
jay-html-parser.tsparseHeadfullFSImports): for a Tier 2 import, also compile its own.jay-htmlto discover the named child-component refs inside it, and record them on the import (new field onJayHeadlessImports, alongsidestructural). - Passthrough (
structural-coercions.tsbuildStructuralPassthroughComp): today emits(_props, _refs) => ({ render: () => _props })and ignores_refs. Forward the inner refs through the returned object so the outerDELEGATE_REFS_TO_COMP_TRAPcan reach them. Pure passthrough — no event re-basing (§B): the forwarded ref keeps the inner component's scope viewState; there is nothing to map, so this is a plain re-export. - Type-gen (
jay-html-compiler.tsrenderHeadlessInstanceref-type selection;jay-html-compile-refs.tsrenderRefsType/renderReferenceManager): compose the forwarded sub-tree into the usage site's Refs type, typed against the inner scope (cta: ButtonRef<CardViewState>) — no re-basing to the usage-site VS.optimizeRefs/graftTemplateOnlyRefsalready know how to graft imported sub-trees — the forwarded refs must actually populate the tree. - No
forEachinside a pure composite (corrected): a pure component cannot receive a composite/array prop (DL#187/DL#84), so it has no internalforEachand no inner collection refs. Collections arise only when the composite itself is placed under a usage-siteforEach(<jay:Card forEach="cards" ref="cards">) →ctabecomesButtonRefs<CardViewState>nested under the usage-site forEach manager. The single-vs-collection decision is made entirely at the usage site; the existing collection machinery covers it once the tree carries the forwarded ref.
Issue 2 — override-introduced refs (inheritance model, Q3-option-c). Requires persisting
override provenance on the AST node (re-introduce overrides: OverrideDeclaration[] on the
<jay:Name> node), then emitting a per-site specialization: Panel$1Refs extends PanelRefs with
the override's refs added (typed against Panel$1ViewState, not the outer VS — §C). The added
refs graft into the specialization's RefsTree and forward up exactly like §B pure-component refs
(same DELEGATE_REFS_TO_COMP_TRAP passthrough, no re-basing). Files: jay-html-overrides.ts,
jay-html-parser.ts injection sites, jay-html-compile-refs.ts (graftTemplateOnlyRefs),
contract-to-view-state-and-refs.ts, jay-html-compiler-shared.ts.
Runtime (B): references-manager.ts (mkRefs/mkManagedRef) and node-reference.ts
(ComponentRefsImpl, DELEGATE_REFS_TO_COMP_TRAP) already forward member access to a mounted
instance's public API. The new requirement is that the passthrough/override component's public
API carries the child refs — mostly a compiler-emission change, minimal runtime change.
Because forwarding does not re-base events (§B), the runtime side is unchanged beyond
carrying the refs: RefImpl keeps delivering its own scope viewState via the existing
formatEvent; no boundary-crossing viewState mapping is added.
Issue 2 — override data binding (inheritance model, recommended option-c)
Emit a per-site specialization Panel$1ViewState extends PanelViewState (§C, Q3-c). The added
members are computed by capture, not declaration: the override is authored in the outer file,
so its bindings resolve against the outer scope by ordinary lexical scoping; the compiler's
free-variable scan of the fragment is the capture list, and each captured outer identifier
(typed by resolving against the outer scope) becomes an added Panel$1ViewState member, supplied
from the outer scope as extra data down the normal update channel — the same props→ViewState
passthrough as a Tier 2 pure component. The override's bindings then read Panel$1ViewState
(pass-through), and its refs forward up carrying Panel$1ViewState (no re-basing). $parent is
never authored here — the compiler emits it internally only for the inner-forEach sub-case
below. Persist override provenance so the compiler knows which nodes belong to the specialization.
Validate outer/inner name collisions (an outer capture colliding with a differently-typed
PanelViewState member makes the extends ill-typed). Files: jay-html-overrides.ts,
jay-html-parser.ts, jay-html-compiler.ts (emit the specialization + extra-data wiring),
expression-compiler.ts (free-var scan / outer-scope typing), contract-to-view-state-and-refs.ts.
The one remaining sub-case: an override mounted inside Panel's own internal forEach — the
hoisted member sits at Panel$1 top level but is read from an item scope, which is Capability A
($parent). The inheritance model (type/surfacing) and the $parent runtime primitive compose.
This is the strongest argument for building A first (Q5).
Implementation plan (phased, pending answers)
- Phase 0 (this DL): approve direction + answer Q1–Q5.
- Phase 1 — Capability A (issue 3): grammar
$parent,resolveAccessorparent walk, type tests, fixtures plus the runtime primitive —ConstructContextparent pointer + parent-aware binding helper and the Q4a gate-weakening (dependsOnParentflag; collection + per-item gate branches). Client + hydrate first. Unblocks 2a disambiguation. Plus a runnable example — add a$parent-in-forEachexample underexamples/jay/(non-jay-stack), following theexamples/jay/tododual layout: alib/build (regular/trusted,index.html) and alib-secure/build (sandbox/bridge,secure.html), both wired through the vite plugin. This is the end-to-end proof that the same{$parent.field}template renders identically in regular and secure mode (secure exercises the receiving-side parent re-attach). Note: secure/bridge parent plumbing is Phase 4, so this example's secure build is the forcing function that Phase 4 must satisfy — track it as the secure-mode acceptance gate, not a Phase-1-only deliverable. - Phase 2 — Issue 2 (overrides): persist override provenance; compile against outer scope; graft override refs into outer RefsTree; forward override refs at runtime.
- Phase 2c — server-target
$parent(SSR): make the server compiler resolve override parent bindings lexically (structural-instance case). Pulled out of the original Phase 4 because it completes Phase 2 for full-stack use and is independent of Phase 3. See the "Phase 2c" section near the end of this doc. - Phase 3 — Issue 1 (pure-component ref forwarding): parser discovers inner refs; passthrough forwards them; type-gen composes forwarded (single + collection) sub-trees.
- Phase 4 (Q4): extend Capability A's parent pointer to secure/bridge code generators (the
runtime
ConstructContextfield lands in Phase 1; this threads it through the remaining emitters). The server target moved to Phase 2c; Phase 4 is now secure/bridge (and React) only.
Each phase: write/adjust fixtures first (full toEqual comparisons, never toContain),
prevention-first (add validation for unsupported target/phase combos before adding syntax).
Test plan
Standards (CLAUDE.md): fixture-based (test/fixtures/<feature>/), full toEqual comparisons with
prettifyHtml for HTML — never toContain on code, helper functions over beforeEach, one
test file per module. Every runtime behavior is validated on the three targets it must hold for:
client (trusted), hydrate, and secure (sandbox/bridge); compiler codegen is checked
per target via generated-element fixtures.
1. Runtime unit tests — Capability A carrier + gates (the crux)
Package packages/runtime/runtime. The gate matrix (Q4a) is the highest-risk area — it gets an
exhaustive truth table because a wrong gate silently renders stale, the exact bug this design
exists to prevent.
context.test.ts— carrier.forItem/forAsync/forScopesetparent;update(newData)mutatesdata;currDatareflects the update;parent.currDatais live after a parent update; chainedparent.parentfor grandparent depth.element.test.ts—$parentgate truth table inside aforEachwith a{$parent.field}leaf, asserting both the DOM result (toEqualon rendered text) and that no redundant DOM write happened when nothing relevant changed (spy/observetextContentsets):# parent data items array ref item member expected 1 changed unchanged (keyed reuse) unchanged $parentleaf updates — the regression case2 changed changed — $parent+ item leaves both correct3 unchanged unchanged changed (one item) item leaf updates; $parentleaf no DOM write4 unchanged unchanged unchanged no DOM writes at all 5 unchanged reordered (same refs) unchanged keyed reuse intact, $parentunchangedelement.test.ts— fast path preserved. AforEachwithout$parent(dependsOnParent=false) keeps theitems !== lastItemsskip and per-itemwrapWithModifiedCheckskip — assert itemupdateis not invoked when the items array ref is unchanged (spy).Scope kinds. Repeat case #1 for
conditional(if) andwithDatascopes, and fordynamicAttribute({$parent.field}in an attribute), not justdynamicText.Depth.
$parent.$parent.fieldinside a nestedforEachupdates when the grand-parent changes and the inner two scopes' data are unchanged.
2. Runtime unit tests — Capability B forwarding + override refs
node-reference.test.ts/references-manager.test.ts. A forwarded inner ref delivers the inner scope viewState in its event (no re-basing) —cta.onClickhandler receivesCardViewState. Collection:refs.cards.cta.onClickfires per card with that card's viewState;refs.cards.cta.find(...)reaches one card.- Override refs.
refs.panel.save.onclickfires to the outer handler; event carriesPanel$1ViewState(includes the captureddocumentName).
3. Compiler tests — type-gen + codegen fixtures (toEqual)
Package packages/compiler/compiler-jay-html. Each gets input .jay-html (+ .jay-contract) and
an expected generated file compared with full toEqual.
- Capability A codegen.
$parentin aforEachemitsdt((vs, p) => p.field)and setsdependsOnParenton the descriptor; per target (client, hydrate). Multi-level, and$parentinif/withData. resolveAccessorunit tests (expression-compilertest):$parent.fieldresolves to the parent type;$parent.$parent; unknown parent member → error;$parentat root (no parent) → error.- Capability B type-gen. Pure composite with
<jay:Button ref="cta">→CardRefs { cta: ButtonRef<CardViewState> }grafted into the usage site; composite under a usage-siteforEach→ButtonRefs<CardViewState>. Auto (unnamed) inner refs are not forwarded. - Override inheritance type-gen.
Panel$1ViewState extends PanelViewState { documentName }andPanel$1Refs extends PanelRefs { save: HTMLElementProxy<Panel$1ViewState, …> }; only the referenced outer members are hoisted; override insidePanel's internalforEachemits the compiler-internal$parent+dependsOnParent.
4. Validation tests (prevention-first)
Package boundary that owns each rule; assert the exact error message (string equality on the
diagnostic, not toContain):
$parentwith no ancestor scope → error.$parent(or override reactive outer binding) on an unsupported target/phase → error.forEachinside a pure (Tier 2) composite → error (no array prop can reach it).- Override outer capture whose name collides with a differently-typed
PanelViewStatemember → error (would makeextendsill-typed).
5. Example (end-to-end) tests — regular + secure
Under examples/jay/, following the examples/jay/todo dual layout (lib/ + index.html,
lib-secure/ + secure.html, both via the vite plugin):
$parent-in-forEachexample (Phase 1 / Phase 4 gate): renders identically in regular and secure mode; updating parent data live re-renders the$parentbindings with items unchanged (the case #1 regression, end-to-end); reordering/removing items keeps them correct.- Pure-composite ref-forwarding example (Phase 3):
refs.card.ctawired at the usage site fires; collection variant under a usage-siteforEach. - Override example (Phase 2): injected
savefires to the outer component and{documentName}renders/updates; a variant with the override inside the container's internalforEach.
Each example is the secure-mode acceptance gate for its phase (secure/bridge codegen is Phase
4): the example's lib-secure build failing to match lib output is a release blocker, not an
optional extra.
6. Coverage traceability
Every verification criterion (below) maps to at least one runtime test and one example:
criterion 1/1a → §1 gate table + §5 $parent example; criterion 2 → §2 forwarding + §3 type-gen +
§5 composite example; criterion 3 → §2 override refs + §3 override type-gen + §5 override example;
criterion 4 → §4 validation. A criterion with no green test in both columns is not "done."
Trade-offs
$parentweakens scope isolation (DL#84's deliberate default). Mitigation: explicit opt-in sigil, not implicit fall-through; keepresolveAccessorerroring on unknown members.- Ref forwarding crosses DL#187's "Tier 2 has no refs" line. It adds no code to the Tier 2 component but does make it a ref surface. Needs an explicit decision (Q2); may warrant a one-line amendment to DL#187.
- Inheritance/specialization for overrides (
Panel$1 extends Panel) unifies overrides with the §B pure-component model (no re-basing, data passes through as ViewState) and avoids a runtime back-pointer, but adds a per-site generated subtype and a free-var hoist pass; nested innerforEachstill needs Capability A ($parent) and is the sharp edge. - Parent bindings cost a
ConstructContextparent pointer on all targets (Examples §A) — the "free on client via lexical capture" shortcut is unsafe (stale on keyed reuse). This is a small but real shared runtime primitive; building it once is cheaper than a client-only special case that later needs redoing.
Verification criteria
- From inside a
forEach,$parent.fieldcompiles to a type-safe binding on the parent var (client) with a full-fixturetoEqualmatch; unknown parent member → validation error. 1a. A runnableexamples/jay/example binds{$parent.field}inside aforEachand renders the same output in regular mode (lib/+index.html) and secure mode (lib-secure/+secure.html), updating live as parent data changes (no staleness on keyed reuse). - A pure composite wrapping
<jay:Button ref="cta">exposesctaat the usage site typedButtonRef<CardViewState>(inner scope, not re-based to the usage-site VS); its event deliversCardViewState. Placing the composite under a usage-siteforEachexposesButtonRefs<CardViewState>(collection). NoforEachinside the pure composite is possible (compile error / validation if attempted, since no array prop can reach it). - An override injecting
<button ref="save">Save {documentName}</button>into a container emits aPanel$1ViewState extends PanelViewState/Panel$1Refs extends PanelRefsspecialization:documentNameis hoisted intoPanel$1ViewState(typed from the outer scope, supplied as extra data);saveforwards up typedHTMLElementProxy<Panel$1ViewState, …>(no re-basing) and its event deliversPanel$1ViewState. An override mounted insidePanel's internalforEachreaches the hoisted member via$parent(Capability A). - Unsupported target/phase combinations produce a clear validation error, not a silent miscompile.
Implementation Results — Phase 1 (Capability A)
Phase 1 ($parent parent-scope binding for reactive text/attribute bindings inside
forEach/if/withData scopes) is complete for the client and hydrate targets, with
React and server producing validation errors (unsupported-target, prevention-first). Event handlers
are out of scope for Phase 1 as designed.
What shipped
- Grammar + accessor resolution.
$parentparses as an Identifier and is recognized inresolveAccessor, which walks up the scope chain. Chainable ($parent.$parent.field). Unknown parent member and$parentat root both error at compile time. parentDepthpropagation.RenderFragment.parentDepthcarries the deepest$parentclimb; propagated via map/merge (Math.max), decrements by 1 at scope-switches (forEach/withData/async), passes through unchanged atif.- Codegen. Leaf closures emit the parent positionally —
da((vs1, _p1) => _p1.groupLabel),dt((vs1, _p1) => _p1.groupLabel)— and the enclosingforEach/hydrateForEachcarries thedependsOnParentflag to weaken the keyed-update gate so parent-only changes re-run item leaves. - Runtime primitive.
ConstructContextparent pointer +parentDataChain(context)(nearest-first_p1, _p2, …); the Q4a gate-weakening branches inforEach/hydrateForEach. - Example.
examples/jay/parent-binding— dual regular (lib/+index.html) + secure (lib-secure/+secure.html) build via the vite plugin, mirroringexamples/jay/todo. The "relabel group" action changes onlygroupLabel(cards array ref untouched) and every{$parent.groupLabel}leaf updates — the case #1 regression, end-to-end.
Test results
- compiler-jay-html full suite: 738 passed / 4 skipped.
- runtime full suite: 278 passed / 3 skipped.
- Example
yarn build:js: both regular and secure builds succeed (23 modules); generatedboard.jay-html.tsverified to emitda/dt((vs1, _p1) => _p1.groupLabel)andforEach(..., 'id', true). yarn build:check-typeson the example reports avite.config.tsTS2769 overload error — this is a pre-existing duplicate-vite-install type conflict shared byexamples/jay/todo(identicalvite.config.ts(15,29): error TS2769), not a defect in the example source. Alllib//lib-secure/sources type-check clean.
Deviations from the initial design
- Closure param naming
_p{level}(not the design sketch'sp)._p1= immediate parent, supplied positionally by the runtime fromparentDataChain(nearest-first). Chosen to avoid collisions with the existingvsNviewState params and to make depth legible in generated code. - Only
forEachcarries the runtimedependsOnParentflag.if/withDatado not gate on a keyed-collection identity check, so they re-render their leaves on any parent update without a flag; the flag exists solely to weakenforEach'sitems !== lastItems+ per-itemwrapWithModifiedCheckskips. - React and server are validation errors, surfaced two different ways.
- React:
guardReactParentBindingappends'$parent bindings are not supported in the React target'whenparentDepth > 0(React compiler preservesfragment.validations). - Server: the server compiler drops
fragment.validationsat ~15w(...)sites, so an appended validation never reached output. InsteadguardServerParentBindingthrowsUnsupportedServerParentBindingError, caught ingenerateServerElementFileand converted tonew WithValidations('', [UNSUPPORTED_TARGET_PARENT_MESSAGE]). Throwing covers all sites uniformly. Message:'$parent bindings are not yet supported in the server target'.
- React:
- Bridge/secure needs no guard in Phase 1. The
.jay-htmlview renders on the main thread via?jay-mainSandboxusing the client target, so$parent/dependsOnParentcodegen is already present and correct; the worker sandbox (?jay-workerSandbox) usessandboxForEach, which only tracks the collection and needs no flag. (Full secure/bridge parent plumbing remains Phase 4; this example's secure build is its acceptance gate.) forScopedata-liveness fix (_liveDataSource).forScopepreviously snapshottedthis.data; the forScope decorator becomes the leaf's_p1parent during hydration and went stale on in-place parent updates. AddedConstructContext._liveDataSource:currDatadelegates to the live source, andforScopesetschild._liveDataSource = this. This is a broad runtime change beyond the original design sketch, required for correctness under hydration.- Hydrate parent-chain plumbing.
adoptTextandadoptBase(dynamic attributes) now pass...parentDataChain(context)on both init and update, with signatures widened to(vs, ...parents: any[]). The original design described the client path; hydrate required the same threading explicitly.
Phase 2 pre-implementation trace & decisions (2026-09-16)
Before implementing Issue 2 (overrides), the injection compile model was traced end-to-end and three open decisions were settled with the user. This section records the findings and decisions; it refines — does not rewrite — the Phase 2 design above.
Compile-model trace (grounding the runtime cost)
A <jay:Panel> headfull-FS usage (Tier 3, has a .ts) compiles via renderHeadlessInstance
(jay-html-compiler.ts:839-1045):
- The injected/overridden body is compiled inline into a per-instance render function
(
_headlessPanel0Render,966-985) that builds its own1039-1041).ReferencesManagerfrom the merged template refs (mergeContractStubRefs,jay-html-compiler-shared.ts:291-333— keeps all template refs), and is mounted at the page aschildComp(_HeadlessPanel0, getProps, ref)( - The page-side ref handed back is a
ComponentRefsImpltypedPanelRefs(contract only, ~1013-1035). The inline instance's refManager public API is passed to Panel's own component code as itsrefsparam (headless-instance-context.ts:154-160) — it is not grafted into the page's refs tree. - Tier 2 (structural) differs only in swapping
buildStructuralPassthroughComp(tags)for the real component at the mount; inner body refs still land in the inline instance's refManager (which the passthrough currently ignores).
Where a spliced <button ref="save"> ends up today: inside the inline instance's
refManager (handed to Panel's code), not the page refs tree, and not discarded. The page's
ComponentRefsImpl forwards refs.panel.save via DELEGATE_REFS_TO_COMP_TRAP
(node-reference.ts:398-401) only to the comp's public API (event-emitters), which has no
save; and the page ref type is PanelRefs, which does not declare save.
Correction to the earlier "minimal runtime change" claim (Capability B, Issue 2 runtime note).
The claim holds for the data-binding half (the body is compiled inline at the page, so
{documentName} and the Panel$1ViewState hoist are in reach during page compilation — cheap) but
is too optimistic for the ref-forwarding half. Surfacing refs.panel.save needs both:
- Type — emit
Panel$1Refs extends PanelRefs { save: … }and type the page-side instance ref asPanel$1Refs(newextends+$1naming machinery — neither exists today). - Runtime — bridge the page-side
ComponentRefsImplto the inline instance's refManager (wheresavephysically lives), not the comp public API. This is a genuine runtime change, localized to the inline-instance mount (makeHeadlessInstanceComponent/ the instance public API), not just compiler emission.
Decisions settled (2026-09-16)
- Binding scope — outer-scope only (DL#193 §C), confirmed. Override
{bindings}resolve against the outer (page) scope and free-vars hoist intoPanel$1ViewState extends PanelViewState. Reaching the target component's own data from an override is out of scope. This supersedes DL#181 Q6/VC#5 (which resolved override bindings against the target's own ViewState). Risk is low: DL#181's own Implementation Results note Q6 was "satisfied by construction … not separately exercised" by any live-binding test. A supersession note is added to DL#181. - Naming/uniquing convention —
Panel$1,Panel$2. Per-site specialization types are named<BaseComponentName>$<n>, wherenis a per-base-component counter in document order within the compiled page (Panel$1,Panel$2,Card$1, …).$is already idiomatic in Jay (exec$,find$). Reordering override sites renumbers the generated names — acceptable, as these are generated types. (Resolves the "still open" naming item from Q3.) - Phasing — split Phase 2 into 2a then 2b.
- Phase 2a — override outer-data binding + ViewState hoist. Persist override provenance;
free-variable scan of the override fragment against the outer scope; emit
Panel$1ViewState extends PanelViewState { …captured }; compile the inline body's bindings againstPanel$1ViewState; supply captured data via the carrier (Q8-b). Client-inline, cheaper, fully testable on its own. - Phase 2b — override ref forwarding. Emit
Panel$1Refs extends PanelRefs { …overrideRefs }, type the page-side instance ref asPanel$1Refs, and add the runtime bridge from the pageComponentRefsImplto the inline instance's refManager. Depends on 2a's provenance + specialization machinery.
- Phase 2a — override outer-data binding + ViewState hoist. Persist override provenance;
free-variable scan of the override fragment against the outer scope; emit
Collision validation (prevention-first, folded into 2a)
An override's captured outer var whose name collides with an existing PanelViewState member of a
different type makes Panel$1ViewState extends PanelViewState ill-typed. This is a hard
compile/validation error with an exact message (test plan §4), not a silent shadow — added as
part of Phase 2a.
Phase 2a design revision — merge-time $parent-remap (2026-09-16)
During Phase 2a implementation, the binding half of §C was reworked with the user to reuse
Capability A instead of building a new capture/hoist path. This supersedes the "capture → hoist
into Panel$1ViewState" data-path described in §C (lines ~380–405), decision 3's "Phase 2a" bullet,
and the "Collision validation" subsection above. The ref-forwarding half (Phase 2b) is
unchanged.
What changed and why. §C's data path is now realized by the compiler-internal $parent
primitive it already anticipated ("the same primitive is used, but under the hood", §C ~line 401) —
which the user confirmed was the intended implementation, not a reversal of Q3. Q3 rejected only the
author-facing $parent. syntax; the author still writes plain {documentName}. This is not
Q3-option-b.
- Merge-time
$parent-remap. When an override's content is spliced into the target (applyOverrides), the compiler rewrites each binding's free roots to$parent.using the real expression parser (not string surgery, so{a ? b : c}→{$parent.a ? $parent.b : $parent.c}and{doc.name}→{$parent.doc.name}are exact). The remapped{$parent.…}is self-describing provenance, so thedata-jay-override-contentmarker is dropped. - No data-hoisting, no
Panel$1ViewStatefor the data path. Captured page data rides the Capability AparentDataChain, typed against the page scope where$parentresolves. It does not become a member of any generated ViewState.classifyOverrideBindingsand thePanel$1ViewState extends …emission are therefore not built for bindings (Phase 2b may still emitPanel$1Refsfor the ref surface). - No cross-scope collision. Because
$parent.documentNameand Panel's owndocumentNamelive in different scopes, the "collision validation" case above cannot occur and is dropped. renderHeadlessInstanceparent wiring. The inline component'sVariablesis given the page scope as its parent so$parent.*resolves; at runtime the page data is supplied as the parent source across thechildCompboundary (the one genuinely new bit of plumbing).
forEach overrides are unsupported (new decision). An override targeting a ref that sits inside
the target component's own forEach is not supported: the outer author has no way to name the
inner per-item ViewState to bind against, and no way to address specific iterations — a per-item
override is semantically undefined from the outer scope. This removes §C's "Sub-case — override
region inside Panel's own forEach" (lines ~420–424). Per prevention-first, this is a hard
compile/validation error (not a silent no-op): <override ref="x"> whose target lies within a
forEach in the target template is rejected with a message pointing at the ref.
Prose correction. §C line ~382 ("supplied … as extra data — no runtime back-pointer") no
longer holds: the $parent/parentDataChain route is a runtime back-pointer. Treat that clause
as superseded by this subsection.
Mechanism refinement — parent-scope pragma (supersedes point 1 above)
Point 1's "rewrite each binding's free roots to $parent." was refined during implementation to a
single mark per binding location, at the user's direction, because a per-accessor $parent.
rewrite requires the merge step to itself parse and classify every sub-expression (field vs. enum
value vs. class name) — duplicating the real parser's job and getting it wrong for compound
expressions.
Instead: applyOverrides prefixes each binding location — every text node and every non-literal
attribute value — with a compiler-internal pragma @jay:parent (PARENT_SCOPE_PRAGMA). A single
mark at the start of a value covers all {…} within it. The pragma is:
- Injected in
remapOverrideBindingsToParent(jay-html-overrides.ts) by walking the override HTML fragment. Literal-read attributes (ref,trackBy,jay-coordinate-base,jay-scope) are not marked — everything else (if,forEach,class,style, boolean attributes, component props, text) funnels through the expression parser and is safe to mark. - Stripped at the single parse choke point
doParse(expression-compiler.ts), which then resolves the value againstvars.withParentShift(1). - Resolved by
Variables.withParentShift(n): a view of the scope whoseresolveAccessortransparently prefixesn$parenttokens (skippingjay.*and explicit$parent.*), so the existing Capability A machinery (parentLevel→parentDepth→(vs, _p1) => …closure → runtime parent chain) drives everything. No grammar change is needed for the mark itself.
Because the discrimination is handed to the real parser, compound expressions resolve correctly —
{status == active ? primary} climbs only status, leaving the enum value active and class name
primary untouched. The data-jay-override-content marker and any per-accessor rewrite are dropped.
Incidental grammar fix. ternaryClassExpression dropped acc.parentDepth, so a parent-scoped
class ternary emitted a body referencing _p1 under a vs => closure. Fixed to propagate
parentDepth (regenerated expression-parser.cjs). This also fixes Capability A class ternaries.
Status (2026-09-16). Compile-time mark + shift implemented and unit-tested (text, attribute,
property, boolean, condition, compound class ternary, style, jay.* passthrough, static-strip;
plus the merge-step marking with the literal denylist). Still open: (a) renderHeadlessInstance
parent wiring, (b) runtime parent-chain supply across the childComp boundary into the inline
instance render, (c) the forEach-target validation error.
Phase 2a runtime revision — synthetic parent context from a __parentContext prop (2026-09-16)
This supersedes open items (a) and (b) above and the earlier "reuse the Capability A carrier /
supply the page data as the parent source across the childComp boundary" note (design revision
point 4). The binding compile model is unchanged — overrides still compile to ordinary
$parent bindings via the pragma → withParentShift → _p1 path already landed. What changes is
how _p1 is supplied at runtime.
Why the earlier "back-pointer across childComp" route was wrong
Two runtime traces (against the real code) settled it:
- The parent chain is deliberately cut at the component boundary, and the cut is only a
policy.
withRootContext(context.ts:344) andwithHydrationChildContext(context.ts:377) create parent-less roots; the comment attributes this to DL#84 data isolation. The cut is not required for coordinates/dataIds/hydration — those are carried by separate fields and are even deliberately inherited across the boundary (withHydrationChildContextcontext.ts:380-382). - But the Capability A liveness machinery does not cross the boundary, so a back-pointer would be
reactively dead.
parentContext.update()fires only inside scope-switch updates within one element tree (element.ts:431, 522; hydrate.ts:560), each component has its ownmkReactivegraph (component.ts:136), and the page root context is never updated in place. Settingchild.parent = pageContextwould deliver stale data: page changes would never re-run the child's leaves.
The props/update channel is the only reactive cross-boundary path (childComp.update →
propsProxy.update → the child's reaction, element.ts:55, component.ts:187-189, 170-181). So the
parent data must ride that channel — not a context back-pointer.
Secure/bridge trace (the double-serialization concern). Props never cross the sandbox
bridge: getProps is computed locally on each side from that side's copy of the parent
ViewState (sandbox sandbox-element.ts:39-44, main main-child-comp.ts:17-34). The one stream that
is serialized up per update is the child's rendered element ViewState (sandbox-refs.ts:414-419).
Therefore the parent data must not be folded into the child's rendered ViewState (that was the
flaw in the "inject a __parent member inside makeHeadlessInstanceComponent.render" idea — that
render runs in the sandbox and would up-serialize the data). It must be reconstructed on the
receiving/DOM side from the locally-computed prop — "direct forwarding in the component bridge",
zero wire bytes.
The mechanism
Usage site (compiler).
renderHeadlessInstancefree-var-scans the override's$parent-marked bindings and emits a narrow, reactive projection of only the captured fields as a reserved prop__parentContext:// <jay:OverrideCard><override ref="cta">{itemName}</override></jay:OverrideCard> childComp(OverrideCard, (vs) => ({ __parentContext: { itemName: vs.itemName } }), ref());Narrow (not the whole page VS) → it only re-fires on changes to fields the overrides use, and it keeps the payload small. Emitted only when the inline body has parent-scoped bindings (
inlineBody.parentDepth > 0).Receiving side (runtime). The component (and, in secure, the component bridge on main) intercepts the
__parentContextprop and builds a syntheticConstructContextseeded with that value, set as the parent of the component's own root context. From there the override bindings are ordinary$parentbindings resolved byparentDataChain— the existing Capability A primitive. On each cascade the handler callssynthetic.update(newValue).No double serialization.
__parentContextis computed locally bygetPropson whichever side needs it; the synthetic parent is built on the DOM side; the parent data never enters the child's up-serialized rendered ViewState. In secure,main-bridge.tsbuilds the synthetic parent from the prop it already receives locally (main-child-comp.ts callschildComp(bridge, getProps, ref)), so no extra wire cost.
The load-bearing wrinkle — root-boundary gate weakening (Q4a analog)
wrapWithModifiedCheck (context.ts:165-168) gates the whole element update on reference equality
of the child's own viewState; the child root is wrapped by it (withRootContext context.ts:354).
So when only __parentContext changes and the child's own VS ref is unchanged, the root update is
skipped and the $parent leaves never recompute → stale. This is exactly the Q4a problem solved
for forEach via dependsOnParent (element.ts:448-453, 461-469). Here we need the component-root
analog: when the synthetic parent's value changed, force the root element update to run even if the
child's own VS ref is unchanged. The synthetic-parent update handler both synthetic.update(v) and
drives the leaves past the modified-check.
Implementation pieces
| Layer | Change |
|---|---|
| Compiler — usage site | renderHeadlessInstance: free-var scan the override's $parent-marked bindings → emit __parentContext: { …captured } in getProps, only when parentDepth > 0 |
| Compiler — bindings | done — pragma → $parent → (vs, _p1) => _p1.x |
| Runtime — context | withRootContext/withHydrationChildContext gain an explicit optional synthetic-parent (page root stays parent-less); root gate-weakening when the synthetic parent changed |
| Runtime — component | detect __parentContext prop → build/maintain synthetic ConstructContext, wire as root .parent, update() on change |
| Runtime — bridge | main-bridge.ts: same synthetic-parent handling from the locally-computed prop (no wire cost) |
Verification criteria
- A page-authored
<override>{pageField}</override>renders the page field, and updates live when the page field changes — in client, hydration, and secure targets. - The secure bridge sends no additional patch for the parent data (it is not part of the child's rendered ViewState) — asserted on the wire/messages.
- Panel's own code and template never see
__parentContext; it is absent from the DL#128 automation snapshot (instanceData.viewStates). forEach-target override remains a hard compile error (open item (c), unchanged).
Reserved key. __parentContext, following the __jay precedent (reserved, compiler-internal,
author never writes it).
Implementation Results — Phase 2a (2026-09-16)
Phase 2a is implemented and green across the client (element), hydration, and secure targets. The
binding compile model landed in Phase 1 is unchanged (pragma → withParentShift → _p1). The
runtime-supply mechanism shipped with three deviations from the design above, all simplifications.
Deviation 1 — vs => vs whole-parent model (no field derivation)
The design proposed a narrow projection of only the captured fields
(__parentContext: { itemName: vs.itemName }) built from a free-var scan of the override bindings.
Per user direction this was dropped in favor of emitting the whole enclosing view state:
childComp(
_HeadlessCard0,
(vs) => ({ heading: 'Premium', jc: 'card', __parentContext: vs }),
refAr0(),
);
Rationale: view states are immutable, so passing the whole vs makes every $parent expression
"just work" (_p1.<anyField>) with no compiler-side derivation of which fields an override uses,
and no risk of missing a field. There is no double-serialization cost because __parentContext is
computed locally by getProps on each side and never enters the child's up-serialized rendered
ViewState (the secure trace above still holds). The compiler emits the splice only when the
inline body has parent-scoped bindings (parentDepth > 0), so pages without overrides are unaffected.
Deviation 2 — root gate-weakening was unnecessary
The design flagged a "load-bearing wrinkle": a component-root analog of dependsOnParent to force
the root element update when only __parentContext changed. In practice no root gate-weakening was
added. Trace: materializeViewState (component.ts) always returns a fresh object each reaction
run, so wrapWithModifiedCheck at the child root (context.ts) never short-circuits. The liveness
comes entirely from the reaction reading the __parentContext prop signal
(component.ts:194-206): that read makes the reaction depend on the prop, so an outer-scope change
re-runs it → syntheticParent.update(newValue) refreshes the parent's currData → element.update
(fresh VS ref, gate passes) → the $parent leaves recompute against fresh parentDataChain
(element.ts:581). Verified by runtime/component/test/parent-context.test.ts ("updates the $parent
leaf live when __parentContext changes (child prop unchanged)").
Deviation 3 — bridge (worker) target needs no codegen change
The design's implementation table listed a main-bridge.ts change. Not needed. The bridge (worker)
output for a page with an override is a plain stub (elementBridge(vs, refManager, () => [])) — the
override renders main-side via the element target compiled in RuntimeMode.MainSandbox, whose
output carries __parentContext: vs and _p1.itemName identically to the trusted output (only the
contract import suffix ?jay-mainSandbox and the makeHeadlessInstanceComponent factory differ).
The single makeJayComponent runtime seam (used by both the trusted component and the sandbox
component-bridge constructor) consumes __parentContext for all targets. Verified by the
page-with-override-parent-binding MainSandbox element test (validations []).
Hydrate target — parentDepth propagation
The hydrate compiler has its own renderHydrateHeadlessInstance. Three fixes were required so
adoptInlineBody.parentDepth was non-zero: (1) link componentVariables to the parent scope
(new Variables(headlessImport.rootType, renderContext.variables)); (2) splice __parentContext
into getProps when parentDepth > 0; (3) thread parentDepth through every child-carrying
return branch of renderHydrateElementContent (it was being dropped, forcing parentDepth to 0).
Runtime seam
runtime/lib/context.ts:withSyntheticParentContext(parent, cb)+ a module-global handoff consumed insidewithRootContextandwithHydrationChildContext, which setcontext.parent.component/lib/component.ts:PARENT_CONTEXT_PROP = '__parentContext'; builds a syntheticConstructContextfrom the prop,synthetic.update(...)inside the reaction, renders the root underwithSyntheticParentContext.
Tests (all green)
compiler-jay-html:generate-element.test.ts— element (default) + MainSandbox override tests;generate-element-hydrate.test.ts— hydrate override test. Fixturecontracts/page-with-override-parent-binding(structural card withheadingprop+tag). Full suite 756 → 758 passing.runtime/component:parent-context.test.ts— 3 end-to-end tests (initial resolve; parent-only live update with child prop unchanged; both change). Component/runtime/secure suites all pass.
Open items (unchanged / new)
- (c)
forEach-target override remains a compile error (unchanged, out of Phase 2a scope). - Resolved — empty-contract unwrap. See the "Fix — empty-contract unwrap" section below.
Fix — pragma leaked into static attribute values (smoke-test regression)
markParentScope (jay-html-overrides.ts) originally prefixed the @jay:parent pragma onto
every non-literal override attribute value. But a static value (no {…}) is emitted verbatim and
never passes through the expression parser that strips the pragma, so the smoke-test /override page
rendered href="@jay:parent /docs" instead of href="/docs". Fix: mark an attribute only when its
value contains a {…} binding or it is a brace-less expression attribute (forEach/if, which
are bindings even without braces — EXPRESSION_ATTRS). This mirrors the text-node branch, which
already gated on {. Covered by new remapOverrideBindingsToParent unit tests (static value
untouched; brace-less forEach/if marked) and the smoke-test /override assertions.
Fix — empty-contract unwrap (Option 1: resolve override bindings at page scope)
Problem. A DL#187 headfull import with an empty contract (no props/tags) and no .ts is
a compiler optimization: parseHeadfullFSImports unwraps the <jay:Name> tag into plain HTML
(jay-html-parser.ts:1233-1234, jayTag.replaceWith(jayTag.innerHTML)), inlining the content
directly into the page body with no component scope boundary. But the override merge
(jay-html-parser.ts:1194) has already injected the @jay:parent pragma, so a dynamic override
binding compiled to _p1.itemName — climbing out of a scope that no longer exists → dangling _p1
(validation: "$parent used but there is no parent scope 1 level(s) up").
Decision (Option 1, chosen over dropping the unwrap). Unwrapping means "inline into the page
scope", so a page-authored override binding there is a current-scope binding — vs.itemName, no
$parent. On unwrap, strip the @jay:parent pragma so the content resolves at page scope. This is
the null-hypothesis-minimal fix: it preserves DL#162/#187's unwrap optimization and its
regression test (criterion #7), adds no runtime instance/coordinate segment, and turns the case from
an error into correct behavior. (Option 2 — always emit a real empty-VS structural instance so
$parent has a boundary — was rejected: it reverses a committed DL#162/#187 decision, breaks their
regression test, and adds an instance + coordinate segment to every structural fragment.)
The strip is a single pragma level because override content is always exactly one shift
(withParentShift(1)); overrides that introduce their own forEach scope are out of Phase 2a scope.
The pragma is namespaced (@jay:parent ) so it never collides with authored content, making the
literal strip safe.
Pre-render path is unaffected. injectHeadfullFSTemplatesRecursive (the SSG/slow-render
best-effort inject, jay-html-parser.ts:930-943) never unwraps — it keeps the <jay:Name> tag with
display: contents and injects the pragma-marked content. That output is always re-compiled
through parseJayFile (server-element-compile.ts:126, :160; dev-server; production-build), where
the empty-contract tag hits this same unwrap branch and the pragma is stripped, and the structural
tag's {…} bindings pass through the expression parser that strips the pragma while resolving
_p1. So the pragma never reaches rendered HTML — confirmed by the smoke-test /override page
(62/62 green) and the two new unwrap fixtures below.
Verification. New fixture contracts/page-with-override-unwrap-parent-binding (empty-contract
card, <override ref="cta">Start {itemName} trial</override>) compiles with validations: [] and
emits dt((vs) => \Start ${vs.itemName} trial`)on the element and hydrate targets, andescapeHtml(String(`Start ${vs.itemName} trial`))on the **server (SSR)** target — page scope, no_p1. Covered by generate-element.test.ts, generate-element-hydrate.test.ts, and generate-server-element.test.ts`.
SSR rejects $parent (structural case) — a phasing deferral, not a fundamental limit. For the
structural fixture contracts/page-with-override-parent-binding (contract with props/tags → real
instance, override binding stays _p1), the server target currently reports
"$parent bindings are not yet supported in the server target". This is the Phase 4 deferral
(guardServerParentBinding throws on parentDepth > 0), not a property of Capability A — SSR
can resolve the parent binding, it just hasn't been wired yet. Locked in by
generate-server-element.test.ts as the current boundary; superseded by Phase 2c below, which
makes SSR render it.
Phase 2c — server-target $parent (SSR) (2026-09-16)
Status: IMPLEMENTED. See "Implementation Results — Phase 2c" below.
Decisions for the implementer (TL;DR)
- Pull the server half of Phase 4 forward; leave secure/bridge in Phase 4. SSR is what makes
the Phase 2a/2b structural-override feature usable on a real full-stack page (see "Why now"
below). The secure/bridge half stays deferred — different mechanism (worker plumbing), separate
acceptance gate (the
examples/jaysecure build). - Independent of Phase 3. Phase 3 (Issue 1, pure-component ref forwarding) shares no code with this and has no ordering dependency. Do this before, after, or interleaved — free choice.
- Emission model: lexical ancestor var, not the client
_pNclosure param. In SSR the whole tree is inlined into onerenderToStream(vs, ctx), so every ancestor scope's variable (vs,vs_card0, a forEach item var) is lexically in scope.$parent.itemNamemust emitvs.itemName— the ancestor'scurrentVar— not_p1. This is the fundamental difference from the client target (where_p1is a closure param the runtime binding helper supplies, because the parent'scurrentVaris not lexically in scope inside the child callback —Accessor.render(),expression-compiler.ts:63-78). - Mechanism (chosen): a per-scope boolean gate; reuse the scope's own
currentVar. Add a booleanlexicallyInScopetoVariables(defaultfalse), meaning "this scope'scurrentVaris a real variable that is lexically in scope at every descendant binding site."resolveAccessoralready roots a$parentaccessor at the landed ancestor'scurrentVar(expression-compiler.ts:183);Accessor.render()just ignores that and emits_pNwheneverparentLevel > 0(:67-69). The change is one line inresolveAccessor: when the landedscope.lexicallyInScopeis set, drop the accumulatedparentLeveltoresolved.parentLevel(i.e.0here) on the returnedAccessor.render()then takes its normal lexical branch and emitsscope.currentVar + '.' + termswith no change torender()at all. Consequences, all desirable:- The fragment's
parentDepthstays0when the gate fires, soguardServerParentBindingpasses untouched — no guard removal, no per-site edits at the ~15w(...)call sites. - Emits
vs.itemNamedirectly. Multi-level ($parent.$parent.x) falls out for free — the climb lands on whichever ancestor and emits itscurrentVar. - The existing "
$parent used but there is no parent scope N level(s) up" validation (expression-compiler.ts:167-176) still fires when the ancestor is genuinely absent (the early return keeps its non-zeroparentLevel) — so a malformed climb is still a clean compile error. - Reusing
currentVar(rather than a separate name field) keeps the new surface to a single boolean: the scope already knows its own variable name; the gate only says "and it is reachable by name from here." The client leaves the gatefalse→_pN, exactly as today.
- The fragment's
- Wire the gate on the server scopes; inherit it down the tree. Every scope the server emits is
lexically addressable, so the root sets the gate and every child scope inherits it (through
childVariableFor/childVariableForWithData/withParentShift), so it only needs setting at the fresh-construction sites:- Page scope in
generateServerElementFile(jay-html-compiler-server.ts:1073):.asLexical(). - Instance scope in
renderServerElement(:254), currentlynew Variables(headlessImport.rootType, undefined, 0, varName)with parentundefined: setparent = context.variables(so the climb has somewhere to go) and.asLexical(). - Async resolved scope (
:966): inheritsvariables.lexicallyInScope. - forEach / with-data item scopes inherit automatically via
childVariableFor— no server edit needed, so an instance (or plain element) nested in a forEach can bind the forEach item via$parent. This is the whole-parent model the client already uses (__parentContext = vs), realized lexically — no__parentContextobject is needed on the server because the ancestor variable is already in scope as a function parameter / local.
- Page scope in
- Scope: structural (Tier 2) instances only. The unwrap (empty-contract) case already works on SSR (resolves at page scope after the pragma strip — see the fix above). Phase 2c is exactly the structural-instance case that currently throws.
- React target stays deferred.
guardReactParentBindingis unchanged; React is a separate integration, not SSR, and out of scope here.
Why now (dependency argument)
A jay-stack page with a structural override + parent binding fails to build for the server
target today: guardServerParentBinding throws → generateServerElementFile returns a validation →
production build's checkValidationErrors throws → the whole page build fails. For a full-stack
framework, "renders on the client after hydration" is not shippable — the slow/fast (SSR) phases
must emit the initial paint with the binding resolved. So the server target is a completion of
Phase 2, mis-filed under Phase 4's "remaining emitters." Phase 4's grouping was an economy
assumption (thread the parent pointer through secure+bridge+server at once); the trace shows the
server path is self-contained and does not share the worker plumbing that secure/bridge need.
Trace (grounding the design in real code)
generateServerElementFilebuilds the page scope:const variables = new Variables(jayFile.types)→context.variables,currentVar = 'vs',parent = undefined(jay-html-compiler-server.ts:1073,:1086).renderServerElementbuilds the instance scope:new Variables(headlessImport.rootType, undefined, 0, varName)and renders the inline body against it (:254,:270-278).varName(e.g.vs_card0) is declared in-function fromvs.__headlessInstances[...](:303-319), so bothvsandvs_card0are lexically live.- The override binding rides the
@jay:parentpragma (merge-time, target-agnostic) →doParsestrips it and applieswithParentShift(1)(expression-compiler.ts:409) →resolveAccessorprefixes a$parenttoken (:149-151) → climbsVariables.parent(:161-188). Todayparentisundefined→ "no parent scope" (or, with parent set,parentLevel = 1→Accessor.render()emits_p1→parentDepth = 1→guardServerParentBindingthrows). - With Phase 2c: parent set + ancestor
lexicallyInScope = true→ the climb lands on the page scope, the accumulatedparentLevelis dropped →render()emitsvs.itemName,parentDepth = 0, guard passes.
Implementation plan
Variables: addlexicallyInScope: boolean(defaultfalse); thread through the constructor andwithParentShift(which must preserve it). Add anasLexical()helper that returns a view with the gate set (mirrorswithParentShift's reconstruction), for readable server call sites.resolveAccessor$parentbranch (:161-188): when the landedscope.lexicallyInScopeis set, useresolved.parentLevel(drop the accumulatedparentLevel) on the returnedAccessorso it renders lexically atscope.currentVar. Keep the absent-ancestor validation path unchanged.childVariableFor/childVariableForWithData: propagatethis.lexicallyInScopeto the child scope, so setting it once at the server root covers forEach / with-data descendants.render()(:63-84): no change — the parentLevel drop makes it take the existing lexical branch.- Server wiring:
.asLexical()on the page scope (generateServerElementFile),.asLexical()+parent: context.variableson the instance scope (renderServerElement), and inherit on the async resolved scope (:966); forEach scopes inherit automatically. - No change to
guardServerParentBinding, the ~15w(...)sites, or the client/hydrate/React targets (they leave the gatefalse→_pN).
Verification criteria
- Structural fixture
contracts/page-with-override-parent-bindingon the server target now compiles withvalidations: []and emitsescapeHtml(String(\Start ${vs.itemName} trial`))(page scope, no_p1). Newgenerated-server-element.tsfixture, fulltoEqual— replaces the current rejection assertion ingenerate-server-element.test.ts`. - Element, hydrate, and secure targets for that fixture are unchanged (still emit
_p1via the client model) — regression-guard the existing fixtures. - Cross-target coordinate alignment for the structural instance still holds (SSR
jay-coordinatevalues equal hydrate adoption coordinates), mirroring the existing DL#183 alignment test. - A genuinely-absent parent climb still produces the "no parent scope N level(s) up" validation (negative test), proving the collapse didn't swallow the error path.
smoke-testgains (or extends) a page whose structural override binds page data, and asserts the server-rendered initial HTML contains the resolved text — end-to-end proof the page now builds and SSRs.
Trade-offs
- New surface: one boolean field on
Variables(lexicallyInScope) plus anasLexical()helper. The scope already stores its owncurrentVar; the gate only adds "and it is reachable by name from here," so no separate name field is needed. The drop-parentLevel-at-resolve-time approach collapsesparentDepthto0before it reaches any emit site, sorender(),guardServerParentBinding, and the ~15w(...)sites are all untouched (null-hypothesis-minimal). Multi-level climbs land-and-emit naturally; the client leaves the gatefalse→_pN. Rejected alternatives — a target flag threaded intorender()(reopens theparentDepth-vs-guard interaction) and a separatelexicalNamestring (redundant withcurrentVar). - Deferred still-deferred: secure/bridge parent plumbing and the React target remain Phase 4 / out of scope; this section narrows Phase 4 to those two.
Implementation Results — Phase 2c (2026-09-16)
Implemented exactly as the (revised) design above — the boolean-gate + reuse-currentVar variant.
Changes:
expression-compiler.ts:Variables: addedreadonly lexicallyInScope(defaultfalse) as the 7th constructor param; preserved throughwithParentShift; addedasLexical()helper.resolveAccessor$parentbranch:effectiveParentLevel = scope.lexicallyInScope ? resolved.parentLevel : parentLevel + resolved.parentLevel. When the landed ancestor is lexical the accumulated climb collapses to0, soAccessor.render()emitsscope.currentVar—render()is unchanged. The absent-ancestor validation (early return with non-zeroparentLevel) is untouched.childVariableFor/childVariableForWithData: propagatethis.lexicallyInScopeto child scopes, so setting the gate once at the server root flows to forEach / with-data descendants.
jay-html-compiler-server.ts:- Page scope:
new Variables(jayFile.types).asLexical(). - Instance scope (
renderServerElement):new Variables(headlessImport.rootType, context.variables, 0, varName).asLexical()— parent now wired to the page scope. - Async resolved scope: inherits
variables.lexicallyInScope. guardServerParentBindingand allw(...)sites unchanged.
- Page scope:
Deviation from the original (pre-revision) design: the mechanism moved from a lexicalName?: string
field to a lexicallyInScope boolean that reuses the scope's existing currentVar (per review
feedback — the name field was redundant with currentVar). render() needed no change (the
original plan expected a render() edit); the parentLevel drop makes it take the existing lexical
branch. forEach-ancestor support required no server edit — it inherits through childVariableFor.
Tests:
generate-server-element.test.ts: the two former rejection tests (collections/foreach-parent-binding,contracts/page-with-override-parent-binding) are now clean-compile + fulltoEqualassertions against newgenerated-server-element.tsfixtures. The structural fixture emitsescapeHtml(String(`Start ${vs.itemName} trial`))(page scope) alongside the instance's ownvs_card0.heading; the forEach fixture emitsvs.listTitle(page scope) insidefor (const vs1 of vs.items)alongsidevs1.name.- Element / hydrate / secure fixtures for these folders unchanged (still
_p1) — regression-guarded by the existing passing tests. smoke-test: new/promoroute — structuralpromo-card(non-empty contract,ctaref) with a page-scope override bindingStart {pageTitle} trial. Two smoke assertions (dev SSR + prod SSG) confirm the initial HTML containsStart Promo Page trialandPremium, notBuy Now.
Test results: compiler-jay-html 763 passing / 4 skipped (was 761 — the two rejection tests became
clean-compile tests, no net count change; the +2 is unrelated); smoke-test 64 passing (was 62, +2 for
/promo).
Not done (still Phase 4 / out of scope): secure/bridge and React $parent remain deferred; the
negative "genuinely-absent parent climb" case is covered by the existing expression-compiler
validation path (no new dedicated fixture added — the absent-ancestor branch is untouched and already
under test).
Implementation Results — Phase 4 (secure/bridge; React deprecated) (2026-09-16)
Finding: secure/bridge is resolved-by-architecture — no runtime or codegen change was needed. Phase 4 was planned as "extend Capability A's parent pointer to the secure/bridge code generators." Tracing the secure architecture end-to-end showed there is nothing to extend:
- In secure mode the view renders on the main thread via the trusted element target.
MainSandboxandMainTrustedsharegenerateElementFile(generate-code-from-structure.ts:66-86), so the main-sandbox output is the Phase-1-patched client output verbatim —dt/dareceive...parentDataChain(context)andforEachcarries thedependsOnParentflag.$parentalready resolves here. - The worker runs only the bridge skeleton (
elementBridge/sandboxElement/sandboxForEach), which never runsdt/da.sandboxForEach(sandbox-element.ts:127-193) tracks the collection only (3-arg form, no child body, noparentfield, no validations), so there is no$parentbinding on the worker side to plumb.
This matches Phase 1's "Bridge/secure needs no guard" note and Phase 2a's "bridge (worker) target
needs no codegen change." Phase 4 therefore reduces to verification, not construction. React is
deprecated and excluded from the acceptance gate (its Phase 1 guardReactParentBinding
validation error stays in place as the surfaced boundary).
Verification (pragmatic full acceptance gate). Per the user's constraint — full acceptance gate, but validate the complex end-to-end path via a built Jay example + manual test rather than a fragile, heavy secure-runtime e2e feature dir — coverage is:
- Compiler regression guards (deterministic, cheap):
- Bridge (worker) target:
generate-element-sandbox.test.ts→ new case$parent binding inside a forEachforcollections/foreach-parent-binding, fulltoEqualagainst a newgenerated-element-bridge.tsfixture. Proves the worker emits only thesandboxForEach(getItems, 'id', () => [])skeleton — nodt/da, no$parent, no validations. - Main-sandbox target:
generate-element.test.ts→ new case$parent binding inside a forEach (DL#193)for the same folder,validations: []+ fulltoEqualagainst a newgenerated-element-main-sandbox.tsfixture. Proves the main side renders the full_p1closure bindings (da((vs1, _p1) => _p1.listTitle),dt((vs1, _p1) => _p1.listTitle)) and thedependsOnParentforEachflag even when sandboxed — byte-identical to the trusted output. - The existing Phase-2a
override binding to parent scope (DL#193)main-sandbox test already covers the structural-instance override case in secure mode.
- Bridge (worker) target:
- Example secure build (acceptance gate) + manual test:
examples/jay/parent-bindingbuilds a dual regular (lib/+index.html) and secure (lib-secure/+secure.html) target through the vite plugin.lib-secure/board.jay-htmlexercises exactly the Phase 4 scenario —{$parent.groupLabel}inside a keyedforEach— with Relabel (mutate parent data live) and Shuffle (reorder keyed items) buttons for manual verification.yarn buildsucceeds (23 modules; themainSandboxtransform ofboard.jay-htmlcompiles clean); user manually verified the secure build renders and updates identically to the regular build.
Deviation from the original acceptance gate. The Test plan (§"Example (end-to-end) tests") and
Phase 1 designated a dedicated secure-runtime e2e feature dir (mirroring
packages/runtime/secure/test/<feature>/, with hand-committed regular + secure/main +
secure/worker compiled files) as the secure-mode gate. That heavy, fragile harness was not
built; the examples/jay/parent-binding lib-secure build + manual verification serves as the
acceptance gate instead, per the user's pragmatic-testing instruction. Since the finding is that the
secure/worker path carries no $parent binding (the view renders main-side via the trusted
target, already covered by the byte-identical main-sandbox fixture), the dedicated worker e2e dir
would only re-assert the trusted path — the cheaper compiler + example gate covers the same surface.
Test results: compiler-jay-html generate-element-sandbox.test.ts 10 passing (+1),
generate-element.test.ts 76 passing (+1); examples/jay/parent-binding yarn build succeeds for
both regular and secure targets.
Phase 4 status: complete (secure/bridge resolved-by-architecture + verified; React deprecated). Phase 3 (Issue 1 — pure-component ref forwarding) remains the only outstanding DL#193 work and is orthogonal.
Implementation Results — Phase 3 (Issue 1 — pure-component ref forwarding) (2026-09-16)
Phase 3 forwards a structural (Tier 2) headfull composite's named inner child-component refs to the
usage site, typed in the inner (composite) scope, with no event re-basing. When the composite is
repeated at the usage site (forEach), the forwarded refs become collections. The design predicted
"the existing collection machinery covers it once the tree carries the forwarded ref." That was
half right — the machinery existed, but three wiring gaps had to be closed, two in the runtime
proxy and one in codegen. The secure build surfaced two more, in the rollup/vite plugin.
Runtime wiring fix 1 — collection hasForwardedInnerRef must use property access, not in
ComponentCollectionRefImpl.hasForwardedInnerRef(innerName) originally tested innerName in api
against the first member's public API. The in operator bypasses the Proxy get trap, so it
tested the raw ComponentRefImpl (which has no forwarded cta) and always returned false — the
collection path refs.cards.cards.cta resolved to undefined. Fixed to !!api[innerName], which
routes through the get-trap → getFromComponent → the instance's forwarded ref
(packages/runtime/runtime/lib/node-reference.ts).
Runtime wiring fix 2 — DELEGATE_COLLECTION_INNER_REF_TRAP must fall through real impl members
The trap that redirects unknown props to the forwarded aggregate originally used an explicit
exclusion list. An unbound impl method re-entering the proxy via this.<member> (e.g.
removeEventListener reading this.listeners, since EVENT_TRAP binds only addEventListener)
hit the trap and got a proxy object instead of the real array → this.listeners.filter is not a function. Replaced the exclusion list with if (prop in target) return false; — every genuine
impl member (listeners, elements, map, find, add/removeEventListener, getPublicAPI, …) falls
through; only true forwarded inner-ref names (not members of the impl) reach the aggregate. onXxx
is handled by EVENT_TRAP first, so it never reaches this trap.
Codegen fix — file-level dedup of forwarded-ref helper types
Two structural instances of the same composite (signupCard single + cards collection) each embed
Counter and each emitted the shared export type CounterRef<ParentVS> = … / CounterRefs<ParentVS>
helpers → duplicate-identifier TS error. This corrects the design's optimism: the collection
machinery covered the runtime, but multi-instance helper emission was not deduped. Added a
file-scoped emittedForwardedRefHelpers: Set<string> on RenderContext
(jay-html-compiler.ts), threaded into renderRefsType via a new emittedHelpers? param
(jay-html-compile-refs.ts); each helper identifier emits once per file. When the set is undefined
(page-level / other callers) the guard is a no-op — backward compatible.
Secure/bridge plugin fix — contracts imported into a worker were mis-routed
The §5 secure example is the first to import an external contract="…" file into the worker
(structural composites require a .jay-contract). Such a contract resolves to
card.jay-contract?jay-workerTrusted.ts — a runtime-mode suffix that also matches
hasJayModeExtension. Both the load and transform hooks checked the mode branch before the
contract branch, so the contract was loaded as raw YAML by loadJayFile (→ esbuild YAML parse
error) and routed to transformJayFile (→ Unknown Jay format jay-contract). Fixed both hooks in
packages/compiler/rollup-plugin/lib/runtime/runtime-compiler.ts to check
hasJayExtension(id, JAY_CONTRACT_EXTENSION, { withTs: true }) first (load: → loadContractFile;
transform: → skip). This makes structural composites usable in secure mode at all.
Tests / verification (all green)
- §2 runtime unit test (
packages/runtime/runtime/test/lib/ref-forwarding.test.ts, 5 tests): forwarded ref exposed; delivers the composite (Card) scope viewState with no re-basing (event.viewState === { heading: 'Sign up' }); collection fans per card;.find(pred)reaches one member;onClickreplays onto a card added after listener registration. All 5 pass; full runtime suite 283 pass / 3 skip. - Codegen regression fixture
(
compiler-jay-html/test/fixtures/contracts/page-with-forwarded-ref-multi/): single +forEachinstances of the same composite; assertsCounterRef/CounterRefsdeclared once andvalidationstoEqual([]). Added one multi-instance case each togenerate-element,generate-element-hydrate,generate-server-element(146 pass across the three files). - §5 example (
examples/jay/ref-forwarding/): dual regular (index.html/lib) + secure (secure.html/lib-secure,sandbox="true").yarn buildsucceeds for both targets (21 modules; theworkerTrustedtransforms ofcard.jay-html,counter.jay-html, and the contract compile clean). Per the Phase 4 precedent, the example secure build is the secure-mode acceptance gate; the rollup-plugin fix is covered by that integration gate (its 47 unit tests still pass).
Codegen fix 2 — hydrate forEach create-variant emitted an undeclared synthetic-ref type
Follow-up bug surfaced by the multi-instance fixture. The hydrate forEach needs both an
adopt-body (existing SSR DOM) and a create-body (fresh render for post-hydration items). The
create-body compiles via the element target, which increments the shared headlessInstanceCounter
and produces an extra instance (e.g. Card2) beyond the two the element pass already numbered.
Its synthetic ref interface (_HeadlessCard2Refs) was never declared because the hydrate file simply
reused the element pass's renderedRefs (which only knew Card0/Card1) — so
page-with-forwarded-ref-multi/generated-element-hydrate.ts referenced an undeclared
_HeadlessCard2Refs. Fixed by (a) threading a shared emittedForwardedRefHelpers: Set<string> across
both passes so CounterRef/CounterRefs are still emitted exactly once, and (b) having
renderHydrate return { fragment, syntheticRefsDecls }; generateElementHydrateFile appends the
hydrate pass's extra synthetic decls after renderedRefs. This also fixed the same latent bug in
page-with-forwarded-ref-foreach. Files: jay-html-compiler-hydrate.ts, jay-html-compiler.ts;
fixtures regenerated. tsc now reports no undeclared-type errors; 778 compiler-jay-html tests pass.
Runtime fix 3 — makeHeadlessInstanceComponent must tolerate an absent HEADLESS_INSTANCES
The §5 example renders the structural composite via a plain client render() with no composite
wrapper, so HEADLESS_INSTANCES is never provided. wrappedConstructor read it with
useContext(HEADLESS_INSTANCES), which throws when the marker is absent (context.ts:
if (!context) throw new Error()) — the "Uncaught Error at useContext … at wrappedConstructor" the
user hit. Yet every downstream consumer already tolerated absence (optional chaining
instanceData?.viewStates, clientDefaults fallback, "no server data" path) — the throw was the lone
inconsistency. Fixed by reading the context tolerantly:
findContext<HeadlessInstancesData>((_) => _ === HEADLESS_INSTANCES) (returns undefined when
absent). Also scoped the "no server data and no clientDefaults" warning to fire only when
instanceData is defined — when the composite runtime is absent entirely (structural composite
under plain client render), empty fast ViewState is the expected, non-erroneous case. File:
packages/jay-stack/stack-client-runtime/lib/headless-instance-context.ts. The full-stack path (where
the composite DOES provide the context) is unchanged: dev-server hydration suite 741/741 pass.
Deviation from design. The design's "existing machinery covers it" note is corrected: the runtime collection machinery did need two proxy-wiring fixes, codegen needed multi-instance helper dedup and a hydrate create-variant synthetic-ref fix, the client runtime needed tolerant context lookup so structural composites work under plain client render, and the secure path needed a plugin contract-routing fix. No re-architecture — seven targeted fixes.
Phase 3 status: complete. With Phases 2a/2c/3/4 done, DL#193 has no outstanding work.
Phase 3 refinement — override-injected forwarded refs carry the override (outer) scope (2026-09-17)
Status: IMPLEMENTED (2026-09-17), but the scope RULE is SUPERSEDED by DL#194.
This refinement re-based only override-injected refs and kept owned refs at composite scope. DL#194
replaces that provenance-based rule with a hasCodeFile-based one: a no-code component exposes all its
refs at external scope. The childComp ref-viewState selector added here is reused by DL#194.
See "Implementation Results — Phase 3 refinement" below for the mechanism.
Trigger
The new examples/jay/override-ref-forwarding example injects a Counter into a generic slot-card
via <override ref="slot"><jay:Counter ref="cta" /></override> and forwards cta to the page.
refs.cards.cards.cta.find((vs) => vs.heading === 'Bravo') never matched, exposing a scope bug.
Root cause — two scopes, and the wrong one for overrides
A forwarded inner ref currently exposes two different viewStates:
onChangefans to the innerctaComponentRefImpl, which was created inside the card'sConstructContext(app.jay-html.ts:70,96) → itsformatEventusesthis.viewState(node-reference.ts:440) = the composite (Card) scope{heading}.find/mapiterate the usage-site card collection (this.elements) →ref.viewState(node-reference.ts:308-316) = the override authoring / forEach-item scope{id, label}.
The declared type collapses both into CounterRef<CardViewState> / CounterRefs<CardViewState> — so it
is right for onChange but wrong for find/map. Empirically confirmed: onChange delivered
{heading:'Bravo'}; a find-probe delivered {id:'b',label:'Bravo'}. The Phase 3 runtime unit test
(ref-forwarding.test.ts:195-208) masks this because it renders the page with cards:[{heading:'A'},…],
making the outer item viewState identical to the composite viewState.
Decision — override-injected refs use the override (outer) scope
Per §C's settled principle (Decisions 2026-09-16 #1, ~line 1132): override content resolves against the
outer scope because the override author knows that scope, not the target component's internals
(this superseded DL#181 Q6/VC#5). This extends to the forwarded ref of an override-injected component:
both onChange and find/map must carry the override (outer) scope viewState.
refs.signupCard.cta(override authored at page root) →AppViewState.refs.cards.cards.cta(override authored insideforEach="cards") →CardOfAppViewState({id,label}).
The composite's own {heading} is deliberately not exposed — consistent with §C superseding
DL#181 Q6 (an override cannot reach the target's own data).
Contrast — plain Phase 3 is unchanged. When the inner ref is declared in the composite's own
template (not injected via <override>), it keeps composite scope (line 1733, settled) — the
composite author knows that scope. Only override-injected refs re-base to the outer scope. The
compiler knows which is which (it performed the splice), so the distinction is made at compile time.
Mechanism (null-hypothesis / minimal surface)
Reuse the existing §C $parent mechanism — no new runtime concept. Today the override-injected
childComp already resolves its data bindings against $parent (page scope) via the Phase 2a
$parent-remap, and the card's ConstructContext is wired with the page scope as parent
(~line 1188-1190), so parentDataChain(context) already reaches the outer item.
The only gap: the ref's viewState is fed the card currData (element.ts:54-59; mkRef pushes
ref.update, which is called with the card ParentVS) instead of $parent. Fix: for override-injected
childComp calls, the compiler emits the ref to track the outer ($parent) viewState rather than
currData. Then:
- the inner
ctaref carries the outer scope →onChangedelivers the outer scope ✓ - the outer card collection member ref already carries the outer scope →
find/mapunchanged ✓ - a single type param suffices:
CounterRef<OuterVS>/CounterRefs<OuterVS>(no onChange/find split)
getForwardedInnerRef (node-reference.ts) needs no change. No runtime override/plain flag — the
distinction stays at compile time.
Proposed runtime surface (smallest option): give childComp an optional ref-viewState selector that
reads from parentDataChain, e.g. childComp(Counter, getProps, refCta(), ($parent) => $parent). With
no selector it keeps the current behavior (composite scope) for plain Phase 3.
Codegen / type changes
- Emit override-injected
childCompwith the parent-scope ref selector. - Type the override forwarded ref against the outer scope:
_HeadlessCard0Refs.cta: CounterRef<AppViewState>,_HeadlessCard1RepeatedRefs.cta: CounterRefs<CardOfAppViewState>. - Example fix:
refs.*.cta.onChange(({viewState}) => viewState.label)andrefs.cards.cards.cta.find((vs) => vs.label === 'Bravo').
Verification criteria
- Override example:
onChangepayload viewState is the outer item ({id,label});find((vs) => vs.label === 'Bravo')returns the Bravo card's counter and only it fires. - Plain
ref-forwardingexample + its unit test: unchanged (composite scope). - New runtime/codegen test with distinct outer vs composite viewStates (the existing unit test uses
{heading}for both, which masked the bug).
Open questions
- Confirm plain Phase 3 stays composite scope (only override re-bases). (recommended) Answer (confirmed): Yes — plain Phase 3 keeps composite scope; only override-injected refs re-base.
- Runtime surface: optional
childCompref-viewState selector vs a dedicatedchildCompOverridehelper? Answer (confirmed): OptionalchildCompref-viewState selector (smallest runtime surface, no new helper).
Implementation Results — Phase 3 refinement (2026-09-17)
Implements the decision above: an override-injected component's forwarded ref carries the override
(outer) scope for both onChange and find/map; plain Phase 3 is untouched (composite scope).
What changed
Runtime —
packages/runtime/runtime/lib/element.ts.childCompgained an optional 4th parameterrefViewState?: (vs: ParentVS, ...parents: any[]) => any. When present, the ref tracksrefViewState(currData, ...parentDataChain(context))on set and on every update (instead of the compositecurrDataviamkRef). With no selector, behavior is identical to before — plain Phase 3 and all existingchildCompcallers are unchanged.Compile-time provenance —
lib/jay-target/jay-html-overrides.ts. Addedexport const OVERRIDE_INJECTED_MARKER = 'jay-from-override'.markParentScopestamps this marker attribute on every<jay:…>component tag that originates inside<override>content. It is read by codegen, ignored by prop generation, and stripped from all rendered output.Codegen (element target) —
lib/jay-target/jay-html-compiler.ts.renderChildCompPropsignores the marker attribute.renderChildCompReftypes the forwarded ref againstvariables.parent.currentType(outer scope) when the tag is override-injected, elsevariables.currentType(composite scope, unchanged).renderNestedComponentappends the ref selector(<currentVar>, _p1) => _p1and emitsparentDepth = 1(so__parentContextis wired) for override-injectedchildComp/secureChildComp.
Codegen (hydrate target) —
lib/jay-target/jay-html-compiler-hydrate.ts. Mirrors the element target in the headful-component branch: same marker check, same(vs, _p1) => _p1selector, sameparentDepth = 1, so the adopt path and its declared outer-scope types stay consistent.Server target — no change. Refs are a client-only concern; the injected
<jay:Counter>renders as literal text exactly as the plain forwarded-ref fixture does. Locked in by a server test.
Type shape
refs.signupCard.cta: CounterRef<AppViewState>(override at page root → page scope).refs.cards.cards.cta: CounterRefs<CardOfAppViewState>(override insideforEach="cards"→ item scope).- A single type param suffices (no onChange/find split) — both channels now carry the outer scope.
Tests (all green)
- Runtime —
test/lib/ref-forwarding.test.ts: 3 new tests under a Phase-3-refinementdescribe, using anOverrideCardhelper that seeds a synthetic parent context and callschildCompwith the(_vs, _p1) => _p1selector. Outer VS ({id,label}/{pageTitle}) is deliberately distinct from the composite VS ({heading}) so a regression to composite scope fails. Single delivers{pageTitle:'Home'}; collectiononChangedelivers each{id,label}item; collectionfind((vs) => vs.label === 'Bravo')returns only the Bravo card. 8/8 pass (5 plain + 3 new). - Compiler — new fixture
test/fixtures/contracts/page-with-override-forwarded-ref/(generic slot-card + page injectingCounter, single +forEach). Golden coverage across all four targets: element (trusted + main-sandbox ingenerate-element.test.ts), hydrate (generate-element-hydrate.test.ts), server (generate-server-element.test.ts). - Overrides unit —
test/jay-target/jay-html-overrides.unit.test.ts: golden updated to expect thejay-from-overridemarker on injected<jay:MenuItem>tags. 27/27 pass. - Full suites: compiler-jay-html 782 passed / 4 skipped; runtime 286 passed / 3 skipped; component 61 passed.
Deviation from design
None. The mechanism matches the "smallest option" proposed (§ Mechanism): optional childComp
ref-viewState selector reading from parentDataChain, reusing the existing $parent/__parentContext
wiring; no new runtime concept, no getForwardedInnerRef change.
Phase 3 refinement status: complete (child-component case). See the follow-up refinement below — the plain-element case is still open.
Phase 3 refinement 2 — override-injected refs re-base uniformly (elements too) (2026-09-17)
Status: SUPERSEDED / MOVED to DL#194. The element-ref-forwarding work described here is subsumed by DL#194's Case 2 ("no-code → all refs, element + component, external scope"). Kept below for the prior-art/runtime findings it records.
Trigger
Review question on refinement 1: the childComp refViewState selector re-bases only override-injected
child components (<jay:Counter>). What about an override that injects a plain interactive
element — <override ref="slot"><button ref="cta" onclick=…></button></override>? And does the
selector accidentally re-base the composite's own refs?
What refinement 1 got right (no change needed)
Re-basing is per forwarded ref, not per component. The refViewState selector is an argument of a
single childComp(...) call, and each forwarded ref is its own call. Proof (generated card body):
e(
'div',
{ class: 'slot' },
[
childComp(
Counter,
(vs) => ({ initialValue: 0 }),
refCta(),
(vs, _p1) => _p1,
), // selector: injected
],
refSlot(),
); // refSlot: no selector
The provenance marker (jay-from-override) is stamped only on the injected tag, so a composite-owned
<jay:Widget ref> or <button ref> compiles with no selector and keeps composite scope. So "the
card has its own interactive element AND an override injects a component" already works correctly.
The real gap — plain injected interactive elements
The marker is stamped only on <jay:…> component tags (jay-html-overrides.ts:96) and read only on the
childComp codegen path. An override-injected plain element ref is therefore not re-based — its
event viewState resolves against the composite ConstructContext (composite scope), violating §C.
Injected via <override> |
Re-based to outer scope? |
|---|---|
<jay:Counter ref="cta"> (child component) |
✅ yes (refinement 1) |
<button ref="cta" onclick=…> (plain element) |
❌ no — still composite scope |
Prior-art / adjacent-mechanism check (null hypothesis)
Before designing, what already exists?
- Element-ref forwarding from pure composites does NOT exist as a typed feature. The compiler
forwards component refs only —
filterToComponentRefs/hasNamedComponentRefs(jay-html-compiler.ts:1028-1030);_HeadlessCard0Refsexposesctabut notslot. - At runtime, element refs partly flow already. Single instances spread all refs via
comp: (_props, _refs) => ({…, ..._refs}), andgetForwardedInnerRef(node-reference.ts:273) is generic — it callsaddEventListener/find/mapon whatever inner ref it finds, so an element ref (anHTMLElementProxy, which supports those) would work in a collection if it were exposed. getForwardedInnerRefexplicitly does NOT re-base ("carrying that instance's viewState — no re-basing",node-reference.ts:270). Re-basing is done at the ref-construction site (thechildCompselector in refinement 1). The element analog must set the element ref's ownviewStateto$parent.
Conclusion: the plain-element gap has two missing pieces — (1) typing/forwarding an override-injected element ref, and (2) re-basing it. Runtime plumbing mostly exists; the work is in the compiler (types + a re-basing hook on the element render path) plus a small runtime hook.
Design — ref-centric provenance (Q2 = option 2)
Represent override provenance on the Ref model rather than as a tag-attribute string read at two
call sites. Add a field (e.g. Ref.overrideInjected: boolean, or a refScope: 'composite' | 'outer')
set when the override splice creates/marks the ref. Forwarding, typing, and re-basing then all key off
that one property, uniformly for element and component refs:
- Forwarding/typing (
jay-html-compile-refs.ts): include override-injected refs regardless of kind. Component →CounterRef<OuterVS>(as today); element →HTMLElementProxy<OuterVS, …>. - Re-basing (component): unchanged — emit the
childCompselector (refinement 1). - Re-basing (element): emit the element ref bound to
$parent. Smallest runtime surface: give the element/dynamicElementrender path the same optional ref-viewState selectorchildCompalready has, readingparentDataChain. Composite-owned element refs (no provenance) are unaffected. - Composite-owned element refs stay unforwarded (unchanged) — this refinement forwards only override-injected element refs, matching the existing "injected → outer scope" principle.
Why option 2 over "extend the marker" (option 1): both need the same element-forwarding + re-basing work;
option 2's extra cost is only threading one Ref property through the ref model and its consumers, and
in return it removes the tag-attribute-string mechanism and makes element+component handling a single
code path. Moderately larger, materially cleaner. Recommended.
Size / honesty
This is bigger than refinement 1 (which was a single optional param): it introduces the first
typed element-ref forwarding for pure composites. Estimated touch set — jay-html-compile-refs.ts
(forwarding filter + element ref types), jay-html-compiler.ts + -hydrate.ts (element render selector
- provenance from the
Ref),element.ts(element render ref-viewState selector), the override splice (setRef.overrideInjected), plus fixtures/tests.
Verification criteria
- New fixture: one card with both a composite-owned interactive element (keeps composite scope) and an override-injected interactive element (re-bases to outer) — distinct viewStates so a regression fails.
- Injected
<button>onclickpayload viewState is the outer item; composite-owned elementonclickpayload is composite scope. - Refinement 1 (injected child component) and plain Phase 3 (composite-owned component ref) unchanged.
- All four targets consistent (element, main-sandbox, hydrate; server = no-op).
Open questions
Ref.overrideInjected: booleanvsrefScope: 'composite' | 'outer'? (boolean recommended — the only two states are "injected → outer" and "owned → composite")- Collection case for injected element refs — confirm
getForwardedInnerRef's generic path needs no change once the element ref is exposed + re-based (expected: no change).
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.