diff --git a/packages/ui/src/core.ts b/packages/ui/src/core.ts index 821b7f6..3d92fa2 100644 --- a/packages/ui/src/core.ts +++ b/packages/ui/src/core.ts @@ -1,2 +1,3 @@ +export * from './core/api.ts'; export * from './core/context.ts'; export * from './core/node.ts'; diff --git a/packages/ui/src/core/api.test.ts b/packages/ui/src/core/api.test.ts new file mode 100644 index 0000000..7b242e3 --- /dev/null +++ b/packages/ui/src/core/api.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, expectTypeOf, test } from 'vitest'; +import { Api } from './api.ts'; +import { Node } from './node.ts'; + +class TestApi extends Api { + name: string; + + constructor(node: Node, name: string) { + super(node); + this.name = name; + } + + trace(events: string[]): string[] { + events.push(`implementation:${this.name}`); + return events; + } + + value(value: number): number { + return value; + } + + observeNode(nodes: Node[]): Node { + nodes.push(this.node); + return this.node; + } +} + +function childOf(parent: Node): Node { + const child = new Node(parent); + parent.children.add(child); + return child; +} + +describe('Api', () => { + test('at throws when there is no implementation in the node lineage', () => { + expect(() => TestApi.at(new Node())).toThrow(); + }); + + test('at returns a stable class-shaped handle backed by the initialized instance', () => { + const node = new Node(); + TestApi.init(node, 'root'); + + const api = TestApi.at(node); + + expectTypeOf(api).toEqualTypeOf(); + expect(api).toBeInstanceOf(TestApi); + expect(api).toBe(TestApi.at(node)); + expect(api.name).toBe('root'); + }); + + test('allows decoration before an implementation is initialized', () => { + const node = new Node(); + + expect(() => + TestApi.decorate(node, () => ({ + trace(events: string[]): string[] { + events.push('decorator:before'); + const result = super.trace(events); + events.push('decorator:after'); + return result; + }, + })), + ).not.toThrow(); + expect(() => TestApi.at(node)).toThrow(); + + TestApi.init(node, 'root'); + + expect(TestApi.at(node).trace([])).toEqual([ + 'decorator:before', + 'implementation:root', + 'decorator:after', + ]); + }); + + test('uses the nearest initialized implementation', () => { + const root = new Node(); + const left = childOf(root); + const right = childOf(root); + const grandchild = childOf(right); + + TestApi.init(root, 'root'); + TestApi.init(right, 'right'); + + expect(TestApi.at(root).name).toBe('root'); + expect(TestApi.at(left).name).toBe('root'); + expect(TestApi.at(right).name).toBe('right'); + expect(TestApi.at(grandchild).name).toBe('right'); + }); + + test('exposes the call node to an inherited implementation', () => { + const root = new Node(); + const child = childOf(root); + const rootApi = TestApi.init(root, 'root'); + const childApi = TestApi.at(child); + const nodes: Node[] = []; + + expect(rootApi.node).toBe(root); + expect(childApi.node).toBe(child); + expect(childApi.observeNode(nodes)).toBe(child); + expect(nodes).toEqual([child]); + }); + + test('exposes the call node to decorators and super', () => { + const root = new Node(); + const child = childOf(root); + TestApi.init(root, 'root'); + TestApi.decorate(root, () => ({ + observeNode(nodes: Node[]): Node { + nodes.push(this.node); + return super.observeNode(nodes); + }, + })); + + const nodes: Node[] = []; + expect(TestApi.at(child).observeNode(nodes)).toBe(child); + expect(nodes).toEqual([child, child]); + }); + + test('uses native super for arguments and return values without affecting other methods', () => { + const node = new Node(); + TestApi.init(node, 'root'); + TestApi.decorate(node, () => ({ + value(value: number): number { + return super.value(value + 1) * 2; + }, + })); + + const api = TestApi.at(node); + + expect(api.value(3)).toBe(8); + expect(api.trace([])).toEqual(['implementation:root']); + }); + + test('routes decorator field access to the initialized implementation', () => { + const node = new Node(); + const api = TestApi.init(node, 'root'); + + TestApi.decorate(node, () => ({ + trace(events: string[]): string[] { + events.push(`decorator:${this.name}`); + this.name = 'updated'; + return super.trace(events); + }, + })); + + expect(api.trace([])).toEqual(['decorator:root', 'implementation:updated']); + expect(api.name).toBe('updated'); + }); + + test('composes ancestors outermost and same-node decorators in installation order', () => { + const root = new Node(); + const parent = childOf(root); + const child = childOf(parent); + TestApi.init(root, 'root'); + + // Install these out of tree order to ensure hierarchy, rather than + // installation chronology, determines which node is outermost. + TestApi.decorate(child, () => ({ + trace(events: string[]): string[] { + events.push('child:before'); + const result = super.trace(events); + events.push('child:after'); + return result; + }, + })); + TestApi.decorate(root, () => ({ + trace(events: string[]): string[] { + events.push('root-one:before'); + const result = super.trace(events); + events.push('root-one:after'); + return result; + }, + })); + TestApi.decorate(parent, () => ({ + trace(events: string[]): string[] { + events.push('parent:before'); + const result = super.trace(events); + events.push('parent:after'); + return result; + }, + })); + TestApi.decorate(root, () => ({ + trace(events: string[]): string[] { + events.push('root-two:before'); + const result = super.trace(events); + events.push('root-two:after'); + return result; + }, + })); + + expect(TestApi.at(child).trace([])).toEqual([ + 'root-one:before', + 'root-two:before', + 'parent:before', + 'child:before', + 'implementation:root', + 'child:after', + 'parent:after', + 'root-two:after', + 'root-one:after', + ]); + }); + + test('a descendant can suppress only the layers inside it', () => { + const root = new Node(); + const child = childOf(root); + TestApi.init(root, 'root'); + TestApi.decorate(root, () => ({ + trace(events: string[]): string[] { + events.push('root:before'); + const result = super.trace(events); + events.push('root:after'); + return result; + }, + })); + TestApi.decorate(child, () => ({ + trace(events: string[]): string[] { + events.push('child'); + return events; + }, + })); + + expect(TestApi.at(child).trace([])).toEqual(['root:before', 'child', 'root:after']); + }); + + test('instantiates a shared ancestor decoration into each sibling dispatch chain', () => { + const root = new Node(); + const left = childOf(root); + const right = childOf(root); + TestApi.init(left, 'left'); + TestApi.init(right, 'right'); + + let nextRootLayer = 0; + TestApi.decorate(root, (): Partial => { + const layer = `root-${nextRootLayer++}`; + return { + trace(events: string[]): string[] { + events.push(`${layer}:before`); + const result = super.trace(events); + events.push(`${layer}:after`); + return result; + }, + }; + }); + TestApi.decorate(left, () => ({ + trace(events: string[]): string[] { + events.push('left:before'); + const result = super.trace(events); + events.push('left:after'); + return result; + }, + })); + TestApi.decorate(right, () => ({ + trace(events: string[]): string[] { + events.push('right:before'); + const result = super.trace(events); + events.push('right:after'); + return result; + }, + })); + + const leftApi = TestApi.at(left); + const rightApi = TestApi.at(right); + const leftFirst = leftApi.trace([]); + const rightFirst = rightApi.trace([]); + const leftAgain = leftApi.trace([]); + + expect(leftFirst.slice(1, -1)).toEqual(['left:before', 'implementation:left', 'left:after']); + expect(leftFirst.at(-1)).toBe(leftFirst[0]?.replace(':before', ':after')); + + expect(rightFirst.slice(1, -1)).toEqual([ + 'right:before', + 'implementation:right', + 'right:after', + ]); + expect(rightFirst.at(-1)).toBe(rightFirst[0]?.replace(':before', ':after')); + + expect(leftFirst[0]).not.toBe(rightFirst[0]); + expect(leftAgain[0]).toBe(leftFirst[0]); + expect(leftAgain.slice(1, -1)).toEqual(['left:before', 'implementation:left', 'left:after']); + expect(leftAgain.at(-1)).toBe(leftFirst.at(-1)); + }); + + test('updates an existing handle after ancestor decoration and a nearer init', () => { + const root = new Node(); + const child = childOf(root); + TestApi.init(root, 'root'); + TestApi.decorate(child, () => ({ + trace(events: string[]): string[] { + events.push('child:before'); + const result = super.trace(events); + events.push('child:after'); + return result; + }, + })); + + const api = TestApi.at(child); + expect(api.trace([])).toEqual(['child:before', 'implementation:root', 'child:after']); + + TestApi.decorate(root, () => ({ + trace(events: string[]): string[] { + events.push('root:before'); + const result = super.trace(events); + events.push('root:after'); + return result; + }, + })); + TestApi.init(child, 'child'); + + expect(TestApi.at(child)).toBe(api); + expect(api.trace([])).toEqual([ + 'root:before', + 'child:before', + 'implementation:child', + 'child:after', + 'root:after', + ]); + }); +}); diff --git a/packages/ui/src/core/api.ts b/packages/ui/src/core/api.ts new file mode 100644 index 0000000..bcfce16 --- /dev/null +++ b/packages/ui/src/core/api.ts @@ -0,0 +1,195 @@ +// oxlint-disable max-params bombshell-dev/max-params +import { createContext, type Context } from './context.ts'; +import type { Node } from './node.ts'; + +export abstract class Api { + /** + * Every api method is dispatched at a node in the tree. + */ + declare readonly node: Node; + + constructor(location: Node) { + void location; + } + + static init( + this: new (...params: [Node, ...Params]) => T, + node: Node, + ...params: Params + ): T { + const contexts = contextsFor(this); + const value = new this(node, ...params); + contexts.impl.set(node, value); + recompile(this, node); + return ensureHandle(this, node); + } + + static at( + this: new (...params: Params) => T, + node: Node, + ): T { + const contexts = contextsFor(this); + + // Throw without allocating a handle. + contexts.impl.expect(node); + + return ensureHandle(this, node); + } + + static decorate( + this: new (...params: Params) => T, + node: Node, + factory: () => Decorator, + ): void { + const contexts = contextsFor(this); + let state = contexts.decorators.hasOwn(node) ? contexts.decorators.get(node) : undefined; + if (!state) { + state = []; + contexts.decorators.set(node, state); + } + state.push(factory); + + recompile(this, node); + } +} +/** + * A type that represents just the methods (no fields) of an Api object. That way, it can decorate it. + */ +export type Decorator = ThisType & { + [M in keyof T as T[M] extends (...args: never[]) => unknown ? M : never]?: T[M]; +}; + +function recompile( + constructor: new (...params: Params) => T, + node: Node, + stack?: Array<() => Decorator>, +): void { + const context = contextsFor(constructor); + const local = context.decorators.hasOwn(node) ? context.decorators.expect(node) : []; + const layers = stack + ? stack.concat(local) + : lineageOf(node).flatMap((node) => { + return context.decorators.hasOwn(node) ? context.decorators.expect(node) : []; + }); + + const impl = context.impl.get(node); + + if (impl && (context.impl.hasOwn(node) || local.length > 0)) { + const head = layers.reduceRight((next, factory) => { + const layer = factory(); + Object.setPrototypeOf(layer, next); + return layer; + }, impl) as T; + + context.assembly.set(node, { head, implementation: impl }); + } + + //if this node has a handle checked out. + const handle = context.handle.hasOwn(node) ? context.handle.expect(node) : undefined; + + if (handle) { + const assembly = context.assembly.expect(node); + handle.current.assembly = assembly; + } + + for (const child of node.children) { + recompile(constructor, child, layers); + } +} + +/** + * A reference to an `Api` that never goes stale even if + * implementations are swapped out or decorators are added. That way, + * you can say `Api.at(node)` and you'll get the same object over and + * over. + */ +interface Handle { + proxy: T; + current: { assembly: Assembly }; +} + +function createHandle( + constructor: new (...params: Params) => T, + node: Node, + assembly: Assembly, +): Handle { + const current = { assembly }; + + const handle: Handle = { + current, + proxy: new Proxy(Object.create(constructor.prototype), { + get(_, key) { + if (key === 'node') { + return node; + } + const { head, implementation } = current.assembly; + if (typeof assembly.head[key as keyof T] === 'function') { + return Reflect.get(head, key, handle.proxy); + } + return Reflect.get(implementation, key, handle.proxy); + }, + set(_, key, value) { + if (key === 'node') { + return false; + } + const { implementation } = current.assembly; + return Reflect.set(implementation, key, value, implementation); + }, + }), + }; + return handle; +} + +function ensureHandle( + constructor: new (...params: Params) => T, + node: Node, +): T { + const contexts = contextsFor(constructor); + let handle = contexts.handle.hasOwn(node) ? contexts.handle.expect(node) : undefined; + + if (!handle) { + handle = createHandle(constructor, node, contexts.assembly.expect(node)); + contexts.handle.set(node, handle); + } + return handle.proxy; +} + +function lineageOf(node: Node): Node[] { + const lineage: Node[] = [node]; + for (let current: Node | undefined = node.parent; current; current = current.parent) { + lineage.push(current); + } + return lineage.reverse(); +} + +let ids = 0; +const contexts = new WeakMap>(); + +function contextsFor( + constructor: new (...params: Params) => T, +): Contexts { + let context = contexts.get(constructor) as Contexts; + if (!context) { + context = { + impl: createContext(`api::${constructor.name}::${ids++}::impl`), + decorators: createContext(`api::${constructor.name}::${ids++}::decorators`), + handle: createContext(`api::${constructor.name}::${ids++}::handle`), + assembly: createContext(`api::${constructor.name}::${ids++}::assembly`), + }; + contexts.set(constructor, context); + } + + return context; +} + +interface Contexts { + impl: Context; + decorators: Context Decorator>>; + handle: Context>; + assembly: Context>; +} + +interface Assembly { + head: T; + implementation: T; +}