decompose reconciler roadmap into 20 implementation tasks across 5 phases

Tasks follow the architecture spec phases:
- Phase 0: key field on UElement (2 tasks + review)
- Phase 1: reactive-host bridge / fiber tree (4 tasks + review)
- Phase 2: key-based children reconciliation (3 tasks + review)
- Phase 3: unmount & dispose support (4 tasks)
- Phase 4: TypeBox value optimizations (4 tasks)

Validated with taskgraph CLI: no cycles, 15 parallel generations,
3 high-risk tasks identified (signal-driven-updates, commit-mutations,
fiber-disposal).
This commit is contained in:
2026-05-18 16:26:52 +00:00
parent 8cd4091afc
commit c9c32a6aa6
20 changed files with 993 additions and 0 deletions

56
tasks/commit-mutations.md Normal file
View File

@@ -0,0 +1,56 @@
---
id: commit-mutations
name: Commit insert/move/remove effects in tree order
status: pending
depends_on: [lis-move-detection]
created: 2026-05-18T16:22:57.246628537Z
modified: 2026-05-18T16:22:57.246628975Z
scope: moderate
risk: high
impact: phase
level: implementation
---
# Description
Implement the commit phase that applies all pending effects (insert, move, remove, update) to the host in the correct order. This is where fiber effects become host mutations.
The commit order matters for host correctness:
1. **Removes** — reverse order (children before parents, bottom-up)
2. **Inserts + Moves** — left-to-right using `insertBefore` or `appendChild`
3. **Updates** — top-down (parent before child, so parent state is consistent)
This task also creates new fibers for inserted children (via `mountNode`-style recursive creation) and updates the fiber tree structure after commit.
`commitMutations` is called after the reconciliation algorithm has classified all changes and queued effects on fibers. It walks the fiber tree and applies effects in the specified order.
## Acceptance Criteria
- [ ] `commitMutations(rootFiber, ctx)` function implemented in `src/host/reconcile.ts`
- [ ] Removes committed in reverse order (children before parents)
- [ ] `host.removeChild(parent, child, ctx)` called for each removal
- [ ] Inserts committed left-to-right, using `host.appendChild` or `host.insertBefore`
- [ ] New fibers created for inserted children, linked into parent's children array
- [ ] Moves committed left-to-right using `host.insertBefore(parent, child, before, ctx)`
- [ ] Updates committed top-down (parent before child) via `host.commitUpdate`
- [ ] Fiber tree is updated after commit: new children added, removed children pruned, moved children reordered
- [ ] Order guarantee: removes → inserts/moves → updates
- [ ] Existing tests pass
- [ ] New test: adding a child calls `host.appendChild`
- [ ] New test: removing a child calls `host.removeChild`
- [ ] New test: reordering children calls `host.insertBefore` for moved children
- [ ] New test: mixed add+remove+update operations apply in correct order
- [ ] New test: `insertBefore` falls back to `appendChild` if `before` is null
## References
- docs/architecture/reconciler.md — Step 3 (commit order), Step 4 (Commit Effects), Effect Types
- docs/architecture/host-config.md — insertBefore, removeChild, appendChild
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion

56
tasks/fiber-disposal.md Normal file
View File

