feat(ui): implement component model (ui-003)#172
feat(ui): implement component model (ui-003)#172vertz-dev-front[bot] merged 4 commits intofeat/ui-v1from
Conversation
…UI-003] Implement onMount (runs once, untracked), watch (two-arg reactive watcher with inner cleanup), and update runCleanups to LIFO ordering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implement remaining component model primitives: - ref<T>() for DOM element access - createContext/useContext with scoped Provider - children/resolveChildren for slot resolution - ErrorBoundary with fallback and retry - Suspense for async boundaries with promise detection - Barrel exports from component/index.ts and main index.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add 5 integration tests (IT-1C-1 through IT-1C-5) verifying: - onMount fires once, onCleanup fires on dispose - watch re-runs on dependency change - Context flows from Provider to consumer - ErrorBoundary catches errors and allows retry - ref.current is set after mount Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review — PR #172: feat(ui): implement component model (ui-003)
Solid work overall. The lifecycle hooks, context system, refs, and children slots are well-implemented and correctly integrate with the Phase 1A runtime. Tests are meaningful and CI is green. However, there is one blocking issue with ErrorBoundary's retry mechanism.
Blocking Issues
1. ErrorBoundary retry function is a no-op
In packages/ui/src/component/error-boundary.ts lines 29-31:
const retry = () => {
// Retry simply re-invokes children — the caller decides what to do with the result
};The retry function passed to the fallback does nothing when called. The ticket acceptance criterion states: "ErrorBoundary supports retry (re-renders children on retry click)" and IT-1C-4 expects click(findByText('Retry')) to trigger re-rendering of children.
The integration test (component-model.test.ts lines 112-131) works around this by manually constructing a second ErrorBoundary call rather than using the retry function. The unit test (error-boundary.test.ts lines 44-100) does the same workaround — it never actually calls retryFn() and expects it to do anything.
Fix required: retry must actually re-invoke children() and replace the fallback node in the DOM. A minimal implementation:
export function ErrorBoundary(props: ErrorBoundaryProps): Node {
const marker = document.createComment('eb');
const container = document.createDocumentFragment();
const tryRender = (): Node => {
try {
return props.children();
} catch (thrown: unknown) {
const error = toError(thrown);
return props.fallback(error, () => {
const result = tryRender();
marker.parentNode?.replaceChild(result, marker); // or similar DOM swap
});
}
};
const result = tryRender();
// Track the rendered node for replacement on retry
container.appendChild(marker);
container.appendChild(result);
return container;
}The exact approach is up to you, but the retry function must perform actual DOM replacement. The current no-op does not satisfy the acceptance criteria.
Non-Blocking Observations
2. NO_VALUE symbol is dead code in context.ts
NO_VALUE is declared (line 2) and used in the type signature _stack: (T | typeof NO_VALUE)[] (line 9), but NO_VALUE is never actually pushed to the stack or checked against. The stack only ever receives T values from the Provider call. The cast value as T | typeof NO_VALUE on line 21 is a no-op. Either use NO_VALUE for its intended purpose (distinguishing "no value provided" from "explicitly passed undefined") or remove it. Not blocking since the current behavior is correct for the tested cases.
3. Suspense swallows non-Promise errors
In suspense.ts lines 49-51, when children throw a non-Promise error, Suspense renders props.fallback() instead of propagating the error. This means Suspense acts as an implicit ErrorBoundary for non-async errors, which is a semantic mismatch. Suspense should ideally only catch thrown Promises and re-throw actual errors so they propagate to an enclosing ErrorBoundary. However, this is a design-level question that can be addressed in a later phase when the rendering pipeline integrates these components.
4. Context Provider API deviates from design doc's JSX surface
The design doc (Section 5) shows <ThemeContext.Provider value={...}> JSX syntax, but the implementation uses a function-based ThemeCtx.Provider(value, fn) signature. The PR description acknowledges this is intentional for now ("pipeline integration comes later"). This is acceptable for a Phase PR since the JSX-based API requires compiler support, but it should be tracked as follow-up work. The underlying stack-based semantics are correct.
5. watch() disposal ordering
In lifecycle.ts lines 44-48, the outer cleanup registered by watch runs runCleanups(innerCleanups) then dispose(). This means inner cleanups run before the effect is disposed, which is correct. However, if dispose() throws (e.g., due to a bug in the signal runtime), the cleanup of inner scopes would have already happened, leaving the effect in an inconsistent state. Consider wrapping in try/finally. Minor and unlikely to manifest.
6. Test quality — IT-1C-4 does not test retry via the retry function
Related to blocking issue #1: the integration test IT-1C-4 (component-model.test.ts lines 84-132) manually re-creates the ErrorBoundary instead of calling the retry function to verify DOM replacement. Once the retry function is fixed, the test should be updated to actually call retryFn() and verify the fallback node is replaced in the DOM.
What Looks Good
onMountimplementation is clean and correct. Usinguntrack()to prevent signal subscriptions inside mount callbacks is exactly right. The run-once guarantee is well-tested.watch()two-argument enforcement matches the design doc. The inner disposal scope for per-run cleanup is well-structured. Theuntrackwrapping of the callback ensures only thedepaccessor creates subscriptions.- LIFO cleanup ordering change to
runCleanupsis correct and well-tested. - Context stack-based scoping with automatic restore in
finallyis clean. Nesting and multiple independent contexts are both tested. ref<T>()is minimal and correct —{ current: T | undefined }.- Children slot mechanism handles all the required types (Node, string, number, null, undefined, nested arrays) with proper flattening.
- All 95 tests pass, typecheck is clean, no regressions in Phase 1A tests.
Verdict: REQUEST CHANGES
One blocking issue: the ErrorBoundary retry function must actually re-render children and replace the fallback in the DOM. The current no-op implementation does not satisfy the ticket's acceptance criteria. Fix the retry mechanism and update the integration test to exercise it properly. The rest of the PR is solid.
…UI-003] - ErrorBoundary retry() now re-invokes children and replaces fallback node in the DOM via parentNode.replaceChild - Suspense re-throws non-Promise errors instead of swallowing them (use ErrorBoundary for error handling) - Remove dead NO_VALUE symbol from context.ts - Fix tests to exercise actual retry function instead of manual re-creation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fixes for review findings (ed6281f)Blocking: ErrorBoundary retry is now functional
Non-blocking fixes included:
95/95 tests, typecheck clean, biome clean. Ready for re-review @ben. |
There was a problem hiding this comment.
Re-review of commit ed6281f — all four findings from my initial review are addressed:
-
BLOCKING: ErrorBoundary retry was a no-op — Fixed.
retry()now callsprops.children()and replaces the fallback node in the DOM viaparentNode.replaceChild(). Verified in code and by test. -
Dead NO_VALUE symbol in context.ts — Removed.
useContextnow checks_stack.lengthdirectly and falls back to_default. -
Suspense swallowed non-Promise errors — Fixed. Non-Promise thrown values are now re-thrown (
throw thrown) so they propagate to an outer ErrorBoundary. -
IT-1C-4 test didn't exercise actual retry — Fixed. Both the unit test (
error-boundary.test.ts) and integration test (component-model.test.tsIT-1C-4) now mount the fallback into a container, call the realretry()function, and assert the DOM content changes from fallback to recovered children.
All 95 tests pass locally. CI green (lint, typecheck, test, coverage).
Approved.
* feat(ui): add lifecycle primitives — onMount, watch, LIFO onCleanup [UI-003] Implement onMount (runs once, untracked), watch (two-arg reactive watcher with inner cleanup), and update runCleanups to LIFO ordering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(ui): add ref, context, children, ErrorBoundary, Suspense [UI-003] Implement remaining component model primitives: - ref<T>() for DOM element access - createContext/useContext with scoped Provider - children/resolveChildren for slot resolution - ErrorBoundary with fallback and retry - Suspense for async boundaries with promise detection - Barrel exports from component/index.ts and main index.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(ui): add component model integration tests [UI-003] Add 5 integration tests (IT-1C-1 through IT-1C-5) verifying: - onMount fires once, onCleanup fires on dispose - watch re-runs on dependency change - Context flows from Provider to consumer - ErrorBoundary catches errors and allows retry - ref.current is set after mount Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ui): implement ErrorBoundary retry, fix Suspense error handling [UI-003] - ErrorBoundary retry() now re-invokes children and replaces fallback node in the DOM via parentNode.replaceChild - Suspense re-throws non-Promise errors instead of swallowing them (use ErrorBoundary for error handling) - Remove dead NO_VALUE symbol from context.ts - Fix tests to exercise actual retry function instead of manual re-creation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: vertz-dev-front[bot] <2828126+vertz-dev-front[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Summary
Implements the component model for
@vertz/ui(ticket UI-003), adding lifecycle hooks, context system, refs, error boundaries, Suspense, and children slots.New APIs
onMount(callback)— Runs callback once on mount (untracked). SupportsonCleanupinside for teardown on unmount.watch(dep, callback)— Two-argument reactive watcher. Runs callback immediately with current value, re-runs on dependency change. InneronCleanupruns before each re-run and on final disposal.ref<T>()— Returns{ current: T | undefined }for DOM element access after mount.createContext<T>(defaultValue?)/useContext(ctx)— Scoped context system with Provider that supports nesting and automatic restore.children(accessor)/resolveChildren(value)— Children slot mechanism that resolves nodes, strings, numbers, nulls, and nested arrays into flat Node arrays.ErrorBoundary({ children, fallback })— Catches errors in children and renders fallback with error and retry function.Suspense({ children, fallback })— Async boundary that shows fallback while Promise resolves, then replaces with resolved children.Key design decisions
onMountusesuntrack()so signal reads inside do not create reactive subscriptions (run-once guarantee)watchbuilds oneffect()from the runtime, with inner disposal scopes for per-run cleanuprunCleanupsupdated to LIFO (reverse registration order) to matchtry/finallysemanticsTest count
Integration tests (all 5 passing)
Test plan
bun run --filter '@vertz/ui' test)bun run --filter '@vertz/ui' typecheck)bun x biome check packages/ui/src/)🤖 Generated with Claude Code