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..84ee9cdbb --- /dev/null +++ b/packages/ui/src/__tests__/component-model.test.ts @@ -0,0 +1,138 @@ +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 container = document.createElement('div'); + 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; + }, + }); + container.appendChild(result); + + // First render: children throws, fallback shown + expect(container.textContent).toBe('Error: component error'); + expect(retryFn).toBeDefined(); + expect(attempts).toBe(1); + + // 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 + 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); + }); +}); 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..76196afd7 --- /dev/null +++ b/packages/ui/src/component/__tests__/error-boundary.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from 'vitest'; +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', () => { + let attempts = 0; + let retryFn: (() => void) | undefined; + + const container = document.createElement('div'); + 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); + + // First render: children throws, fallback shown + expect(container.textContent).toBe('error'); + expect(retryFn).toBeDefined(); + + // Call the actual retry function — it should replace the fallback in the DOM + retryFn?.(); + 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__/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/__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..99f73a576 --- /dev/null +++ b/packages/ui/src/component/__tests__/suspense.test.ts @@ -0,0 +1,80 @@ +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('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/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..8831a80d1 --- /dev/null +++ b/packages/ui/src/component/context.ts @@ -0,0 +1,40 @@ +/** 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[]; + /** @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); + 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..e671b761d --- /dev/null +++ b/packages/ui/src/component/error-boundary.ts @@ -0,0 +1,47 @@ +/** 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. + * + * 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 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/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/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/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..3d15eba0b --- /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, 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)) { + // Non-Promise errors are not Suspense's concern — re-throw for ErrorBoundary + throw 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; + } +} 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'; 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; }