Headless Instances In Interactive ForEach Without Slow Phase
Headless Instances in Interactive forEach (No Slow Phase)
Written for AI agents. See Log Methodology Note below for details.
Date: February 11, 2026
Status: Draft
Related: Design Log #84 (headless component props and repeater support), #50 (rendering phases)
Background
Design Log #84 introduced nested headless component instances (<jay:xxx>) with support for forEach and slowForEach repeaters. During implementation, we added a compile-time restriction: headless instances cannot appear inside fast-phase (interactive) forEach.
The reason was that headless instances may need server-side slowlyRender() calls, but fast-phase forEach items are only known at request time — after the slow phase has already completed. Since the slow phase runs once (and is cached), we can't retroactively call slowlyRender() for dynamically-discovered items.
The restriction was implemented as a compiler error in renderHeadlessInstance() when insideFastForEach is true.
Problem
The restriction is overly broad. Not all headless components have a slow phase. A component built with:
makeJayStackComponent<MyContract>()
.withProps<MyProps>()
.withFastRender(async (props, ...services) => { ... })
.withInteractive(MyConstructor)
...has no slowlyRender at all. For such components, the "can't render slow data for dynamic items" concern doesn't apply.
Current State
Compiler: <jay:xxx> inside forEach → always error
Desired State
Compiler: <jay:xxx> inside forEach → allowed (defer validation to server)
Server: <jay:xxx> inside forEach → error ONLY if component has slowlyRender
Key Insight
The slow rendering phase already loads all headless component definitions (via loadPageParts → vite.ssrLoadModule). At that point, we have access to compDefinition.slowlyRender and can check if it's defined. This information is not available at compile time (the compiler only sees contracts and jay-html, not runtime component definitions), but is available at slow render time.
Questions and Answers
Q1: Should the validation move from compile-time to server-time?
Answer: Yes. The compiler cannot know whether a component has a slow phase — that's determined by the builder API (withSlowlyRender), not by the contract. The server already loads components and checks compDefinition.slowlyRender (see instance-slow-render.ts line 69). We should:
- Remove the compile-time
insideFastForEacherror - Add a server-time validation when discovering instances inside forEach
Q2: How do we discover instances inside a preserved forEach at slow-render time?
Currently, discoverHeadlessInstances skips instances inside preserved forEach elements (the insidePreservedForEach flag). We need to still discover them, but handle them differently.
Answer: Discover them in a separate list. discoverHeadlessInstances should return two lists:
instances— instances with resolved props (existing, outside forEach)forEachInstances— instances inside preserved forEach (new, props may have bindings)
For forEachInstances, we only need the contractName to look up the component definition and validate it has no slow phase. We don't need to call slowlyRender for them — they will be rendered entirely in the fast + interactive phases.
Q3: How does the fast phase render these instances?
Currently, the fast phase for instances (renderFastChangingDataForInstances) relies on slow-phase discovery data (InstancePhaseData.discovered). For instances inside forEach, there IS no slow-phase data.
Answer: These instances need a different rendering path. Since forEach items are resolved during fast rendering:
- The page's
fastRender()returns the forEach array (e.g.,allProducts.items) - For each item, the props are known (e.g.,
productId = item._id) - We can call the nested component's
fastRender(props, {}, ...services)with empty carryForward (no slow phase → no carryForward)
This happens in the dev server after the page's fast render completes. The flow becomes:
Page fastRender() → returns forEach array data
↓
For each forEach item:
→ Resolve instance props from item data
→ Call component.fastRender(props, {}, ...services) (empty carryForward)
→ Collect viewStates keyed by dynamic coordinate
Q4: How do dynamic coordinates work for forEach instances?
Static instances have fixed coordinates (e.g., product-card:0). ForEach instances need per-item coordinates since the number of items varies at request time.
Answer: Use the existing Coordinate system from the runtime/secure packages. Coordinate = string[] is already defined in element-types.ts. The secure package builds coordinates by accumulating dataIds — each sandboxForEach level pushes item[matchBy] onto the array, and the final segment is the ref/component name:
// secure/lib/sandbox/sandbox-refs.ts
coordinate: (refName: string) => [...dataIds, refName];
// secure/lib/sandbox/sandbox-element.ts — forEach extends dataIds:
dataIds: [...dataIds, item[matchBy]];
Headless instances inside forEach follow the same pattern:
Static: ["product-card"]
ForEach: ["prod-123", "product-card"]
["prod-456", "product-card"]
Nested: ["electronics", "item-42", "product-card"]
For __headlessInstances ViewState keying, coordinates are joined with coordinate.toString() (the existing convention in the secure package, which uses Map<string, ...> keyed by coordinate.toString()).
Q5: Does the client-side makeHeadlessInstanceComponent need changes?
Answer: Yes. Currently it accepts a static coordinateKey: string. For forEach instances, it needs a coordinate factory that receives the trackBy value and returns the coordinate. The runtime's forEach mechanism already has access to the trackBy key for each item.
Two options:
- A) Overload:
coordinateKey: string | ((trackByKey: string) => string) - B) Always use factory, static instances just ignore the argument
Answer: Option A — keeps the common (static) case simple while supporting the dynamic case.
Q6: What about nested forEach with headless instances?
Example:
<div forEach="categories" trackBy="id">
<div forEach="items" trackBy="_id">
<jay:product-card productId="{_id}">...</jay:product-card>
</div>
</div>
Answer: Supported. Runtime coordinates already handle nested forEach — each level adds the trackBy value as a path segment. The coordinate for the above becomes:
["electronics", "item-42", "product-card"]
Which is [category.id, items._id, contractName]. This matches how the runtime package already builds coordinates for nested repeaters. The server-side rendering iterates nested loops to produce the same coordinate keys.
Q7: Can a component with a fast phase but no slow phase exist inside forEach?
Answer: Yes, that's the primary use case. Example:
// No .withSlowlyRender() → no slow phase
makeJayStackComponent<PriceWidgetContract>()
.withProps<{ productId: string }>()
.withServices(PRICING_SERVICE)
.withFastRender(async (props, pricingService) => {
const price = await pricingService.getPrice(props.productId);
return {
viewState: { price, currency: 'USD' },
carryForward: { productId: props.productId },
};
})
.withInteractive(PriceWidgetConstructor);
This component fetches pricing data at request time (fast phase) and has interactive behavior. It can safely appear inside forEach because there's no slow phase data dependency.
Q8: What about a component with ONLY an interactive phase (no slow, no fast)?
Answer: This is technically possible but limited. If a component has neither slowlyRender nor fastRender, there is no mechanism to generate any ViewState on the server. This means the component cannot have data tags in its contract — there's no phase to produce their values. The component can only have ref tags and interactive behavior (event handlers, client-side state).
In practice, a purely interactive headless component inside forEach is unlikely to be useful since it can't provide any server-rendered data to its inline template. The primary use case for this design is fast-phase components (Q7).
Design
Reference: Secure Package Coordinate System
The secure package already implements coordinate-based ViewState routing for element bridges. This is the pattern to follow:
- Type:
Coordinate = string[](runtime/lib/element-types.ts) - Building:
SandboxCreationContext.dataIdsaccumulates forEach trackBy values; final coordinate is[...dataIds, refName](secure/lib/sandbox/sandbox-refs.ts) - forEach extension:
sandboxForEachpushesitem[matchBy]ontodataIdsfor each level (secure/lib/sandbox/sandbox-element.ts) - Storage:
Map<string, RefImpl>keyed bycoordinate.toString()(secure/lib/sandbox/sandbox-refs.ts) - Lookup:
items.get(coordinate.toString())returns the ViewState for a specific element (secure/lib/sandbox/sandbox-refs.ts) - Nested: Each forEach level adds one segment, so nested forEach produces
[outerTrackBy, innerTrackBy, refName]
The headless instance coordinate system should use the same Coordinate type and the same toString() keying convention.
Architecture Change
BEFORE:
Compiler → ERROR if <jay:xxx> inside forEach
AFTER:
Compiler → allows <jay:xxx> inside forEach (generates dynamic coordinate factory)
Server (slow phase) → discovers forEach instances, validates no slow phase
Server (fast phase) → renders forEach instances per item
Client → resolves dynamic coordinates from trackBy keys
Rendering Flow
Server-Side Changes
1. discoverHeadlessInstances — new forEachInstances output
export interface ForEachHeadlessInstance {
contractName: string;
/** The forEach attribute path (e.g., "allProducts.items") */
forEachPath: string;
/** TrackBy key for the forEach */
trackBy: string;
/** Prop bindings referencing forEach item fields (e.g., { productId: "{_id}" }) */
propBindings: Record<string, string>;
/** Coordinate suffix after trackBy values, e.g., ["product-widget"] */
coordinateSuffix: string[];
}
export interface HeadlessInstanceDiscoveryResult {
instances: DiscoveredHeadlessInstance[];
forEachInstances: ForEachHeadlessInstance[];
preRenderedJayHtml: string;
}
2. Slow phase validation
In slowRenderInstances (or a new validation function), after discovering forEachInstances:
for (const forEachInstance of forEachInstances) {
const comp = componentByContractName.get(forEachInstance.contractName);
if (comp?.compDefinition.slowlyRender) {
validations.push(
`<jay:${forEachInstance.contractName}> inside forEach requires server-side slow rendering ` +
`which is not available for dynamically-iterated arrays. ` +
`Either remove the slow phase from the component or use slowForEach instead.`,
);
}
}
3. Fast phase rendering for forEach instances
New function: renderFastChangingDataForForEachInstances
async function renderFastChangingDataForForEachInstances(
forEachInstances: ForEachHeadlessInstance[],
headlessInstanceComponents: HeadlessInstanceComponent[],
fastViewState: object, // page's fast ViewState (contains forEach arrays)
): Promise<Record<string, object> | undefined> {
const viewStates: Record<string, object> = {};
for (const instance of forEachInstances) {
const comp = componentByContractName.get(instance.contractName);
if (!comp) continue;
// Resolve the forEach array from the page's fast ViewState
const items = resolvePathValue(fastViewState, instance.forEachPath) as any[];
if (!Array.isArray(items)) continue;
for (const item of items) {
const trackByValue = item[instance.trackBy];
// Resolve props from item data
const props = resolvePropsFromBindings(instance.propBindings, item);
if (comp.compDefinition.fastRender) {
const services = resolveServices(comp.compDefinition.services);
const fastResult = await comp.compDefinition.fastRender(
props,
{}, // empty carryForward (no slow phase)
...services,
);
if (fastResult.kind === 'PhaseOutput') {
// Coordinate: [trackByValue, ...suffix] → "trackByValue/contract"
const coord = [trackByValue, ...instance.coordinateSuffix].join('/');
viewStates[coord] = fastResult.rendered;
}
}
}
}
return Object.keys(viewStates).length > 0 ? viewStates : undefined;
}
Compiler Changes
1. Remove insideFastForEach compile-time error
In renderHeadlessInstance, remove the early return when insideFastForEach is true.
2. Generate dynamic coordinate factory
When insideFastForEach is true, instead of a static coordinate string:
// Static (existing):
makeHeadlessInstanceComponent(render, comp, 'product-widget:0', contexts);
// Dynamic (new, inside forEach — uses trackBy value as coordinate prefix):
makeHeadlessInstanceComponent(
render,
comp,
(trackByKey) => `${trackByKey}/product-widget`,
contexts,
);
// Nested forEach — each level adds its trackBy value:
makeHeadlessInstanceComponent(
render,
comp,
(trackByKey) => `${trackByKey}/product-card`, // trackByKey already includes outer levels
contexts,
);
Client-Side Changes
1. makeHeadlessInstanceComponent — support coordinate factory
export function makeHeadlessInstanceComponent(
preRender: Function,
interactiveConstructor: Function,
coordinateKey: string | ((trackByKey: string) => string),
pluginContexts?: ContextMarkers<any>,
): JayComponentConstructor {
// ...
// When coordinateKey is a function, resolve it from the forEach item's trackBy
const resolvedKey =
typeof coordinateKey === 'function'
? coordinateKey(currentTrackByKey()) // from forEach runtime context
: coordinateKey;
// ...
}
2. forEach runtime — expose trackBy key
The runtime's forEach mechanism needs to expose the current item's trackBy value so makeHeadlessInstanceComponent can access it. This could be via:
- A context value set during forEach iteration
- A parameter passed through
childComp
Implementation Plan
Phase 1: Remove compile-time restriction
- Remove
insideFastForEachcheck inrenderHeadlessInstance - Keep
insideFastForEachflag onRenderContext(still useful for coordinate generation) - When
insideFastForEach, generate dynamic coordinate factory instead of static string - Update
makeHeadlessInstanceComponenttype to acceptstring | ((key: string) => string)
Phase 2: Server-side discovery and validation
- Update
discoverHeadlessInstancesto returnforEachInstancesalongsideinstances - Add validation:
forEachInstanceswhose components haveslowlyRenderproduce errors - Store
forEachInstancesmetadata inInstancePhaseDatafor the fast phase
Phase 3: Server-side fast rendering for forEach instances
- Implement
renderFastChangingDataForForEachInstances - Wire it into
handleDirectRequest,handlePreRenderRequest, andhandleCachedRequest - Dynamic coordinate keys in
__headlessInstancesViewState
Phase 4: Client-side dynamic coordinate resolution
- Update
makeHeadlessInstanceComponentto support coordinate factory - Expose forEach trackBy key in runtime context
- Wire up: forEach item → trackBy key → coordinate factory → ViewState lookup
Phase 5: Fake-shop example
- Add a new headless component to the fake-shop plugin without a slow phase (fast + interactive only, e.g., a
price-badgeorstock-statuswidget) - Add a contract for the new component (only fast-phase data tags, props for
productId) - Use
<jay:new-component>inside an interactiveforEachon the fake-shop products page - Verify end-to-end: server fast-renders per item, client hydrates with correct ViewState per forEach item
Phase 6: Tests
- Compiler test:
<jay:xxx>inside forEach compiles without error, generates dynamic coordinate - Slow-render test: forEach instances discovered with prop bindings
- Slow-render test: validation error when component has
slowlyRender - Slow-render test: no error when component lacks
slowlyRender - Dev server test: end-to-end fast-only component inside forEach
- Client runtime test: dynamic coordinate resolution
Examples
Allowed: fast-only component inside forEach
<div forEach="allProducts.items" trackBy="_id">
<jay:price-widget productId="{_id}">
<span class="price">{price} {currency}</span>
</jay:price-widget>
</div>
// price-widget: NO slow phase
makeJayStackComponent<PriceWidgetContract>()
.withProps<{ productId: string }>()
.withFastRender(async (props, ...services) => ({ ... }))
.withInteractive(PriceWidgetConstructor);
Allowed: nested forEach with fast-only component
<div forEach="categories" trackBy="id">
<h2>{name}</h2>
<div forEach="items" trackBy="_id">
<jay:price-widget productId="{_id}">
<span class="price">{price} {currency}</span>
</jay:price-widget>
</div>
</div>
Coordinate for an item: ["electronics", "item-42", "price-widget"] → key "electronics/item-42/price-widget"
Disallowed: slow-phase component inside forEach (server error)
<div forEach="productIds" trackBy="id">
<jay:product-card productId="{id}">
<h2>{name}</h2>
<!-- slow-phase data -->
<span>{price}</span>
<!-- fast-phase data -->
</jay:product-card>
</div>
Server error: <jay:product-card> inside forEach has a slow rendering phase.
Headless components with slow phases cannot be used inside forEach because
forEach items are only known at request time, after slow rendering completes.
Use slowForEach instead, or remove the slow phase from the component.
Trade-offs
Advantages
- Unlocks common pattern — fast-only widgets (pricing, availability, ratings) inside dynamic lists
- No unnecessary limitation — only restricts when technically impossible (slow phase)
- Server-validated — error message at render time is more helpful than a generic compile-time error
- Incremental — doesn't change existing behavior for static instances or slowForEach
Disadvantages
- Late error detection — validation moves from compile-time to server-time (user sees error later in the workflow)
- Dynamic coordinates — adds complexity to coordinate system and ViewState delivery
- forEach trackBy required — instances inside forEach must have trackBy (already required for forEach, but now also used for coordinates)
- Server-side prop resolution — need to resolve prop bindings from forEach item data on the server
Alternatives Considered
Contract-level phase declaration — mark the contract itself as "no slow phase" so the compiler can validate. Rejected: the contract doesn't control which builder methods the component uses; it only describes the data shape.
Client-side rendering for forEach instances — skip server fast rendering, let the client call an API. Rejected for now: adds latency (extra round-trip) and doesn't leverage existing infrastructure. Could be a future enhancement.
Keep compile-time error, add explicit opt-in — e.g.,
<jay:xxx noSlow>attribute. Rejected: leaks implementation details into the template.
Verification Criteria
-
<jay:xxx>insideforEachcompiles successfully (no compiler error) - Compiled output uses dynamic coordinate factory (trackBy-based) for forEach instances
- Server discovers forEach instances during slow phase
- Server emits error if forEach instance's component has
slowlyRender - Server renders fast phase for forEach instances (per item, empty carryForward)
- Client resolves dynamic coordinates from trackBy keys
- ViewState correctly delivered to each forEach item's headless instance (needs E2E verification)
- Interactive phase works for forEach instances (signals, refs, events) (needs E2E verification)
- Existing static instances and slowForEach instances unaffected
- Nested forEach with headless instances produces correct multi-segment coordinates
Implementation Results
Files Modified
Compiler:
packages/compiler/compiler-jay-html/lib/jay-target/jay-html-compiler.ts— RemovedinsideFastForEachcompile-time error inrenderHeadlessInstance; when inside forEach, generates(dataIds) => [...dataIds, 'contractName:ref'].toString()factory instead of static stringpackages/compiler/compiler-jay-html/lib/slow-render/slow-render-transform.ts— UpdateddiscoverHeadlessInstancesto return bothinstances(static) andforEachInstances(inside preserved forEach); addedForEachHeadlessInstanceinterfacepackages/compiler/compiler-jay-html/lib/index.ts— ExportedForEachHeadlessInstancetype
Server:
packages/jay-stack/stack-server-runtime/lib/instance-slow-render.ts— AddedvalidateForEachInstances()function; addedforEachInstancesfield toInstancePhaseData; re-exportedForEachHeadlessInstancepackages/jay-stack/dev-server/lib/dev-server.ts— Integrated forEach instance validation in all three request handlers (direct, pre-render, cached); addedrenderFastChangingDataForForEachInstances()for per-item fast rendering; added helper functionsresolvePathValue()andresolveBinding()
Client:
packages/runtime/runtime/lib/context.ts— AddeddataIdsgetter onConstructContextto expose coordinate basepackages/jay-stack/stack-client-runtime/lib/headless-instance-context.ts— UpdatedmakeHeadlessInstanceComponentto acceptstring | ((dataIds: string[]) => string)for coordinate key; resolves dynamic coordinates usingcurrentConstructionContext().dataIds
Example:
examples/jay-stack/fake-shop/src/plugins/stock-status/— New fast-only headless component (stock-status) with contract, component, plugin.yaml, and.d.tsexamples/jay-stack/fake-shop/src/pages/page.jay-html— Added<jay:stock-status>inside interactiveforEach="allProducts"section
Tests:
packages/compiler/compiler-jay-html/test/jay-target/generate-element.test.ts— Updated forEach test from expecting validation error to expecting successful compilationpackages/compiler/compiler-jay-html/test/fixtures/contracts/page-with-headless-in-foreach/page-with-headless-in-foreach.jay-html.ts— Updated fixture to use dynamic coordinate factorypackages/compiler/compiler-jay-html/test/slow-render/slow-render-transform.test.ts— Updated forEach discovery test to verify bothinstancesandforEachInstances; added nested forEach discovery testpackages/jay-stack/stack-server-runtime/test/validate-foreach-instances.test.ts— New test file with 5 tests forvalidateForEachInstances
Test Results
- compiler-jay-html: 514 passed, 4 skipped (518 total)
- stack-server-runtime: 71 passed (71 total)
- stack-client-runtime: 19 passed (19 total)
- fake-shop: 6 passed (6 total)
Deviations from Design
- Coordinate format: Design proposed
[trackByValue, contractName]but implementation uses[trackByValue, contractName:ref]— keeping the:refsuffix for consistency with static instance coordinates and to disambiguate multiple instances of the same contract inside one forEach - Factory receives
dataIdsnottrackByKey: The coordinate factory(dataIds: string[]) => stringreceives the full coordinate base (all accumulated trackBy values from ancestor forEach loops), not just the immediate trackBy value. This naturally supports nested forEach without additional logic ConstructContext.dataIdsgetter added: Instead of a separate runtime context for forEach trackBy values, we reused the existingConstructContext.coordinateBasewhich already accumulates trackBy values viaforItem(). The newdataIdsgetter exposes this cleanly
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.