Deep Merge View States With Track-By
Deep Merge View States with Track-By
Written for AI agents. See Log Methodology Note below for details.
Problem
Currently, in dev-server.ts line 117, we perform a shallow merge of slow and fast view states:
viewState = { ...renderedSlowly.rendered, ...renderedFast.rendered };
This shallow merge has several issues:
Nested objects are overwritten entirely - If slow rendering produces
{ user: { name: "John", age: 30 } }and fast rendering produces{ user: { status: "online" } }, the result is{ user: { status: "online" } }(age and name are lost).Arrays cannot be partially updated - Arrays from fast phase completely replace arrays from slow phase, even when they represent the same items with different properties updated.
Loss of type safety guarantees - The phase-based type system (Design Log #50) ensures slow properties are set in slow phase and fast properties in fast phase, but shallow merge breaks this structure.
Why This Matters
The rendering phase model (Design Logs #34, #50) splits ViewState properties across phases:
- Slow phase: Static data set at build time (e.g., product name, SKU, static images)
- Fast phase: Dynamic data set per request (e.g., pricing, inventory, user-specific data)
For deeply nested ViewState structures (common in real applications), we need to combine these partial ViewStates correctly, preserving properties from both phases.
Example: Product Page
// Slow render produces:
{
name: "Widget",
sku: "W-123",
images: [
{ id: "1", url: "/img1.jpg", alt: "Front view" },
{ id: "2", url: "/img2.jpg", alt: "Side view" }
],
discount: {
type: "percentage"
}
}
// Fast render produces:
{
price: 29.99,
inStock: true,
images: [
{ id: "1", loading: false },
{ id: "2", loading: false }
],
discount: {
amount: 5
}
}
// Desired merged result:
{
name: "Widget", // from slow
sku: "W-123", // from slow
price: 29.99, // from fast
inStock: true, // from fast
images: [
{ id: "1", url: "/img1.jpg", alt: "Front view", loading: false }, // merged
{ id: "2", url: "/img2.jpg", alt: "Side view", loading: false } // merged
],
discount: {
type: "percentage", // from slow
amount: 5 // from fast
}
}
Solution: Deep Merge with Track-By
1. Add trackBy to Jay Contract Repeated Sub-Contracts
Extend the .jay-contract format to require a trackBy attribute for repeated sub-contracts. This matches the same concept from jay-html's forEach directive (see /docs/core/jay-html.md).
Contract Syntax:
name: ProductPage
tags:
- tag: images
type: repeated
phase: slow
trackBy: id # NEW: Required for repeated sub-contracts
tags:
- tag: id
type: data
dataType: string
- tag: url
type: data
dataType: string
phase: slow
- tag: alt
type: data
dataType: string
phase: slow
- tag: loading
type: variant
dataType: boolean
phase: fast
Key Points:
trackByis required for all repeated sub-contracts- The
trackByproperty must reference adatatag within the sub-contract - The referenced property must be of type
stringornumber(identity types) - This metadata enables:
- Array merging by object identity (this design log)
- Editor integration - design tools can track which items are being modified (future)
- Efficient DOM updates - runtime uses trackBy for minimal re-renders (existing in forEach)
2. Deep Merge Algorithm
Implement a deep merge function in dev-server.ts that:
- For primitive values: Fast value overwrites slow value (existing behavior)
- For objects: Recursively merge properties from both slow and fast
- For arrays: Merge by object identity using
trackBymetadata
Algorithm Pseudocode:
function deepMerge(
slow: object,
fast: object,
contract: Contract, // Contains trackBy metadata
): object {
const result = {};
// Merge all keys from both objects
const allKeys = new Set([...Object.keys(slow), ...Object.keys(fast)]);
for (const key of allKeys) {
const slowValue = slow[key];
const fastValue = fast[key];
const contractTag = contract.getTag(key);
if (fastValue === undefined) {
// Only in slow
result[key] = slowValue;
} else if (slowValue === undefined) {
// Only in fast
result[key] = fastValue;
} else if (contractTag.type === 'repeated') {
// Array: merge by trackBy
result[key] = mergeArraysByTrackBy(
slowValue,
fastValue,
contractTag.trackBy,
contractTag.subContract,
);
} else if (typeof slowValue === 'object' && typeof fastValue === 'object') {
// Nested object: recurse
result[key] = deepMerge(slowValue, fastValue, contractTag.subContract);
} else {
// Primitive or conflicting types: fast wins
result[key] = fastValue;
}
}
return result;
}
function mergeArraysByTrackBy(
slowArray: any[],
fastArray: any[],
trackBy: string,
itemContract: Contract,
): any[] {
// Build index of slow items by trackBy key
const slowByKey = new Map(slowArray.map((item) => [item[trackBy], item]));
// Build index of fast items by trackBy key
const fastByKey = new Map(fastArray.map((item) => [item[trackBy], item]));
// Merge: Start with slow array order, merge matching fast items
const result = slowArray.map((slowItem) => {
const key = slowItem[trackBy];
const fastItem = fastByKey.get(key);
if (fastItem) {
// Item exists in both: deep merge
return deepMerge(slowItem, fastItem, itemContract);
} else {
// Item only in slow
return slowItem;
}
});
// Add items that only exist in fast (should be rare based on phase semantics)
for (const [key, fastItem] of fastByKey) {
if (!slowByKey.has(key)) {
result.push(fastItem);
}
}
return result;
}
3. Implementation Location
Primary Change:
packages/jay-stack/dev-server/lib/dev-server.ts(line 117)- Replace shallow merge with
deepMerge(renderedSlowly.rendered, renderedFast.rendered, pageContract)
- Replace shallow merge with
Supporting Changes:
packages/compiler/compiler-jay-html/lib/contract/contract.ts- Add
trackBy?: stringtoRepeatedContractTagtype - Add validation:
trackByis required for repeated contracts
- Add
packages/compiler/compiler-jay-html/lib/contract/contract-parser.ts- Parse
trackByattribute from YAML - Validate that
trackByreferences a valid data tag
- Parse
packages/jay-stack/dev-server/lib/view-state-merger.ts(new file)- Implement
deepMerge()function - Implement
mergeArraysByTrackBy()function - Export for use in dev-server
- Implement
4. Future Benefits
Beyond solving the immediate merge problem, trackBy metadata enables:
Editor Integration (out of scope for this task)
- Design tools can track item identity across design iterations
- Enables fine-grained updates to array items without losing identity
- Example: Reorder images in design tool without breaking developer's property overrides
Optimized Runtime Updates
- Jay runtime already uses
trackByinforEachfor efficient DOM updates - Contract-level
trackByensures consistency across all tooling
- Jay runtime already uses
Type Safety
- Compiler can validate that merged ViewStates maintain type structure
- Runtime can assert that trackBy keys are unique within arrays
Validation Rules
When parsing contracts, validate:
- ✅
trackByis required for allrepeatedsub-contracts - ✅
trackBymust reference a tag within the sub-contract - ✅ Referenced tag must have
type: data - ✅ Referenced tag's
dataTypemust bestringornumber - ✅ Referenced tag must be in the
slowphase (identity is static)
Error Examples:
# ❌ Missing trackBy
- tag: items
type: repeated
tags:
- tag: id
type: data
dataType: string
# Error: repeated sub-contract 'items' requires trackBy attribute
# ❌ trackBy references non-existent tag
- tag: items
type: repeated
trackBy: key # 'key' doesn't exist
tags:
- tag: id
type: data
dataType: string
# Error: trackBy 'key' not found in sub-contract
# ❌ trackBy references variant tag
- tag: items
type: repeated
trackBy: selected
tags:
- tag: id
type: data
dataType: string
- tag: selected
type: variant
dataType: boolean
# Error: trackBy must reference a data tag, not variant
# ✅ Valid trackBy
- tag: items
type: repeated
trackBy: id
phase: slow
tags:
- tag: id
type: data
dataType: string
phase: slow
- tag: name
type: data
dataType: string
phase: slow
- tag: selected
type: variant
dataType: boolean
phase: fast
Edge Cases
Array Length Differs Between Phases
Scenario: Slow phase defines 5 items, fast phase only returns 3 items.
Behavior: Merge all 5 items, using slow-only data for items missing in fast.
Rationale: Array structure is defined in slow phase. Fast phase may partially update items.
TrackBy Key Missing in One Phase
Scenario: Item exists in slow but trackBy key is undefined.
Behavior: Warning in dev mode, skip merge for that item.
Rationale: Contract violation - trackBy property should always be defined.
Duplicate TrackBy Keys
Scenario: Two items have the same trackBy value.
Behavior: Error during merge, fail fast.
Rationale: Identity violation - trackBy must be unique within array.
Migration Path
Existing Contracts Without TrackBy
Make trackBy optional initially with:
- Warning Mode: Log warning if repeated contract lacks
trackBy - Fallback Behavior: Use array index for merging (current shallow behavior)
- Timeline: Make required in next major version
Updating Contracts
For existing repeated contracts, identify identity property:
# Before
- tag: todos
type: repeated
tags:
- tag: id
type: data
dataType: string
- tag: title
type: data
dataType: string
# After
- tag: todos
type: repeated
trackBy: id # Add this
tags:
- tag: id
type: data
dataType: string
- tag: title
type: data
dataType: string
Testing Strategy
Unit Tests for
deepMerge- Primitive values
- Nested objects (2-3 levels deep)
- Arrays with matching trackBy keys
- Arrays with missing items
- Mixed nested structures
Integration Tests
- Dev server with sample page
- Slow render → Fast render → Verify merged ViewState
- Edge cases: empty arrays, null values, undefined properties
Contract Validation Tests
- Missing trackBy
- Invalid trackBy reference
- Wrong trackBy type
- Duplicate trackBy values (runtime)
Summary
Problem: Shallow merge of slow/fast ViewStates loses nested properties.
Solution:
- Add
trackByattribute to repeated sub-contracts in jay-contract format - Implement deep merge algorithm that uses
trackByfor array merging - Enable future editor integration with item identity tracking
Benefits:
- Correctly combines multi-phase ViewStates
- Maintains type structure across phases
- Enables editor tools to track item identity
- Consistent with existing
forEach trackByconcept
Next Steps:
- Update contract parser to support
trackByattribute - Add validation for
trackByrequirements - Implement deep merge algorithm in dev-server
- Add comprehensive tests
- Document in jay-contract format guide
Issue: TrackBy Identity Fields Must Be Present in Both Phases
The Problem
Critical Issue Discovered During Implementation:
The trackBy field (e.g., id) must be present in both slow and fast view states for the merge algorithm to work. However, our current phase system only allows a tag to belong to one phase:
phase: slow→ Only inSlowViewStatephase: fast→ Only inFastViewStatephase: fast+interactive→ In bothFastViewStateandInteractiveViewState
Why This is a Problem:
The merge algorithm needs to build maps of items by their identity:
// In mergeArraysByTrackBy:
const slowByKey = new Map(
slowArray.map((item) => [item[trackBy], item]), // ❌ Needs item[trackBy]
);
const fastByKey = new Map(
fastArray.map((item) => [item[trackBy], item]), // ❌ Needs item[trackBy]
);
If id has phase: slow, it won't be in FastViewState, so fastArray items won't have the id field. If id has phase: fast, it won't be in SlowViewState, so slowArray items won't have the id field.
Example of the Problem:
- tag: images
type: repeated
trackBy: id
phase: slow
tags:
- tag: id
type: data
dataType: string
phase: slow # ❌ Only in slow phase!
- tag: url
type: data
dataType: string
phase: slow
- tag: loading
type: variant
dataType: boolean
phase: fast
Generated types:
SlowViewState = {
images: Array<{ id: string; url: string }>, // ✅ Has id
};
FastViewState = {
images: Array<{ loading: boolean }>, // ❌ No id field!
};
When merging, we can't match items because fastArray items don't have id.
Proposed Solutions
Option 1: Implicit Requirement (Simple, Immediate Fix)
Approach: Mandate that trackBy fields are automatically included in all phases where the array appears.
Contract Syntax (Unchanged):
- tag: images
type: repeated
trackBy: id
phase: slow
tags:
- tag: id
type: data
dataType: string
phase: slow # Declared phase, but actually in all phases
- tag: url
type: data
dataType: string
phase: slow
- tag: loading
type: variant
dataType: boolean
phase: fast
Generated Types:
// Slow phase: id explicitly included
SlowViewState = {
images: Array<{ id: string; url: string }>,
};
// Fast phase: id implicitly added because it's the trackBy field
FastViewState = {
images: Array<{ id: string; loading: boolean }>,
};
// Interactive phase: id implicitly added
InteractiveViewState = {
images: Array<{ id: string; loading: boolean }>,
};
Implementation:
- In type generator, when processing a repeated sub-contract with
trackBy - Always include the
trackByfield in all phase ViewStates for that array - Validation: The
trackByfield should havephase: slow(or default) to indicate it's the canonical identity
Pros:
- Simple to implement
- No contract syntax changes
- Clear semantic: identity fields are always present
- Minimal impact on existing code
Cons:
- Implicit behavior (not visible in contract)
- The declared phase on the
trackByfield is somewhat misleading - Developers might be confused why
idappears in fast phase when marked as slow
Option 2: Explicit Multi-Phase Marker
Approach: Add explicit syntax to mark tags as belonging to multiple phases.
Option 2a: allPhases attribute:
- tag: images
type: repeated
trackBy: id
phase: slow
tags:
- tag: id
type: data
dataType: string
allPhases: true # NEW: Explicitly in all phases
- tag: url
type: data
dataType: string
phase: slow
- tag: loading
type: variant
dataType: boolean
phase: fast
Option 2b: Multiple phases syntax:
- tag: images
type: repeated
trackBy: id
phase: slow
tags:
- tag: id
type: data
dataType: string
phase: [slow, fast, fast+interactive] # NEW: Array of phases
- tag: url
type: data
dataType: string
phase: slow
- tag: loading
type: variant
dataType: boolean
phase: fast
Option 2c: Special identity phase:
- tag: images
type: repeated
trackBy: id
phase: slow
tags:
- tag: id
type: data
dataType: string
phase: identity # NEW: Special phase meaning "all phases"
- tag: url
type: data
dataType: string
phase: slow
- tag: loading
type: variant
dataType: boolean
phase: fast
Pros:
- Explicit and clear
- Self-documenting contract
- No surprising implicit behavior
Cons:
- More verbose
- Requires contract syntax extension
- More complex validation rules
Option 3: Identity Tag Attribute (Inferred TrackBy)
Approach: Instead of trackBy on the repeated contract, mark the identity tag itself and infer which field is the trackBy.
Contract Syntax:
- tag: images
type: repeated
phase: slow
tags:
- tag: id
type: data
dataType: string
identity: true # NEW: This is the identity field
- tag: url
type: data
dataType: string
phase: slow
- tag: loading
type: variant
dataType: boolean
phase: fast
Implementation:
- No
trackByon repeated contract - System looks for
identity: truewithin the sub-contract tags - Validation: Exactly one tag must have
identity: truein a repeated sub-contract - Identity fields are automatically in all phases
Pros:
- Single source of truth (the tag itself declares it's an identity)
- Semantic meaning is clear
- No redundancy (
trackBy: id+ tag namedid)
Cons:
- Breaking change from current implementation
- Validation is more complex (must find the identity tag)
- Less explicit at the repeated contract level
Recommended Approach
Recommendation: Option 1 (Implicit Requirement)
Rationale:
- Minimal Breaking Changes: Works with current syntax, just changes type generation behavior
- Clear Semantics: Identity fields being in all phases makes semantic sense - you always need the identity to reference an item
- Simple Implementation: Small change to type generator, no parser changes needed
- Consistent with forEach: In jay-html runtime,
trackByis always available regardless of phase - Practical: Solves the immediate problem without complex new syntax
Implementation Details:
Type Generator Change (
phase-type-generator.ts):- When generating phase-specific ViewStates for a repeated sub-contract
- Always include the
trackByfield regardless of its declared phase - This ensures both
SlowViewStateandFastViewStateinclude the identity field
Validation Rule (add to existing validation):
- The
trackByfield should be adatatag (already validated) - The
trackByfield should havephase: slowor no phase (defaults to slow) - Rationale: Identity is conceptually slow-changing data
- The
Documentation:
- Clearly document that
trackByfields are implicitly included in all phases - Explain why: identity is needed for merging across phases
- Clearly document that
Alternative Consideration for Future:
If implicit behavior proves confusing, we can later add Option 2c (phase: identity) as syntactic sugar that makes the behavior explicit, while still maintaining backward compatibility with Option 1.
Action Items
- ✅ Add note to existing validation rules that trackBy fields are in all phases (implicit)
- ✅ Update type generator to include trackBy fields in all phase ViewStates
- ✅ Add test cases verifying trackBy field appears in all phases
- ⏳ Document the implicit behavior in contract format guide
- ✅ Add warning if trackBy field has
phase: fast(should be slow)
Implementation Complete: Option 1 (Implicit Requirement)
Changes Made:
Type Generator (
packages/compiler/compiler-jay-html/lib/contract/phase-type-generator.ts):- Modified
extractPropertyPathsAndArraysfunction to accept aparentTrackByparameter - For repeated sub-contracts, pass the
trackByfield name to nested tag processing - When processing leaf data tags, check if the tag name matches
parentTrackBy - If it matches, include the field in the current phase ViewState regardless of its declared phase
- This ensures trackBy fields automatically appear in all phases (slow, fast, interactive)
- Modified
Validation (
packages/compiler/compiler-jay-html/lib/contract/contract-parser.ts):- Added validation to warn when a trackBy field has
phase: fastorphase: fast+interactive - Warning message: "trackBy field [x] should have phase 'slow' (or no phase) since identity is slow-changing data. Found phase: [y]. Note: trackBy fields are automatically included in all phases for merging."
- Also fixed validation to accept both
stringandnumberas valid trackBy data types
- Added validation to warn when a trackBy field has
Test Coverage (
packages/compiler/compiler-jay-html/test/contract/contract-compiler.test.ts):- Test 1: "should include trackBy field in all phases (slow, fast, interactive)"
- Verifies that a trackBy field declared as
phase: slowappears inSlowViewState,FastViewState, andInteractiveViewState - Uses a product catalog example with
productIdas trackBy
- Verifies that a trackBy field declared as
- Test 2: "should warn when trackBy field has phase: fast"
- Verifies that the compiler emits a validation warning when trackBy field has an incorrect phase
- Updated existing test: "should compile contract with repeated sub-contract"
- Updated expected output to show
idfield appearing in all three phase ViewStates
- Updated expected output to show
- Test 1: "should include trackBy field in all phases (slow, fast, interactive)"
Behavior:
Before (incorrect):
// Contract with trackBy: id, phase: slow
SlowViewState = {
items: Array<{ id: string; title: string }>,
};
FastViewState = {
items: Array<{ completed: boolean }>, // ❌ No id field!
};
After (correct):
// Contract with trackBy: id, phase: slow
SlowViewState = {
items: Array<{ id: string }>,
};
FastViewState = {
items: Array<{ id: string; title: string; completed: boolean }>, // ✅ id is here!
};
InteractiveViewState = {
items: Array<{ id: string; title: string; completed: boolean }>, // ✅ id is here too!
};
Benefits:
- Automatic Correctness: Developers don't need to remember special phase rules for trackBy fields
- Seamless Merging: The deep merge algorithm can now reliably match array items across phases
- Backward Compatible: Existing contracts continue to work, with improved type generation
- Clear Semantics: Identity fields being present in all phases is semantically correct
- Simple Mental Model: "trackBy means identity, identity is always available"
Next Steps:
- Document this implicit behavior in the contract format guide
- Consider adding to VS Code extension: highlight trackBy fields differently to show they're special
- Monitor for any confusion from developers about why trackBy fields appear in all phases
Optimization: Skip Arrays with Only TrackBy Fields
The Issue
After implementing automatic trackBy inclusion in all phases, we discovered a problem: some phases would have arrays containing only the identity field, which serves no practical purpose.
Example of the problem:
// Contract with only slow-phase properties
FastViewState = {
items: Array<Pick<ViewState['items'][number], 'id'>>, // Only identity!
};
This creates meaningless type structures where arrays only contain identity fields with no actual data.
The Solution
Modified the type generator to skip repeated arrays when they would only contain the trackBy field in a given phase.
Implementation (phase-type-generator.ts):
// For repeated sub-contracts, skip if only the trackBy field is present
const hasOnlyTrackBy =
isArray &&
trackByForChildren &&
result.paths.length === 1 &&
result.paths[0].propertyName === camelCase(trackByForChildren);
// Only include if it has properties AND it's not an array with only trackBy
if (result.paths.length > 0 && !hasOnlyTrackBy) {
// Include the array
}
Example Results
Before optimization:
// productId: phase slow, name: phase fast
SlowViewState = {
items: Array<Pick<ViewState['items'][number], 'productId'>>, // Only id!
};
FastViewState = {
items: Array<Pick<ViewState['items'][number], 'productId' | 'name'>>,
};
After optimization:
SlowViewState = {}; // ✅ Omitted - would only have id
FastViewState = {
items: Array<ViewState['items'][number]>, // ✅ Has both productId and name
};
Benefits
- Cleaner Types: No meaningless array structures with only identity fields
- Better Semantics: A phase ViewState only includes arrays when they have meaningful data
- Correct Behavior: The trackBy field is still automatically added when the array does appear in a phase
- Type Safety: Empty ViewStates are correctly typed as
{}
When Arrays Are Included
An array appears in a phase's ViewState when it has at least one non-trackBy property in that phase:
- ✅ Array has data properties in the phase → included
- ✅ Array has variant properties in the phase → included
- ❌ Array only has the trackBy property → omitted
This ensures that every included array structure has actual purpose beyond just identity tracking.
Implementation Lessons Learned: TrackByMap Solution
The Contract Merging Challenge
During implementation, we discovered that the dev-server needs to merge view states not just from the page's contract, but also from headless components imported into the page. Each headless component has its own contract, and these contracts are nested under the page's ViewState using the component's key attribute.
Example Structure:
// Page with two headless components
PageViewState = {
title: string; // From page contract
counter: { // From headless component with key="counter"
count: number;
items: Array<{ id: string; name: string; }>;
};
productList: { // From headless component with key="productList"
products: Array<{ productId: string; title: string; }>;
};
};
The Problem: The deepMergeViewStates function needs trackBy metadata for arrays at any depth, including:
- Arrays directly in the page contract (
pageContract.tags) - Arrays within headless component contracts (nested under their keys)
Initial Approaches Considered
Approach 1: Always Create Contract from Inline Data
Idea: If a page doesn't have an explicit contract, generate one from the inline data: section.
Why we rejected it: This broke backward compatibility. Simple pages without headless components suddenly had contracts created, changing their behavior. The old behavior (no contract = everything in InteractiveViewState) was correct for pages without complex phase splits.
Approach 2: Merge Headless Contracts into Page Contract
Idea: Modify parseJayFile to merge headless component contracts into the page's contract structure during parsing.
Why we rejected it:
- Scope creep: The parser's job is to parse, not to transform contract structures
- Complexity: Required traversing and merging nested contract structures
- Side effects: Modified the contract AST in ways that could affect other tools
- Type generation issues: Created redundant intersection types for merged contracts (e.g.,
PageViewState['counter'] & Pick<PageViewState, 'counter'>)
The Solution: TrackByMap
Key Insight: The deep merge algorithm doesn't need the full contract structure - it only needs to know:
- Which properties are arrays
- What the
trackByfield name is for each array - This mapping by property path
Implementation:
Added
trackByMaptoJayHtmlSourceFile:interface JayHtmlSourceFile { // ... existing fields trackByMap?: Record<string, string>; // Map from property path to trackBy field name }Created
extractTrackByMapfunction injay-html-parser.ts:function extractTrackByMap( pageContract: Contract | undefined, headlessImports: JayHeadlessImports[], ): Record<string, string>;This function:
- Recursively traverses the page contract (if it exists)
- Recursively traverses each headless component contract
- For headless contracts, prepends the component's
keyto all paths - Returns a flat map like:
{ "items": "id", // From page contract "counter.items": "itemId", // From headless component with key="counter" "productList.products": "productId" // From headless component with key="productList" }
Updated
LoadedPagePartsinterface:interface LoadedPageParts { parts: DevServerPagePart[]; trackByMap?: Record<string, string>; // NEW: Include trackByMap }Modified
deepMergeViewStatessignature:// Before: accepted Contract function deepMergeViewStates(slow: object, fast: object, contract: Contract, path: string); // After: accepts trackByMap function deepMergeViewStates( slow: object, fast: object, trackByMap: Record<string, string>, path: string = '', );Updated dev-server to use trackByMap:
const { parts: pageParts, trackByMap } = pagePartsResult.val; if (trackByMap && Object.keys(trackByMap).length > 0) { viewState = deepMergeViewStates(renderedSlowly.rendered, renderedFast.rendered, trackByMap); } else { // Fallback to shallow merge if no trackBy info available viewState = { ...renderedSlowly.rendered, ...renderedFast.rendered }; }
Why This Solution Works
Separation of Concerns:
- Parser extracts metadata without modifying contracts
- Dev-server uses metadata for runtime merging
- Clear boundary between parsing and execution
Minimal Surface Area:
- Single new field on
JayHtmlSourceFile - Single new field on
LoadedPageParts - One extraction function in parser
- One parameter change to merge function
- Single new field on
Backward Compatible:
- Pages without contracts still work (empty trackByMap → shallow merge)
- Simple pages without headless components unchanged
- No forced contract generation
Scalable:
- Works with any number of nested headless components
- Handles deep nesting naturally via path prefixing
- Easy to extend to other metadata in the future
Type Safe:
- TypeScript validates all the interfaces
- Clear types for
trackByMapstructure - Optional field (
trackByMap?) indicates when it's present
Test Coverage
Added comprehensive tests to verify:
parse-jay-file.unit.test.ts:- Page with explicit contract and headless components → merged trackByMap
- Page with only headless components (no page contract) → headless trackByMap only
- Page without contracts → undefined trackByMap
- Nested trackBy in headless components → correct path prefixing
view-state-merger.test.ts:- Arrays with trackByMap merge correctly
- Nested objects merge correctly
- Headless component arrays merge under their keys
dev-server.test.ts:- Integration test with real page containing headless components
- Verifies full pipeline: parse → extract trackByMap → load → merge
Key Design Principles Applied
Don't Modify the AST:
- Contracts remain immutable after parsing
- Metadata is extracted, not embedded
- Tools can rely on stable contract structure
Keep It Simple:
- Flat map is easier to work with than nested structures
- Property paths as strings are human-readable and debuggable
- Single source of truth for trackBy information
Fail Gracefully:
- Missing trackByMap → fall back to shallow merge
- Empty trackByMap → fall back to shallow merge
- Missing trackBy for specific array → use fast array only
Document Through Tests:
- Each edge case has a test
- Test names describe expected behavior
- Tests serve as usage examples
Future Improvements
Editor Integration:
- VS Code extension could use
trackByMapto highlight arrays with identity fields - Quick-fix to add missing
trackByattributes - Validation warnings directly in editor
- VS Code extension could use
Runtime Validation:
- Dev mode could check that trackBy keys are unique within arrays
- Warn when items are missing their trackBy field
- Report merge conflicts when items can't be matched
Performance Optimization:
- Cache trackByMap extraction results
- Memoize deep merge operations for unchanged data
- Profile and optimize hot paths in merge algorithm
Summary
The trackByMap solution provides a clean, focused mechanism for the deep merge algorithm to access trackBy metadata from both page and headless component contracts. By keeping it as extracted metadata rather than modifying contract structures, we maintained backward compatibility while enabling the sophisticated merging behavior required for multi-phase rendering with nested components.
Key takeaway: When facing a choice between modifying core data structures vs. extracting derived metadata, prefer extraction. It keeps concerns separated, maintains backward compatibility, and provides flexibility for future changes.
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.