Safe Refs Stubs For Unused Contract Refs

Design Log #138 — Safe Ref Stubs for Unused Contract Refs

Written for AI agents. See Log Methodology Note below for details.

Background

A headless component's contract declares refs (e.g., addToCartButton, productImage, wishlistButton). The component code uses these refs to wire up interactivity. But the actual template is written by the page designer, who may not include all declared refs — a minimal product card template might only use productImage and skip addToCartButton entirely.

Currently, accessing a ref not present in the template returns undefined, causing a runtime crash:

// In plugin component code (shared across many templates)
refs.addToCartButton.onclick(({ coordinate }) => { ... });
// TypeError: Cannot read properties of undefined (reading 'onclick')

The workaround is optional chaining (refs.addToCartButton?.onclick(...)), but this is fragile — every ref access in every shared utility must remember to use ?., and TypeScript won't warn about missing ?. since the type says the ref exists.

Related Design Logs

  • #84 — Headless component props and repeater support
  • #111 — Nested headfull full-stack components
  • #123 — Deeply nested headfull and headless components

Problem

There are two kinds of "missing ref" at runtime:

Scenario Ref object Behavior
Ref in template, element conditionally hidden (if="false") Proxy exists Safe — listeners accumulate, auto-attach when element renders
Ref in contract but not in template at all undefined Crash on any property access

The inconsistency is the problem. A conditional ref is safe because ReferencesManager.for() creates an entry for it (the element just has no target yet). A missing ref is undefined because ReferencesManager.for() only receives refs that appear in the template — the compiler scans ref="..." attributes, not the contract.

This matters specifically for instance-based headless components (<jay:xxx> tags with inline templates):

  • Instance headless components — the inline template is written by the page designer per-page, who may use only a subset of the contract's refs. The plugin's component code expects all contract refs to exist.
  • Repeated instances (forEach) — same problem, but with collection refs. A <jay:product-card> inside forEach="products" declares ProductCardRepeatedRefs with collection proxies for each ref, but the template may only use ref="products.productLink" and skip ref="products.addToCartButton".
  • Headfull components with headless instances — a headfull component's template includes <jay:xxx> instances where the same mismatch occurs.

Keyed headless components (with key="..." in the <script> tag) are NOT affected — even though they also use the page template (not their own), the compiler already handles this correctly. processImportedHeadless() adds ALL contract refs to the page's ref tree, and optimizeRefs() preserves them in the ReferencesManager.for() call regardless of whether they appear in the template. The autoRef flag only controls TypeScript type generation, not runtime ref creation. This is effectively the same fix that this design log proposes for inline instances.

Real-world example (golf project)

The related-products headless component contract declares 6 refs in ProductCardRepeatedRefs:

  • productLink, addToCartButton, cardContainer, viewOptionsButton, quickOption, secondQuickOption

But the page's inline template only provides ref="products.productLink". The shared setupCardInteractions() utility accesses all 5 missing refs — currently guarded with ?. everywhere, which is fragile and verbose.

Design

Approach: contract-aware ref stubs in ReferencesManager

