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:

  1. 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).

  2. 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.

  3. 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:

  • trackBy is required for all repeated sub-contracts
  • The trackBy property must reference a data tag within the sub-contract
  • The referenced property must be of type string or number (identity types)
  • This metadata enables:
    1. Array merging by object identity (this design log)
    2. Editor integration - design tools can track which items are being modified (future)
    3. 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:

  1. For primitive values: Fast value overwrites slow value (existing behavior)
  2. For objects: Recursively merge properties from both slow and fast
  3. For arrays: Merge by object identity using trackBy metadata

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)

Supporting Changes:

  • packages/compiler/compiler-jay-html/lib/contract/contract.ts

    • Add trackBy?: string to RepeatedContractTag type
    • Add validation: trackBy is required for repeated contracts
  • packages/compiler/compiler-jay-html/lib/contract/contract-parser.ts

    • Parse trackBy attribute from YAML
    • Validate that trackBy references a valid data tag
  • packages/jay-stack/dev-server/lib/view-state-merger.ts (new file)

    • Implement deepMerge() function
    • Implement mergeArraysByTrackBy() function
    • Export for use in dev-server

4. Future Benefits

Beyond solving the immediate merge problem, trackBy metadata enables:

  1. 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
  2. Optimized Runtime Updates

    • Jay runtime already uses trackBy in forEach for efficient DOM updates
    • Contract-level trackBy ensures consistency across all tooling
  3. 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:

  1. trackBy is required for all repeated sub-contracts
  2. trackBy must reference a tag within the sub-contract
  3. ✅ Referenced tag must have type: data
  4. ✅ Referenced tag's dataType must be string or number
  5. ✅ Referenced tag must be in the slow phase (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:

  1. Warning Mode: Log warning if repeated contract lacks trackBy
  2. Fallback Behavior: Use array index for merging (current shallow behavior)
  3. 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

  1. Unit Tests for deepMerge

    • Primitive values
    • Nested objects (2-3 levels deep)
    • Arrays with matching trackBy keys
    • Arrays with missing items
    • Mixed nested structures
  2. Integration Tests

    • Dev server with sample page
    • Slow render → Fast render → Verify merged ViewState
    • Edge cases: empty arrays, null values, undefined properties
  3. 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:

  1. Add trackBy attribute to repeated sub-contracts in jay-contract format
  2. Implement deep merge algorithm that uses trackBy for array merging
  3. 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 trackBy concept

Next Steps:

  1. Update contract parser to support trackBy attribute
  2. Add validation for trackBy requirements
  3. Implement deep merge algorithm in dev-server
  4. Add comprehensive tests
  5. 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 in SlowViewState
  • phase: fast → Only in FastViewState
  • phase: fast+interactive → In both FastViewState and InteractiveViewState

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 trackBy field in all phase ViewStates for that array
  • Validation: The trackBy field should have phase: 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 trackBy field is somewhat misleading
  • Developers might be confused why id appears 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 trackBy on repeated contract
  • System looks for identity: true within the sub-contract tags
  • Validation: Exactly one tag must have identity: true in 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 named id)

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:

  1. Minimal Breaking Changes: Works with current syntax, just changes type generation behavior
  2. Clear Semantics: Identity fields being in all phases makes semantic sense - you always need the identity to reference an item
  3. Simple Implementation: Small change to type generator, no parser changes needed
  4. Consistent with forEach: In jay-html runtime, trackBy is always available regardless of phase
  5. Practical: Solves the immediate problem without complex new syntax

Implementation Details:

  1. Type Generator Change (phase-type-generator.ts):

    • When generating phase-specific ViewStates for a repeated sub-contract
    • Always include the trackBy field regardless of its declared phase
    • This ensures both SlowViewState and FastViewState include the identity field
  2. Validation Rule (add to existing validation):

    • The trackBy field should be a data tag (already validated)
    • The trackBy field should have phase: slow or no phase (defaults to slow)
    • Rationale: Identity is conceptually slow-changing data
  3. Documentation:

    • Clearly document that trackBy fields are implicitly included in all phases
    • Explain why: identity is needed for merging across phases

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

  1. ✅ Add note to existing validation rules that trackBy fields are in all phases (implicit)
  2. ✅ Update type generator to include trackBy fields in all phase ViewStates
  3. ✅ Add test cases verifying trackBy field appears in all phases
  4. ⏳ Document the implicit behavior in contract format guide
  5. ✅ Add warning if trackBy field has phase: fast (should be slow)

