From adaa08eb2f247628a45f67119eac5f472027bc26 Mon Sep 17 00:00:00 2001 From: "vertz-dev-front[bot]" <2828126+vertz-dev-front[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:44:20 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(ui):=20add=20lifecycle=20primitives=20?= =?UTF-8?q?=E2=80=94=20onMount,=20watch,=20LIFO=20onCleanup=20[UI-003]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/component/__tests__/lifecycle.test.ts | 146 ++++++++++++++++++ packages/ui/src/component/lifecycle.ts | 50 ++++++ packages/ui/src/runtime/disposal.ts | 7 +- 3 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/component/__tests__/lifecycle.test.ts create mode 100644 packages/ui/src/component/lifecycle.ts diff --git a/packages/ui/src/component/__tests__/lifecycle.test.ts b/packages/ui/src/component/__tests__/lifecycle.test.ts new file mode 100644 index 000000000..d7ce42c88 --- /dev/null +++ b/packages/ui/src/component/__tests__/lifecycle.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from 'vitest'; +import { onCleanup, popScope, pushScope, runCleanups } from '../../runtime/disposal'; +import { signal } from '../../runtime/signal'; +import { onMount, watch } from '../lifecycle'; + +describe('onMount', () => { + test('runs callback immediately within a disposal scope', () => { + let mounted = false; + pushScope(); + onMount(() => { + mounted = true; + }); + popScope(); + expect(mounted).toBe(true); + }); + + test('onCleanup inside onMount runs on scope disposal', () => { + let cleaned = false; + const scope = pushScope(); + onMount(() => { + onCleanup(() => { + cleaned = true; + }); + }); + popScope(); + expect(cleaned).toBe(false); + runCleanups(scope); + expect(cleaned).toBe(true); + }); + + test('onMount runs exactly once and does not re-execute', () => { + let mountCount = 0; + const count = signal(0); + pushScope(); + onMount(() => { + // Read a signal inside onMount — should NOT cause re-execution + count.value; + mountCount++; + }); + popScope(); + expect(mountCount).toBe(1); + count.value = 1; + count.value = 2; + // Should still be 1 — onMount never re-runs + expect(mountCount).toBe(1); + }); +}); + +describe('watch', () => { + test('runs callback immediately with current value', () => { + const values: number[] = []; + const count = signal(0); + pushScope(); + watch( + () => count.value, + (val) => values.push(val), + ); + popScope(); + expect(values).toEqual([0]); + }); + + test('re-runs callback when dependency changes', () => { + const values: number[] = []; + const count = signal(0); + pushScope(); + watch( + () => count.value, + (val) => values.push(val), + ); + popScope(); + count.value = 1; + expect(values).toEqual([0, 1]); + }); + + test('disposes when scope is cleaned up', () => { + const values: number[] = []; + const count = signal(0); + const scope = pushScope(); + watch( + () => count.value, + (val) => values.push(val), + ); + popScope(); + count.value = 1; + expect(values).toEqual([0, 1]); + runCleanups(scope); + count.value = 2; + // After disposal, watch should not run anymore + expect(values).toEqual([0, 1]); + }); + + test('onCleanup inside watch runs before each re-run', () => { + const log: string[] = []; + const count = signal(0); + pushScope(); + watch( + () => count.value, + (val) => { + onCleanup(() => { + log.push(`cleanup-${val}`); + }); + log.push(`run-${val}`); + }, + ); + popScope(); + expect(log).toEqual(['run-0']); + count.value = 1; + // Previous cleanup runs before new run + expect(log).toEqual(['run-0', 'cleanup-0', 'run-1']); + count.value = 2; + expect(log).toEqual(['run-0', 'cleanup-0', 'run-1', 'cleanup-1', 'run-2']); + }); + + test('onCleanup inside watch runs on final disposal', () => { + const log: string[] = []; + const count = signal(0); + const scope = pushScope(); + watch( + () => count.value, + (val) => { + onCleanup(() => { + log.push(`cleanup-${val}`); + }); + log.push(`run-${val}`); + }, + ); + popScope(); + expect(log).toEqual(['run-0']); + // Dispose without any re-runs + runCleanups(scope); + expect(log).toEqual(['run-0', 'cleanup-0']); + }); +}); + +describe('onCleanup LIFO ordering', () => { + test('cleanup handlers run in reverse registration order', () => { + const order: number[] = []; + const scope = pushScope(); + onCleanup(() => order.push(1)); + onCleanup(() => order.push(2)); + onCleanup(() => order.push(3)); + popScope(); + runCleanups(scope); + expect(order).toEqual([3, 2, 1]); + }); +}); diff --git a/packages/ui/src/component/lifecycle.ts b/packages/ui/src/component/lifecycle.ts new file mode 100644 index 000000000..9f882ba5b --- /dev/null +++ b/packages/ui/src/component/lifecycle.ts @@ -0,0 +1,50 @@ +import { onCleanup, popScope, pushScope, runCleanups } from '../runtime/disposal'; +import { effect } from '../runtime/signal'; +import type { DisposeFn } from '../runtime/signal-types'; +import { untrack } from '../runtime/tracking'; + +/** + * Runs callback once on mount. Never re-executes. + * Supports `onCleanup` inside for teardown on unmount. + */ +export function onMount(callback: () => void): void { + // Execute untracked so signal reads inside do not create subscriptions + untrack(callback); +} + +/** + * Watches a dependency accessor and runs callback whenever it changes. + * Always takes TWO arguments: a dependency accessor and a callback. + * Runs callback immediately with current value. + * Before each re-run, any `onCleanup` from previous run executes first. + */ +export function watch(dep: () => T, callback: (value: T) => void): void { + let innerCleanups: DisposeFn[] | null = null; + + const dispose = effect(() => { + // Run previous inner cleanups before re-running + if (innerCleanups) { + runCleanups(innerCleanups); + } + + // Read the dependency (tracked) + const value = dep(); + + // Set up a new inner scope for onCleanup calls inside the callback + innerCleanups = pushScope(); + try { + // Execute callback untracked so only `dep` is the reactive dependency + untrack(() => callback(value)); + } finally { + popScope(); + } + }); + + // Register disposal of the effect + final inner cleanups with the outer scope + onCleanup(() => { + if (innerCleanups) { + runCleanups(innerCleanups); + } + dispose(); + }); +} diff --git a/packages/ui/src/runtime/disposal.ts b/packages/ui/src/runtime/disposal.ts index eb1281742..07b031108 100644 --- a/packages/ui/src/runtime/disposal.ts +++ b/packages/ui/src/runtime/disposal.ts @@ -36,11 +36,12 @@ export function popScope(): void { } /** - * Run all collected cleanup functions and clear the list. + * Run all collected cleanup functions in LIFO (reverse) order and clear the list. + * Reverse order matches try/finally semantics — last registered, first cleaned up. */ export function runCleanups(cleanups: DisposeFn[]): void { - for (const fn of cleanups) { - fn(); + for (let i = cleanups.length - 1; i >= 0; i--) { + cleanups[i]?.(); } cleanups.length = 0; } From 02ced50ca9d8b1d36bf423772d675e16b151b3f2 Mon Sep 17 00:00:00 2001 From: "vertz-dev-front[bot]" <2828126+vertz-dev-front[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:47:35 +0000 Subject: [PATCH 2/4] feat(ui): add ref, context, children, ErrorBoundary, Suspense [UI-003] Implement remaining component model primitives: - ref() 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 --- .../src/component/__tests__/children.test.ts | 79 ++++++++++ .../src/component/__tests__/context.test.ts | 59 ++++++++ .../__tests__/error-boundary.test.ts | 136 ++++++++++++++++++ .../ui/src/component/__tests__/refs.test.ts | 33 +++++ .../src/component/__tests__/suspense.test.ts | 84 +++++++++++ packages/ui/src/component/children.ts | 44 ++++++ packages/ui/src/component/context.ts | 43 ++++++ packages/ui/src/component/error-boundary.ts | 34 +++++ packages/ui/src/component/index.ts | 11 ++ packages/ui/src/component/refs.ts | 13 ++ packages/ui/src/component/suspense.ts | 53 +++++++ packages/ui/src/index.ts | 11 ++ 12 files changed, 600 insertions(+) create mode 100644 packages/ui/src/component/__tests__/children.test.ts create mode 100644 packages/ui/src/component/__tests__/context.test.ts create mode 100644 packages/ui/src/component/__tests__/error-boundary.test.ts create mode 100644 packages/ui/src/component/__tests__/refs.test.ts create mode 100644 packages/ui/src/component/__tests__/suspense.test.ts create mode 100644 packages/ui/src/component/children.ts create mode 100644 packages/ui/src/component/context.ts create mode 100644 packages/ui/src/component/error-boundary.ts create mode 100644 packages/ui/src/component/index.ts create mode 100644 packages/ui/src/component/refs.ts create mode 100644 packages/ui/src/component/suspense.ts diff --git a/packages/ui/src/component/__tests__/children.test.ts b/packages/ui/src/component/__tests__/children.test.ts new file mode 100644 index 000000000..ad52eadc1 --- /dev/null +++ b/packages/ui/src/component/__tests__/children.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'vitest'; +import type { ChildrenAccessor } from '../children'; +import { children, resolveChildren } from '../children'; + +describe('children', () => { + test('returns a getter that resolves a single child node', () => { + const el = document.createElement('div'); + const accessor: ChildrenAccessor = () => el; + const resolved = children(accessor); + expect(resolved()).toEqual([el]); + }); + + test('returns a getter that resolves an array of child nodes', () => { + const a = document.createElement('span'); + const b = document.createElement('span'); + const accessor: ChildrenAccessor = () => [a, b]; + const resolved = children(accessor); + expect(resolved()).toEqual([a, b]); + }); + + test('flattens nested arrays', () => { + const a = document.createElement('span'); + const b = document.createElement('span'); + const c = document.createElement('span'); + const accessor: ChildrenAccessor = () => [a, [b, c]]; + const resolved = children(accessor); + expect(resolved()).toEqual([a, b, c]); + }); + + test('filters out null and undefined', () => { + const a = document.createElement('span'); + const accessor: ChildrenAccessor = () => [null, a, undefined]; + const resolved = children(accessor); + expect(resolved()).toEqual([a]); + }); + + test('handles string children as text nodes', () => { + const accessor: ChildrenAccessor = () => 'hello'; + const resolved = children(accessor); + const result = resolved(); + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(Text); + expect((result[0] as Text).textContent).toBe('hello'); + }); + + test('handles number children as text nodes', () => { + const accessor: ChildrenAccessor = () => 42; + const resolved = children(accessor); + const result = resolved(); + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(Text); + expect((result[0] as Text).textContent).toBe('42'); + }); +}); + +describe('resolveChildren', () => { + test('resolves a single node', () => { + const el = document.createElement('div'); + expect(resolveChildren(el)).toEqual([el]); + }); + + test('resolves null to empty array', () => { + expect(resolveChildren(null)).toEqual([]); + }); + + test('resolves undefined to empty array', () => { + expect(resolveChildren(undefined)).toEqual([]); + }); + + test('resolves mixed array with filtering and flattening', () => { + const a = document.createElement('span'); + const b = document.createElement('span'); + const result = resolveChildren([null, a, [b, undefined], 'text']); + expect(result).toHaveLength(3); + expect(result[0]).toBe(a); + expect(result[1]).toBe(b); + expect(result[2]).toBeInstanceOf(Text); + }); +}); diff --git a/packages/ui/src/component/__tests__/context.test.ts b/packages/ui/src/component/__tests__/context.test.ts new file mode 100644 index 000000000..4ad0a18d5 --- /dev/null +++ b/packages/ui/src/component/__tests__/context.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'vitest'; +import { createContext, useContext } from '../context'; + +describe('createContext / useContext', () => { + test('useContext returns default value when no Provider is set', () => { + const ThemeCtx = createContext('light'); + expect(useContext(ThemeCtx)).toBe('light'); + }); + + test('useContext returns undefined when no Provider and no default', () => { + const Ctx = createContext(); + expect(useContext(Ctx)).toBeUndefined(); + }); + + test('Provider sets value that useContext retrieves', () => { + const ThemeCtx = createContext('light'); + ThemeCtx.Provider('dark', () => { + expect(useContext(ThemeCtx)).toBe('dark'); + }); + }); + + test('nested Providers shadow outer values', () => { + const ThemeCtx = createContext('light'); + ThemeCtx.Provider('dark', () => { + expect(useContext(ThemeCtx)).toBe('dark'); + ThemeCtx.Provider('blue', () => { + expect(useContext(ThemeCtx)).toBe('blue'); + }); + // After inner Provider scope ends, outer value restored + expect(useContext(ThemeCtx)).toBe('dark'); + }); + // After all Providers, default restored + expect(useContext(ThemeCtx)).toBe('light'); + }); + + test('multiple independent contexts do not interfere', () => { + const ThemeCtx = createContext('light'); + const LangCtx = createContext('en'); + ThemeCtx.Provider('dark', () => { + LangCtx.Provider('fr', () => { + expect(useContext(ThemeCtx)).toBe('dark'); + expect(useContext(LangCtx)).toBe('fr'); + }); + expect(useContext(LangCtx)).toBe('en'); + }); + }); + + test('Provider works with complex types', () => { + interface Config { + api: string; + debug: boolean; + } + const ConfigCtx = createContext({ api: '/api', debug: false }); + const customConfig = { api: '/v2/api', debug: true }; + ConfigCtx.Provider(customConfig, () => { + expect(useContext(ConfigCtx)).toBe(customConfig); + }); + }); +}); diff --git a/packages/ui/src/component/__tests__/error-boundary.test.ts b/packages/ui/src/component/__tests__/error-boundary.test.ts new file mode 100644 index 000000000..589bb5444 --- /dev/null +++ b/packages/ui/src/component/__tests__/error-boundary.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from 'vitest'; +import { signal } from '../../runtime/signal'; +import { ErrorBoundary } from '../error-boundary'; + +describe('ErrorBoundary', () => { + test('renders children when no error occurs', () => { + const result = ErrorBoundary({ + children: () => document.createElement('div'), + fallback: () => document.createElement('span'), + }); + expect(result.tagName).toBe('DIV'); + }); + + test('renders fallback when children throw', () => { + const result = ErrorBoundary({ + children: () => { + throw new TypeError('test error'); + }, + fallback: (error) => { + const el = document.createElement('span'); + el.textContent = error.message; + return el; + }, + }); + expect(result.tagName).toBe('SPAN'); + expect(result.textContent).toBe('test error'); + }); + + test('fallback receives the error object', () => { + let capturedError: Error | undefined; + ErrorBoundary({ + children: () => { + throw new RangeError('out of bounds'); + }, + fallback: (error) => { + capturedError = error; + return document.createElement('div'); + }, + }); + expect(capturedError).toBeInstanceOf(RangeError); + expect(capturedError?.message).toBe('out of bounds'); + }); + + test('fallback receives a retry function that re-renders children', () => { + const attempts = signal(0); + let retryFn: (() => void) | undefined; + + const container = document.createElement('div'); + const render = () => { + const result = ErrorBoundary({ + children: () => { + attempts.value++; + if (attempts.peek() < 2) { + throw new TypeError('not ready'); + } + const el = document.createElement('p'); + el.textContent = 'success'; + return el; + }, + fallback: (_error, retry) => { + retryFn = retry; + const el = document.createElement('span'); + el.textContent = 'error'; + return el; + }, + }); + container.innerHTML = ''; + container.appendChild(result); + }; + + render(); + // First render: children throws, fallback shown + expect(container.textContent).toBe('error'); + expect(retryFn).toBeDefined(); + + // Retry: this time children should succeed (attempts >= 2) + // We need to re-render with the retry + if (retryFn) { + // Retry re-invokes the ErrorBoundary + const retryResult = ErrorBoundary({ + children: () => { + attempts.value++; + if (attempts.peek() < 2) { + throw new TypeError('not ready'); + } + const el = document.createElement('p'); + el.textContent = 'success'; + return el; + }, + fallback: (_error, retry) => { + retryFn = retry; + const el = document.createElement('span'); + el.textContent = 'error'; + return el; + }, + }); + container.innerHTML = ''; + container.appendChild(retryResult); + } + expect(container.textContent).toBe('success'); + }); + + test('catches errors from nested children', () => { + const result = ErrorBoundary({ + children: () => { + // Simulate a deeply nested error + const inner = () => { + throw new TypeError('deep error'); + }; + inner(); + return document.createElement('div'); + }, + fallback: (error) => { + const el = document.createElement('span'); + el.textContent = error.message; + return el; + }, + }); + expect(result.textContent).toBe('deep error'); + }); + + test('non-Error throws are wrapped in Error', () => { + let capturedError: Error | undefined; + ErrorBoundary({ + children: () => { + throw 'string error'; // eslint-disable-line no-throw-literal + }, + fallback: (error) => { + capturedError = error; + return document.createElement('div'); + }, + }); + expect(capturedError).toBeInstanceOf(Error); + expect(capturedError?.message).toBe('string error'); + }); +}); diff --git a/packages/ui/src/component/__tests__/refs.test.ts b/packages/ui/src/component/__tests__/refs.test.ts new file mode 100644 index 000000000..fa87d5eed --- /dev/null +++ b/packages/ui/src/component/__tests__/refs.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'vitest'; +import { ref } from '../refs'; + +describe('ref', () => { + test('returns an object with current initially undefined', () => { + const r = ref(); + expect(r.current).toBeUndefined(); + }); + + test('current can be assigned a value', () => { + const r = ref(); + const el = document.createElement('div'); + r.current = el; + expect(r.current).toBe(el); + }); + + test('current can be reassigned', () => { + const r = ref(); + const el1 = document.createElement('div'); + const el2 = document.createElement('div'); + r.current = el1; + expect(r.current).toBe(el1); + r.current = el2; + expect(r.current).toBe(el2); + }); + + test('works with generic types', () => { + const numRef = ref(); + expect(numRef.current).toBeUndefined(); + numRef.current = 42; + expect(numRef.current).toBe(42); + }); +}); diff --git a/packages/ui/src/component/__tests__/suspense.test.ts b/packages/ui/src/component/__tests__/suspense.test.ts new file mode 100644 index 000000000..460a141d7 --- /dev/null +++ b/packages/ui/src/component/__tests__/suspense.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'vitest'; +import { Suspense } from '../suspense'; + +describe('Suspense', () => { + test('renders children synchronously when no async', () => { + const child = document.createElement('div'); + child.textContent = 'loaded'; + const result = Suspense({ + children: () => child, + fallback: () => { + const el = document.createElement('span'); + el.textContent = 'loading...'; + return el; + }, + }); + expect(result.textContent).toBe('loaded'); + }); + + test('renders fallback when children throw a Promise', () => { + const result = Suspense({ + children: () => { + throw Promise.resolve('data'); + }, + fallback: () => { + const el = document.createElement('span'); + el.textContent = 'loading...'; + return el; + }, + }); + expect(result.textContent).toBe('loading...'); + }); + + test('replaces fallback with children after promise resolves', async () => { + const container = document.createElement('div'); + let resolvePromise: (value: string) => void; + const pending = new Promise((resolve) => { + resolvePromise = resolve; + }); + + let attempt = 0; + const result = Suspense({ + children: () => { + attempt++; + if (attempt === 1) { + throw pending; + } + const el = document.createElement('p'); + el.textContent = 'done'; + return el; + }, + fallback: () => { + const el = document.createElement('span'); + el.textContent = 'loading...'; + return el; + }, + }); + + container.appendChild(result); + expect(container.textContent).toBe('loading...'); + + // Resolve the promise + resolvePromise?.('data'); + await pending; + // Wait for microtask to flush + await new Promise((r) => setTimeout(r, 0)); + + expect(container.textContent).toBe('done'); + }); + + test('renders fallback when children throw an error (not a Promise)', () => { + // Non-promise errors should be caught and fallback shown + const result = Suspense({ + children: () => { + throw new TypeError('real error'); + }, + fallback: () => { + const el = document.createElement('span'); + el.textContent = 'error fallback'; + return el; + }, + }); + expect(result.textContent).toBe('error fallback'); + }); +}); diff --git a/packages/ui/src/component/children.ts b/packages/ui/src/component/children.ts new file mode 100644 index 000000000..81a7fbae9 --- /dev/null +++ b/packages/ui/src/component/children.ts @@ -0,0 +1,44 @@ +/** A single child value: DOM node, string, number, null, undefined, or nested array. */ +export type ChildValue = Node | string | number | null | undefined | ChildValue[]; + +/** A function that returns children (slot accessor). */ +export type ChildrenAccessor = () => ChildValue; + +/** + * Resolve a raw child value into a flat array of DOM nodes. + * Strings and numbers are converted to Text nodes. + * Null and undefined are filtered out. + * Arrays are flattened recursively. + */ +export function resolveChildren(value: ChildValue): Node[] { + if (value == null) { + return []; + } + if (typeof value === 'string') { + return [document.createTextNode(value)]; + } + if (typeof value === 'number') { + return [document.createTextNode(String(value))]; + } + if (Array.isArray(value)) { + const result: Node[] = []; + for (const child of value) { + const resolved = resolveChildren(child); + for (const node of resolved) { + result.push(node); + } + } + return result; + } + // It's a Node + return [value]; +} + +/** + * Create a children resolver from a children accessor. + * Returns a function that, when called, resolves the children + * to a flat array of DOM nodes. + */ +export function children(accessor: ChildrenAccessor): () => Node[] { + return () => resolveChildren(accessor()); +} diff --git a/packages/ui/src/component/context.ts b/packages/ui/src/component/context.ts new file mode 100644 index 000000000..924a5ad2f --- /dev/null +++ b/packages/ui/src/component/context.ts @@ -0,0 +1,43 @@ +/** Unique token to detect "no value provided" vs explicit undefined. */ +const NO_VALUE: unique symbol = Symbol('NO_VALUE'); + +/** A context object created by `createContext`. */ +export interface Context { + /** Provide a value to all `useContext` calls within the scope. */ + Provider: (value: T, fn: () => void) => void; + /** @internal — current value stack */ + _stack: (T | typeof NO_VALUE)[]; + /** @internal — default value */ + _default: T | undefined; +} + +/** + * Create a context with an optional default value. + * Returns an object with a `Provider` function. + */ +export function createContext(defaultValue?: T): Context { + const ctx: Context = { + Provider(value: T, fn: () => void): void { + ctx._stack.push(value as T | typeof NO_VALUE); + try { + fn(); + } finally { + ctx._stack.pop(); + } + }, + _default: defaultValue, + _stack: [], + }; + return ctx; +} + +/** + * Retrieve the current value from the nearest Provider. + * Returns the default value if no Provider is active. + */ +export function useContext(ctx: Context): T | undefined { + if (ctx._stack.length > 0) { + return ctx._stack[ctx._stack.length - 1] as T; + } + return ctx._default; +} diff --git a/packages/ui/src/component/error-boundary.ts b/packages/ui/src/component/error-boundary.ts new file mode 100644 index 000000000..77056107a --- /dev/null +++ b/packages/ui/src/component/error-boundary.ts @@ -0,0 +1,34 @@ +/** Props for the ErrorBoundary component. */ +export interface ErrorBoundaryProps { + /** Function that returns the children to render. */ + children: () => Node; + /** Fallback renderer that receives the caught error and a retry function. */ + fallback: (error: Error, retry: () => void) => Node; +} + +/** + * Normalize a caught value into an Error instance. + */ +function toError(value: unknown): Error { + if (value instanceof Error) { + return value; + } + return new Error(String(value)); +} + +/** + * ErrorBoundary component. + * Catches errors thrown by `children()` and renders `fallback` instead. + * The fallback receives the error and a retry function to re-attempt rendering. + */ +export function ErrorBoundary(props: ErrorBoundaryProps): Node { + try { + return props.children(); + } catch (thrown: unknown) { + const error = toError(thrown); + const retry = () => { + // Retry simply re-invokes children — the caller decides what to do with the result + }; + return props.fallback(error, retry); + } +} diff --git a/packages/ui/src/component/index.ts b/packages/ui/src/component/index.ts new file mode 100644 index 000000000..85710ff51 --- /dev/null +++ b/packages/ui/src/component/index.ts @@ -0,0 +1,11 @@ +export type { ChildrenAccessor, ChildValue } from './children'; +export { children, resolveChildren } from './children'; +export type { Context } from './context'; +export { createContext, useContext } from './context'; +export type { ErrorBoundaryProps } from './error-boundary'; +export { ErrorBoundary } from './error-boundary'; +export { onMount, watch } from './lifecycle'; +export type { Ref } from './refs'; +export { ref } from './refs'; +export type { SuspenseProps } from './suspense'; +export { Suspense } from './suspense'; diff --git a/packages/ui/src/component/refs.ts b/packages/ui/src/component/refs.ts new file mode 100644 index 000000000..428ef27f1 --- /dev/null +++ b/packages/ui/src/component/refs.ts @@ -0,0 +1,13 @@ +/** A ref container for DOM element access. */ +export interface Ref { + current: T | undefined; +} + +/** + * Create a ref for accessing a DOM element after mount. + * Returns `{ current: undefined }` initially; after mount, + * `ref.current` will hold the DOM element. + */ +export function ref(): Ref { + return { current: undefined }; +} diff --git a/packages/ui/src/component/suspense.ts b/packages/ui/src/component/suspense.ts new file mode 100644 index 000000000..da84e9f06 --- /dev/null +++ b/packages/ui/src/component/suspense.ts @@ -0,0 +1,53 @@ +/** Props for the Suspense component. */ +export interface SuspenseProps { + /** Function that returns the children to render (may throw a Promise). */ + children: () => Node; + /** Fallback renderer shown while children are pending. */ + fallback: () => Node; +} + +/** + * Check if a value is a thenable (Promise-like). + */ +function isPromise(value: unknown): value is Promise { + return ( + value != null && + typeof value === 'object' && + typeof (value as Promise).then === 'function' + ); +} + +/** + * Suspense component for async boundaries. + * Renders children synchronously if possible. + * If children throw a Promise, renders the fallback and waits for resolution, + * then replaces the fallback with the children result. + * If children throw a non-Promise error, renders the fallback. + */ +export function Suspense(props: SuspenseProps): Node { + try { + return props.children(); + } catch (thrown: unknown) { + if (isPromise(thrown)) { + // Create a placeholder that will be replaced after the promise resolves + const placeholder = props.fallback(); + + thrown.then(() => { + try { + const resolved = props.children(); + // Replace placeholder in the DOM if it has a parent + if (placeholder.parentNode) { + placeholder.parentNode.replaceChild(resolved, placeholder); + } + } catch (_retryError: unknown) { + // If children throw again on retry, keep the fallback + } + }); + + return placeholder; + } + + // Non-Promise error: show fallback + return props.fallback(); + } +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index ba28bdeb0..c35e97bc0 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,3 +1,14 @@ +export type { ChildrenAccessor, ChildValue } from './component/children'; +export { children, resolveChildren } from './component/children'; +export type { Context } from './component/context'; +export { createContext, useContext } from './component/context'; +export type { ErrorBoundaryProps } from './component/error-boundary'; +export { ErrorBoundary } from './component/error-boundary'; +export { onMount, watch } from './component/lifecycle'; +export type { Ref } from './component/refs'; +export { ref } from './component/refs'; +export type { SuspenseProps } from './component/suspense'; +export { Suspense } from './component/suspense'; export { __attr, __classList, __show } from './dom/attributes'; export { __conditional } from './dom/conditional'; export { __element, __text } from './dom/element'; From 655bb6bae0f97c43b50f754aa8b1d8b0e7c15249 Mon Sep 17 00:00:00 2001 From: "vertz-dev-front[bot]" <2828126+vertz-dev-front[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:48:14 +0000 Subject: [PATCH 3/4] 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 --- .../ui/src/__tests__/component-model.test.ts | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 packages/ui/src/__tests__/component-model.test.ts diff --git a/packages/ui/src/__tests__/component-model.test.ts b/packages/ui/src/__tests__/component-model.test.ts new file mode 100644 index 000000000..cbd5146fd --- /dev/null +++ b/packages/ui/src/__tests__/component-model.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test } from 'vitest'; +import { createContext, useContext } from '../component/context'; +import { ErrorBoundary } from '../component/error-boundary'; +import { onMount, watch } from '../component/lifecycle'; +import { ref } from '../component/refs'; +import { onCleanup, popScope, pushScope, runCleanups } from '../runtime/disposal'; +import { signal } from '../runtime/signal'; + +describe('Integration Tests — Component Model', () => { + // IT-1C-1: onMount runs once, onCleanup runs on unmount + test('onMount fires once, onCleanup fires on dispose', () => { + let mounted = false; + let cleaned = false; + + const scope = pushScope(); + onMount(() => { + mounted = true; + onCleanup(() => { + cleaned = true; + }); + }); + popScope(); + + // onMount callback ran immediately + expect(mounted).toBe(true); + // Cleanup not yet called + expect(cleaned).toBe(false); + + // Simulate unmount by running disposal + runCleanups(scope); + expect(cleaned).toBe(true); + }); + + // IT-1C-2: watch() re-runs callback when dependency changes + test('watch re-runs on dependency change', () => { + const values: number[] = []; + const count = signal(0); + + pushScope(); + watch( + () => count.value, + (val) => values.push(val), + ); + popScope(); + + // Initial run captures value 0 + expect(values).toEqual([0]); + + // Dependency change triggers re-run + count.value = 1; + expect(values).toEqual([0, 1]); + }); + + // IT-1C-3: Context flows through component tree + test('context value flows from Provider to consumer', () => { + const ThemeCtx = createContext('light'); + + // Without Provider, default is returned + expect(useContext(ThemeCtx)).toBe('light'); + + // Provider sets the value for the scope + ThemeCtx.Provider('dark', () => { + expect(useContext(ThemeCtx)).toBe('dark'); + + // Nested Provider shadows the outer value + ThemeCtx.Provider('blue', () => { + expect(useContext(ThemeCtx)).toBe('blue'); + }); + + // After inner scope ends, outer value is restored + expect(useContext(ThemeCtx)).toBe('dark'); + }); + + // After all Providers, default is restored + expect(useContext(ThemeCtx)).toBe('light'); + }); + + // IT-1C-4: ErrorBoundary catches errors and renders fallback with retry + test('ErrorBoundary catches and allows retry', () => { + let attempts = 0; + let retryFn: (() => void) | undefined; + + const result = ErrorBoundary({ + children: () => { + attempts++; + if (attempts < 2) { + throw new TypeError('component error'); + } + const el = document.createElement('p'); + el.textContent = 'recovered'; + return el; + }, + fallback: (error, retry) => { + retryFn = retry; + const el = document.createElement('span'); + el.textContent = `Error: ${error.message}`; + return el; + }, + }); + + // First render: children throws, fallback shown + expect(result.textContent).toBe('Error: component error'); + expect(retryFn).toBeDefined(); + expect(attempts).toBe(1); + + // Retry: re-invoke ErrorBoundary (attempts is now >= 2, so children succeeds) + const retryResult = ErrorBoundary({ + children: () => { + attempts++; + if (attempts < 2) { + throw new TypeError('component error'); + } + const el = document.createElement('p'); + el.textContent = 'recovered'; + return el; + }, + fallback: (error, retry) => { + retryFn = retry; + const el = document.createElement('span'); + el.textContent = `Error: ${error.message}`; + return el; + }, + }); + + expect(retryResult.textContent).toBe('recovered'); + }); + + // IT-1C-5: ref provides access to DOM element after mount + test('ref.current is set after mount', () => { + const r = ref(); + + // Before mount, ref is undefined + expect(r.current).toBeUndefined(); + + // Simulate mount: assign the DOM element to the ref + const scope = pushScope(); + onMount(() => { + const el = document.createElement('div'); + el.id = 'test-ref'; + r.current = el; + }); + popScope(); + + // After mount, ref.current is set + expect(r.current).toBeDefined(); + expect(r.current?.id).toBe('test-ref'); + expect(r.current).toBeInstanceOf(HTMLDivElement); + + // Cleanup + runCleanups(scope); + }); +}); From ed6281f046a29166cbeecc3d506fe207908dfad1 Mon Sep 17 00:00:00 2001 From: "vertz-dev-front[bot]" <2828126+vertz-dev-front[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:55:23 +0000 Subject: [PATCH 4/4] 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 --- .../ui/src/__tests__/component-model.test.ts | 28 ++------ .../__tests__/error-boundary.test.ts | 69 ++++++------------- .../src/component/__tests__/suspense.test.ts | 22 +++--- packages/ui/src/component/context.ts | 7 +- packages/ui/src/component/error-boundary.ts | 21 ++++-- packages/ui/src/component/suspense.ts | 38 +++++----- 6 files changed, 75 insertions(+), 110 deletions(-) diff --git a/packages/ui/src/__tests__/component-model.test.ts b/packages/ui/src/__tests__/component-model.test.ts index cbd5146fd..84ee9cdbb 100644 --- a/packages/ui/src/__tests__/component-model.test.ts +++ b/packages/ui/src/__tests__/component-model.test.ts @@ -80,6 +80,7 @@ describe('Integration Tests — Component Model', () => { let attempts = 0; let retryFn: (() => void) | undefined; + const container = document.createElement('div'); const result = ErrorBoundary({ children: () => { attempts++; @@ -97,32 +98,17 @@ describe('Integration Tests — Component Model', () => { return el; }, }); + container.appendChild(result); // First render: children throws, fallback shown - expect(result.textContent).toBe('Error: component error'); + expect(container.textContent).toBe('Error: component error'); expect(retryFn).toBeDefined(); expect(attempts).toBe(1); - // Retry: re-invoke ErrorBoundary (attempts is now >= 2, so children succeeds) - const retryResult = ErrorBoundary({ - children: () => { - attempts++; - if (attempts < 2) { - throw new TypeError('component error'); - } - const el = document.createElement('p'); - el.textContent = 'recovered'; - return el; - }, - fallback: (error, retry) => { - retryFn = retry; - const el = document.createElement('span'); - el.textContent = `Error: ${error.message}`; - return el; - }, - }); - - expect(retryResult.textContent).toBe('recovered'); + // Call actual retry — it replaces fallback with children result in the DOM + retryFn?.(); + expect(container.textContent).toBe('recovered'); + expect(attempts).toBe(2); }); // IT-1C-5: ref provides access to DOM element after mount diff --git a/packages/ui/src/component/__tests__/error-boundary.test.ts b/packages/ui/src/component/__tests__/error-boundary.test.ts index 589bb5444..76196afd7 100644 --- a/packages/ui/src/component/__tests__/error-boundary.test.ts +++ b/packages/ui/src/component/__tests__/error-boundary.test.ts @@ -1,5 +1,4 @@ import { describe, expect, test } from 'vitest'; -import { signal } from '../../runtime/signal'; import { ErrorBoundary } from '../error-boundary'; describe('ErrorBoundary', () => { @@ -42,61 +41,35 @@ describe('ErrorBoundary', () => { }); test('fallback receives a retry function that re-renders children', () => { - const attempts = signal(0); + let attempts = 0; let retryFn: (() => void) | undefined; const container = document.createElement('div'); - const render = () => { - const result = ErrorBoundary({ - children: () => { - attempts.value++; - if (attempts.peek() < 2) { - throw new TypeError('not ready'); - } - const el = document.createElement('p'); - el.textContent = 'success'; - return el; - }, - fallback: (_error, retry) => { - retryFn = retry; - const el = document.createElement('span'); - el.textContent = 'error'; - return el; - }, - }); - container.innerHTML = ''; - container.appendChild(result); - }; + const result = ErrorBoundary({ + children: () => { + attempts++; + if (attempts < 2) { + throw new TypeError('not ready'); + } + const el = document.createElement('p'); + el.textContent = 'success'; + return el; + }, + fallback: (_error, retry) => { + retryFn = retry; + const el = document.createElement('span'); + el.textContent = 'error'; + return el; + }, + }); + container.appendChild(result); - render(); // First render: children throws, fallback shown expect(container.textContent).toBe('error'); expect(retryFn).toBeDefined(); - // Retry: this time children should succeed (attempts >= 2) - // We need to re-render with the retry - if (retryFn) { - // Retry re-invokes the ErrorBoundary - const retryResult = ErrorBoundary({ - children: () => { - attempts.value++; - if (attempts.peek() < 2) { - throw new TypeError('not ready'); - } - const el = document.createElement('p'); - el.textContent = 'success'; - return el; - }, - fallback: (_error, retry) => { - retryFn = retry; - const el = document.createElement('span'); - el.textContent = 'error'; - return el; - }, - }); - container.innerHTML = ''; - container.appendChild(retryResult); - } + // Call the actual retry function — it should replace the fallback in the DOM + retryFn?.(); expect(container.textContent).toBe('success'); }); diff --git a/packages/ui/src/component/__tests__/suspense.test.ts b/packages/ui/src/component/__tests__/suspense.test.ts index 460a141d7..99f73a576 100644 --- a/packages/ui/src/component/__tests__/suspense.test.ts +++ b/packages/ui/src/component/__tests__/suspense.test.ts @@ -67,18 +67,14 @@ describe('Suspense', () => { expect(container.textContent).toBe('done'); }); - test('renders fallback when children throw an error (not a Promise)', () => { - // Non-promise errors should be caught and fallback shown - const result = Suspense({ - children: () => { - throw new TypeError('real error'); - }, - fallback: () => { - const el = document.createElement('span'); - el.textContent = 'error fallback'; - return el; - }, - }); - expect(result.textContent).toBe('error fallback'); + test('re-throws non-Promise errors (use ErrorBoundary for those)', () => { + expect(() => + Suspense({ + children: () => { + throw new TypeError('real error'); + }, + fallback: () => document.createElement('span'), + }), + ).toThrow('real error'); }); }); diff --git a/packages/ui/src/component/context.ts b/packages/ui/src/component/context.ts index 924a5ad2f..8831a80d1 100644 --- a/packages/ui/src/component/context.ts +++ b/packages/ui/src/component/context.ts @@ -1,12 +1,9 @@ -/** Unique token to detect "no value provided" vs explicit undefined. */ -const NO_VALUE: unique symbol = Symbol('NO_VALUE'); - /** A context object created by `createContext`. */ export interface Context { /** Provide a value to all `useContext` calls within the scope. */ Provider: (value: T, fn: () => void) => void; /** @internal — current value stack */ - _stack: (T | typeof NO_VALUE)[]; + _stack: T[]; /** @internal — default value */ _default: T | undefined; } @@ -18,7 +15,7 @@ export interface Context { export function createContext(defaultValue?: T): Context { const ctx: Context = { Provider(value: T, fn: () => void): void { - ctx._stack.push(value as T | typeof NO_VALUE); + ctx._stack.push(value); try { fn(); } finally { diff --git a/packages/ui/src/component/error-boundary.ts b/packages/ui/src/component/error-boundary.ts index 77056107a..e671b761d 100644 --- a/packages/ui/src/component/error-boundary.ts +++ b/packages/ui/src/component/error-boundary.ts @@ -20,15 +20,28 @@ function toError(value: unknown): Error { * ErrorBoundary component. * Catches errors thrown by `children()` and renders `fallback` instead. * The fallback receives the error and a retry function to re-attempt rendering. + * + * When retry is called, children() is re-invoked. If it succeeds, the fallback + * node in the DOM is replaced with the new children result. */ export function ErrorBoundary(props: ErrorBoundaryProps): Node { try { return props.children(); } catch (thrown: unknown) { const error = toError(thrown); - const retry = () => { - // Retry simply re-invokes children — the caller decides what to do with the result - }; - return props.fallback(error, retry); + const fallbackNode = props.fallback(error, retry); + + function retry(): void { + try { + const retryResult = props.children(); + if (fallbackNode.parentNode) { + fallbackNode.parentNode.replaceChild(retryResult, fallbackNode); + } + } catch (_retryThrown: unknown) { + // If children throw again on retry, keep the current fallback + } + } + + return fallbackNode; } } diff --git a/packages/ui/src/component/suspense.ts b/packages/ui/src/component/suspense.ts index da84e9f06..3d15eba0b 100644 --- a/packages/ui/src/component/suspense.ts +++ b/packages/ui/src/component/suspense.ts @@ -22,32 +22,32 @@ function isPromise(value: unknown): value is Promise { * Renders children synchronously if possible. * If children throw a Promise, renders the fallback and waits for resolution, * then replaces the fallback with the children result. - * If children throw a non-Promise error, renders the fallback. + * If children throw a non-Promise error, it is re-thrown (use ErrorBoundary for error handling). */ export function Suspense(props: SuspenseProps): Node { try { return props.children(); } catch (thrown: unknown) { - if (isPromise(thrown)) { - // Create a placeholder that will be replaced after the promise resolves - const placeholder = props.fallback(); + if (!isPromise(thrown)) { + // Non-Promise errors are not Suspense's concern — re-throw for ErrorBoundary + throw thrown; + } - thrown.then(() => { - try { - const resolved = props.children(); - // Replace placeholder in the DOM if it has a parent - if (placeholder.parentNode) { - placeholder.parentNode.replaceChild(resolved, placeholder); - } - } catch (_retryError: unknown) { - // If children throw again on retry, keep the fallback - } - }); + // Create a placeholder that will be replaced after the promise resolves + const placeholder = props.fallback(); - return placeholder; - } + thrown.then(() => { + try { + const resolved = props.children(); + // Replace placeholder in the DOM if it has a parent + if (placeholder.parentNode) { + placeholder.parentNode.replaceChild(resolved, placeholder); + } + } catch (_retryError: unknown) { + // If children throw again on retry, keep the fallback + } + }); - // Non-Promise error: show fallback - return props.fallback(); + return placeholder; } }