When a contract declares refs, ALL declared ref names should have entries in the refs object — not just those matched by ref="..." in the template. Refs without a template match behave like conditional refs with no target: a proxy exists, method calls are no-ops, listeners accumulate (and would auto-attach if the ref were ever populated, though in this case it won't be).

Where to create stubs

The ReferencesManager.for() call is generated by the compiler. It receives arrays of ref names extracted from the template:

// Generated by compiler (only refs found in template)
const [refManager, [productImageRef]] = ReferencesManager.for(
  options,
  ['productImage'], // Only this ref is in the template
  [],
  [],
  [],
);

But the contract declares productImage, addToCartButton, wishlistButton. The component receives refs from refManager.getPublicAPI(), which only has productImage.

Fix: pass contract ref names to ReferencesManager.for() so it can create stub entries for all declared refs, not just template-matched ones.

const [refManager, [productImageRef]] = ReferencesManager.for(
  options,
  ['productImage'],
  [],
  [],
  [],
  undefined, // childRefManagers
  ['productImage', 'addToCartButton', 'wishlistButton'], // contract ref names
);

For any contract ref name not in the template ref arrays, ReferencesManager creates a stub PrivateRef that:

  • Has an HTMLElementProxy public API (same type as a real ref)
  • Has no element target (same as a conditional ref that hasn't rendered)
  • Accepts event listener registration (listeners accumulate but never fire)
  • exec$() resolves immediately (no element to execute on)

This is identical to how conditional refs work today — the only difference is the ref was never expected to have a target.

What the stub ref looks like

No new class needed. The existing HTMLElementRefImpl already handles the case where this.element is undefined:

  • addEventListener stores the listener in this.listeners (line 237)
  • exec$ — currently accesses this.element directly, would need a guard

The stub is just a PrivateRef instance that never gets set() called — same as a conditional ref whose condition was never true.

Where contract ref names come from

For headless instances: the contract's refs section lists all ref names. The compiler already parses contracts and has access to the ref names via headlessImport.contract.refs.

For headfull components: the contract (or generated .d.ts) declares the Refs type. The compiler already knows the ref names from the type.

The compiler needs to:

  1. Collect all ref names from the contract
  2. Pass them as an additional parameter to ReferencesManager.for()

Compiler changes

In the element compiler (jay-html-compiler.ts), when generating the ReferencesManager.for() call for a headless instance's inline template, add the contract's full ref list:

// Current (only template refs)
ReferencesManager.for(options, ['productImage'], [], [], []);

// New (template refs + contract ref stubs)
ReferencesManager.for(options, ['productImage'], [], [], [], undefined, [
  'productImage',
  'addToCartButton',
  'wishlistButton',
]);

The hydrate compiler needs the same change.

Runtime changes

In ReferencesManager:

  1. Add optional contractRefNames?: string[] parameter to ReferencesManager.for()
  2. After creating refs for template-matched names, create stub entries for any contract ref name not already present
  3. Stub entries use the same HTMLElementRefImpl / proxy as real refs, just never get set() called

In HTMLElementRefImpl:

  1. Guard exec$ against missing element (if not already guarded)

What does NOT change

  • The compiler still generates ref() constructor calls only for template-matched refs
  • Template rendering is unchanged — only refs with ref="..." attributes bind to elements
  • The refs TypeScript types are unchanged — they already declare all contract refs
  • Component code is unchanged — no ?. needed

Implementation Plan

Phase 1: Runtime — ReferencesManager stub support

  1. Add contractRefNames?: string[] parameter to ReferencesManager.for()
  2. After creating template refs, iterate contractRefNames and create stub entries for missing names
  3. Ensure exec$ and event handlers on refs with no element are safe no-ops
  4. Tests: create a ref manager with contract names that include names not in the template arrays, verify accessing them returns a working proxy

Phase 2: Compiler — pass contract refs to ReferencesManager

  1. In the element compiler's headless instance rendering, collect ref names from the contract
  2. Generate the contractRefNames argument in the ReferencesManager.for() call
  3. Same for the hydrate compiler
  4. Tests: fixture with a contract declaring refs not used in the template, verify generated code passes contract ref names

Phase 3: Verify with headfull components

  1. Ensure headfull components with contracts also pass contract ref names
  2. Test with shared interaction utilities that access optional refs without ?.

Examples

Before (crashes)

// Plugin component code
function ProductCardComponent(props, refs) {
  refs.addToCartButton.onclick(() => addToCart(props.productId()));
  // ^ TypeError: Cannot read properties of undefined
}
<!-- Designer's template (doesn't include addToCartButton) -->
<jay:product-card>
  <div>
    <img ref="productImage" src="{imageUrl}" />
    <span>{name}</span>
  </div>
</jay:product-card>

After (safe no-op)

Same component code and template — no changes needed. refs.addToCartButton returns a proxy whose onclick() is a no-op (listener stored but never fires since no element exists).

exec$ During Component Creation

There is a related issue: calling refs.<name>.exec$() at the top level of a component (during creation) also fails, because elements haven't been created yet — even for refs that ARE in the template. The element is only available after the render function completes and the DOM is built.

Two options were considered:

  1. Delay the call — queue exec$ calls and replay after mount. Adds complexity.
  2. Agent-kit instruction — document that exec$ must only be used inside event handlers or effects, never at top-level component creation.

Decision: agent-kit instruction. Top-level exec$ is a misuse pattern. The null guard added for stub refs makes it a silent no-op instead of a crash, and the agent-kit instructions should document the correct usage pattern. Note: effects also won't work for the first invocation — the effect runs before elements are mounted, so exec$ silently does nothing. Only event handlers are guaranteed to have elements available.

Trade-offs

Decision Pro Con
Stub refs via contract names Component code doesn't need ?. everywhere Silent no-ops could mask template mistakes
Reuse existing ref implementation (no new class) Consistent behavior with conditional refs Slight overhead creating unused ref instances
Pass contract names through compiler Works automatically for all contract-based components Compiler changes in multiple targets (element, hydrate)

Verification

  1. Plugin component accesses a ref not in the template — no crash, no-op
  2. Same plugin component with a template that includes the ref — works normally
  3. Event listeners on stub refs don't fire (expected)
  4. Conditional ref behavior unchanged (listeners still auto-attach when element renders)
  5. TypeScript types unchanged — contract refs are already typed as present

Implementation Results

Initial implementation

mergeContractStubRefs() was added to jay-html-compiler-shared.ts. It merges contract-declared refs into the template's RefsTree as autoRef: true stubs. Called from both the element and hydrate compiler targets when compiling headless instance inline templates.

Bug fix: nested sub-contract refs (2026-06-23)

The initial implementation only merged refs at the top level — it ignored contractRefs.children. When a contract had a sub-contract like sortBy containing sortDropdown, and the template didn't use any refs from sortBy, the entire child tree was dropped.

Symptom: refs.sortBy.sortDropdown.oninput(...) crashed with Cannot read properties of undefined (reading 'sortDropdown') on the onsko-shop home page, which uses <jay:product-search> without including the sort UI.

Fix: Made mergeContractStubRefs recursive — it now merges children from both template and contract. Added markAllRefsAsStubs() helper that recursively marks all refs in a tree as autoRef: true for contract children that have no template counterpart.

No deviations from original design — the fix extends the same approach (stub refs as autoRef entries) to nested sub-contracts.


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.