Headless Component Props And Repeater Support
Headless Component Props and Repeater Support
Written for AI agents. See Log Methodology Note below for details.
Date: February 4, 2026
Status: Draft
Related: Design Logs #50, #58, #60, #80
Background
Jay Stack supports headless components from plugins that provide data contracts for page rendering. Currently, headless components are imported once per page via <script type="application/jay-headless"> tags:
<script
type="application/jay-headless"
plugin="wix-stores"
contract="product-list"
key="products"
></script>
The component renders once per page, with its data bound to the key attribute (e.g., {products.items}).
Problem Statement
We need to support scenarios where the same headless component is used multiple times on a page with different configuration/props:
Multiple instances with different IDs: A page showing 3 product cards for featured products, each with a different
productIdRepeater/forEach usage: A product grid where each cell is a product card component bound to an item from a list
Static vs dynamic props: Props that are:
- Static (known at build time):
productId="prod-123" - Dynamic from page data:
productId={featuredProducts[0]._id} - From repeater context:
productId={_id}inside aforEach
- Static (known at build time):
Example Scenarios
Scenario A: Multiple featured products
<!-- Current: Cannot do this -->
<div class="featured">
<product-card productId="prod-123" />
<product-card productId="prod-456" />
<product-card productId="prod-789" />
</div>
Scenario B: Product grid with repeater
<!-- Current: Cannot do this -->
<div class="product-grid" forEach="products.items" trackBy="_id">
<product-card productId="{_id}" />
</div>
Scenario C: Agent discovering available product IDs
- Agent needs to know valid
productIdvalues to generate meaningful pages - Plugin should expose discoverable data sources
Questions and Answers
Q1: How should props be passed to headless component instances?
Options:
A) Extended script tag with props attribute
<script
type="application/jay-headless"
plugin="wix-stores"
contract="product-card"
key="featured1"
props='{"productId": "prod-123"}'
></script>
B) Web component style with custom element
<wix-stores-product-card productId="prod-123" key="featured1" />
C) Named instances with inline props
<script
type="application/jay-headless"
plugin="wix-stores"
contract="product-card"
key="featured"
></script>
<!-- Later in template -->
<jay-headless use="featured" productId="prod-123" />
D) Import once, instantiate with data binding
<script type="application/jay-headless" plugin="wix-stores" contract="product-card"></script>
<!-- Use with props and inline template -->
<jay:product-card productId="prod-123">
<h1>{name}</h1>
<span class="price">{price}</span>
</jay:product-card>
<jay:product-card productId="prod-456">
<article class="compact">{name} - ${price}</article>
</jay:product-card>
Answer: Option D - Import once, instantiate with data binding.
The inline template for the headless component is placed within the instance tag. Each instance:
- Gets its own props (e.g.,
productId) - Creates a local data context (bindings resolve against component's ViewState)
- Can have different inline templates (different presentation for same data)
Considerations:
- Option A keeps existing pattern but gets verbose
- Option B is most web-like but requires component registration mechanism
- Option C separates import from usage (like ES modules)
- Option D makes the namespace/component relationship explicit
Q1b: What element name should be used for component instances?
Given import:
<script type="application/jay-headless" plugin="wix-stores" contract="product-card"></script>
Options:
A) Namespaced with jay: prefix
<jay:product-card productId="prod-123">
<h1>{name}</h1>
</jay:product-card>
B) Plain element name (contract name)
<product-card productId="prod-123">
<h1>{name}</h1>
</product-card>
C) Namespaced with contract: prefix
<contract:product-card productId="prod-123">
<h1>{name}</h1>
</contract:product-card>
Answer: Option A - Use jay: prefix for consistency.
Headful components currently use plain element names (Option B style). We should migrate headful components to also use the jay: prefix for consistency. This is a breaking change requiring test updates, but creates a unified element syntax across the framework.
Considerations for agent usage:
- Option A (
jay:): Clear framework namespace, greppable, 4 extra chars - Option B (plain): Simplest, but could conflict with HTML custom elements, no namespace isolation
- Option C (
contract:): Semantically accurate, self-documenting, 9 extra chars
Prerequisite: Migrate Headful Components to jay: Prefix
Before implementing headless component instances, we should migrate existing headful components to use the jay: prefix. This:
- Creates unified syntax for all Jay components (headful and headless)
- Simplifies compiler implementation (one pattern for component detection)
- Avoids potential conflicts with HTML custom elements
Migration scope:
- Update compiler to recognize
<jay:component-name>for headful components - Update all existing templates and tests
- Deprecate (then remove) plain element name support
Two Ways to Use Headless Components
After this design, headless components can be used in two distinct ways:
1. Top-Level Import with key Attribute
<script
type="application/jay-headless"
plugin="wix-stores"
contract="product-list"
key="catalog"
></script>
<!-- Data bound via key name -->
<h1>{catalog.title}</h1>
<div forEach="catalog.items">...</div>
- Single instance per page for this contract
- Data accessed via
{key.property}bindings - No inline template - component doesn't render visible UI
- Used for data providers (lists, configurations, page-level data)
2. Component Instances with jay: Element
<script type="application/jay-headless" plugin="wix-stores" contract="product-card"></script>
<!-- Multiple instances with inline templates -->
<jay:product-card productId="prod-123">
<h1>{name}</h1>
<!-- Resolved from component ViewState -->
</jay:product-card>
<jay:product-card productId="prod-456">
<h1>{name}</h1>
<!-- Different instance, different data -->
</jay:product-card>
- Multiple instances allowed (different props)
- Each instance creates a new data context (ViewState)
- Inline template defines the presentation
- Props passed to configure each instance
- Used for reusable widgets (cards, tiles, interactive elements)
Both can coexist:
<head>
<!-- Data provider (top-level with key) -->
<script
type="application/jay-headless"
plugin="wix-stores"
contract="product-list"
key="allProducts"
></script>
<!-- Widget (imported, used as instances) -->
<script type="application/jay-headless" plugin="wix-stores" contract="product-card"></script>
</head>
<body>
<!-- Use list data to drive forEach -->
<div forEach="allProducts.items" trackBy="_id">
<!-- Use product-card as widget for each item -->
<jay:product-card productId="{_id}">
<article>{name} - ${price}</article>
</jay:product-card>
</div>
</body>
Q2: How do headless components work inside forEach?
Options:
A) Implicit context binding: Component automatically receives repeater item as props
<div forEach="products.items" trackBy="_id">
<jay:product-card />
<!-- Receives current item as props -->
</div>
B) Explicit prop binding from repeater context
<div forEach="products.items" trackBy="_id">
<jay:product-card productId="{_id}" name="{name}" price="{price}" />
</div>
C) Spread operator for all item props
<div forEach="products.items" trackBy="_id">
<jay:product-card {.} />
</div>
Answer: Support both Option B and Option C.
- Option B: Explicit prop binding for selective/renamed props
- Option C: Spread operator
{.}passes all properties of the current forEach item (note: we use{.}not{...item}since forEach items don't have explicit names)
Considerations:
- Option A: Magic/implicit - component may expect specific shape
- Option B: Explicit and type-safe - verbose but clear
- Option C: Convenient for matching shapes - less type safety
Q3: Where does the headless component render its output?
Currently: Component output bound to key (e.g., {products.name})
New question: With multiple instances, how is data accessed?
Options:
A) Each instance has unique key
<jay:product-card productId="prod-123" as="featured1" />
<jay:product-card productId="prod-456" as="featured2" />
<h1>{featured1.name}</h1>
<h1>{featured2.name}</h1>
B) Component is self-contained (slot-based)
<jay:product-card productId="prod-123">
<template slot="name"><h1>{name}</h1></template>
<template slot="price"><span>{price}</span></template>
</jay:product-card>
C) Component provides render template
<jay:product-card productId="prod-123">
<h1>{name}</h1>
<span class="price">{price}</span>
</jay:product-card>
D) Component has fixed rendering
- Component controls its own HTML
- Props configure behavior, not layout
Answer: Option C - Component creates a new data context.
The headless component instance creates a new data context based on its ViewState. Template bindings inside the component tag resolve against the component's ViewState, not the parent page's ViewState.
<jay:product-card productId="prod-123">
<h1>{name}</h1>
<!-- resolved from component ViewState -->
<span class="price">{price}</span>
<!-- resolved from component ViewState -->
</jay:product-card>
{name}and{price}resolve against theproduct-cardcomponent's ViewStateproductId="prod-123"is a static prop passed to the componentproductId={someValue}would resolvesomeValuefrom the parent ViewState before passing to component
Note on as Attribute
With Option C (local data context), the as attribute is not needed. Earlier examples showed:
<jay:product-card productId="prod-123" as="featured1" />
<h1>{featured1.name}</h1>
<!-- This pattern is NOT used -->
With inline templates and local data context, this becomes:
<jay:product-card productId="prod-123">
<h1>{name}</h1>
<!-- Data accessed inside component scope -->
</jay:product-card>
Refs are handled by the compiler: childComp requires a ref parameter, and the compiler auto-generates refs for all component instances. No explicit as attribute needed.
Q4: What props should a product-card headless component accept?
Example contract extension for props:
# product-card.jay-contract
name: ProductCard
# Props the component accepts (input)
props:
- name: productId
type: string
required: true
description: 'The ID of the product to display'
# Data the component provides (output)
tags:
- tag: name
type: data
dataType: string
phase: slow
- tag: price
type: data
dataType: number
phase: fast
- tag: inStock
type: data
dataType: boolean
phase: fast
Answer: Yes, extend the contract format to include a props section.
The example format above captures the key elements:
name: prop identifiertype: data type (string, number, boolean, enum, etc.)required: whether the prop must be provideddescription: human-readable description for agents/documentationdefault: optional default value
Q4b: How should load params be exposed in contracts?
Background: Components using withLoadParams have URL parameters (e.g., slug, id) that:
- Drive static site generation (SSG)
- Are merged into props before rendering
- Have discoverable valid values (via the
loadParamsgenerator)
Props vs Load Params:
| Aspect | Props (withProps) |
Load Params (withLoadParams) |
|---|---|---|
| Source | Template/parent | URL path segments |
| Purpose | Component configuration | URL routing + SSG |
| Discovery | Via plugin actions | Via loadParams generator |
| Example | productId, variant |
slug, categoryId |
Connection via plugin.yaml:
The plugin.yaml already links contracts to their components (which may have withLoadParams):
# plugin.yaml
contracts:
- name: product-page
contract: product-page.jay-contract
component: productPage # This component has withLoadParams
description: Product page with URL slug param
No need to duplicate load params schema in the contract file - the connection is already established.
Agent discovery for load params:
Load params are generator functions, not actions. Agents discover valid values via dedicated CLI command:
# Discover valid load param values for a contract
jay-stack params wix-stores/product-page
# Returns all valid param combinations:
[
{ "slug": "ceramic-vase" },
{ "slug": "wooden-bowl" },
{ "slug": "glass-pitcher" }
]
How it works:
- CLI finds the component referenced by the contract in plugin.yaml
- Runs the component's
loadParamsgenerator function - Collects and returns all yielded param combinations
Runtime behavior:
- Load params are validated against the generator before slow render
- If params don't match any generated values, returns 404
- Valid params are merged into props:
{ ...baseProps, ...loadParams }
Q5: How can agents discover valid prop values?
Problem: An agent generating a page with <product-card productId="???"> needs to know valid product IDs.
Options:
A) Plugin exposes data discovery endpoint
# plugin.yaml
contracts:
- name: product-card
contract: ./contracts/product-card.jay-contract
component: ./components/product-card
data_sources:
- name: products
description: 'Available products for product-card component'
endpoint: ./data/get-products.ts # Returns list of valid productIds
B) Contract declares related list source
# product-card.jay-contract
name: ProductCard
props:
- name: productId
type: string
source:
plugin: wix-stores
contract: product-list
path: items[*]._id
C) Separate discovery contract/command
# Agent runs this to discover available data
jay-stack discover wix-stores/product-card/productId
# Returns: ["prod-123", "prod-456", "prod-789", ...]
D) Materialized data index (similar to contracts-index.yaml)
# build/materialized-data/data-index.yaml
data_sources:
- plugin: wix-stores
prop_source: productId
contract: product-card
values_path: ./build/materialized-data/wix-stores/product-ids.json
Answer: Leverage existing plugin actions.
Plugins already define actions (e.g., "search products" in wix-stores) in plugin.yaml. These actions can be:
- Described with metadata for agent understanding
- Exposed via MCP server for agent invocation
- Callable via CLI command for scripted discovery
This aligns with the existing plugin architecture - actions already exist, we just need to make them discoverable and invocable by agents.
Q5b: How should action descriptions be stored?
Answer: Single file per action with two parts: MCP-based schema (YAML) + markdown description.
# ./actions/search-products.action.yaml
# Part 1: MCP-based schema
name: searchProducts
handler: ./search-products.ts
inputSchema:
type: object
properties:
query:
type: string
description: Search query text
limit:
type: number
default: 10
description: Maximum results to return
required:
- query
outputSchema:
type: array
items:
type: object
properties:
_id:
type: string
name:
type: string
price:
type: number
---
# Part 2: Markdown description (after YAML frontmatter separator)
# Search Products Action
Search for products by query string. Returns matching products with their IDs,
names, and prices. Use this to discover valid `productId` values for the
`product-card` component.
## When to Use
Call this action when you need to:
- Find valid product IDs for `<jay:product-card productId="...">`
- Display search results to users
- Populate product grids or carousels
## Example
```bash
jay-stack action wix-stores/searchProducts --query="blue shirt"
Returns:
[
{ "_id": "prod-123", "name": "Blue Cotton Shirt", "price": 29.99 },
{ "_id": "prod-456", "name": "Blue Denim Shirt", "price": 49.99 }
]
```yaml
# plugin.yaml - references action files
actions:
- ./actions/search-products.action.yaml
- ./actions/get-product.action.yaml
Agent usage:
The .action.yaml file contains both the schema (for validation/MCP) and the description (for agents). The CLI invocation is derived from the schema:
# CLI format: jay-stack action <plugin>/<action-name> [--param value]...
jay-stack action wix-stores/searchProducts --query="blue shirt" --limit=5
# Returns JSON to stdout:
[
{"_id": "prod-123", "name": "Blue Cotton Shirt", "price": 29.99},
{"_id": "prod-456", "name": "Blue Denim Shirt", "price": 49.99}
]
Parameter passing:
- Required params:
--query="value"(error if missing) - Optional params with defaults:
--limit=10(uses default if omitted) - Boolean flags:
--includeOutOfStockor--includeOutOfStock=false
MCP exposure:
The same .action.yaml schema can be used to register actions as MCP tools:
// jay-stack exposes actions as MCP tools
{
name: "wix-stores/searchProducts",
description: "Search for products...", // from markdown section
inputSchema: { /* from inputSchema in yaml */ },
}
Considerations:
- Option A: Plugin-defined, but requires API at build time
- Option B: Declarative relationship - agent follows the source
- Option C: CLI command for exploration - explicit but separate step
- Option D: Pre-materialized like dynamic contracts - fits existing pattern
Q6: Should props be validated at compile time or runtime?
Answer: Compile time.
Props are validated against the contract schema at compile time. This provides:
- Early error detection before runtime
- Type safety for static props
- Better agent feedback when generating pages
Design
Key Insight: Headless + Inline Template = Headful Component
The jay-runtime library already supports headful components with a similar pattern. A headless component with an inline template should be treated as a headful component during compilation.
<jay:product-card productId="prod-123">
<article class="card">
<h1>{name}</h1>
<span class="price">{price}</span>
<button ref="addToCart">Add to Cart</button>
</article>
</jay:product-card>
This compiles to a makeJayComponent call with the headless component's data providing the context.
Compilation by Phase
Slow Phase:
- Transform the inline template using the headless component's slow ViewState
- Props are resolved and passed to the headless component's
slowlyRender - Output: Static HTML with slow-phase data bindings resolved
Fast Phase:
- Headless component's
fastRenderproduces fast ViewState - Create
carryForwarddata to hand over to interactive phase - Output: HTML with fast-phase data bindings resolved
Interactive Phase:
- Compile the inline template into a
makeJayComponentcall - Two special contexts are injected:
Signals<FastViewState>- Reactive signals for the component's ViewStateFastCarryForward- Data carried from fast phase
- Refs from the inline template (e.g.,
ref="addToCart") are wired up - Event handlers and reactive updates work within the component's data context
// Conceptual compilation output
const ProductCardInstance = makeJayComponent({
// Injected from headless component
viewState: productCardViewStateSignals,
carryForward: productCardCarryForward,
// From inline template
template: compiledInlineTemplate,
refs: { addToCart: buttonRef },
});
Nested Headless Component Rendering
When a page contains nested headless component instances (<jay:product-card>), the page's rendering phases must orchestrate the child component's phases.
Slow Rendering Flow
┌─────────────────────────────────────────────────────────────────┐
│ Page slowlyRender() │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ 1. Resolve page props and load params │ │
│ │ 2. Call page's slowlyRender function │ │
│ │ │ │
│ │ For each <jay:product-card productId="...">: │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ productCard.slowlyRender({ productId }) │ │ │
│ │ │ → Returns: { viewState, carryForward } │ │ │
│ │ │ → ViewState used to transform inline template │ │ │
│ │ │ → CarryForward stored for fast phase │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ 3. Transform inline template with component's slow VS │ │
│ │ 4. Track component carryForward for fast phase │ │
│ │ 5. Return page viewState + page carryForward │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Key: ViewStates are isolated
Each component (page and nested components) has its own isolated ViewState. The child component's ViewState is used to transform its inline template, NOT merged into the page ViewState.
page.ts slowlyRender implementation:
async function renderSlowlyChanging(props: PageProps) {
// Page's own slow render logic
const pageData = await fetchPageMetadata();
// Call nested component's slow render
// The returned viewState transforms the inline template
// The returned carryForward is tracked for fast phase
const heroResult = await productCard.slowlyRender({ productId: 'prod-hero' });
// For forEach items
const catalogResults = await Promise.all(
featuredIds.map((id) => productCard.slowlyRender({ productId: id })),
);
return {
// Page's own viewState (NOT including child viewStates)
viewState: {
pageTitle: 'Our Products',
// Page-level data only
},
// Track component carryForwards to pass in fast phase
carryForward: {
heroCarryForward: heroResult.carryForward,
catalogCarryForwards: catalogResults.map((r) => r.carryForward),
},
};
}
Fast Rendering Flow
┌─────────────────────────────────────────────────────────────────┐
│ Page fastRender(props, carryForward) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ 1. Receive page carryForward from slow phase │ │
│ │ │ │
│ │ For each nested component: │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ productCard.fastRender( │ │ │
│ │ │ { productId }, │ │ │
│ │ │ componentCarryForward // from slow phase │ │ │
│ │ │ ) │ │ │
│ │ │ → Returns: { viewState, carryForward } │ │ │
│ │ │ → ViewState transforms inline template bindings │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ 2. Transform component templates with fast viewState │ │
│ │ 3. Return page fast viewState + carryForward │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
page.ts fastRender implementation:
async function renderFastChanging(props: PageProps, carryForward: PageCarryForward) {
// Call nested component's fast render with its carryForward from slow phase
const heroFast = await productCard.fastRender(
{ productId: 'prod-hero' },
carryForward.heroCarryForward, // Pass component's own carryForward
);
const catalogFast = await Promise.all(
carryForward.catalogCarryForwards.map((cf, i) =>
productCard.fastRender({ productId: featuredIds[i] }, cf),
),
);
return {
// Page's own fast viewState (NOT including child viewStates)
viewState: {
// Page-level fast data only
},
carryForward,
};
}
Key Points
ViewStates are isolated - Each component (page and nested) has its own ViewState space. Child ViewState is NOT merged into parent.
Props flow down - Props for nested components are resolved from:
- Static values in template:
productId="prod-123" - Dynamic bindings:
productId={someValue}resolved from page viewState - ForEach context:
productId={_id}resolved from item
- Static values in template:
CarryForward links phases - Component carryForward from slow phase must be passed to the same component instance in fast phase
Template transformation is per-component - Each component's inline template is transformed using that component's ViewState
Parallel execution - Independent child components can render in parallel via
Promise.all
Detailed Compilation Example
The key insight: The headless component plugin provides a constructor function (from makeJayStackComponent). The compiler produces a jay-html.ts file that combines:
- The page's compiled template
- The inline component template (compiled)
- The constructor function from the plugin
Source: page.jay-html
<html>
<head>
<script type="application/jay-data" contract="./page.jay-contract"></script>
<script type="application/jay-headless" plugin="wix-stores" contract="product-card"></script>
<script
type="application/jay-headless"
plugin="wix-stores"
contract="product-list"
key="catalog"
></script>
</head>
<body>
<h1>{pageTitle}</h1>
<!-- Single headless component instance with static prop -->
<section class="hero">
<jay:product-card productId="prod-hero">
<article class="hero-card">
<h2>{name}</h2>
<p class="desc">{description}</p>
<span class="price">${price}</span>
<button ref="buyNow">Buy Now</button>
</article>
</jay:product-card>
</section>
<!-- Headless components in a repeater -->
<section class="catalog">
<div class="grid" forEach="catalog.items" trackBy="_id">
<jay:product-card productId="{_id}">
<article class="product-tile">
<img src="{imageUrl}" alt="{name}" />
<h3>{name}</h3>
<span class="price">${price}</span>
<button ref="addToCart">Add to Cart</button>
</article>
</jay:product-card>
</div>
</section>
</body>
</html>
Plugin provides: product-card component
// @wix/stores - product-card.ts
import { makeJayStackComponent, Signals } from '@jay-framework/fullstack-component';
import { Props } from '@jay-framework/component';
import type { ProductCardContract, ProductCardFastViewState } from './product-card.jay-contract';
interface ProductCardProps {
productId: string;
}
interface ProductCardCarryForward {
productId: string;
}
// Plugin's interactive constructor
// Note: refs type is generic - comes from inline template, not defined by plugin
function ProductCardConstructor<TRefs>(
props: Props<ProductCardProps>,
refs: TRefs,
fastViewState: Signals<ProductCardFastViewState>,
fastCarryForward: ProductCardCarryForward,
) {
// Plugin provides data-related interactive logic
// The inline template handles ref wiring
return {
render: () => ({
// ViewState values to render
}),
};
}
export const productCard = makeJayStackComponent<ProductCardContract>()
.withProps<ProductCardProps>()
.withServices(PRODUCTS_SERVICE)
.withSlowlyRender(async (props, productsService) => {
const product = await productsService.getProduct(props.productId);
return {
viewState: {
name: product.name,
description: product.description,
imageUrl: product.imageUrl,
},
carryForward: { productId: props.productId },
};
})
.withFastRender(async (props, carryForward, productsService) => {
const inventory = await productsService.getInventory(carryForward.productId);
return {
viewState: {
price: inventory.price,
inStock: inventory.quantity > 0,
},
carryForward,
};
})
.withInteractive(ProductCardConstructor);
Compiled Output: page.jay-html.ts
Following the actual compilation pattern from generated-element-main-trusted.ts:
import {
JayElement,
element as e,
dynamicText as dt,
RenderElement,
ReferencesManager,
ConstructContext,
HTMLElementProxy,
RenderElementOptions,
childComp,
forEach,
} from '@jay-framework/runtime';
import { makeJayComponent, Props, createSignal } from '@jay-framework/component';
import { productCard } from '@wix/stores'; // Headless component from plugin
// ============================================================
// TYPES FROM PRODUCT-CARD CONTRACT (ViewState provided by plugin)
// ============================================================
interface ProductCardViewState {
name: string;
description: string;
imageUrl: string;
price: number;
inStock: boolean;
}
// ============================================================
// COMPILED INLINE TEMPLATE: Hero Product Card
// Source: <jay:product-card productId="prod-hero"> ... </jay:product-card>
// ============================================================
interface HeroProductCardRefs {
buyNow: HTMLElementProxy<ProductCardViewState, HTMLButtonElement>;
}
// Compiled render function (same pattern as generated-element-main-trusted.ts)
function heroProductCardRender(options?: RenderElementOptions) {
const [refManager, [refBuyNow]] = ReferencesManager.for(
options,
['buyNow'],
[],
[],
[],
);
const render = (viewState: ProductCardViewState) =>
ConstructContext.withRootContext(viewState, refManager, () =>
e('article', { class: 'hero-card' }, [
e('h2', {}, [dt((vs) => vs.name)]),
e('p', { class: 'desc' }, [dt((vs) => vs.description)]),
e('span', { class: 'price' }, [dt((vs) => `$${vs.price}`)]),
e('button', {}, ['Buy Now'], refBuyNow()),
]),
);
return [refManager.getPublicAPI() as HeroProductCardRefs, render] as const;
}
// Use the plugin's interactive constructor directly - no need to inline it
// productCard.withInteractive provides the constructor
export const HeroProductCard = makeJayComponent(
heroProductCardRender,
productCard.interactiveConstructor, // From plugin's makeJayStackComponent
);
// ============================================================
// COMPILED INLINE TEMPLATE: Catalog Item (forEach - REUSABLE)
// Source: <jay:product-card productId={_id}> ... </jay:product-card>
// ============================================================
interface CatalogItemRefs {
addToCart: HTMLElementProxy<ProductCardViewState, HTMLButtonElement>;
}
// forEach: Compiled render function - extracted ONCE, reused for all items
// This works because forEach items have IDENTICAL template structure
function catalogItemRender(options?: RenderElementOptions) {
const [refManager, [refAddToCart]] = ReferencesManager.for(
options,
['addToCart'],
[],
[],
[],
);
const render = (viewState: ProductCardViewState) =>
ConstructContext.withRootContext(viewState, refManager, () =>
e('article', { class: 'product-tile' }, [
e('img', { src: dt((vs) => vs.imageUrl), alt: dt((vs) => vs.name) }, []),
e('h3', {}, [dt((vs) => vs.name)]),
e('span', { class: 'price' }, [dt((vs) => `$${vs.price}`)]),
e('button', {}, ['Add to Cart'], refAddToCart()),
]),
);
return [refManager.getPublicAPI() as CatalogItemRefs, render] as const;
}
// For forEach: Component defined ONCE, reused for all items via childComp
export const CatalogItem = makeJayComponent(
catalogItemRender, // Same render function for all items
productCard.interactiveConstructor, // From plugin
);
// ============================================================
// slowForEach: SEPARATE TEMPLATE PER ITEM
// Each item may have different template structure due to conditionals
// ============================================================
// slowForEach items are compiled individually - cannot reuse templates
// Example: item 0 might have `if={hasVariants}` resolve to true,
// item 1 might have it resolve to false - different HTML!
// Generated at slow-render time for item[0]
function catalogItemRender_0(options?: RenderElementOptions) {
// This item HAS variants (conditional resolved to true)
const [refManager, [refAddToCart]] = ReferencesManager.for(options, ['addToCart'], [], [], []);
const render = (viewState: ProductCardViewState) =>
ConstructContext.withRootContext(viewState, refManager, () =>
e('article', { class: 'product-tile' }, [
e('h3', {}, [dt((vs) => vs.name)]),
e('div', { class: 'variants' }, [/* variant options */]), // EXISTS
e('button', {}, ['Add to Cart'], refAddToCart()),
]),
);
return [refManager.getPublicAPI(), render] as const;
}
// Generated at slow-render time for item[1]
function catalogItemRender_1(options?: RenderElementOptions) {
// This item does NOT have variants (conditional resolved to false)
const [refManager, [refAddToCart]] = ReferencesManager.for(options, ['addToCart'], [], [], []);
const render = (viewState: ProductCardViewState) =>
ConstructContext.withRootContext(viewState, refManager, () =>
e('article', { class: 'product-tile' }, [
e('h3', {}, [dt((vs) => vs.name)]),
// NO variants div - different structure!
e('button', {}, ['Add to Cart'], refAddToCart()),
]),
);
return [refManager.getPublicAPI(), render] as const;
}
// slowForEach: each item gets its own compiled template + component instance
export const slowForEachCatalogItems = [
makeJayComponent(catalogItemRender_0, productCard.interactiveConstructor),
makeJayComponent(catalogItemRender_1, productCard.interactiveConstructor),
// ... one per slow-rendered item
];
// ============================================================
// PAGE-LEVEL TEMPLATE
// ============================================================
// Item type within the page's catalog (before component hydration)
interface CatalogItemViewState {
_id: string;
// Other fields passed through from slow render...
}
interface PageViewState {
pageTitle: string;
catalog: {
items: CatalogItemViewState[];
};
}
interface PageRefs {
heroProductCard: /* component ref */;
catalogItems: /* collection ref */;
}
function pageRender(options?: RenderElementOptions) {
const [refManager, [refHeroProductCard, refCatalogItems]] = ReferencesManager.for(
options,
[],
[],
['heroProductCard'], // Single component ref
['catalogItems'], // Collection ref for forEach
);
const render = (viewState: PageViewState) =>
ConstructContext.withRootContext(viewState, refManager, () =>
e('div', {}, [
e('h1', {}, [dt((vs) => vs.pageTitle)]),
e('section', { class: 'hero' }, [
// Static headless component - rendered via childComp
childComp(
HeroProductCard,
(vs: PageViewState) => ({ /* props mapped from page viewState */ }),
refHeroProductCard(),
),
]),
e('section', { class: 'catalog' }, [
e('div', { class: 'grid' }, [
// forEach items - same component, different data per item
forEach(
(vs: PageViewState) => vs.catalog.items,
(itemVs: CatalogItemViewState) =>
childComp(
CatalogItem, // Same component for all items
(vs: CatalogItemViewState) => ({ productId: vs._id }),
refCatalogItems(),
),
'_id', // trackBy key
),
]),
]),
]),
);
return [refManager.getPublicAPI() as PageRefs, render] as const;
}
export { pageRender as render };
page.ts - Wiring headless component data with compiled templates
import { makeJayStackComponent, PageProps, Signals } from '@jay-framework/fullstack-component';
import { Props } from '@jay-framework/component';
import { render as pageRender, HeroProductCard, CatalogItem } from './page.jay-html';
import { productCard, productList } from '@wix/stores';
import type {
PageContract,
PageRefs,
PageSlowViewState,
PageFastViewState,
} from './page.jay-contract';
interface PageCarryForward {
heroProductId: string;
catalogItemIds: string[];
}
async function renderSlowlyChanging(props: PageProps) {
// 1. Get data from headless components
const heroData = await productCard.slowlyRender({ productId: 'prod-hero' });
const catalogData = await productList.slowlyRender({});
return {
viewState: {
pageTitle: 'Our Products',
// Hero component's slow ViewState
heroProduct: heroData.viewState,
// Catalog list data
catalog: catalogData.viewState,
},
carryForward: {
heroProductId: 'prod-hero',
catalogItemIds: catalogData.viewState.items.map((i) => i._id),
},
};
}
async function renderFastChanging(props: PageProps, carryForward: PageCarryForward) {
// Fast data for hero
const heroFast = await productCard.fastRender(
{ productId: carryForward.heroProductId },
{ productId: carryForward.heroProductId },
);
// Fast data for each catalog item
const catalogItemsFast = await Promise.all(
carryForward.catalogItemIds.map((id) =>
productCard.fastRender({ productId: id }, { productId: id }),
),
);
return {
viewState: {
heroProduct: heroFast.viewState,
catalogItems: catalogItemsFast.map((f) => f.viewState),
},
carryForward,
};
}
function PageConstructor(
props: Props<PageProps>,
refs: PageRefs,
fastViewState: Signals<PageFastViewState>,
fastCarryForward: PageCarryForward,
) {
// HeroProductCard is already makeJayComponent(heroProductCardRender, HeroProductCardConstructor)
// It receives ViewState from productCard's phases via the render props
// For catalog: createCatalogItem(productId) creates component per item
// Each uses the same catalogItemRender (template reuse for forEach)
return {
render: () => ({}),
};
}
export const page = makeJayStackComponent<PageContract>()
.withProps<PageProps>()
.withSlowlyRender(renderSlowlyChanging)
.withFastRender(renderFastChanging)
.withInteractive(PageConstructor);
Key Points
Plugin provides the component constructor -
productCardfrom@wix/storesincludesslowlyRender,fastRender, andwithInteractiveCompiler extracts inline templates - Each
<jay:product-card>block's content is compiled into a template objectmakeJayComponentcombines both:component: The plugin's component definition (data + services)template: The compiled inline template (presentation)interactive: Merges plugin's interactive + inline template's refs
Props resolution:
- Static:
productId="prod-hero"→ compiled directly into props - Dynamic:
productId={_id}→ factory function receives value from forEach
- Static:
Template is just presentation - The inline template only controls HTML structure; the plugin controls data fetching and business logic
Refs bridge both worlds:
- Plugin can provide refs for its functionality
- Inline template can add refs for page-specific behavior
- Both are wired up in the interactive phase
Template Reuse: slowForEach vs forEach
Critical distinction for repeated items:
forEach (fast/interactive phase)
- Template structure is identical for all items
- Only data bindings differ between items
- Define component once, render via
childCompin forEach
// forEach: Single compiled render function
function catalogItemRender(options?: RenderElementOptions) {
// Compiled ONCE
const [refManager, refs] = ReferencesManager.for(options, ['addToCart'], [], [], []);
const render = (viewState) => e('article', {}, [/* ... */]);
return [refManager.getPublicAPI(), render] as const;
}
// Component defined ONCE - makeJayComponent creates the prototype, not instance
export const CatalogItem = makeJayComponent(
catalogItemRender,
productCard.interactiveConstructor, // From plugin
);
// In page template: forEach uses childComp to render each item
forEach(
(vs: PageViewState) => vs.catalog.items,
(itemVs: CatalogItemViewState) =>
childComp(
CatalogItem, // Same component definition for all items
(vs) => ({ productId: vs._id }), // Props from item viewState
refCatalogItems(),
),
'_id', // trackBy key
);
slowForEach (slow phase)
- Each item may produce different template structure
- Conditionals, visibility, nested loops resolve per-item at slow time
- Each item gets its own compiled template
<!-- slowForEach: conditionals resolve differently per item -->
<div slowForEach="products" trackBy="_id">
<jay:product-card productId="{_id}">
<h3>{name}</h3>
<span class="price" if="{hasPrice}">{price}</span>
<!-- may/may not exist -->
<span class="badge" if="{isNew}">NEW</span>
<!-- may/may not exist -->
<div if="{hasVariants}" forEach="variants">
<!-- nested structure varies -->
<span>{variantName}</span>
</div>
</jay:product-card>
</div>
// slowForEach: Separate compiled template per item
// Item 0: has variants, has badge
function catalogItemRender_0(options?: RenderElementOptions) {
const [refManager, [refAddToCart]] = ReferencesManager.for(options, ['addToCart'], [], [], []);
const render = (viewState: ProductCardViewState) =>
ConstructContext.withRootContext(viewState, refManager, () =>
e('article', {}, [
e('h3', {}, [dt((vs) => vs.name)]),
e('span', { class: 'badge' }, ['NEW']), // Exists for this item
e('div', { class: 'variants' }, [/* ... */]), // Exists for this item
e('button', {}, ['Add to Cart'], refAddToCart()),
]),
);
return [refManager.getPublicAPI(), render] as const;
}
// Item 1: no variants, no badge - different structure!
function catalogItemRender_1(options?: RenderElementOptions) {
const [refManager, [refAddToCart]] = ReferencesManager.for(options, ['addToCart'], [], [], []);
const render = (viewState: ProductCardViewState) =>
ConstructContext.withRootContext(viewState, refManager, () =>
e('article', {}, [
e('h3', {}, [dt((vs) => vs.name)]),
// NO badge, NO variants - different structure!
e('button', {}, ['Add to Cart'], refAddToCart()),
]),
);
return [refManager.getPublicAPI(), render] as const;
}
// Each item is a separate component with its own template
const slowForEachItems = [
makeJayComponent(catalogItemRender_0, productCard.interactiveConstructor),
makeJayComponent(catalogItemRender_1, productCard.interactiveConstructor),
];
Compilation Strategy Summary
| Repeater Type | Template Extraction | Why |
|---|---|---|
forEach |
✅ One render function, reused | Same structure, different data |
slowForEach |
❌ Separate render per item | Conditionals resolve differently |
Implications:
- Bundle size:
forEach= one template;slowForEach= N templates - Build time:
forEachcompiles once;slowForEachcompiles per item - Interactive logic: Both use plugin's
interactiveConstructorfrommakeJayStackComponent
Proposed Contract Format with Props
# product-card.jay-contract
name: ProductCard
# Props the component accepts (configuration input)
props:
- name: productId
type: string
required: true
description: 'The product ID to display'
- name: showPrice
type: boolean
required: false
default: true
description: 'Whether to show price'
- name: variant
type: enum
values: [compact, full, featured]
default: compact
# Output data provided by the component
tags:
- tag: name
type: data
dataType: string
phase: slow
- tag: price
type: data
dataType: number
phase: fast
Proposed Usage Syntax
<html>
<head>
<!-- Import the component (makes it available) -->
<script type="application/jay-headless" plugin="wix-stores" contract="product-card"></script>
<!-- Import a list for the repeater -->
<script
type="application/jay-headless"
plugin="wix-stores"
contract="product-list"
key="allProducts"
></script>
</head>
<body>
<!-- Static props - multiple instances -->
<section class="featured">
<jay:product-card productId="prod-hero" variant="featured">
<h1 class="hero-title">{name}</h1>
</jay:product-card>
</section>
<!-- Dynamic props from repeater -->
<section class="catalog">
<div class="grid" forEach="allProducts.items" trackBy="_id">
<jay:product-card productId="{_id}" variant="compact">
<article class="card">
<h2>{name}</h2>
<span class="price">{price}</span>
</article>
</jay:product-card>
</div>
</section>
</body>
</html>
Proposed Data Discovery for Agents
# plugin.yaml
name: wix-stores
module: '@wix/stores'
contracts:
- name: product-card
contract: ./contracts/product-card.jay-contract
component: ./components/product-card
# NEW: Data sources that agents can discover
discoverable_data:
- name: product-ids
description: 'Available product IDs for product-card'
generator: ./data/product-ids-generator.ts
for_props:
- contract: product-card
prop: productId
// ./data/product-ids-generator.ts
import { makeDataGenerator } from '@jay-framework/fullstack-component';
import { PRODUCTS_SERVICE } from '../services';
export const generator = makeDataGenerator()
.withServices(PRODUCTS_SERVICE)
.generateWith(async (productsService) => {
const products = await productsService.getAllProducts();
return products.map((p) => ({
value: p._id,
label: p.name, // Human-readable label
preview: p.imageUrl, // Optional preview for agent/IDE
}));
});
Materialized output:
# build/discoverable-data/wix-stores/product-ids.yaml
generator: product-ids
for_contract: product-card
for_prop: productId
generated_at: '2026-02-04T10:00:00Z'
values:
- value: 'prod-123'
label: 'Classic Blue T-Shirt'
preview: 'https://...'
- value: 'prod-456'
label: 'Red Running Shoes'
preview: 'https://...'
Implementation Plan
Phase 0: Migrate Headful Components to jay: Prefix (Prerequisite) ✅
- ✅ Update compiler to recognize
<jay:component-name>for existing headful components - ✅ Migrate all existing templates from
<counter>to<jay:counter>style - ✅ Update all test fixtures and snapshots
- Deprecate plain element name support (warning phase) - deferred, both syntaxes work
- Remove plain element name support (cleanup phase) - deferred
Phase 1: Contract Props Definition ✅
- ✅ Extend
.jay-contractformat to supportpropssection - ✅ Update contract parser to extract props schema
- ✅ Generate TypeScript types for component props (including
ExtractProps<A>)
Phase 1b: Contract Params + Agent Kit ✅ (via Design Log #85)
- ✅ Contract params format:
params: { slug: string }→export interface XxxParams extends UrlParams { ... } - ✅
jay-stack agent-kitCLI command (materializes contracts toagent-kit/materialized-contracts/) - ✅ Plugins-index.yaml generation
- ✅ INSTRUCTIONS.md template auto-creation
jay-stack params <plugin>/<contract>CLI to run loadParams generator - deferred
Phase 2: Component Instance Syntax ✅
- ✅ Implement
<jay:component-name>element syntax for headless components - ✅ Implement prop passing to component instances
- ✅ Parse inline template content within component tags
- ✅ Integrate with existing
forEachbinding
Phase 3: Repeater Integration ✅
- ✅ Allow headless components inside
forEachblocks - ✅ Bind props from repeater context
- ✅ Generate component instances per forEach item
Phase 4: Nested Component Phase Orchestration (Server)
Slow phase orchestration:
- Page's
slowlyRendercalls each nested component'sslowlyRender - Props resolved from template (static or bound)
- Component's slow ViewState transforms inline template
- Component's carryForward stored for fast phase
- Page's
Fast phase orchestration:
- Page's
fastRendercalls each nested component'sfastRender - Pass component's carryForward from slow phase
- Component's fast ViewState transforms inline template bindings
- Page's
CarryForward tracking:
- Page carryForward includes nested component carryForwards
- Each component instance identified by props/position
- ForEach items tracked by trackBy key
Phase 4b: Interactive Phase (Client)
Compilation (from Phases 2 & 3):
- Compile inline templates to render functions
- Generate
makeJayComponentcalls combining:- Inline template render function
- Plugin's
interactiveConstructor
- Wire up refs from inline template
- Handle forEach item component generation
Client script generation:
- Generate client-side hydration code
- Include component instances with their compiled templates
- Wire up plugin's interactive logic
- Connect signals and reactive bindings
Phase 5: Data Discovery for Agents ✅
Revised to align with Design Logs #85 and #86 — agents discover data via existing CLI commands, not a separate discovery mechanism:
- ✅
jay-stack action <plugin>/<action>— CLI command to run plugin actions (agents use this to discover prop values, e.g.,jay-stack action product-widget/listProducts) - ✅
jay-stack params <plugin>/<contract>— CLI command to discover load param values (runsloadParamsgenerator) - ✅ Plugins expose discovery via
actionsin plugin.yaml (existing infrastructure, no new schema)
Removed (inconsistent with #85/#86): discoverable_data in plugin.yaml, makeDataGenerator, jay-stack discover command, discoverData() in stack-server-runtime. These were replaced by the action-based approach above.
Trade-offs
Advantages
- Flexible widget usage - Same component, different data
- Repeater compatible - Natural integration with
forEach - Agent-friendly - Discoverable prop values enable AI generation
- Type-safe - Props validated against contract schema
Disadvantages
- Complexity - More concepts to learn (props, instances, discovery)
- Multiple rendering - Performance considerations for many instances
- Contract changes - Need to update existing contracts
Alternatives Considered
- No props, only page-level data - Rejected: too limiting
- React-style components - Rejected: different mental model
- No discovery, agent figures it out - Rejected: poor DX
Verification Criteria
Phase 0: jay: prefix migration
- Headful components compile with
<jay:component-name>syntax - All existing tests updated and passing with new syntax
- Deprecation warning for old plain element names (deferred - both syntaxes supported)
Headless component props and instances 4. [x] Can render same headless component multiple times with different props (Phase 2 syntax + Phase 4 runtime + fake-shop demo) 5. [x] Can use headless component inside forEach with bound props (Phase 3) 6. [ ] Props are validated at compile time against contract schema (not implemented — needs compiler changes) 7. [x] Agents can discover valid prop values via actions/CLI (Phase 5 — jay-stack action <plugin>/<action> + jay-stack params <plugin>/<contract>) 8. [x] Static props work correctly (Phase 2 - productId="prod-hero") 9. [x] Dynamic props work correctly (productId={_id} from forEach context — Phase 3) 10. [x] Rendering phases (slow/fast/interactive) work with instances (Phase 4 — slow/fast server done, client wiring Phase 4b done) 11. [x] slowForEach generates separate template per item (Phase 3 — each item gets its own _HeadlessProductCard{N} component) 12. [x] forEach reuses single template for all items (Phase 3 — component defined once at module level)
Load params discovery 12. [ ] jay-stack params <plugin>/<contract> CLI command works 13. [ ] CLI runs loadParams generator and returns valid combinations 14. [ ] Agents can discover valid URL params for SSG
Nested component rendering 15. [x] ViewStates are isolated per component (not merged) — Phase 2 compilation uses component's ViewState 16. [x] CarryForward tracked per component instance across phases (Phase 4 — server passes per-instance carryForward via InstancePhaseData, client delivers via makeHeadlessInstanceComponent) 17. [x] Inline templates transformed with component's ViewState — Phase 2 compilation confirmed
Open Questions (Answered)
How does caching work with parameterized components?
Answer: Caching is done at the page level. Nested component slow rendering is included in the page cache. The page is cached as a whole, with all nested component renders included.
How do we handle props that change at different phases?
Answer:
- Slow props are rendered as hardcoded values in slow phase (baked into HTML)
- Fast/interactive props are rendered as prop bindings that support dynamic changes
- See runtime and compiler jay-html tests for examples of this pattern
Should there be limits on instance count?
Answer: No limits needed at this point. Optimizations can be considered later. In general, having 100 instances of pre-rendered templates is more efficient compared to one dynamic template (pre-rendered = less runtime work).
How does this interact with linked contracts (#79)?
Answer: Props are data values, not contracts. They can be represented as contracts, but in this context we consider the contract as representing data types. Props don't reference other contracts as dependencies.
Implementation Results
Phase 0: jay: prefix migration (Completed)
Date: February 4, 2026
Successfully migrated headful components to use jay: prefix syntax.
Changes Made
Compiler changes:
Added helper functions in
jay-html-helpers.ts:JAY_COMPONENT_PREFIX = 'jay:'hasJayPrefix(tagName)- checks for prefixextractComponentName(tagName)- strips prefixgetComponentName(tagName, importedSymbols)- detects components (both new and legacy syntax)
Updated
jay-html-compiler.ts:- Modified
renderHtmlElementto usegetComponentNamefor component detection - Updated
renderNestedComponentto acceptcomponentNameparameter - Updated
renderChildCompRefto acceptcomponentNamefor correct type generation - Updated sandbox/bridge code paths similarly
- Modified
Updated
jay-html-compiler-react.ts:- Same pattern as main compiler
Updated
tag-to-namespace.ts:- Fixed crash when colon-separated tag is not a known namespace (like
jay:prefix)
- Fixed crash when colon-separated tag is not a known namespace (like
Test fixture updates:
- Updated 37+
.jay-htmlfiles across compiler tests and examples - All component usages changed from
<Counter>to<jay:Counter>style
Verification
- 478/478 tests pass in compiler-jay-html
- 252/252 tests pass in compiler package
- All workspace tests pass
Backward Compatibility
Both syntaxes are currently supported:
- New:
<jay:Counter initialValue={count}/> - Legacy:
<Counter initialValue={count}/>(deprecated)
Legacy syntax still works to allow gradual migration of external projects.
Files Modified
packages/compiler/compiler-jay-html/lib/jay-target/jay-html-helpers.ts
packages/compiler/compiler-jay-html/lib/jay-target/jay-html-compiler.ts
packages/compiler/compiler-jay-html/lib/jay-target/tag-to-namespace.ts
packages/compiler/compiler-jay-html/lib/react-target/jay-html-compiler-react.ts
+ 37 .jay-html test fixtures
Phase 1: Contract Props Definition - Implementation Results
Date: February 4, 2026
Successfully extended the contract format to support a props section.
Changes Made
1. Contract type (contract.ts):
- Added
ContractPropinterface:{ name, dataType, required?, description?, default? } - Extended
Contractinterface with optionalprops: Array<ContractProp>
2. Contract parser (contract-parser.ts):
- Added
ParsedYamlPropinterface for YAML parsing - Added
parseProp()function supporting: string, number, boolean, date, enum types - Updated
parseContract()to parsepropssection, validate duplicates - Props default to
type: stringwhen type is omitted
3. Contract compiler (contract-compiler.ts):
- Added
generatePropsInterface()function - Generates
export interface XxxProps { ... }with required/optional markers - Supports enum props (generates enum types)
- Contracts WITH props get 6th
JayContracttype parameter - Contracts WITHOUT props remain 5-parameter (backward compatible)
4. Runtime type (element-types.ts):
- Extended
JayContractwith 6th generic param:Props extends object = {} - Added
ExtractProps<A>helper type - Updated all
Extract*helpers to account for 6th parameter - Default
{}ensures backward compatibility
YAML Format
name: ProductCard
props:
- name: productId
type: string
required: true
description: The ID of the product to display
- name: variant
type: string
tags:
- tag: name
...
Generated Output (with props)
export interface ProductCardProps {
productId: string;
variant?: string;
}
export type ProductCardContract = JayContract<
ProductCardViewState,
ProductCardRefs,
ProductCardSlowViewState,
ProductCardFastViewState,
ProductCardInteractiveViewState,
ProductCardProps
>;
Verification
- 490/490 tests pass in compiler-jay-html (36 parser + 26 compiler + rest)
- 252/252 tests pass in compiler package
- 190/190 tests pass in runtime package
Files Modified
packages/compiler/compiler-jay-html/lib/contract/contract.ts
packages/compiler/compiler-jay-html/lib/contract/contract-parser.ts
packages/compiler/compiler-jay-html/lib/contract/contract-compiler.ts
packages/runtime/runtime/lib/element-types.ts
packages/compiler/compiler-jay-html/test/contract/contract-parser.test.ts
packages/compiler/compiler-jay-html/test/contract/contract-compiler.test.ts
Phase 2: Component Instance Syntax — Implementation Results
Date: February 4, 2026
Successfully implemented <jay:contract-name> syntax for headless component instances with inline templates.
What Works
A page can now use headless component instances like:
<jay:product-card productId="prod-hero">
<article class="hero-card">
<h2>{name}</h2>
<span class="price">{price}</span>
<button ref="addToCart">Add to Cart</button>
</article>
</jay:product-card>
The compiler:
- Detects
<jay:contract-name>matching headless import contract names - Compiles inline children against the component's ViewState (not the page's)
- Generates a render function +
makeJayComponentcall at module level - Generates
childCompin the page render function with props from the page ViewState
Changes Made
1. JayHeadlessImports (jay-html-source-file.ts):
keyis now optional (no key = instance-only headless component)- Added
contractName: stringfield (stores the contract attribute value from the script tag)
2. Parser (jay-html-parser.ts):
keyattribute no longer required in<script type="application/jay-headless">- Stores
contractNamein headless imports - Filters page-level behavior (ViewState merging, trackBy extraction) to key-bearing imports only
3. Component detection (jay-html-helpers.ts):
getComponentNamereturnsComponentMatch { name, kind }instead ofstring | null- Three kinds:
'headful','headless-instance','unknown' - Accepts optional
headlessContractNamesset for matchingjay:xxxagainst known contracts
4. RenderContext (jay-html-compiler.ts):
- Added
headlessContractNames: Set<string> - Added
headlessImports: JayHeadlessImports[] - Added
headlessInstanceDefs: HeadlessInstanceDefinition[](accumulator) - Added
headlessInstanceCounter: { count: number }(shared counter for unique naming)
5. renderHeadlessInstance (jay-html-compiler.ts):
- Finds matching headless import by
contractName - Creates
Variableswith component's ViewState type - Compiles inline children using
renderNodewith the component's context - Generates
_headlessProductCard0Renderfunction and_HeadlessProductCard0component symbol - Pushes definition to
headlessInstanceDefsaccumulator - Returns
childComp(_HeadlessProductCard0, propsMapper)fragment
6. Module-level code emission (renderFunctionImplementation):
- Accumulated
headlessInstanceDefsare emitted before the page render function - Imports from inline templates are merged into the file's imports
7. Import registry (compiler-shared/imports.ts):
- Added
Import.makeJayComponentfrom@jay-framework/component
8. Dev server (load-page-parts.ts):
- Instance-only headless imports (no key) are skipped when creating page parts
- Page-level headless imports continue to work as before
Compiled Output Example
Source: <jay:product-card productId="prod-hero"> with inline template
// Module-level: inline template compiled against ProductCardViewState
function _headlessProductCard0Render(options) {
const render = (viewState) =>
ConstructContext.withRootContext(viewState, undefined, () =>
e('article', { class: 'hero-card' }, [
e('h2', {}, [dt((vs) => vs.name)]),
e('span', { class: 'price' }, [dt((vs) => vs.price)]),
e('button', {}, ['Add to Cart'], refAddToCart()),
]),
);
return [undefined, render];
}
const _HeadlessProductCard0 = makeJayComponent(
_headlessProductCard0Render,
productCard.interactiveConstructor,
);
// In page render function:
childComp(_HeadlessProductCard0, (vs: PageViewState) => ({
productId: 'prod-hero',
}));
Design Note: Coordinates for Phase Matching
CarryForward tracking across phases (slow→fast, fast→interactive) can use element coordinates — the same mechanism the secure package uses to match ViewState from the secure context to elements. Coordinates identify each component instance by its position in the tree, avoiding the need for explicit tracking maps. This applies to:
- Slow→fast: match each nested component's carryForward by coordinate
- Fast→interactive: match carryForward to client-side component instances by coordinate
Known Limitations (to address in later phases)
Render function not fully typed— Resolved: Render function now has proper type aliases,RenderElementOptionsparameter, return type, andascastsRefs inside inline template— Resolved: Refs are now wired to a properReferencesManagerwith contract tag names and typed return- No nested headless instances — Headless instances inside headless instances are disabled for now
- Sandbox mode — Headless instances silently produce empty output in sandbox mode
Verification
- 496/500 tests pass (4 skipped are pre-existing)
- 1 new test for headless instance compilation with fixture comparison
- All existing headless component tests unchanged
Files Modified
packages/compiler/compiler-shared/lib/imports.ts
packages/compiler/compiler-jay-html/lib/jay-target/jay-html-source-file.ts
packages/compiler/compiler-jay-html/lib/jay-target/jay-html-parser.ts
packages/compiler/compiler-jay-html/lib/jay-target/jay-html-helpers.ts
packages/compiler/compiler-jay-html/lib/jay-target/jay-html-compiler.ts
packages/compiler/compiler-jay-html/lib/jay-target/jay-html-compile-refs.ts
packages/compiler/compiler-jay-html/lib/react-target/jay-html-compiler-react.ts
packages/jay-stack/stack-server-runtime/lib/load-page-parts.ts
packages/compiler/compiler-jay-html/test/test-utils/test-resolver.ts
packages/compiler/compiler-jay-html/test/jay-target/generate-element.test.ts
+ test fixtures: product-card contract, page-with-headless-instance
Phase 2 Follow-up: Inline Template Typing and Refs
Date: February 4, 2026
Addressed Phase 2's known limitations #1 and #2.
Changes Made
renderHeadlessInstance (jay-html-compiler.ts):
- Generates type aliases:
_HeadlessProductCard0Element,_HeadlessProductCard0ElementRender,_HeadlessProductCard0ElementPreRenderusingProductCardInteractiveViewStateandProductCardRefs - Generates proper
ReferencesManager.for()from inline template refs usingrenderReferenceManager() - Builds
importedRefNameToReffrom contract's refs tree so templateref="addToCart"maps to contract tag name'add to cart' - Uses
originalNamefor ref field soReferencesManager.for()uses original tag names - Typed render function with
options?: RenderElementOptionsparameter, return type,ascasts, andrefManager.getPublicAPI() as ProductCardRefs InteractiveViewStatedynamically added to contract link names andusedComponentImportsonly when headless instances exist
Verification
- 498/502 tests pass (4 skipped are pre-existing)
Phase 3: Repeater Integration — Implementation Results
Date: February 4, 2026
Phase 3 required no new compiler changes — the Phase 2 implementation already handled forEach integration correctly.
Why It Works
The renderHeadlessInstance function:
- Component definition at module level —
_HeadlessProductCard0is defined once, reused for all forEach items childCompplaced in current position — When inside aforEach, thechildCompcall naturally appears inside theforEachcallback- Props resolved from current context —
renderChildCompPropsusescontext.variables, which inside aforEachpoints to the forEach item's ViewState, soproductId="{_id}"correctly resolves_idfrom the item
Test Added
New fixture: page-with-headless-in-foreach — a page with <jay:product-card productId="{_id}"> inside a forEach="products" trackBy="_id" block.
Compiled output confirms:
- Module-level component definition (one
makeJayComponentcall, not per item) childCompinsideforEachcallback- Props bound from forEach item:
(vs1: ProductOfPageWithHeadlessInForeachViewState) => ({ productId: vs1._id })
Verification
- 498/502 tests pass (4 skipped are pre-existing)
- 2 new tests: headless instance + headless instance inside forEach
slowForEach test added: page-with-headless-in-slow-foreach — two pre-unrolled items with different inline templates (hero card with button ref vs compact card without). Confirms:
- Each item gets its own module-level component (
_HeadlessProductCard0,_HeadlessProductCard1) - Different refs per item (item 0 has
refAddToCart, item 1 has none) - Each wrapped in
slowForEachItemwith correct index and trackBy key
Files Added
test/fixtures/contracts/page-with-headless-in-foreach/page-with-headless-in-foreach.jay-html
test/fixtures/contracts/page-with-headless-in-foreach/page-with-headless-in-foreach.jay-html.ts
test/fixtures/contracts/page-with-headless-in-slow-foreach/page-with-headless-in-slow-foreach.jay-html
test/fixtures/contracts/page-with-headless-in-slow-foreach/page-with-headless-in-slow-foreach.jay-html.ts
Phase 4: Slow Phase Orchestration for Headless Instances — Implementation Results
Date: February 4, 2026
Implemented server-side slow render orchestration for <jay:xxx> headless component instances using a two-pass pipeline.
Design: Two-Pass Slow Render Pipeline
The challenge: <jay:product-card> instances may have dynamic props (from forEach or page bindings) that need resolving before calling slowlyRender. Also, slowlyRender is async but the slow render transform is synchronous.
Solution — Two-pass approach:
Pass 1: slowRenderTransform()
→ Resolves page-level slow bindings
→ Unrolls slow forEach (dynamic props become concrete)
→ Preserves <jay:xxx> elements and their inline template bindings
↓ Between passes (async, in dev server):
discoverHeadlessInstances(pass1Output)
→ Finds <jay:xxx> elements with concrete props
→ Skips instances inside preserved forEach (fast phase)
→ Skips instances with unresolved prop bindings
For each discovered instance:
→ component.slowlyRender(props) → { viewState, carryForward }
↓
Pass 2: resolveHeadlessInstances(pass1Output, instanceData)
→ Resolves slow bindings inside inline templates using component ViewState
→ Preserves fast/interactive bindings
→ Preserves <jay:xxx> element structure
New Functions
discoverHeadlessInstances(jayHtml) — Finds <jay:xxx> elements after Pass 1:
- Extracts contract name from tag (e.g.,
jay:product-card→product-card) - Extracts props from attributes (camelCased, string values)
- Skips instances inside preserved
forEach(fast phase — props still dynamic) - Skips instances with unresolved bindings in props
resolveHeadlessInstances(jayHtml, instanceData) — Applies instance ViewState (Pass 2):
- Matches instance data to elements in document order
- Builds per-instance phase map from component contract
- Calls existing
transformChildrento resolve slow bindings - Preserves fast/interactive bindings
Dev Server Changes
load-page-parts.ts:
- New
HeadlessInstanceComponentinterface:{ contractName, compDefinition, contract } LoadedPagePartsnow includesheadlessInstanceComponentsfor instance-only imports (no key)- These are populated alongside key-based parts during headless import processing
dev-server.ts (preRenderJayHtml):
- Now returns
PreRenderResultwithpreRenderedJayHtmlandinstanceCarryForwards - After Pass 1, calls
discoverHeadlessInstancesto find instances - For each discovered instance, calls
slowlyRender(props, ...services)via component definition - Runs Pass 2 with
resolveHeadlessInstancesto resolve instance bindings - Instance carryForwards stored under
__instanceskey in page carryForward
Bug Found: getAttribute returns undefined not null
In node-html-parser, getAttribute('forEach') returns undefined (not null) when the attribute doesn't exist. Using !== null incorrectly treated ALL elements as having a forEach attribute. Fixed by using != null (loose equality) which matches both null and undefined.
Tests Added
12 new tests in slow-render-transform.test.ts:
discoverHeadlessInstances (5 tests):
- Discover instances with static props
- Skip instances inside preserved forEach
- Skip instances with unresolved prop bindings
- Discover instances after slow forEach unrolling
- CamelCase prop names
resolveHeadlessInstances (7 tests):
- Resolve slow bindings inside headless instances
- Preserve fast/interactive bindings
- Resolve multiple instances in document order
- Resolve instances after slow forEach unrolling
- Skip instances inside preserved forEach
- Full two-pass pipeline: page bindings + instance bindings
- Full two-pass pipeline with slow forEach unrolling
Verification
- 511/515 compiler tests pass (4 skipped pre-existing)
- 66/66 server runtime tests pass
- 12 new tests cover discovery, resolution, and end-to-end pipeline
Remaining for Phase 4 (slow phase)
-
Fast phase orchestration for instances— Done (see below) - Client-side instance ViewState delivery (fast→interactive) — Phase 4b done
- [~] End-to-end dev server test with a real headless component plugin — fake-shop example added as demo (not a formal automated test)
Files Modified
packages/compiler/compiler-jay-html/lib/slow-render/slow-render-transform.ts (new functions)
packages/compiler/compiler-jay-html/lib/index.ts (new exports)
packages/compiler/compiler-jay-html/test/slow-render/slow-render-transform.test.ts (12 new tests)
packages/jay-stack/stack-server-runtime/lib/load-page-parts.ts (HeadlessInstanceComponent, new field)
packages/jay-stack/stack-server-runtime/lib/index.ts (new exports)
packages/jay-stack/dev-server/lib/dev-server.ts (two-pass pipeline in preRenderJayHtml)
Phase 4 (continued): Fast Phase Orchestration for Headless Instances
Date: February 4, 2026
Added server-side fast render orchestration for <jay:xxx> headless component instances.
Design: Instance Phase Data Flow
The slow phase stores discovery info alongside carryForwards so the fast phase knows what instances exist:
interface InstancePhaseData {
discovered: Array<{ contractName: string; props: Record<string, string> }>;
carryForwards: Record<string, object>; // keyed by "contractName:index"
}
Stored in carryForward.__instances — persisted to cache so both pre-render and cached flows can use it.
Fast Render Flow
carryForward.__instances (from slow phase / cache)
↓
renderFastChangingDataForInstances()
→ For each discovered instance:
→ Find component definition by contractName
→ Call fastRender(props, instanceCarryForward, ...services)
→ Returns: { viewStates, carryForwards }
↓
fastViewState.__headlessInstances = instance fast ViewStates
fastCarryForward.__headlessInstances = instance fast carryForwards
↓
Embedded in HTML via generateClientScript()
Both handlePreRenderRequest and handleCachedRequest follow the same pattern:
- Run
renderFastChangingDatafor key-based parts (existing) - Extract
__instancesfrom carryForward - Call
renderFastChangingDataForInstancesfor each discovered instance - Merge instance fast ViewStates into
fastViewState.__headlessInstances
Changes
dev-server.ts:
PreRenderResultnow usesInstancePhaseData(discovery info + carryForwards)- New
renderFastChangingDataForInstances()function - Both
handlePreRenderRequestandhandleCachedRequestrun instance fast render - Instance fast ViewStates embedded in
fastViewState.__headlessInstances - Instance fast carryForwards stored in
fastCarryForward.__headlessInstances(for Phase 4b)
Verification
- 66/66 server runtime tests pass
- No regression in existing behavior
Remaining for Phase 4b (Client-Side Interactive)
-
makeCompositeJayComponentextracts__headlessInstancesfrom ViewState -
childCompormakeJayComponentreceives instance fast ViewState via context - Coordinate-based matching of instances to their ViewState on the client
Phase 4b: Client-Side Interactive — Implementation Results
Date: February 4, 2026
Architecture
The client-side delivery of server-produced ViewState to headless component instances uses the runtime context system:
makeCompositeJayComponent
├─ Extracts __headlessInstances from ViewState/carryForward
├─ Registers HEADLESS_INSTANCES context (Map<coordKey, data>)
└─ During render, childComp creates headless instance components
└─ makeHeadlessInstanceComponent resolves HEADLESS_INSTANCES context
└─ Looks up this instance's data by coordinate key
└─ Creates signals from fast ViewState
└─ Injects (signalVS, carryForward) into plugin's interactive constructor
New Files
stack-client-runtime/lib/headless-instance-context.ts:
HEADLESS_INSTANCEScontext marker — registered bymakeCompositeJayComponent, consumed during instance constructionHeadlessInstancesDatainterface —{ viewStates: Record<string, object>, carryForwards: Record<string, object> }keyed by coordinatemakeHeadlessInstanceComponent(preRender, interactiveConstructor, coordinateKey, pluginContexts?)— wraps the plugin's interactive constructor to inject instance-specific fast ViewState and carryForward from the context. UsesHEADLESS_INSTANCESas the first context marker before any plugin-defined markers.
Modified Files
stack-client-runtime/lib/composite-component.ts:
- Extracts
defaultViewState.__headlessInstancesandfastCarryForward.__headlessInstances - Deletes them from the main data to avoid polluting key-based part lookups
- Pushes
[HEADLESS_INSTANCES, data]tocomponentContext.provideContextsin thecompcallback - This makes instance data available during rendering via the context stack
compiler-shared/lib/constants.ts + imports.ts:
- Added
JAY_STACK_CLIENT_RUNTIMEconstant - Added
Import.makeHeadlessInstanceComponentimport definition
compiler-jay-html/lib/jay-target/jay-html-compiler.ts:
renderHeadlessInstance()now computes coordinate key viabuildInstanceCoordinateKey()- Generates
makeHeadlessInstanceComponent(render, plugin.comp, coordinateKey, plugin.contexts)instead ofmakeJayComponent(render, plugin.interactiveConstructor) - Uses
plugin.comp(the raw constructor) instead ofplugin.interactiveConstructorsince the wrapping is now done bymakeHeadlessInstanceComponent
compiler-jay-html/lib/slow-render/slow-render-transform.ts:
- Exported
buildCoordinatePrefix(),localIndexAmongSiblings()for reuse - Added
buildInstanceCoordinateKey(element, contractName)convenience function
Compiled Output Change
Before:
import { makeJayComponent } from '@jay-framework/component';
const _HeadlessProductCard0 = makeJayComponent(
_headlessProductCard0Render,
productCard.interactiveConstructor,
);
After:
import { makeHeadlessInstanceComponent } from '@jay-framework/stack-client-runtime';
const _HeadlessProductCard0 = makeHeadlessInstanceComponent(
_headlessProductCard0Render,
productCard.comp,
'product-card:0',
productCard.contexts,
);
For slowForEach instances, coordinate includes ancestor trackBy IDs:
const _HeadlessProductCard0 = makeHeadlessInstanceComponent(
_headlessProductCard0Render,
productCard.comp,
'p1/product-card:0',
productCard.contexts,
);
const _HeadlessProductCard1 = makeHeadlessInstanceComponent(
_headlessProductCard1Render,
productCard.comp,
'p2/product-card:0',
productCard.contexts,
);
Verification
- compiler-jay-html: 511 passed, 4 skipped
- dev-server: 13 passed
- stack-server-runtime: 66 passed
- stack-client-runtime: typechecks cleanly
Phase 5: Data Discovery for Agents — Implementation Results
Date: February 4, 2026
Revised: Aligned with Design Logs #85 and #86. Discovery uses existing action + params CLI commands, not a separate mechanism.
Architecture
Agents discover data through two CLI commands that leverage existing infrastructure:
jay-stack action <plugin>/<action> → runs plugin action, returns JSON/YAML
└─ Uses: action registry, service injection, plugin.yaml actions[]
jay-stack params <plugin>/<contract> → runs loadParams generator, returns param combinations
└─ Uses: plugin resolution, component loadParams, service injection
Discovery flow (per Design Log #85):
- Agent reads
agent-kit/INSTRUCTIONS.md - Runs
jay-stack agent-kitto materialize contracts - Runs
jay-stack params <plugin>/<contract>for load param values - Runs
jay-stack action <plugin>/<action>for prop values
Modified Files
stack-cli/lib/cli.ts:
- Added
jay-stack action <plugin>/<action>command — initializes services, discovers + registers actions, executes the specified action, outputs JSON or YAML - Added
jay-stack params <plugin>/<contract>command — resolves plugin component, runs loadParams generator, outputs param combinations
Removed (inconsistent with #85/#86)
→ agents usediscoverable_datain plugin.yamlactionsinstead→ plugins usemakeDataGeneratorbuilder APImakeJayQuery/makeJayActionfor data actions→ replaced byjay-stack discovercommandjay-stack action→ not neededdiscoverData()in stack-server-runtime→ not neededDiscoverableDataValue/DataGeneratortypes
Example: Plugin with action-based discovery
# plugin.yaml
name: product-widget
module: ./product-widget
contracts:
- name: product-widget
contract: ./product-widget.jay-contract
component: productWidget
description: Product widget card
actions:
- listProducts # Agent calls: jay-stack action product-widget/listProducts
// product-widget.ts
export const listProducts = makeJayQuery('productWidget.listProducts')
.withCaching({ maxAge: 300 })
.withServices(PRODUCTS_DATABASE_SERVICE)
.withHandler(async (input: {}, productsDb) => {
const products = await productsDb.getProducts();
return products.map((p) => ({
productId: p.id,
name: p.name,
price: p.price,
}));
});
Verification
- compiler-shared: builds cleanly (DiscoverableDataConfig removed)
- full-stack-component: typechecks cleanly (data generator types removed)
- stack-server-runtime: 66 passed (data-discovery.ts removed)
- stack-cli: typechecks cleanly (action + params commands added)
- compiler-jay-html: 511 passed, 4 skipped
- dev-server: 13 passed
Integration Testing — Fake-Shop End-to-End
Ran the fake-shop example end-to-end with jay-stack dev --test-mode and discovered + fixed several issues:
Bug Fixes
Missing
codeLinkimport in compiled output — Parser only addedcontractLinks(type imports) tojayFile.imports, not thecodeLink(runtime component import). Headless instance code referencedproductWidget.compandproductWidget.contextsbutproductWidgetwas never imported.- Fix:
jay-html-parser.ts— changedheadlessImports.flatMap((_) => _.contractLinks)toheadlessImports.flatMap((_) => [..._.contractLinks, _.codeLink])
- Fix:
refManagerpassed asundefinedin inline templates — Compiler template for headless instance render functions hardcodedundefinedas the second arg toConstructContext.withRootContextinstead ofrefManager.- Fix:
jay-html-compiler.ts— changedwithRootContext(viewState, undefined, ...)towithRootContext(viewState, refManager, ...)
- Fix:
HEADLESS_INSTANCEScontext caused WeakSet crash —makeHeadlessInstanceComponentpassedHEADLESS_INSTANCESas acontextMarkertomakeJayComponent. The runtime'senablePairing()tried to add the plain data object to aWeakSet<Reactive>, but it has no reactive symbol.- Fix:
headless-instance-context.ts— Instead of passingHEADLESS_INSTANCESas a contextMarker, the wrapped constructor reads it directly viauseContext(HEADLESS_INSTANCES). The parent (composite component) provides it viaprovideContexts, which already has a null-check for reactive symbols.
- Fix:
slowForEachnot recognized by slow render transform — Template usedslowForEach="featuredProducts"directly, butslowRenderTransformonly recognizesforEachand auto-detects slow-phase arrays from the contract.slowForEachis an output attribute of the transform, not an input.- Fix:
page.jay-html— changedslowForEach="featuredProducts" jayTrackBy="_id"toforEach="featuredProducts" trackBy="_id"
- Fix:
Fast render signature mismatch —
page.tsandproduct-widget.tsfast render functions had wrong argument order. Runtime callsfastRender(props, carryForward, ...services)but the functions declared(props: Props & CarryForward, ...services).- Fix:
page.tsandproduct-widget.ts— added explicitcarryForwardparameter betweenpropsand services
- Fix:
Dev Server Enhancement
- Client script saved to build folder —
sendResponseindev-server.tsnow writes the generated client HTML tobuild/client-scripts/<pageName>.htmlfor easier debugging of the compiled output.
Current Rendering State
- Mood Tracker (key-based headless): renders correctly with interactive buttons ✓
- Static product widgets: render with correct slow data and unique coordinates ✓
- slowForEach products: correctly expanded — "Gaming Laptop", "Smartphone Pro", "Wireless Headphones" ✓
- forEach products: renders product IDs (headless instances disallowed in fast forEach — compiler error)
Known Remaining Issues
forEach headless instances show "undefined" for slow-phase fields— Fixed: now a compiler error (see "Undefined Values Fix" section below)
Coordinate Collision Fix & Ref Support
Problem
localIndexAmongSiblings counted same-tag siblings within the immediate parent element. When <jay:product-widget> instances were each wrapped in their own <div class="product-card">, both got coordinate product-widget:0 — the second overwrote the first in __headlessInstances.
Fix: Scope-level counter + ref as coordinate
Replaced localIndexAmongSiblings with a DFS-order scope counter per (coordinatePrefix, contractName). Both compiler and slow-render use the same algorithm, producing matching coordinates.
Ref embedding approach:
discoverHeadlessInstancesauto-generates arefattribute for<jay:xxx>elements without one (using scope counter), embeds it in the HTML, and returns both instances + modified HTMLresolveHeadlessInstancesreads therefattribute directly — no counter needed since refs are already embedded- User-specified
ref="myWidget"is preserved as-is - Return type changed:
DiscoveredHeadlessInstance[]→HeadlessInstanceDiscoveryResult { instances, preRenderedJayHtml }
Compiler ref support for headless instances:
childCompcalls for headless instances now include an auto-generated ref (same as headful components)- Fixes the
TODO: proper refs for headless instances - Coordinate key uses explicit
refor auto-generated index:prefix/contractName:ref
codeLink import fix:
- Parser now only emits
codeLinkimport for headless contracts that have<jay:xxx>tags in the template - Key-based headless components (e.g.,
counter,namedCounter) no longer get unused imports
Files Changed
slow-render-transform.ts—discoverHeadlessInstancesreturnsHeadlessInstanceDiscoveryResult, embeds refs;resolveHeadlessInstancesreads refs;buildInstanceCoordinateKeyupdatedjay-html-compiler.ts— scope counter inRenderContext, ref-aware coordinate,renderChildCompReffor headlesschildCompjay-html-parser.ts— only includecodeLinkfor contracts used as<jay:xxx>instancesdev-server.ts— updated call sites for new discovery return type- Test fixtures — updated for new imports, ref in
childComp,refManagerin inline templates
Undefined Values Fix
Mood Tracker Data Tags
Problem: {mt.happy}, {mt.sad}, {mt.neutral} rendered as "undefined" in the browser.
Root cause: buildPhaseMap defaulted tags without explicit phase to the parent's phase ('slow'). The mood tracker's happy/sad/neutral tags have type: [data, interactive] but no phase annotation — they were incorrectly treated as slow-phase, causing resolveTextBindings to resolve them from the empty slow ViewState.
Fix: In buildPhaseMap's processTag, when a tag has no explicit phase and its type includes ContractTagType.interactive, the effective phase defaults to 'fast+interactive' instead of inheriting the parent's phase. This matches how the contract .d.ts generator already classifies these tags (they appear in FastViewState, not SlowViewState).
slow-render-transform.ts—processTagandcheckTagboth updated
Headless Instances in Fast-Phase forEach
Problem: <jay:product-widget> inside forEach="allProducts" (phase: fast) showed "undefined" for all slow-phase bindings ({name}, {price}, {sku}).
Root cause: Fast-phase forEach items are only known at request time. Headless instances inside them have unresolved prop bindings (e.g., productId="{_id}"), so:
discoverHeadlessInstancescorrectly skips them (unresolved bindings)- No
__headlessInstancesdata is produced for these items - The compiled code uses a static
coordinateKeyshared by all forEach iterations - The inline template bindings resolve to
undefined
Decision: For now, disallow headless instances inside fast-phase forEach as a compiler error. The coordinate system would need dynamic (runtime) coordinates, and the server pipeline would need to render instances after the fast phase completes.
Future approach: Have the component provide a client-side equivalent of the slow phase — essentially an automatic action triggered from the client when forEach items are rendered. This avoids the need for server-side rendering of dynamic instances.
Fix:
jay-html-compiler.ts— addedinsideFastForEach: booleantoRenderContext;renderHeadlessInstanceemits validation error wheninsideFastForEachis truegenerate-element.test.ts— updatedpage-with-headless-in-foreachtest to expect the validation errorfake-shop/page.jay-html— removed<jay:product-widget>from fast forEach section
Bug Fix: Multi-Child Inline Templates
Date: March 3, 2026
Problem
When a headless component instance has multiple sibling children in its inline template (no single root element), the compiled render function produces invalid JavaScript. The children were merged with commas inside the ConstructContext.withRootContext arrow callback, causing JavaScript's comma operator to return only the last expression.
Example template:
<jay:product-widget productId="3" ref="1">
<h3>Wireless Headphones</h3>
<div>Price: $149.99</div>
<div>SKU: HDP-003</div>
<span if="inStock">In Stock</span>
<span if="!inStock">Out of Stock</span>
<button ref="addToCart">Add to Cart</button>
</jay:product-widget>
Broken compiled output:
const render = (viewState) =>
ConstructContext.withRootContext(
viewState,
refManager,
() => e('h3', {}, ['Wireless Headphones']), // comma operator — only last value returned
e('div', {}, ['Price: $149.99']),
// ...
);
All existing test fixtures used single-root inline templates (e.g., <article>...</article>), so this was not caught.
Fix
When the inline template has multiple children, wrap them in a de('div', {}, [...]) (dynamic element). Uses de (not e) because children may include conditionals (c(vs => vs.inStock, ...)), which require a dynamic element parent for proper update tracking.
Fixed compiled output:
const render = (viewState) =>
ConstructContext.withRootContext(viewState, refManager, () =>
de('div', {}, [
e('h3', {}, ['Wireless Headphones']),
e('div', {}, ['Price: $149.99']),
// ...all children rendered
]),
);
Single-child inline templates are unaffected — no wrapping div is added.
Files Changed
jay-html-compiler.ts—renderHeadlessInstance: wrap multi-childinlineBodyinde('div', {}, [...])generate-element.test.ts— new test: "headless component instance with multiple children"- New fixture:
test/fixtures/contracts/page-with-headless-multi-child/
Verification
- compiler-jay-html: 562 passed, 4 skipped (566 total)
- fake-shop: 6 passed
Bug Fix: Union Component Refs Misclassified as HTML Element Refs
Date: March 3, 2026
Problem
When multiple headless component instances inside a slowForEach share the same auto-ref name (e.g., 0), the deduplication step in optimizeRefs merges their element types into a JayUnionType. The isComponentRef function only checked for JayComponentType and JayTypeAlias — not JayUnionType — so the merged ref was misclassified as an HTML element ref.
This caused two issues in the generated .jay-html.ts:
ReferencesManager.for()placed the ref in the 2nd array (element collection refs) instead of the 4th array (component collection refs)- The type was generated as
HTMLElementCollectionProxy<VS, Widget0 | Widget1 | Widget2>instead ofWidget0Refs<VS> | Widget1Refs<VS> | Widget2Refs<VS>
Fix
jay-html-compile-refs.ts:
- Extracted
isComponentTypehelper that recursively handlesJayUnionType— a union is a component type if all its members are component types isComponentRefnow delegates toisComponentTyperenderRefsType: when generating types for component collection refs with union element types, registers each member component individually and generates a union of theirRefstypes- Same handling added for non-collection component refs with union types
jay-html-compile-refs.test.ts:
- New unit test: "should render component collection refs with union element type"
New fixture: test/fixtures/contracts/page-with-headless-mixed/:
- Covers all three headless instance placements: direct child, conditional (
if), andslowForEachwith shared ref name - The
slowForEachcase exercises the union type path: two instances withref="0"get deduplicated into one ref withJayUnionType, correctly classified as component collection ref
generate-element.test.ts:
- New test: "generate element file with headless component instances mixed: child, conditional, slowForEach"
headless-instance-context.ts (stack-client-runtime):
makeHeadlessInstanceComponentnow has an explicit return type matchingmakeJayComponent's. Previously theas anycast leaked to callers. Also removed theas anycast on themakeJayComponentcall itself.
ComponentCollectionProxy type parameter — kept as Ref alias:
ComponentCollectionProxy<ParentVS, ComponentRef<ParentVS>>is correct (notReturnType<typeof Component>). TheRefalias appliesMapEventEmitterViewStatewhich remaps event handler ViewState types to the parent's ViewState — needed for correctmap/findsignatures on the collection proxy.
Use contract types directly for headless instance refs:
- Headless instance refs now use the contract's
Refs/RepeatedRefstypes directly instead of generating per-instance wrapper types (MapEventEmitterViewState,ComponentCollectionProxy,OnlyEventEmitters). This matches the pattern used by key-based headless components (e.g.,page-using-named-counter). jay-html-compiler.ts—renderHeadlessInstancecreates refs withJayTypeAlias(contractRefType)instead ofJayComponentType(componentSymbol). UsesProductCardRefsfor single refs andProductCardRepeatedRefsfor collection refs.jay-html-compile-refs.ts—renderRefsTypehandlesJayTypeAliasrefs by using the type name directly (no wrapper types generated, no entry incomponentRefsmap).- This eliminates the union type problem entirely: multiple instances of the same contract produce the same
JayTypeAliasname, so deduplication correctly merges them without creating a union.
Verification
- compiler-jay-html: 564 passed, 4 skipped (568 total)
- stack-client-runtime: 19 passed, builds cleanly with correct types
Bug Fix — Static UUID Props Fail to Parse After Slow Rendering (Resolved)
Problem: When a headless component prop is bound to a slow-phase value like productId="{productId}", slow rendering bakes the value into the jay-html as productId="42941ee7-1707-4b5d-a7d7-41e12da6ab9e". The subsequent compilation pass tries to parse this static value via parseComponentPropExpression, which uses the dynamicComponentProp PEG rule. That rule tries integer first (line 308), which greedily matches the leading digits 42941, then the parser expects end-of-input but finds ee7..., producing: Parse error: Expected end of input but "e" found.
Root cause: PEG.js uses ordered choice (/). The integer rule matched the leading digits, and since it succeeded, the parser never tried the template alternative which would have handled the full UUID as a static string. The integer rule existed as first alternative to preserve numeric literals as numbers (e.g., count="5" → 5 not '5').
Fix: Added !. (negative lookahead for any character) after integer in the dynamicComponentProp rule, ensuring integer only matches when it consumes the entire input:
dynamicComponentProp
= num:integer !. { return new RenderFragment(num) }
/ template:template { ... }
When the input is 42941ee7-..., integer matches 42941 but !. fails (there's more input), so PEG.js backtracks to the template alternative which handles it as a static string '42941ee7-1707-4b5d-a7d7-41e12da6ab9e'.
Files modified:
compiler-jay-html/lib/expressions/expression-parser.pegjs— Added!.afterintegerindynamicComponentProprulecompiler-jay-html/lib/expressions/expression-parser.cjs— Regenerated
Test added:
test/expressions/expression-compiler.unit.test.ts— "static UUID string (digits followed by non-digit chars)"
Test results: 653/653 passing (649 compiler-jay-html + 4 skipped), 0 regressions.
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.