// reconciler-core.ts — DOM-free reconciler engine shared by every mobile DOM // reconciler task. Deliberately has ZERO import statements: // - the custom client bundler cannot resolve `../` requires from // src/client/effects, and a file without imports has nothing to resolve; // - node:test imports it directly (Node's native type stripping) without a // DOM or DSH runtime, so registration / dirty routing / coalescing / // error-isolation can be covered by plain unit tests. // // The browser half (reconciler-adapter.ts) is a thin adapter: it owns the // MutationObserver and requestAnimationFrame scheduler, feeds mutation keys // into note(), and delegates task lifecycle to register() / activate() / // deactivate(). The dirty-key vocabulary both halves must agree on lives just // below: the core never interprets a key, it only publishes the spellings so // the adapter, every task's scopes and the unit tests cannot drift apart. /** * Tree-change key: the sentinel for a childList record (a node was added, * removed or moved anywhere in the observed tree). Coarse on purpose -- one * observer watches the whole tree, so any structural change can invalidate a * task's cached node, and the core cannot know which subtree a task cares * about. */ export const TREE_MUTATION_SCOPE = '*' /** * Text-change key: one characterData record (a text node's value was rewritten * in place, no node added or removed). Kept separate from the tree sentinel so * a task that only measures structure is not re-run for every streamed token, * while a task that reads text declares this key and stays woken. A text * reader MUST declare it next to TREE_MUTATION_SCOPE: React rebuilds a node * for some updates and rewrites the text in place for others, so both record * shapes reach a live readout. */ export const TEXT_MUTATION_SCOPE = 'characterData' /** The part of a mutation record this mapping reads (structural: no DOM). */ export interface MutationLike { readonly type: string readonly attributeName: string | null } /** * Map one observer record onto the dirty key it dirties: attribute records * route by attribute name, text records to TEXT_MUTATION_SCOPE, tree records * to TREE_MUTATION_SCOPE. Fail-open on the two shapes it cannot name -- an * attribute record without a name, and any record type this mapping does not * know, both widen to the tree sentinel, so a mapping gap can only cost extra * wakeups, never a missed one. */ export function mutationDirtyKey(record: MutationLike): string { if (record.type === 'attributes') { return record.attributeName === null ? TREE_MUTATION_SCOPE : record.attributeName } return record.type === 'characterData' ? TEXT_MUTATION_SCOPE : TREE_MUTATION_SCOPE } /** One unit of DOM reconciliation driven by the shared full-tree observer. */ export interface ReconcilerTask { readonly name: string /** * Dirty keys that must be present for this task to re-run on a flush. * Undefined = unscoped: run on every non-empty flush (legacy behavior). * A key is TREE_MUTATION_SCOPE, TEXT_MUTATION_SCOPE, or an attribute name; * the core compares keys literally and never interprets them. */ readonly scopes?: readonly string[] /** Called once on activation and after every matching DOM mutation. */ ensure(): void /** Called on deactivation, disposal, or explicit removal. */ dispose(): void } /** * Schedule `flush` on the next frame and return a cancel for a still-pending * frame. Injected so the core stays DOM-free: browsers pass an * requestAnimationFrame adapter, tests pass a manual queue. */ export type FrameRequest = (flush: () => void) => () => void /** Error sink for task failures. Defaults to a console.error shim. */ export type ReconcilerErrorHandler = ( taskName: string, error: unknown, phase: 'ensure' | 'dispose', ) => void export interface ReconcilerCoreOptions { readonly requestFrame: FrameRequest readonly onError?: ReconcilerErrorHandler } export interface ReconcilerCore { /** Number of tasks in the registry (registered but not necessarily active). */ readonly size: number /** Register a task; the returned disposer removes and disposes it. */ register(task: ReconcilerTask): () => void /** Snapshot the registry as the active task set and run every task once. */ activate(): void /** Dispose every active task, cancel any pending frame, clear dirty keys. */ deactivate(): void /** Mark scope keys dirty and schedule one coalesced flush. */ note(keys: Iterable): void /** Run dirty tasks now (activation runs everything; empty dirty runs none). */ flush(): void } export function createReconcilerCore(options: ReconcilerCoreOptions): ReconcilerCore { const onError: ReconcilerErrorHandler = options.onError ?? ((taskName, error, phase) => { console.error( `[dsh-web-mobile] reconciler task ${taskName}${phase === 'dispose' ? ' dispose' : ''} failed`, error, ) }) const registered = new Set() let active: Set | null = null let dirty = new Set() let forceAll = false let pending: (() => void) | null = null const runEnsure = (task: ReconcilerTask): void => { try { task.ensure() } catch (error) { onError(task.name, error, 'ensure') } } const runDispose = (task: ReconcilerTask): void => { try { task.dispose() } catch (error) { onError(task.name, error, 'dispose') } } const flush = (): void => { if (pending !== null) { pending() pending = null } if (active === null) { dirty.clear() forceAll = false return } // Snapshot and clear BEFORE running tasks: a task that calls note() from // inside ensure() must leave its key dirty for the next pass. Clearing at // the end swallowed exactly those keys (issue #86). const keys = dirty const all = forceAll dirty = new Set() forceAll = false if (all) { for (const task of active) runEnsure(task) } else if (keys.size > 0) { for (const task of active) { const scopes = task.scopes if (scopes === undefined || scopes.some((key) => keys.has(key))) runEnsure(task) } } } const schedule = (): void => { if (pending !== null) return pending = options.requestFrame(() => { pending = null flush() }) } const register = (task: ReconcilerTask): (() => void) => { registered.add(task) if (active !== null) { active.add(task) runEnsure(task) } return () => { registered.delete(task) // Ownership check: the disposer is idempotent and only disposes a task // that is actually active. Without it, calling one disposer twice ran // dispose() twice (and a stale disposer could dispose a task this call // never registered — issue #86). if (active !== null && active.delete(task)) runDispose(task) } } const activate = (): void => { if (active !== null) return active = new Set(registered) forceAll = true flush() } const deactivate = (): void => { if (pending !== null) { pending() pending = null } dirty.clear() forceAll = false if (active !== null) { const snapshot = active active = null for (const task of snapshot) runDispose(task) } } return { get size(): number { return registered.size }, register, activate, deactivate, note: (keys: Iterable) => { for (const key of keys) dirty.add(key) schedule() }, flush, } }