Deeply Nested Headfull And Headless Components
Design Log #123: Deeply Nested Headfull and Headless Components
Written for AI agents. See Log Methodology Note below for details.
Background
DL#111 introduced headfull full-stack components — components that bring their own jay-html template and participate in three-phase rendering (slow/fast/interactive). The implementation reuses the headless instance pipeline by injecting the headfull component's template body into matching <jay:Name> tags at parse time. After injection, the component is compiled identically to a headless instance.
This works well for single-level usage: a page imports a headfull FS component and uses it. But real applications need deeper composition — a layout component containing a header, a header containing a cart indicator, or a page-level keyed headless whose data flows into a headfull component's template.
This design log analyzes three nesting scenarios that are currently unsupported or broken, with concrete examples of what fails and why.
Related Design Logs
- DL#84 — Headless component props and repeater support (
<jay:xxx>syntax) - DL#90 — Headless instances in interactive forEach (no slow phase)
- DL#102 — Headless instance SSR and hydration compilation
- DL#105 — Headless component client defaults
- DL#111 — Nested headfull full-stack components (template injection)
Problem
Three composition patterns that users need are not supported:
Scenario A: Headfull FS with a page-level keyed headless
A page has a headfull FS header and a page-level keyed headless for the cart. The header's template needs to show the cart item count.
Scenario B: Headfull FS inside headfull FS
A page uses a layout component, and the layout's own jay-html uses a header component.
Scenario C: Headless instance inside headfull FS template
A headfull FS header component's jay-html contains a <jay:cart-indicator> headless instance from a plugin.
Analysis
Scenario A: Headfull FS + page-level keyed headless
What the developer writes:
Page page.jay-html:
<head>
<script
type="application/jay-headfull"
src="./header/header"
contract="./header/header.jay-contract"
names="Header"
></script>
<script
type="application/jay-headless"
plugin="wix-cart"
contract="cart-indicator"
key="cart"
></script>
<script type="application/jay-data">
data:
pageTitle: string
</script>
</head>
<body>
<jay:header />
<main>
<h1>{pageTitle}</h1>
</main>
</body>
Header's header/header.jay-html:
<head>
<script
type="application/jay-headless"
plugin="wix-cart"
contract="cart-indicator"
key="cart"
></script>
</head>
<body>
<header>
<img src="/logo.png" />
<span>{cart.itemCount} items</span>
</header>
</body>
Header's header/header.jay-contract:
name: Header
tags:
- tag: logoUrl
type: data
dataType: string
What breaks:
parseHeadfullFSImportsreadsheader.jay-htmlbut only extracts the<body>content (line 949). The<head>with its<script type="application/jay-headless">is ignored.The injected template
<span>{cart.itemCount} items</span>is compiled withinrenderHeadlessInstanceagainst the Header contract's ViewState (componentVariables = new Variables(headlessImport.rootType)atjay-html-compiler.ts:764). The Header contract has nocarttag, so{cart.itemCount}fails compilation.Even if the header declared
cartin its own contract, there's no mechanism to pass page-level keyed headless data into a headfull FS component's scope.
What the developer expects:
The header component declares it needs a cart headless, the page provides it, and the cart data flows into the header's template automatically.
What would need to work:
The page should declare the keyed headless at page level (since the page owns the data pipeline), and the headfull component's template should receive the keyed headless data. This requires:
- The headfull component's jay-html head declares the keyed headless dependency
parseHeadfullFSImportsprocesses the component's head to discover keyed headless imports- Those keyed headless imports get hoisted to the page level (added to page's
allHeadlessImports) - The component's
renderHeadlessInstancechild context includes the keyed headless in its variables
Scenario B: Headfull FS inside headfull FS
What the developer writes:
Page page.jay-html:
<head>
<script
type="application/jay-headfull"
src="./layout/layout"
contract="./layout/layout.jay-contract"
names="Layout"
></script>
</head>
<body>
<jay:layout>
<main>
<h1>{pageTitle}</h1>
</main>
</jay:layout>
</body>
Layout's layout/layout.jay-html:
<head>
<script
type="application/jay-headfull"
src="./header/header"
contract="./header/header.jay-contract"
names="Header"
></script>
</head>
<body>
<div class="layout">
<jay:header />
<slot />
<footer>footer content</footer>
</div>
</body>
Header's header/header.jay-html:
<body>
<header>
<img src="/logo.png" />
<nav>...</nav>
</header>
</body>
What breaks:
parseHeadfullFSImportsprocesses page-level headfull imports only. It readslayout.jay-html, extracts its body, and injects it into<jay:layout>.After injection, the page body contains:
<jay:layout> <div class="layout"> <jay:header /> <!-- NOT injected — layout's head was ignored --> <slot /> <footer>footer content</footer> </div> </jay:layout>Layout's
<script type="application/jay-headfull" src="./header/header">in its<head>is never processed.parseHeadfullFSImportsdoes not recurse into nested component jay-html heads.<jay:header>is not recognized during compilation because noJayHeadlessImportsentry exists forheader. It would be treated as an unknown HTML tag or cause a validation error.
What would need to work:
parseHeadfullFSImports should recursively process headfull FS imports found in nested component jay-html heads. Each level's imports get hoisted to the page level (since all compilation happens at page scope after injection). Template injection must also be recursive — the layout's <jay:header> needs its content injected before the layout body is injected into the page.
Scenario C: Headless instance inside headfull FS template
What the developer writes:
Page page.jay-html:
<head>
<script
type="application/jay-headfull"
src="./header/header"
contract="./header/header.jay-contract"
names="Header"
></script>
<script
type="application/jay-headless"
plugin="wix-cart"
contract="cart-indicator"
names="CartIndicator"
></script>
</head>
<body>
<jay:header />
<main>
<h1>{pageTitle}</h1>
</main>
</body>
Header's header/header.jay-html:
<body>
<header>
<img src="/logo.png" />
<jay:cart-indicator />
</header>
</body>
What breaks:
Template injection works — the header body is injected into
<jay:header>, including<jay:cart-indicator />.After injection, the page body is:
<jay:header> <header> <img src="/logo.png" /> <jay:cart-indicator /> </header> </jay:header>The page compiler encounters
<jay:header>and callsrenderHeadlessInstance. This creates a child context withheadlessContractNames: new Set()(jay-html-compiler.ts:798).Inside the child context,
<jay:cart-indicator>is processed byrenderNode→getComponentName. SinceheadlessContractNamesis empty,cart-indicatoris not recognized as a headless instance. It's treated as a regular (unknown) HTML tag.The cart indicator component never gets instantiated, its SSR/hydration code is never generated, and it silently disappears from the output.
What would need to work:
The child context inside renderHeadlessInstance needs to know about headless contracts that are available in the scope. Currently the comment says "Don't detect nested headless instances inside headless instances (for now)". This restriction needs to be lifted, at least for headfull FS components whose templates may legitimately contain headless instances.
Decisions
Scenario A: Do not support — restrict via validation
Page-level keyed headless components are designed for pages without code, where all functionality comes from a plugin. They don't support props — only params (URL parameters). Params are a page-level concept and make no sense for a nested component.
A headfull component that needs cart data should import the cart as a nested headless instance (Scenario C), not as a page-level keyed headless. The headfull component has its own contract and rendering phases — it can manage the cart indicator as a child component within its own template.
Action: Add a validation rule in stack-cli validate that headfull FS component jay-html files cannot declare page-level keyed headless imports (<script type="application/jay-headless" key="..."> in a headfull component's head).
Scenario B: Support — recursive headfull nesting
Headfull components should be able to import other headfull components. This is the natural composition model:
- A page imports a layout (headfull)
- The layout imports a header (headfull)
- The header imports a cart-indicator (headless, see Scenario C)
parseHeadfullFSImports must process component jay-html heads recursively. When loading a headfull component's jay-html, its <head> is scanned for nested <script type="application/jay-headfull"> imports. Those are processed the same way — load their jay-html, inject templates, collect JayHeadlessImports entries — and all results are hoisted to the page level. Template injection must happen bottom-up: the innermost component templates are injected first, then their parents, so the final page body has all templates fully expanded.
The same applies to injectHeadfullFSTemplates (dev server runtime injection).
No depth limit — recursion terminates naturally since each level loads a different file. Circular imports should be detected and reported as an error.
Scenario C: Support — headless instances inside headfull templates
Headfull components should be able to use headless instances in their templates. This is how a header component includes a cart indicator from a plugin:
Header's header/header.jay-html:
<head>
<script
type="application/jay-headless"
plugin="wix-cart"
contract="cart-indicator"
names="CartIndicator"
></script>
</head>
<body>
<header>
<img src="/logo.png" />
<jay:cart-indicator />
</header>
</body>
parseHeadfullFSImports must process the headfull component's <head> to discover headless imports (<script type="application/jay-headless">), not just headfull imports. These headless imports are hoisted to the page level alongside the headfull's own JayHeadlessImports entry. After template injection, <jay:cart-indicator> appears inside the headfull's injected body. The compiler must recognize it as a headless instance.
This requires lifting the headlessContractNames: new Set() restriction in renderHeadlessInstance. The child context should carry the set of headless contract names that the headfull component declared in its head.
Component nesting rules summary
| Component type | Can import headfull? | Can import headless (instance)? | Can import keyed headless? |
|---|---|---|---|
| Page | Yes | Yes | Yes |
| Headfull FS | Yes (recursive) | Yes (from its own head) | No (validation error) |
| Headless | No (no template) | No (no template) | No (no template) |
Import hoisting
All component imports declared in nested headfull jay-html heads get hoisted to the page level. After recursive processing, the page's allHeadlessImports array contains entries from every level. Template injection flattens the entire tree into the page body. From the compiler's perspective, all <jay:xxx> tags exist in a single flat document — the nesting is resolved at parse time.
Questions
For Scenario A (keyed headless in headfull), should the headfull component declare its keyed headless dependencies in its own jay-html head, and have them hoisted to the page? Or should the page declare all keyed headless imports and pass data via props?Answered: Not supported. Headfull components use headless instances (Scenario C) instead.For Scenario B (headfull in headfull), should nesting depth be limited (e.g., max 2 levels) or fully recursive?Answered: Fully recursive, with circular import detection.For Scenario C (headless inside headfull), should the headless import be declared in the headfull component's jay-html head (and hoisted), or must it be declared at the page level?Answered: Declared in the headfull component's own jay-html head. Hoisted to page level during parsing.Does Scenario B require a slot/children mechanism (the layout's
<slot />above) or is the headfull FS component always a leaf with no page-provided children?What's the priority order for implementing these? Are some scenarios more immediately needed than others?Answered: Scenario C (headless in headfull) first, then Scenario B (headfull in headfull). Scenario A is a validation rule only.
Appendix: Current Code Paths
Template injection
jay-html-parser.ts:parseHeadfullFSImports (line 858):
- Iterates page-level
<script type="application/jay-headfull">elements - Reads each component's jay-html, extracts
<body>content - Injects body innerHTML into matching
<jay:Name>tags in the page body - Does not recurse into nested component heads
jay-html-parser.ts:injectHeadfullFSTemplates (line 800):
- Runtime injection for dev server (pre-render and SSR paths)
- Same single-level behavior
Headless instance compilation
jay-html-compiler.ts:renderHeadlessInstance (line 698):
- Creates
componentVariables = new Variables(headlessImport.rootType)— isolated to the component's contract ViewState - Child context at line 788-799:
const childContext: RenderContext = { ...newContext, variables: componentVariables, // component's own ViewState only headlessContractNames: new Set(), // blocks nested headless detection };
Slow render discovery
slow-render-transform.ts:discoverHeadlessInstances:
- Walks pre-rendered HTML to find
<jay:xxx>tags - Finds instances at the page level for slow phase rendering
- After template injection, would find
<jay:cart-indicator>inside the injected template - But compilation blocks it (see above), so discovery alone isn't sufficient
Implementation Results
Scenarios B+C implemented, Scenario A deferred
All compiler targets (element, hydrate, server), the slow-render pipeline, and the dev server were updated.
Files modified
| File | Change |
|---|---|
compiler-jay-html/lib/jay-target/jay-html-parser.ts |
Recursive parseHeadfullFSImports with circular detection, headless import discovery in headfull heads, recursive injectHeadfullFSTemplates |
compiler-jay-html/lib/jay-target/jay-html-compiler.ts |
Pass headlessContractNames through child context in renderHeadlessInstance |
compiler-jay-html/lib/jay-target/jay-html-compiler-hydrate.ts |
Pass headlessContractNames/headlessImports through child contexts; strip parent instanceCoordPrefix from nested coordinate |
compiler-jay-html/lib/jay-target/jay-html-compiler-server.ts |
Pass headlessContractNames through child context |
compiler-jay-html/lib/slow-render/slow-render-transform.ts |
discoverHeadlessInstances and resolveHeadlessInstances recurse into <jay:xxx> children |
stack-server-runtime/lib/load-page-parts.ts |
Inject headfull FS templates before instance discovery (fixes client-only mode) |
Deviations from original plan
All three compiler targets needed the same fix — the plan only mentioned
jay-html-compiler.tsbutjay-html-compiler-hydrate.tsandjay-html-compiler-server.tsalso hadheadlessContractNames: new Set()blocking nested detection.Slow-render discovery/resolution needed recursion — not in the original plan.
discoverHeadlessInstancesandresolveHeadlessInstanceshadreturnafter processing<jay:xxx>, preventing nested instance discovery. Changed to recurse into children after processing.Hydrate coordinate stripping —
childCompHydratereceived absolute coordinates, but nested instances need coordinates relative to their parent. Added prefix stripping wheninstanceCoordPrefixis set.Recursive path resolution — nested headfull element
src/contractattributes are relative to the component's directory, but generated imports must be relative to the page. Added path rewriting before recursing.loadPagePartstemplate injection — the client-only (SSR-disabled) path randiscoverHeadlessInstanceson raw HTML without template injection. AddedinjectHeadfullFSTemplatescall before discovery.Headfull FS components must have all three phases — the runtime's
makeHeadlessInstanceComponentexpects an interactive constructor. Components with onlywithSlowlyRender()fail with "interactiveConstructor is not a function". This is a fixture requirement, not a compiler issue.
Test results
- 571/571 hydration tests pass (including 32 new tests for 8i + 8j)
- 631/631 compiler tests pass (zero regressions)
Post-implementation bug fix: headfull FS module path resolution
Bug: Headfull FS src attribute resolved from projectRoot instead of filePath (the importing jay-html file's directory). For deeply nested pages like src/pages/products/kitan/[[category]]/page.jay-html, the relative path ../../../../components/kitan-header was resolved from the project root, producing /Users/components/kitan-header/kitan-header instead of src/components/kitan-header.
Root cause: parseHeadfullFSImports set moduleResolveDir = projectRoot whenever projectRoot !== filePath. This condition is true for ALL nested pages (not just pre-rendered), but the projectRoot fallback only makes sense when parsing pre-rendered files from build/pre-rendered/ where the source modules are back in the source directory.
Fix (three parts):
readJayHtmlreturn type and directory convention — ChangedreadJayHtmlto return{ content, componentDir }instead of just the content string. ThecomponentDiris the actual directory containing the jay-html file. Also added directory convention: tries<src>.jay-htmlfirst, then<src>/<basename>.jay-html. This supports component directories likecomponents/kitan-header/kitan-header.jay-htmlwheresrcpoints to the directory.Module path resolution —
moduleResolveDirtracks which base directory found the jay-html file. IfreadJayHtml(filePath, src)succeeds, module resolution usesfilePath. If it falls back toreadJayHtml(projectRoot, src), module resolution usesprojectRoot.Contract and CSS resolution from componentDir — Moved
readJayHtmlbeforeloadContract. Contract loading now has three fallbacks:filePath,projectRoot, andcomponentDir(the directory where the jay-html was found). CSS extraction usescomponentDirdirectly instead of computing it frompath.dirname(path.resolve(filePath, src))— which was wrong for the directory convention. This fixes pre-rendered files with deep relative paths where neitherfilePathnorprojectRootresolves correctly.
Post-implementation: hydrate compiler and server pipeline fixes for headfull FS
Testing the golf project (headfull FS header with nested headless plugins in a separate components/ directory) revealed several issues in the hydrate compiler and server-side rendering pipeline.
Bug 1: Case-insensitive component name matching
Problem: Headfull FS contract names are stored lowercase (from names attribute via .toLowerCase()), but getComponentName() uses rawTagName which preserves original casing (e.g., KitanHeader). The Set.has() check is case-sensitive, so headlessContractNames.has("KitanHeader") fails when the set contains "kitanheader".
Result: The hydrate compiler treated the component as headful (childComp) instead of headless-instance (childCompHydrate + makeHeadlessInstanceComponent).
Fix: Lowercase at the three comparison points where rawTagName meets stored names:
getComponentName()injay-html-helpers.ts—componentName.toLowerCase()forheadlessContractNames.has()resolveHeadlessImport()injay-html-compiler-shared.ts— lowercase for matchingextractHeadlessCoordinate()injay-html-compiler-shared.ts— lowercase for coordinate segment lookup
Additional case-sensitivity fixes:
usedAsInstanceinjay-html-parser.ts— used lowercased tag names to build the set, ensuringcodeLinkimports are included for headfull FS components regardless ofnamescasingdiscoverHeadlessInstances()inslow-render-transform.ts— uses lowercased tag names consistently
Bug 2: Duplicate ref bindings from slowForEach-expanded items
Problem: mergeRefsTrees() concatenated all refs from all trees without deduplication. When 50 slowForEach-expanded items each contributed a categoryLink ref, the hydrate render function got 50 refCategoryLink bindings — a JavaScript error (duplicate declarations).
Fix: Deduplicate refs by constName in mergeRefsTrees() (render-fragment.ts). When the same ref appears in multiple sibling trees (e.g., slowForEach items), keep one entry, preferring the repeated/collection variant. Refs with null constName (react target) are excluded from dedup.
Bug 3: Plugin client imports not rewritten in hydrate output
Problem: The plugin-client-import-resolver Vite plugin ran with enforce: 'pre', processing files before the jay-runtime transform generated hydrate JS code. Plugin imports like @jay-framework/wix-cart in the generated hydrate code were never rewritten to /client, causing the server module to load in the browser.
Fix: Changed enforce: 'pre' to enforce: 'post' in plugin-client-import-resolver.ts so it runs after the jay-runtime transform generates the import statements.
Bug 4: Slow-only headfull FS components not rendered in SSR
Problem: The server element code checks __headlessInstances['header:AR0'] to conditionally render a headfull FS component's content. But fast-changing-runner.ts only populated __headlessInstances for components with fastRender. Slow-only components (no fast phase) were never included, so their SSR output was empty.
Fix: In fast-changing-runner.ts, populate __headlessInstances for all discovered instances: slow ViewState for slow-only components, {} for static-only components (no phases at all). Also added slowViewStates field to InstancePhaseData so slow data flows through both the pre-render path and the runSlowlyForPage path.
Bug 5: Fast-only components dropped from instance discovery
Problem: slowRenderInstances() only added instances with slowlyRender to discoveredForFast. Fast-only components (like cart-indicator which has fastRender but no slowlyRender) were silently dropped. Since other instances DID have slowlyRender, the function returned non-undefined, skipping the fallback that would include all instances.
Fix: In slowRenderInstances(), always add all instances to discoveredForFast regardless of whether they have slowlyRender. The slow render step is optional — not having it shouldn't exclude the instance from the fast phase.
Test coverage added
- 8k fixture — Headfull FS with separate
pages/andcomponents/directories, deep relative paths, PascalCase component names - 8l fixture — Slow-only headfull FS component (no fast/interactive phases), verifies SSR renders the component content
Known remaining issues
- SSR disabled mode with PascalCase names — The standard element target (client-only rendering) has issues with PascalCase headfull FS component names. SSR-enabled modes work correctly. 5 test failures in SSR disabled mode for 8j/8k.
- Hydration warnings — The golf project still has hydration-related issues to investigate separately.
Scenario D fix: Headless instance inside keyed headless forEach (2026-08-26)
Problem: When a keyless headless instance (e.g., <jay:clipboard-copy>) is nested inside a forEach that iterates over keyed headless import data (e.g., forEach="plugins.items" where plugins is a keyed data-list import), the hydration code generates refAr0() calls for the instance but the ref is never declared in any ReferencesManager.for(...).
Root cause: optimizeRefs in jay-html-compile-refs.ts replaces the template's subtree for each keyed import key with the import contract's refs tree. The import contract has no knowledge of headless instances nested inside the template, so their auto-refs are silently discarded. The render code (childCompHydrate/childComp calls) is generated before optimizeRefs runs, so it references variables that no longer exist in the refs tree.
Fix: Added graftTemplateOnlyRefs() — after building the import's refs tree, graft back any template refs whose names don't appear anywhere in the import contract's ref tree. This preserves headless instance auto-refs while filtering out stale ref-path artifacts (e.g., template compilation of ref="filters.filter2.categories.isSelected" creates nested ref copies at every depth level). The filter uses collectAllRefNames() on the full import tree to distinguish contract refs (which exist somewhere in the import tree) from template-only refs (which don't).
Test: contracts/headless-instance-in-keyed-foreach — keyed data-list import with forEach="data.items" containing a <jay:simple-action> instance. Verifies the generated hydrate code declares refAr0 in itemsRefManager.
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.