Coordinate Assignment Rules
Design Log #126 — Scoped Coordinate System
Written for AI agents. See Log Methodology Note below for details.
Background
The coordinate system ensures SSR and hydration consistently resolve elements in the DOM tree. Coordinates are assigned at compile/pre-render time, written to jay-coordinate attributes in SSR HTML, and resolved at runtime during hydration.
DL#103 introduced coordinate pre-processing. DL#106 added Kindergarten for dynamic elements. DL#123 added headfull FS components. This design log addresses a fundamental inconsistency in how coordinates are computed and proposes a scoped coordinate system to replace the current approach.
The Problem
The current system has two inconsistent mechanisms:
SSR (assign-coordinates + server element): Writes coordinates that mix absolute paths with relative segments depending on context. slowForEach items use a separate
slowForEachPrefixchain that skips headless instance segments.Hydrate compiler: Strips prefixes from coordinates to produce relative coordinates, then relies on the runtime's
coordinateBaseaccumulation (forInstance/forItem) to reconstruct the full path.
These two mechanisms produce different results when slowForEach items are nested inside headless instances:
SSR writes: jay-coordinate="001de78a-..." (trackBy value only — no parent prefix)
Hydrate resolves: 0/kitanheader:AR0/0/category-list:AR0/001de78a-... (full accumulated path)
Root Cause
The inconsistency comes from coordinate stripping in the hydrate compiler that has no equivalent in the SSR path. The hydrate compiler strips prefixes when entering headless instances, forEach, and slowForEach — producing relative coordinates that the runtime rebuilds via forInstance/forItem. But the SSR assigns coordinates using assign-coordinates.ts which has its own independent prefix tracking (slowForEachPrefix) that doesn't match the hydrate's stripping rules.
The more nesting levels (headfull FS → headless plugin → slowForEach), the more the two systems diverge.
Current Rules (Status Quo)
Element Types and Coordinates
| Element Type | Coordinate Format | Example |
|---|---|---|
| Regular HTML element | parentCoord/childIndex |
0/1/2 |
| slowForEach item | slowForEachPrefix/jayTrackBy |
p1/p2 |
Headless instance <jay:xxx> |
parentCoord/contractName:ref |
0/widget:AR0 |
| forEach item | $trackBy/childIndex (template) |
$_id/0 |
The Two Coordinate Chains
Chain A — parentCoord (full DOM path):
Includes every nesting level: element indices, headless instance identifiers, and slowForEach trackBy values.
Chain B — slowForEachPrefix (trackBy-only chain):
Only includes jayTrackBy values. Skips element indices and headless instance coordinates.
For page-level slowForEach, both chains produce the same result. For nested cases (headless instance → slowForEach), Chain A has extra segments that Chain B skips — causing the mismatch.
Runtime Resolution
The runtime maintains coordinateBase that mirrors Chain A:
| Method | Adds to coordinateBase | Used by |
|---|---|---|
forItem(id) |
[...base, id] |
forEach, slowForEach |
forInstance(coord) |
[...base, ...segments] |
headless instances |
Resolution: fullKey = coordinateBase.join('/') + '/' + localCoordinate
Design: Scoped Coordinates
Core Idea
Define coordinate scopes. Each scope is a self-contained coordinate namespace. Coordinates within a scope are relative to the scope root. Scopes are either static (compile-time, shared between SSR and hydrate) or DOM-relative (runtime, for dynamic forEach).
Scope Boundaries
Three element types define scope boundaries:
- Headless instance (
<jay:xxx>) — each instance starts a new scope - forEach item — each iteration starts a new scope
- slowForEach item — each concrete item starts a new scope
The page root is the initial scope (S0).
Scope IDs are assigned by a depth-first counter in assign-coordinates: S0, S1, S2, ... Both SSR and hydrate compilers process the same template in the same order, so they produce the same IDs.
The Universal Rule
Every scope is resolved against its own DOM subtree, not a global map.
When entering a scope boundary, the runtime builds a local coordinate map from that element's subtree. All coordinate lookups within the scope search this local map. This is the same mechanism used for the page root (withHydrationRootContext builds a map from the root element's descendants).
This means scope IDs CAN repeat — for example, a forEach body compiled once always uses the same scope IDs (S3, S4, ...). Each forEach iteration builds its own local map from its own DOM branch, so S3/0 in iteration A resolves to a different element than S3/0 in iteration B. No collisions because different local maps.
Coordinate Format
All coordinates use the same format:
jay-coordinate="S<n>/<relativeCoord>"
S<n>: Scope ID (e.g.,S0,S1,S2)<relativeCoord>: Position within the scope (e.g.,0,0/1)
Example: Golf Project Structure
Page root (scope S0):
<div jay-coordinate="S0/0">
<jay:kitanheader> → defines scope S1
Kitanheader (scope S1):
<div jay-coordinate="S1/0"> ← wrapper for multi-child body
<div class="top-bar"> ← S1/0/0
<div class="top-bar-actions"> ← S1/0/0/1
<jay:cart-indicator> → defines scope S2
Cart-indicator (scope S2):
<a jay-coordinate="S2/0"> ← cart link
<span jay-coordinate="S2/0/0"> ← count (conditional)
Back in Kitanheader (scope S1):
<jay:category-list> → defines scope S3
Category-list (scope S3):
<div jay-coordinate="S3/0"> ← category bar wrapper
<div slowForEach jayTrackBy="001de78a"> → defines scope S4
SlowForEach item (scope S4):
<a jay-coordinate="S4/0"> ← category link
Example: forEach with Nested Component
Page (scope S0):
<div jay-coordinate="S0/0">
<div forEach="items" trackBy="_id"> ← scope S1 (per item)
forEach item (scope S1, local map per iteration):
<div jay-coordinate="S1/0">
<jay:widget> → scope S2
Widget (scope S2, local map per iteration):
<div jay-coordinate="S2/0">
<span jay-coordinate="S2/0/1">
The forEach body is compiled once. Every iteration uses the same scope IDs (S1, S2). This works because each iteration builds its own local coordinate map from its own DOM subtree. S2/0 in iteration A resolves to a different element than S2/0 in iteration B — different local maps, no collisions.
Comparison with Current System
Current: jay-coordinate="0/kitanheader:AR0/0/0/1/cart-indicator:AR0/0"
Scoped: jay-coordinate="S2/0"
Current: jay-coordinate="001de78a" (broken — missing parent prefix)
Scoped: jay-coordinate="S4/0" (always correct — scope is self-contained)
Current forEach: jay-coordinate="$_id/0/widget:AR0/0"
Scoped forEach: item root has jay-coordinate="S1/0", widget has jay-coordinate="S2/0"
How Scope Resolution Works
All scopes work the same way — no static vs dynamic distinction:
assign-coordinatesassignsjay-scope="S<n>"on the boundary element andjay-coordinate="S<n>/..."on descendants within that scope- SSR writes these attributes to the HTML
- At hydration time, when entering a scope:
a. Locate the scope boundary element in the parent scope
b. Build a local coordinate map from that element's
jay-coordinatedescendants c. Create a scoped context with this local map d. Adopt children using scoped coordinates (S<n>/...) - This is the same mechanism used for the page root (
withHydrationRootContext)
Every scope is a mini hydration root. The page root is scope S0. Each headless instance, forEach item, and slowForEach item creates a new scope with its own local map.
Runtime Changes
Context — entering a scope:
forScope(scopeRootElement: Element) {
// Build a LOCAL coordinate map from this element's subtree
const localMap = buildCoordinateMap(scopeRootElement);
return new ConstructContext(
childViewState,
false,
[], // RESET coordinateBase — scoped coordinates are self-contained
localMap, // LOCAL map scoped to this subtree
scopeRootElement,
this._dataIds,
);
}
No more forInstance / forItem accumulation. resolveCoordinate('S2/0') just does localMap.get('S2/0').
Hydrate compiler — no more stripping:
// Current (stripping + accumulation):
adoptElement('0/1', {}, [
childCompHydrate(_Widget, ..., '0/1/widget:AR0', ...)
])
// Scoped (direct):
adoptElement('S1/0/1', {}, [
childCompHydrate(_Widget, ..., 'S2', ...)
])
Scope ID Generation
assign-coordinates maintains a counter per compilation unit:
S0 — page root (always)
S1 — first scope boundary encountered (depth-first)
S2 — second scope boundary
...
The same IDs appear in every forEach iteration — this is correct because each iteration builds its own local map. S2/0 in iteration A resolves against iteration A's local map, S2/0 in iteration B resolves against B's.
Benefits
- Uniform mechanism — all scopes (page, headless, forEach, slowForEach) work identically
- No stripping logic — coordinates are always self-contained within their scope
- No accumulation — no
forInstance/forItemcoordinateBase stacking - No two-chain divergence —
slowForEachPrefixconcept eliminated - Naturally handles arbitrary nesting — any combination of headfull FS → headless → slowForEach → forEach just creates nested scopes
- forEach repetition is safe — same scope IDs, different local maps
Trade-offs
- Scope IDs are positional — adding/removing a scope boundary changes subsequent IDs. Fine because SSR and hydrate are compiled from the same template.
- Larger refactor — touches assign-coordinates, server element compiler, hydrate compiler, and runtime context
- Local map per scope — small overhead to build a coordinate map per scope boundary. Already done once for the page root.
Implementation Plan
Phase 1: assign-coordinates
- Add scope counter (
S0,S1, ...) - Add
jay-scope="S<n>"attribute on ALL scope boundary elements (headless instances, forEach items, slowForEach items) - All coordinates within a scope use format
S<n>/<relativeCoord> - Remove
slowForEachPrefixparameter — no longer needed - Remove
$trackByprefix from forEach coordinate templates — forEach items are scopes
Phase 2: Server element compiler
- Read
jay-scopeto determine scope transitions - Write
jay-coordinate="S<n>/..."consistently for all scopes - When entering a scope boundary, emit the scope marker
__headlessInstanceskey: use scope ID (replacescontractName:ref)
Phase 3: Hydrate compiler
- Remove all coordinate stripping logic (
instanceCoordPrefix, forEach prefix stripping, slowForEach prefix stripping) - Generate scope IDs in the same depth-first order as assign-coordinates
- All
adoptElement/adoptTextcalls useS<n>/<relative>directly childCompHydratepasses scope IDhydrateForEach/slowForEachItempass scope ID
Phase 4: Runtime
- Add
forScope(scopeRootElement)— builds local coordinate map from subtree, resets coordinateBase resolveCoordinateunchanged — looks up in the current (local) map- Remove
forInstance, simplifyforItemto just callforScope withHydrationRootContextbecomes just theS0scope entry
Phase 5: Tests
- Update all expected-ssr.html and expected-hydrate.ts fixtures (coordinate format changes)
- Add test: forEach with nested headless component
- Verify golf project (slowForEach inside headless inside headfull FS)
- Run full test suite
Verification Criteria
- All existing hydration tests pass (coordinate format changes, but behavior preserved)
- Golf project: slowForEach items inside category-list inside kitanheader render and hydrate correctly
- No hydration coordinate warnings in any test or the golf project
- Test 8m (ViewState mismatch in nested headless) continues to work
Implementation Results
Test Results
- compiler-jay-html: 633 passed, 4 skipped (637 total)
- runtime hydration: 67 passed
- dev-server hydration: 618 passed
- dev-server unit: 4 passed
Deviations from Original Design
Local scope maps via
forScope()instead of global map only. The design mentioned using the same mechanism aswithHydrationRootContext, but the implementation revealed that forEach items sharing the same scope IDs require LOCAL coordinate maps per item.forScope(element)builds a map from the element's subtree. Without this,adoptText(which usespeekCoordinate) would resolve the same entry for all items, causing cross-item contamination.forItemstill accumulatescoordinateBase. The design said to remove coordinateBase accumulation, but the non-hydration path (element target) still usescoordinateBasevia thecoordinate()method for ref resolution.forItemkeeps accumulating for backward compatibility;resolveCoordinatesimply ignores it (uses key directly).childCompHydratetakesscopeRootCoordinateparameter. The design said no coordinate argument needed. In practice,childCompHydrateneeds to find the scope root element to build a local map viaforScope(). The coordinate is passed and consumed from the parent scope's map.hydrateForEachtakesitemCoordinateparameter. Each forEach item's root element shares the same coordinate (e.g.,S0/0/1).hydrateForEachresolves each item root from the parent scope (consuming entries in document order), then builds a local scope map for each item.__headlessInstanceskey changed to fulljay-coordinate-basevalue. The design didn't specify key format changes. The implementation uses the full scoped coordinate (e.g.,S0/0/widget:AR0) instead of just the suffix (widget:AR0). This required:- Running
assignCoordinatesToJayHtmlbeforediscoverHeadlessInstancesin the dev-server pipeline - Running discovery twice: first to assign refs, then coordinate assignment, then re-discovery to read
jay-coordinate-base - Updating the element target to also run
assignCoordinatesand readjay-coordinate-basefor the key
- Running
adoptTextstill usespeekCoordinate(unchanged). The design implied removing the peek/resolve distinction. In practice,adoptTextandadoptElementcan share the same coordinate (element with dynamic attrs + text content), soadoptTextmust peek whileadoptElementconsumes. The forEach cross-contamination is solved by local scope maps, not by changing peek behavior.Coordinate format
S<n>/<path>includes full path within scope. The design showedS<n>/<relativeCoord>as a flat index. The implementation uses the full positional path within the scope (e.g.,S0/0/0/1for a deeply nested element), matching the old system's path structure but prefixed with the scope ID.
Follow-up: Nested forEach Ref Coordinates Missing Outer TrackBy
Problem
Refs inside two levels of forEach only included the inner forEach's trackBy value in their coordinate, missing the outer forEach's trackBy. For example, a ref at search.filters.optionFilters.choices.isSelected (two forEach levels: optionFilters trackBy optionId, choices trackBy choiceId) produced coordinate [choiceId, refName] instead of the expected [optionId, choiceId, refName]. Single-level forEach refs worked correctly.
Root Cause
forScope() in context.ts unconditionally reset coordinateBase to []. During hydration, hydrateForEach calls context.forScope(itemDom).forItem(item, id) for each item. In nested forEach:
- Outer forEach:
forScope()resets to[], thenforItem()adds outer id →[outerId] - Inner forEach:
forScope()resets to[]again, thenforItem()adds inner id →[innerId] - The outer id is lost.
The non-hydration path (element target) was unaffected because it calls forItem() directly without forScope().
Fix
Changed forScope() to preserve this.coordinateBase instead of resetting to []. The hydration coordinate map resolution doesn't use coordinateBase (it uses the local map directly via resolveCoordinate), so preserving it only affects ref coordinate generation — which is exactly what was broken.
File changed: packages/runtime/runtime/lib/context.ts — forScope() method.
Tests Added
packages/runtime/runtime/test/lib/ref-operations.test.ts— "nested forEach — ref coordinates include both trackBy levels": non-hydration path (confirmed already working).packages/runtime/runtime/test/lib/hydration/hydrate-for-each.test.ts— "nested hydrateForEach — ref coordinates include both trackBy levels": hydration path with nested forEach and refs, verifying coordinates include both outer and inner trackBy values.
All runtime tests (262) and dev-server tests (632) pass.
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.