Implementation Complete: Option 1 (Implicit Requirement)

Changes Made:

  1. Type Generator (packages/compiler/compiler-jay-html/lib/contract/phase-type-generator.ts):

    • Modified extractPropertyPathsAndArrays function to accept a parentTrackBy parameter
    • For repeated sub-contracts, pass the trackBy field 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)
  2. Validation (packages/compiler/compiler-jay-html/lib/contract/contract-parser.ts):

    • Added validation to warn when a trackBy field has phase: fast or phase: 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 string and number as valid trackBy data types
  3. 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: slow appears in SlowViewState, FastViewState, and InteractiveViewState
      • Uses a product catalog example with productId as trackBy
    • 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 id field appearing in all three phase ViewStates

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:

  1. Automatic Correctness: Developers don't need to remember special phase rules for trackBy fields
  2. Seamless Merging: The deep merge algorithm can now reliably match array items across phases
  3. Backward Compatible: Existing contracts continue to work, with improved type generation
  4. Clear Semantics: Identity fields being present in all phases is semantically correct
  5. 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

  1. Cleaner Types: No meaningless array structures with only identity fields
  2. Better Semantics: A phase ViewState only includes arrays when they have meaningful data
  3. Correct Behavior: The trackBy field is still automatically added when the array does appear in a phase
  4. 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:

  1. Scope creep: The parser's job is to parse, not to transform contract structures
  2. Complexity: Required traversing and merging nested contract structures
  3. Side effects: Modified the contract AST in ways that could affect other tools
  4. 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 trackBy field name is for each array
  • This mapping by property path

Implementation:

  1. Added trackByMap to JayHtmlSourceFile:

    interface JayHtmlSourceFile {
      // ... existing fields
      trackByMap?: Record<string, string>; // Map from property path to trackBy field name
    }
  2. Created extractTrackByMap function in jay-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 key to 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"
      }
  3. Updated LoadedPageParts interface:

    interface LoadedPageParts {
      parts: DevServerPagePart[];
      trackByMap?: Record<string, string>; // NEW: Include trackByMap
    }
  4. Modified deepMergeViewStates signature:

    // 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 = '',
    );
  5. 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

  1. Separation of Concerns:

    • Parser extracts metadata without modifying contracts
    • Dev-server uses metadata for runtime merging
    • Clear boundary between parsing and execution
  2. Minimal Surface Area:

    • Single new field on JayHtmlSourceFile
    • Single new field on LoadedPageParts
    • One extraction function in parser
    • One parameter change to merge function
  3. Backward Compatible:

    • Pages without contracts still work (empty trackByMap → shallow merge)
    • Simple pages without headless components unchanged
    • No forced contract generation
  4. 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
  5. Type Safe:

    • TypeScript validates all the interfaces
    • Clear types for trackByMap structure
    • Optional field (trackByMap?) indicates when it's present

Test Coverage

Added comprehensive tests to verify:

  1. 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
  2. view-state-merger.test.ts:

    • Arrays with trackByMap merge correctly
    • Nested objects merge correctly
    • Headless component arrays merge under their keys
  3. dev-server.test.ts:

    • Integration test with real page containing headless components
    • Verifies full pipeline: parse → extract trackByMap → load → merge

Key Design Principles Applied

  1. Don't Modify the AST:

    • Contracts remain immutable after parsing
    • Metadata is extracted, not embedded
    • Tools can rely on stable contract structure
  2. 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
  3. 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
  4. Document Through Tests:

    • Each edge case has a test
    • Test names describe expected behavior
    • Tests serve as usage examples

Future Improvements

  1. Editor Integration:

    • VS Code extension could use trackByMap to highlight arrays with identity fields
    • Quick-fix to add missing trackBy attributes
    • Validation warnings directly in editor
  2. 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
  3. 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.