diff --git a/packages/ui/src/core.ts b/packages/ui/src/core.ts index 3d92fa2..8ce36a4 100644 --- a/packages/ui/src/core.ts +++ b/packages/ui/src/core.ts @@ -1,3 +1,4 @@ export * from './core/api.ts'; export * from './core/context.ts'; +export * from './core/lifecycle.ts'; export * from './core/node.ts'; diff --git a/packages/ui/src/core/lifecycle.test.ts b/packages/ui/src/core/lifecycle.test.ts new file mode 100644 index 0000000..6d72944 --- /dev/null +++ b/packages/ui/src/core/lifecycle.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'vitest'; +import { lifecycle, LifecycleApi } from './lifecycle.ts'; +import type { Node } from './node.ts'; + +describe('lifecycle', () => { + describe('create', () => { + test('with no parent, roots the node in the global scope', () => { + const node = lifecycle.create(); + expect(node.parent).toBeDefined(); + }); + + test('wires the child into its parent', () => { + const parent = lifecycle.create(); + const child = lifecycle.create(parent); + expect(child.parent).toBe(parent); + expect(parent.children.has(child)).toBe(true); + }); + + test('autogenerates an id', () => { + const node = lifecycle.create(); + expect(typeof lifecycle.id(node)).toBe('string'); + expect(lifecycle.id(node).length).toBeGreaterThan(0); + }); + + test('autogenerated ids are distinct', () => { + expect(lifecycle.id(lifecycle.create())).not.toBe(lifecycle.id(lifecycle.create())); + }); + + test('a child owns its id rather than inheriting its parent’s', () => { + const parent = lifecycle.create(); + const child = lifecycle.create(parent); + expect(lifecycle.id(child)).not.toBe(lifecycle.id(parent)); + }); + + test('hooks fire through create middleware', () => { + const created: Node[] = []; + const root = lifecycle.create(); + LifecycleApi.decorate(root, () => ({ + create(parent) { + const node = super.create(parent); + created.push(node); + return node; + }, + })); + const a = LifecycleApi.at(root).create(root); + const b = LifecycleApi.at(a).create(a); + expect(created).toEqual([a, b]); + }); + }); + + describe('destroy', () => { + test('detaches a node from its parent', () => { + const parent = lifecycle.create(); + const child = lifecycle.create(parent); + lifecycle.destroy(child); + expect(parent.children.has(child)).toBe(false); + }); + + test('cascades to descendants', () => { + const root = lifecycle.create(); + const parent = lifecycle.create(root); + const child = lifecycle.create(parent); + lifecycle.destroy(root); + expect(root.children.has(parent)).toBe(false); + expect(parent.children.has(child)).toBe(false); + }); + + test('tears down bottom-up, each node running its destroy middleware', () => { + const order: Node[] = []; + const root = lifecycle.create(); + LifecycleApi.decorate(root, () => ({ + destroy(node) { + const result = super.destroy(node); + order.push(node); + return result; + }, + })); + const parent = lifecycle.create(root); + const child = lifecycle.create(parent); + LifecycleApi.at(root).destroy(root); + expect(order).toEqual([child, parent, root]); + }); + }); +}); diff --git a/packages/ui/src/core/lifecycle.ts b/packages/ui/src/core/lifecycle.ts new file mode 100644 index 0000000..7046349 --- /dev/null +++ b/packages/ui/src/core/lifecycle.ts @@ -0,0 +1,35 @@ +import { Node } from './node.ts'; +import { createContext } from './context.ts'; +import { Api } from './api.ts'; + +export class LifecycleApi extends Api { + ids = 0; + global: Node; + constructor(global: Node) { + super(global); + this.global = global; + IdContext.set(this.global, 'root'); + } + + create(parent: Node = this.global) { + const node = new Node(parent); + parent.children.add(node); + IdContext.set(node, `${this.ids++}`); + return node; + } + + destroy(node: Node) { + for (const child of node.children) { + this.destroy(child); + } + node.parent?.children.delete(node); + } + + id(node: Node): string { + return IdContext.expect(node); + } +} + +const IdContext = createContext('id'); + +export const lifecycle = LifecycleApi.init(new Node());