@@ -0,0 +1,56 @@
---
id: fiber-disposal
name: Implement fiber tree disposal
status: pending
depends_on: [review-reconciler]
created: 2026-05-18T16:22:57.277429897Z
modified: 2026-05-18T16:22:57.277430341Z
scope: moderate
risk: high
impact: phase
level: implementation
---
# Description
Implement `disposeFiber()` for full teardown of a fiber subtree. This is the foundation for both root unmount and partial tree removal during reconciliation.
Disposal is bottom-up (children before parents) and covers:
1. Recursively dispose all child fibers
2. Call `host.finalizeInstance?(instance, ctx)` for per-instance cleanup
3. Call each disposer in `fiber.signalDisposers` to clean up signal subscriptions
4. Clear fiber state (children = [], parent = null, effect = null, signalDisposers = [])
**Important constraint:** `disposeFiber` disposes resources but does NOT call `host.removeChild()`. Instance removal happens during the commit phase, in a specific order, as part of the reconciliation algorithm. This separation ensures correct mutation ordering when batching removes.
All disposal operations are idempotent — calling `dispose()` twice is safe.
## Acceptance Criteria
- [ ] `disposeFiber(fiber, ctx)` function implemented in `src/host/fiber.ts`
- [ ] Bottom-up disposal: children disposed before their parent
- [ ] Calls `host.finalizeInstance?(instance, ctx)` for each fiber after children are disposed
- [ ] Calls every disposer in `fiber.signalDisposers`
- [ ] Clears `fiber.signalDisposers = []` after calling (prevents double-call)
- [ ] Clears `fiber.parent = null` after disposal
- [ ] Clears `fiber.effect = null`
- [ ] Idempotent: calling `disposeFiber` twice is safe (no double-dispose, no errors)
- [ ] Does NOT call `host.removeChild()` — that's the commit phase's job
- [ ] `computed` signals are NOT explicitly disposed (they're garbage collected when references clear)
- [ ] Only `effect()` return values need explicit disposal
- [ ] New test: diposeFiber on a tree with 3 levels calls disposers and finalizeInstance bottom-up
- [ ] New test: idempotent double-dispose does not error
- [ ] New test: signal subscriptions are cleaned up (effect no longer fires after dispose)
## References
- docs/architecture/lifecycle.md — Fiber Tree Disposal, Root Unmount Flow, Disposal Idempotency, computed vs effect Cleanup
- docs/architecture/reconciler.md — Effect type "remove"
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion

40
tasks/fiber-type.md Normal file
View File

@@ -0,0 +1,40 @@
---
id: fiber-type
name: Define Fiber and Effect types
status: pending
depends_on: [review-key-field]
created: 2026-05-18T16:22:57.140011417Z
modified: 2026-05-18T16:22:57.140011873Z
scope: narrow
risk: medium
impact: phase
level: implementation
---
# Description
Define the `Fiber<I>` interface and `Effect` type in a new file `src/host/fiber.ts`. The fiber tree is the reconciler's internal state — it maps `UElement` positions to host instances across renders. This is the foundational type that all reconciler work builds on.
The `Fiber` type is generic over `I` (matching `HostConfig`'s `Instance` type) so each fiber carries a reference to its host instance. This is the bridge: fiber → host instance → host-specific state.
## Acceptance Criteria
- [ ] `Fiber<I>` interface defined with all fields: `instance`, `tag`, `props`, `key`, `children`, `parent`, `effect`, `signalDisposers`, `prevProps`
- [ ] `Effect` union type defined with variants: `update`, `insert`, `move`, `remove`
- [ ] Types are exported from `src/host/fiber.ts`
- [ ] Types are re-exported from `src/mod.ts` barrel
- [ ] TypeScript compiles (`npm run build:tsc`)
- [ ] No runtime behavior changes — this is type-only
## References
- docs/architecture/reconciler.md — Fiber Node, Effect Types sections
- docs/architecture/lifecycle.md — Fiber Tree Disposal (signalDisposers field)
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion

View File

@@ -0,0 +1,42 @@
---
id: host-finalize-instance
name: Add finalizeInstance to HostConfig
status: pending
depends_on: [fiber-disposal]
created: 2026-05-18T16:22:57.323634873Z
modified: 2026-05-18T16:22:57.323635316Z
scope: single
risk: low
impact: component
level: implementation
---
# Description
Add the optional `finalizeInstance?(instance: Instance, ctx: RootCtx): void` method to the `HostConfig` interface. This method allows hosts to perform per-instance cleanup when an instance is removed (e.g., releasing GPU buffer slots, closing database connections, removing graphology nodes).
This is a backward-compatible change: the method is optional, so existing `HostConfig` implementations continue to work without modification. The reconciler calls `finalizeInstance` during `disposeFiber` after children are disposed but before the fiber's own state is cleared.
## Acceptance Criteria
- [ ] `finalizeInstance?(instance: Instance, ctx: RootCtx): void` added to `HostConfig` interface
- [ ] Method is optional (existing hosts don't need to implement it)
- [ ] Called by `disposeFiber` during fiber tree disposal
- [ ] Called bottom-up: children's `finalizeInstance` before parent's
- [ ] Updated in `src/host/config.ts` interface definition
- [ ] TypeScript compiles (`npm run build:tsc`)
- [ ] Existing tests pass (existing HostConfig implementations don't break)
- [ ] New test: host implementing `finalizeInstance` receives calls during disposal
## References
- docs/architecture/lifecycle.md — Host Notification section, finalizeInstance interface
- docs/architecture/host-config.md — Open Question 3 (should HostConfig include finalizeInstance)
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion

View File

@@ -0,0 +1,44 @@
---
id: key-extraction-in-h
name: Extract key from props in h()
status: pending
depends_on: [key-on-uelement]
created: 2026-05-18T16:22:57.111275411Z
modified: 2026-05-18T16:22:57.111275823Z
scope: narrow
risk: low
impact: component
level: implementation
---
# Description
Modify `h()` and `createRoot()` to extract `key` from props and promote it to the element-level `key` field. This ensures component functions never receive `key` in their props — it is stripped during construction, not during component invocation.
Per ADR-004: `key` is a reconciler concern, not a prop. The factory layer is where the extraction happens, so that every downstream consumer (components, hosts, transforms) sees `key` only on the element, never in `props`.
## Acceptance Criteria
- [ ] `h()` extracts `key` from `props` before constructing the element
- [ ] `key` is promoted to the returned element's `key` field (top-level)
- [ ] `key` is removed from `resolvedProps` so component functions never see it
- [ ] `createRoot()` does NOT extract `key` (URoot has no key field)
- [ ] `h("root", { key: "x" })``key` stays in props for URoot (no promotion)
- [ ] Existing tests pass (`npm run test`)
- [ ] New test: `h("div", { key: "a" })` produces `UElement` with `key: "a"` and no `key` in `props`
- [ ] New test: `h("div", { key: "b", class: "x" })``key` promoted, `class` remains in props
- [ ] New test: `h("div", null)``key` is `undefined`, no `key` in props
## References
- docs/architecture/element-factory.md — Known Gaps: `key` prop not extracted
- docs/architecture/decisions/004-key-as-first-class-field.md — ADR-004 decision and consequences
- docs/architecture/reconciler.md — Fiber Node `key` field
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion

View File

@@ -0,0 +1,55 @@
---
id: key-matching-algorithm
name: Key-based old↔new child matching
status: pending
depends_on: [review-reactive-host-bridge]
created: 2026-05-18T16:22:57.216123897Z
modified: 2026-05-18T16:22:57.216124341Z
scope: narrow
risk: medium
impact: component
level: implementation
---
# Description
Implement the key-based matching algorithm that maps old children to new children by `key` field. When the tree structure changes (children added, removed, reordered), positional matching is insufficient — it would destroy and recreate instances instead of moving them.
This algorithm builds two maps from the old and new child lists:
- `oldKeyMap`: `Map<key | null, Fiber>` — old children keyed by their `key` field (positional fallback for `null` keys)
- New children are classified as: matched (key exists in old), added (new key), removed (old key not in new)
For matched children:
- Same type: will reconcile props (same as Step 2)
- Type changed: remove old + insert new
Unkeyed children (key = undefined) fall back to positional matching.
## Acceptance Criteria
- [ ] `reconcileChildren` function implemented in `src/host/reconcile.ts`
- [ ] Builds `oldKeyMap` from existing fiber children
- [ ] Classifies new children into matched, added, removed sets
- [ ] Matched children with same type → flag for prop reconciliation
- [ ] Matched children with different type → old removed, new inserted
- [ ] Unkeyed children (key = undefined) use positional matching fallback
- [ ] Duplicate keys: warn and use last-wins (per ADR-004 consequences)
- [ ] Pure function — no side effects, returns classification for downstream use
- [ ] New test: keyed children reordered → matched correctly by key
- [ ] New test: keyed child added at start → old children matched, new child added
- [ ] New test: keyed child removed → classified as removed
- [ ] New test: mixed keyed and unkeyed children → key-based for keyed, positional for unkeyed
- [ ] New test: duplicate key → last-wins, no crash
## References
- docs/architecture/reconciler.md — Step 3 (Reconcile Children, Key-Based)
- docs/architecture/decisions/004-key-as-first-class-field.md — ADR-004, duplicate key behavior
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion

45
tasks/key-on-uelement.md Normal file
View File

@@ -0,0 +1,45 @@
---
id: key-on-uelement
name: Add key field to UElement
status: pending
depends_on: []
created: 2026-05-18T16:22:57.098951833Z
modified: 2026-05-18T16:22:57.098952376Z
scope: narrow
risk: low
impact: component
level: implementation
---
# Description
Add `key?: string` as a first-class field on `UElement` in both the TypeScript type and the TypeBox Module schema. This is Reconciler Roadmap Phase 0 (ADR-004).
The `key` field enables identity-based children matching in the reconciler. Without it, reconciliation is positional-only, which breaks when children are reordered, inserted, or removed. `key` is a reconciler concern, not a component concern — it must live outside `props` so that component schemas don't need to declare it and component functions never receive it.
`URoot` does NOT get a `key` field. Roots are unique per `createRoot()` call and are never children of another element.
## Acceptance Criteria
- [ ] `UElement` TypeScript type has `key?: string` field
- [ ] TypeBox Module `UElement` schema includes `Type.Optional(Type.String())` for `key`
- [ ] `URoot` TypeScript type has no `key` field
- [ ] `URoot` TypeBox schema has no `key` field
- [ ] `isUElement` type guard still correctly discriminates UElement from URoot
- [ ] Existing tests pass (`npm run test`)
- [ ] New test: `UElement` with `key` field validates and type-checks
- [ ] New test: `UElement` without `key` field still works (backward compatible)
## References
- docs/architecture/schema.md — Known Gaps: `key` field on `UElement`
- docs/architecture/decisions/004-key-as-first-class-field.md — ADR-004
- docs/architecture/reconciler.md — Reconciler Roadmap Phase 0
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion

View File

@@ -0,0 +1,51 @@
---
id: lis-move-detection
name: LIS-based move detection for children
status: pending
depends_on: [key-matching-algorithm]
created: 2026-05-18T16:22:57.231648316Z
modified: 2026-05-18T16:22:57.231648758Z
scope: narrow
risk: medium
impact: component
level: implementation
---
# Description
Implement the Longest Increasing Subsequence (LIS) algorithm to determine which matched children need to be moved. When the order of keyed children changes between renders, the LIS identifies the largest subset of children that are already in the correct relative order. Only children NOT in the LIS need to be moved via `insertBefore`.
The LIS approach minimizes the number of DOM (or host) operations: instead of removing and re-inserting every child, only the out-of-order children are moved.
Algorithm:
1. Get the old indices for each matched child in the new order
2. Compute LIS of those indices
3. Children whose old index IS in the LIS → stay in place
4. Children whose old index is NOT in the LIS → flag as "move" effects
Open question from reconciler.md: should there be a minimum threshold (e.g., < 5 items) below which positional matching is used instead? This task should document the decision but can start with LIS for all sizes.
## Acceptance Criteria
- [ ] `longestIncreasingSubsequence` function implemented (pure function)
- [ ] Returns the set of indices that form the longest increasing subsequence
- [ ] LIS used in `reconcileChildren` to classify matched children as "stay" vs "move"
- [ ] Children in LIS → no effect needed (staying in place)
- [ ] Children NOT in LIS → flagged with `effect: { type: "move", before }`
- [ ] New test: LIS of [0, 1, 2] → all stay (already ordered)
- [ ] New test: LIS of [2, 0, 1] → index 0 stays, indices 2 and 1 move
- [ ] New test: reversed list [3, 2, 1, 0] → LIS = [0], three moves
- [ ] New test: adding child at start doesn't move existing ones (LIS covers all old)
## References
- docs/architecture/reconciler.md — Step 3 (Reconcile Children), LIS move detection
- docs/architecture/reconciler.md — Open Question 4 (minimum LIS threshold)
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion

View File

@@ -0,0 +1,48 @@
---
id: mount-with-fibers
name: Refactor mountNode to build fiber tree
status: pending
depends_on: [fiber-type]
created: 2026-05-18T16:22:57.152634856Z
modified: 2026-05-18T16:22:57.152635298Z
scope: moderate
risk: medium
impact: phase
level: implementation
---
# Description
Refactor the existing `mountNode` in `src/host/config.ts` to build a fiber tree alongside host instances during the mount phase. Currently, `mountNode` creates instances but discards them — the instance tree is unreachable for updates. After this task, `Root` holds a reference to the root fiber, enabling re-render and unmount.
The mount pipeline stays the same (depth-first, post-order append). The change is that each created instance is wrapped in a `Fiber` node that tracks its parent, children, and props. Function components remain transparent — they don't get their own fiber.
This task also stores the root fiber on the `Root` object so that subsequent renders can diff against it.
## Acceptance Criteria
- [ ] `mountNode` creates `Fiber` nodes for each intrinsic element and text instance
- [ ] Function components are transparent — no fiber for the component itself
- [ ] `URoot` children are mounted into the parent fiber (root is a transparent container)
- [ ] Primitives get text fibers with `tag: "#text"`
- [ ] Fiber tree is linked: `parent` and `children` fields are populated
- [ ] `Root` object holds a `rootFiber` reference (new field)
- [ ] `signalDisposers` initialized as empty array on each fiber
- [ ] `effect` initialized as `null` on each fiber
- [ ] Existing mount behavior preserved — hosts still receive same `createInstance`/`appendChild` calls
- [ ] Existing tests pass
- [ ] New test: after `render()`, `Root.rootFiber` has correct children structure matching the UNode tree
## References
- docs/architecture/reconciler.md — Fiber Node, Reconciliation Algorithm, Function Components sections
- docs/architecture/host-config.md — Mount Pipeline, No Instance Tree Reference gap
- docs/architecture/lifecycle.md — Fiber Tree Disposal
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion

View File

@@ -0,0 +1,57 @@
---
id: reactiveroot-dispose
name: Implement ReactiveRoot.dispose()
status: pending
depends_on: [review-reconciler]
created: 2026-05-18T16:22:57.292895316Z
modified: 2026-05-18T16:22:57.292895758Z
scope: narrow
risk: medium
impact: component
level: implementation
---
# Description
Implement real disposal in `ReactiveRoot` to close the signal subscription leak gap. Currently, `ReactiveRoot` has no `dispose()` method, `subscribe()` return values are not tracked, and `render()` overwrites the previous disposer without disposing it.
`ReactiveRoot.dispose()`:
1. Calls the render effect disposer and clears it
2. Iterates all tracked subscriber disposers and calls each one
3. Clears internal tracking state
`subscribe()` is updated to track disposer returns: each `effect()` created by `subscribe()` has its disposer stored in an internal list. The returned unsubscribe function both calls the disposer and removes it from tracking.
Both `dispose()` and the returned unsubscribe functions are idempotent.
Also implement real `dispose` on `ReactiveNode` — currently both `reactiveComponent` and `reactiveElement` return `dispose: () => {}`. With proper disposal tracking, these should return actual cleanup functions.
## Acceptance Criteria
- [ ] `ReactiveRoot.dispose()` method implemented
- [ ] `dispose()` calls render effect disposer and clears `renderDisposer`
- [ ] `dispose()` iterates all tracked subscriber disposers and calls each
- [ ] `dispose()` clears internal tracking state after running
- [ ] `subscribe()` tracks disposer returns in an internal `Set<() => void>`
- [ ] `subscribe()` returned unsubscribe function calls disposer AND removes from tracking
- [ ] `dispose()` and unsubscribe functions are idempotent (safe to call twice)
- [ ] `reactiveComponent` and `reactiveElement` return real dispose functions (not no-ops)
- [ ] `ReactiveNode.dispose` cleans up the underlying computed subscription
- [ ] Existing tests pass
- [ ] New test: `ReactiveRoot.dispose()` prevents future effect fires
- [ ] New test: `subscribe()` unsubscribe removes from tracking
- [ ] New test: double `dispose()` is safe
- [ ] New test: `reactiveComponent.dispose()` prevents future computed evaluations
## References
- docs/architecture/reactive-layer.md — Known Gaps: all dispose functions are no-ops, subscribe return value thrown away, render overwrites previous disposer, no auto-dispose on unmount
- docs/architecture/lifecycle.md — ReactiveRoot Disposal section
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion

View File

@@ -0,0 +1,48 @@
---
id: render-re-renderable
name: Make Root.render() re-renderable
status: pending
depends_on: [mount-with-fibers]
created: 2026-05-18T16:22:57.183371873Z
modified: 2026-05-18T16:22:57.183372316Z
scope: moderate
risk: medium
impact: component
level: implementation
---
# Description
Make `Root.render()` support being called multiple times. Currently, calling `render()` twice creates two independent instance trees appended alongside each other. After this task, the second `render()` call reconciles against the stored fiber tree: it compares the new UNode tree to the existing fiber tree and applies property-only updates (structural reconciliation is Phase 2).
This task also addresses the `render()``mount()` naming question from host-config.md Open Question 1: consider whether renaming `render()` to `mount()` for the first call makes the semantic clearer.
For Phase 1 (before key-based reconciliation), `render()` handles:
- First call: mounts the tree and builds the fiber tree
- Subsequent calls: reconciles props positionally (same structure assumed), calls `prepareUpdate`/`commitUpdate` for changed props
## Acceptance Criteria
- [ ] `Root.render(node)` on first call mounts and builds fiber tree (existing behavior)
- [ ] `Root.render(node)` on second call reconciles props against existing fiber tree
- [ ] Second `render()` does NOT create duplicate instances
- [ ] Positional children matching: Nth old child → Nth new child
- [ ] If child count differs, excess old children remain (structural changes deferred to Phase 2)
- [ ] `Root.unmount()` still a stub (proper unmount is Phase 3)
- [ ] Existing tests pass
- [ ] New test: calling `render()` twice on same root produces one instance tree with updated props
- [ ] New test: `prepareUpdate` is called for changed props on re-render
## References
- docs/architecture/host-config.md — Known Gaps: The Reconciler Gap, render() is not idempotent
- docs/architecture/reconciler.md — Step 2 (Reconcile Props), Changes to Existing Files
- docs/architecture/host-config.md — Open Question 1 (render → mount rename)
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion

42
tasks/review-key-field.md Normal file
View File

@@ -0,0 +1,42 @@
---
id: review-key-field
name: Review key field implementation
status: pending
depends_on: [key-on-uelement, key-extraction-in-h]
created: 2026-05-18T16:22:57.125725833Z
modified: 2026-05-18T16:22:57.125726289Z
scope: narrow
risk: low
impact: phase
level: review
---
# Description
Review the implementation of the `key` field on `UElement` before proceeding to the fiber tree and reconciler phases. This is a critical checkpoint because every downstream task depends on `key` being correctly added to the schema and extracted by `h()`.
Verify schema/type consistency, backward compatibility, and that ADR-004's consequences are properly handled.
## Acceptance Criteria
- [ ] `UElement` TypeScript type matches TypeBox Module schema for `key` field
- [ ] `key` never appears in component function props
- [ ] Backward compatibility: unkeyed elements work identically to pre-key behavior
- [ ] TypeBox `Value.Check` passes for both keyed and unkeyed elements
- [ ] No unintended `key: undefined` serialization on unkeyed elements (verify JSON output)
- [ ] All existing + new tests pass
- [ ] `isUElement` guard still correctly discriminates from `URoot`
## References
- docs/architecture/schema.md
- docs/architecture/decisions/004-key-as-first-class-field.md
- docs/architecture/element-factory.md
## Notes
> To be filled on completion of review
## Summary
> To be filled on completion

View File

@@ -0,0 +1,46 @@
---
id: review-reactive-host-bridge
name: Review reactive-host bridge
status: pending
depends_on: [signal-driven-updates, render-re-renderable]
created: 2026-05-18T16:22:57.199831873Z
modified: 2026-05-18T16:22:57.199832316Z
scope: narrow
risk: low
impact: phase
level: review
---
# Description
Review the reactive-host bridge implementation before proceeding to key-based children reconciliation. This checkpoint verifies that the fiber tree is correctly built during mount, signal changes correctly propagate to `prepareUpdate`/`commitUpdate`, and re-rendering works without duplicate instances.
This is critical because Phase 2 (key-based reconciliation) builds on top of the fiber tree structure and the reconciliation loop. Any bugs in the bridge will compound in the more complex reconciliation logic.
## Acceptance Criteria
- [ ] Fiber tree structure matches the UNode tree after mount
- [ ] Signal-driven prop updates flow correctly through `prepareUpdate``commitUpdate`
- [ ] Multiple signals changing in one batch produce one reconciliation pass
- [ ] `render()` called twice produces one instance tree, not two
- [ ] `signalDisposers` are correctly populated for later cleanup
- [ ] Effect commit order is top-down (parent before child)
- [ ] Function components remain transparent in the fiber tree
- [ ] All existing + new tests pass
- [ ] No signal subscription leaks (verify effect disposers stored on fibers)
- [ ] HostConfig implementations that don't implement `prepareUpdate`/`commitUpdate` still work (no-op)
## References
- docs/architecture/reconciler.md — Steps 1-4
- docs/architecture/decisions/005-signal-driven-updates-over-tree-diffing.md
- docs/architecture/reactive-layer.md
- docs/architecture/host-config.md
## Notes
> To be filled on completion of review
## Summary
> To be filled on completion

View File

@@ -0,0 +1,45 @@
---
id: review-reconciler
name: Review key-based reconciliation
status: pending
depends_on: [commit-mutations]
created: 2026-05-18T16:22:57.261831485Z
modified: 2026-05-18T16:22:57.261831927Z
scope: narrow
risk: low
impact: phase
level: review
---
# Description
Review the full key-based reconciliation implementation before proceeding to unmount/dispose. This checkpoint verifies that the reconciler correctly handles add, remove, reorder, and update operations for both keyed and unkeyed children.
This is important because Phase 3 (dispose) will add teardown logic that depends on the reconciliation algorithm correctly identifying removed fibers. If reconciliation misclassifies a child as "removed" when it should be "moved," disposal will destroy an instance that still exists.
## Acceptance Criteria
- [ ] Keyed children: add/remove/reorder all produce correct host calls
- [ ] Unkeyed children: positional matching works for prop updates
- [ ] Mixed keyed + unkeyed: key-based for keyed, positional for unkeyed
- [ ] Duplicate keys: last-wins, no crash, warn logged
- [ ] Commit order: removes → inserts/moves → updates
- [ ] `insertBefore` used for moves and inserts with a target sibling
- [ ] Fiber tree structure matches the new UNode tree after reconciliation
- [ ] No orphaned fibers (removed fibers are pruned from parent's children)
- [ ] No leaked host instances (every `removeChild` called for removed fibers)
- [ ] Signal-driven prop updates still work alongside structural reconciliation
- [ ] All tests pass including host integration tests
## References
- docs/architecture/reconciler.md — Full reconciliation algorithm
- docs/architecture/lifecycle.md — Disposal depends on correct remove classification
## Notes
> To be filled on completion of review
## Summary
> To be filled on completion

View File

@@ -0,0 +1,52 @@
---
id: signal-driven-updates
name: Wire signal changes to prepareUpdate/commitUpdate
status: pending
depends_on: [mount-with-fibers]
created: 2026-05-18T16:22:57.167895441Z
modified: 2026-05-18T16:22:57.167895878Z
scope: moderate
risk: high
impact: phase
level: implementation
---
# Description
Bridge the reactive layer to the host layer by wiring signal changes to `HostConfig.prepareUpdate`/`commitUpdate` via fibers. This is the core of ADR-005: signals handle 90% of updates (property changes), reconciliation handles structural changes.
When a signal that drives a `ReactiveNode` changes, the `computed` recomputes and an `effect` fires. This effect should:
1. Compare the fiber's current props to the new element's props via `host.prepareUpdate`
2. If `prepareUpdate` returns a non-null payload, queue an "update" effect on the fiber
3. Call `host.commitUpdate` with the payload
Multiple signal changes within a batch are collapsed into one reconciliation pass (Preact signals already batch within `batch()` calls). Updates are committed top-down (parent before child) to ensure parent state is consistent when child updates fire.
## Acceptance Criteria
- [ ] Signal change triggers `scheduleUpdate(fiber, nextNode)` on the associated fiber
- [ ] `scheduleUpdate` batches pending updates and queues `flushUpdates` via microtask
- [ ] `flushUpdates` calls `host.prepareUpdate(fiber.instance, fiber.tag, fiber.props, nextNode.props, ctx)`
- [ ] If `prepareUpdate` returns non-null payload, fiber gets `effect: { type: "update", payload }`
- [ ] `commitEffects` calls `host.commitUpdate(fiber.instance, payload, tag, prevProps, nextProps, ctx)`
- [ ] Commit order is top-down (parent before child)
- [ ] Signal effect disposers are stored in `fiber.signalDisposers` for later cleanup
- [ ] `host.prepareUpdate` and `host.commitUpdate` are optional — if not implemented, update is a no-op
- [ ] Existing tests pass
- [ ] New test: signal change on a reactive element triggers `prepareUpdate` + `commitUpdate`
- [ ] New test: batch of signal changes results in single reconciliation pass
## References
- docs/architecture/reconciler.md — Step 1 (Schedule Update), Step 2 (Reconcile Props), Step 4 (Commit Effects)
- docs/architecture/decisions/005-signal-driven-updates-over-tree-diffing.md — ADR-005
- docs/architecture/reactive-layer.md — ReactiveNode, ReactiveRoot, signal/computed/effect
- docs/architecture/host-config.md — prepareUpdate, commitUpdate methods
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion

View File

@@ -0,0 +1,69 @@
---
id: unmount-implementation
name: Implement full Root.unmount()
status: pending
depends_on: [fiber-disposal, reactiveroot-dispose]
created: 2026-05-18T16:22:57.308494756Z
modified: 2026-05-18T16:22:57.308495198Z
scope: moderate
risk: medium
impact: component
level: implementation
---
# Description
Replace the stub `Root.unmount()` with a full implementation that tears down the fiber tree, removes host instances, disposes signal subscriptions, and calls host cleanup methods.
Current stub:
```typescript
unmount() {
host.finalizeRoot?.(ctx);
host.emit?.("root.unmount", `root_${Date.now()}`, {});
}
```
New flow:
```
root.unmount()
→ disposeFiber(rootFiber, ctx) // disposes signals, calls finalizeInstance bottom-up
→ removeChild for each top-level child // host removal (top-down from root, each child subtree already disposed)
→ host.finalizeRoot(ctx) // root-level host cleanup
→ emit "root.unmount"
→ clear rootFiber reference
```
Note: `disposeFiber` handles resource cleanup (signals, finalizeInstance) but NOT `host.removeChild`. We need to walk the fiber tree and call `removeChild` for host instance removal. However, since children are already disposed, we only need to remove top-level children from the root context.
Open question from lifecycle.md: Should `ReactiveRoot` auto-dispose on `root.unmount()`? The architecture doc suggests decoupling is safer (a ReactiveRoot could drive multiple roots). This task should keep them decoupled — the consumer is responsible for calling `ReactiveRoot.dispose()` separately if needed.
## Acceptance Criteria
- [ ] `Root.unmount()` calls `disposeFiber(rootFiber, ctx)`
- [ ] `Root.unmount()` calls `host.removeChild` for top-level children
- [ ] `host.finalizeRoot(ctx)` called after disposal and removal
- [ ] `host.emit("root.unmount", ...)` still fires
- [ ] `rootFiber` reference cleared after unmount
- [ ] Subsequent `render()` calls after `unmount()` start fresh (new mount)
- [ ] Signal subscriptions from the fiber tree are cleaned up
- [ ] Does NOT auto-dispose `ReactiveRoot` — consumer responsibility
- [ ] `unmount()` is idempotent (safe to call twice)
- [ ] Existing tests pass
- [ ] New test: unmount cleans up all host instances
- [ ] New test: unmount then re-render works (fresh mount)
- [ ] New test: double unmount is safe
- [ ] New test: unmount calls finalizeInstance for each fiber
## References
- docs/architecture/lifecycle.md — Root Unmount Flow, Host Notification
- docs/architecture/host-config.md — Known Gaps: unmount is a stub
- docs/architecture/lifecycle.md — Open Question 2 (ReactiveRoot auto-dispose on unmount)
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion

View File

@@ -0,0 +1,43 @@
---
id: value-clone-prevprops
name: Value.Clone for prevProps snapshots
status: pending
depends_on: [value-equal-bailout]
created: 2026-05-18T16:22:57.354731873Z
modified: 2026-05-18T16:22:57.354732316Z
scope: narrow
risk: low
impact: component
level: implementation
---
# Description
Use `Value.Clone` to create proper `prevProps` snapshots before mutation. Currently the architecture spec says `fiber.prevProps` should hold the snapshot before reconciliation, but without deep cloning, `prevProps` and `fiber.props` could share nested references.
`Value.Clone` provides a deep clone that the `commitUpdate` contract relies on: `host.commitUpdate(instance, payload, tag, prevProps, nextProps, ctx)` needs both the before and after state to be independent.
This is the second optimization in the strategy (after `Value.Equal`), and it's about correctness, not just performance. Without `Value.Clone`, hosts that read `prevProps` during `commitUpdate` might see already-mutated values.
## Acceptance Criteria
- [ ] `Value.Clone` imported from `@alkdev/typebox/value`
- [ ] `fiber.prevProps` set to `Value.Clone(fiber.props)` before reconciliation updates props
- [ ] `prevProps` is a deep clone — no shared references with `fiber.props`
- [ ] `commitUpdate` receives independent `prevProps` and `nextProps`
- [ ] Existing tests pass
- [ ] New test: mutating `fiber.props` after setting `prevProps` does not affect `prevProps`
- [ ] New test: `commitUpdate` receives correct before/after prop values
## References
- docs/architecture/reconciler.md — TypeBox Optimization Layer, Value.Clone row
- docs/architecture/reconciler.md — Optimization Strategy (step 3)
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion

View File

@@ -0,0 +1,52 @@
---
id: value-diff-payloads
name: Value.Diff granular prop payloads
status: pending
depends_on: [value-hash-detection]
created: 2026-05-18T16:22:57.384875485Z
modified: 2026-05-18T16:22:57.384875927Z
scope: narrow
risk: medium
impact: component
level: implementation
---
# Description
Add `Value.Diff` to produce granular property-level diff payloads for `commitUpdate`. Instead of passing the full `prevProps` and `nextProps` to the host, `Value.Diff` identifies exactly which properties changed, allowing hosts to apply targeted updates.
This is the lowest-priority optimization per the architecture doc — "nice-to-have for hosts wanting granular updates." It's optional and the reconciler is correct without it.
**Critical constraint:** `Value.Diff` throws `ValueDiffError` on function values. Since `PropValue` includes functions, `Value.Diff` must either strip function props before diffing or catch the error and fall back to full replacement.
Implementation approach:
1. Before calling `Value.Diff`, strip function-valued props
2. Call `Value.Diff(cleanPrevProps, cleanNextProps)`
3. If `Value.Diff` throws (unexpected function somehow), catch and fall back to full `prepareUpdate`
4. Use the diff result as the payload for `commitUpdate`
## Acceptance Criteria
- [ ] `Value.Diff` imported from `@alkdev/typebox/value`
- [ ] Function-valued props stripped before `Value.Diff` call
- [ ] `ValueDiffError` caught and falls back to full `prepareUpdate`
- [ ] Diff result used as `commitUpdate` payload
- [ ] Hosts that don't need granular diffs can ignore the diff payload and use `prevProps`/`nextProps`
- [ ] Existing tests pass
- [ ] New test: `Value.Diff` payload contains only changed keys
- [ ] New test: function-valued props don't crash `Value.Diff` (stripped or caught)
- [ ] New test: diff error falls back gracefully to `prepareUpdate`
## References
- docs/architecture/reconciler.md — TypeBox Optimization Layer, Value.Diff row, Value.Diff on Functions
- docs/architecture/reconciler.md — Optimization Strategy (step 5, optional)
- docs/architecture/schema.md — PropValue includes functions
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion

View File

@@ -0,0 +1,48 @@
---
id: value-equal-bailout
name: Value.Equal bail-out for unchanged subtrees
status: pending
depends_on: [review-reconciler]
created: 2026-05-18T16:22:57.339429429Z
modified: 2026-05-18T16:22:57.339429873Z
scope: narrow
risk: low
impact: component
level: implementation
---
# Description
Add `Value.Equal` as the first TypeBox optimization layer. When reconciling, if a fiber's cached node is deep-equal to the next node, skip the entire subtree — no `prepareUpdate`, no `commitUpdate`, no children reconciliation.
This is the highest-impact optimization according to the architecture doc because it skips entire subtrees. It's also the simplest to implement and has no constraints (unlike `Value.Hash` which has the global accumulator issue).
The optimization is applied during `reconcileProps`:
```
if Value.Equal(fiber.cachedNode, nextNode):
return // skip this fiber and all children
```
## Acceptance Criteria
- [ ] `Value.Equal` check added before property reconciliation
- [ ] If fiber's cached node equals next node, skip `prepareUpdate` and children reconciliation
- [ ] Cached node stored on fiber (new `cachedNode` field or reuse `props` snapshot)
- [ ] Correctness: behavior is identical with and without the optimization
- [ ] Existing tests pass
- [ ] New test: unchanged subtree skips `prepareUpdate`/`commitUpdate`
- [ ] New test: changed prop still triggers `prepareUpdate`
- [ ] New test: deeply nested unchanged subtree is fully skipped
## References
- docs/architecture/reconciler.md — TypeBox Optimization Layer, Value.Equal row
- docs/architecture/reconciler.md — Optimization Strategy (step 1)
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion

View File

@@ -0,0 +1,54 @@
---
id: value-hash-detection
name: Value.Hash O(1) change detection
status: pending
depends_on: [value-clone-prevprops]
created: 2026-05-18T16:22:57.369878873Z
modified: 2026-05-18T16:22:57.369879316Z
scope: narrow
risk: medium
impact: component
level: implementation
---
# Description
Add `Value.Hash` as an O(1) change detection layer before `Value.Equal`. Hash comparison is faster than deep-equal for large subtrees because it's a single integer comparison.
**Critical constraint:** `Value.Hash` uses a global mutable accumulator (FNV-1a state). It is NOT re-entrant. You cannot call `Value.Hash` from within a `computed` or `effect` that is itself triggered by a hash comparison. Hashes must be computed outside reactive computations, during the commit phase.
The optimization strategy from the architecture doc says to add `Value.Hash` after confirming the global accumulator constraint is manageable. This task must verify that the reconciler never calls `Value.Hash` from within a `computed` or `effect`.
Approach:
1. Cache hash on fiber after each commit
2. On next reconciliation, compute hash of new node
3. If hashes differ, proceed to `Value.Equal` check
4. If hashes match, skip subtree (hash match is sufficient for "unchanged")
## Acceptance Criteria
- [ ] `Value.Hash` imported from `@alkdev/typebox/value`
- [ ] Fiber caches hash value after each commit (new `hash` field)
- [ ] On reconciliation: hash comparison before `Value.Equal` comparison
- [ ] Hash computed OUTSIDE reactive computations (commit phase only)
- [ ] Verified: no `Value.Hash` call happens inside `computed` or `effect` callbacks
- [ ] When hashes differ, falls through to `Value.Equal` (then to full reconciliation)
- [ ] When hashes match, skip subtree (fast path)
- [ ] Existing tests pass
- [ ] New test: unchanged subtree detected by hash match
- [ ] New test: hash mismatch falls through to Value.Equal / reconciliation
- [ ] New test: hash never called from within a computed/effect
## References
- docs/architecture/reconciler.md — TypeBox Optimization Layer, Value.Hash row, Value.Hash Constraint
- docs/architecture/reconciler.md — Optimization Strategy (step 2)
- docs/architecture/reconciler.md — Open Question 3 (should Value.Hash be used given the constraint)
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion