From 2f102353fa83b429f346ac3436747114f523da10 Mon Sep 17 00:00:00 2001 From: Connor Byrne Date: Mon, 18 May 2026 14:46:51 -0700 Subject: [PATCH] =?UTF-8?q?feat(extension-api):=20converge=20DOMWidget=20?= =?UTF-8?q?=E2=86=94=20Widget=20via=20mount-lifecycle=20(D-widget-converge?= =?UTF-8?q?,=20A12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per D-widget-converge ACCEPTED 2026-05-18 (W6.P3.B PICK: option iii) and new Axiom A12 (Mount-Lifecycle as the Sole DOM Seam): - Add WidgetCleanup, WidgetMountContext, WidgetMountFn to widget.ts. The mount context provides widget/node handles plus onUnmount / onBeforeRemount / onAfterRemount lifecycle hooks. Cleanup fires on destruction only; host remount uses the remount hooks. - Replace WidgetExtensionOptions.created({render, destroy}) with optional mount: WidgetMountFn. Authors capture host element via closure inside mount() — there is no widget.element accessor on the handle. - Delete DOMWidgetOptions interface from node.ts. - Delete NodeHandle.addDOMWidget() method (replaced by defineWidget + the same addWidget call as native widgets). - Update WidgetHandle.setHeight() JSDoc — applies to widgets registered with a mount() body, not "DOM widgets" as a category. - Drop DOMWidgetOptions from public barrel; add WidgetCleanup, WidgetMountContext, WidgetMountFn to the barrel. Phase A gates (lint + format:check + knip) pass. typecheck/test on v2 stack continue to fail per AGENTS.md Rule #8 (parallel-paths stack). Migration path: v1 patterns (addDOMWidget, widget.element, widget.inputEl, addWidget('unregistered-type', …)) keep working through the v1 surface during the D6 parallel-paths window and break deliberately at D6 Phase D sunset. See decisions/D-widget-converge.md §Migration-guide rows. --- src/extension-api/index.ts | 7 ++- src/extension-api/node.ts | 38 +++------------ src/extension-api/types.ts | 45 +++++++++++------- src/extension-api/widget.ts | 93 ++++++++++++++++++++++++++++++++++--- 4 files changed, 126 insertions(+), 57 deletions(-) diff --git a/src/extension-api/index.ts b/src/extension-api/index.ts index 1476e544af..9a0cde489c 100644 --- a/src/extension-api/index.ts +++ b/src/extension-api/index.ts @@ -83,7 +83,6 @@ export type { NodeMode, Point, Size, - DOMWidgetOptions, NodeExecutedEvent, NodeConnectedEvent, NodeDisconnectedEvent, @@ -101,7 +100,11 @@ export type { WidgetOptionChangeEvent, WidgetPropertyChangeEvent, WidgetBeforeSerializeEvent, - WidgetBeforeQueueEvent + WidgetBeforeQueueEvent, + // Mount-lifecycle surface per D-widget-converge / Axiom A12 ──────────── + WidgetCleanup, + WidgetMountContext, + WidgetMountFn } from './widget' export type { Handler, AsyncHandler, Unsubscribe } from './events' diff --git a/src/extension-api/node.ts b/src/extension-api/node.ts index 8b884a15b5..7c0c3c740f 100644 --- a/src/extension-api/node.ts +++ b/src/extension-api/node.ts @@ -202,20 +202,6 @@ export interface NodeBeforeSerializeEvent { replace(fn: (orig: Record) => Record): void } -/** - * Options for `NodeHandle.addDOMWidget()`. - * - * @stability experimental - */ -export interface DOMWidgetOptions { - /** Unique widget name within this node. */ - name: string - /** The DOM element to embed in the node widget area. */ - element: HTMLElement - /** Reserved height in pixels. Defaults to `element.offsetHeight` at mount time. */ - height?: number -} - /** * Controlled surface for node access. Reads query the ECS World; writes * dispatch commands. Events are Vue-reactive watches on World components. @@ -418,24 +404,12 @@ export interface NodeHandle { options?: Partial ): WidgetHandle - /** - * Adds a DOM-backed widget to this node. - * - * Replaces the v1 `node.addDOMWidget(name, type, element, opts)` pattern. - * The runtime automatically: - * - Reserves node height for the element (via auto-computeSize integration). - * - Removes the element from the DOM when the node is removed. - * - Includes the widget in `NodeHandle.getWidgets()`. - * - * Use `WidgetHandle.setHeight(px)` to resize the reservation after initial mount. - * - * @param opts.name - Unique widget name on this node. - * @param opts.element - The DOM element to embed. - * @param opts.height - Initial reserved height in pixels. Defaults to `element.offsetHeight`. - * @returns A `WidgetHandle` for the registered DOM widget. - * @stability experimental - */ - addDOMWidget(opts: DOMWidgetOptions): WidgetHandle + // NOTE: `addDOMWidget(opts)` was removed per D-widget-converge / Axiom A12. + // Custom DOM widgets are now registered via `defineWidget({type, mount})` + // and instantiated through the same `addWidget(type, name, …)` call as + // every other widget. The runtime invokes the registered `mount(host, ctx)` + // hook against a per-widget host `
` it owns. See `WidgetMountFn` and + // `WidgetMountContext` in `./widget` for the lifecycle contract. // ── SLOTS ───────────────────────────────────────────────────────────────── diff --git a/src/extension-api/types.ts b/src/extension-api/types.ts index fcb8ed109a..784af819c3 100644 --- a/src/extension-api/types.ts +++ b/src/extension-api/types.ts @@ -12,7 +12,7 @@ */ import type { NodeHandle } from './node' -import type { WidgetHandle } from './widget' +import type { WidgetMountFn } from './widget' /** * Options for `defineNode`. Describes an extension that reacts to @@ -118,8 +118,18 @@ export interface ExtensionOptions { } /** - * Options for `defineWidget`. Describes an extension that provides a - * custom widget type with its own DOM rendering. + * Options for `defineWidget`. Registers a custom widget type that renders + * through the mount-lifecycle seam (Axiom A12 / D-widget-converge). + * + * Once registered, the widget can be instantiated on any node via + * `node.addWidget(type, name, defaultValue, opts?)`. The runtime allocates + * a per-widget host `
` and invokes the registered `mount(host, ctx)` + * hook against it. The widget's mount body captures the host (and any DOM + * it constructs) via closure — there is no `widget.element` accessor on + * the handle. + * + * `mount` is optional: omit it for value-only widgets (numeric, combo, etc.) + * that render through the native widget renderer with no custom DOM. * * @stability experimental * @example @@ -130,13 +140,14 @@ export interface ExtensionOptions { * name: 'my-org.color-picker', * type: 'COLOR_PICKER', * - * created(widget, node) { - * return { - * // mount color picker DOM - * render(container) {}, - * // cleanup - * destroy() {} - * } + * mount(host, ctx) { + * const input = document.createElement('input') + * input.type = 'color' + * input.value = String(ctx.widget.getValue() ?? '#000000') + * input.addEventListener('input', () => ctx.widget.setValue(input.value)) + * host.appendChild(input) + * // Optional cleanup — fires once on widget destruction. + * return () => input.remove() * } * }) * ``` @@ -148,13 +159,15 @@ export interface WidgetExtensionOptions { type: string /** - * Called once per widget instance. Return a `{ render, destroy }` pair for - * custom DOM rendering, or `void` for non-visual widgets. + * Mount lifecycle hook — the **sole** DOM seam per Axiom A12. Called once + * per widget instance when the widget is first attached to its node host + * in the DOM. May return a `WidgetCleanup` function that fires on widget + * destruction (host remount does NOT fire cleanup; see + * `WidgetMountContext.onBeforeRemount` / `onAfterRemount`). + * + * Omit entirely for value-only widgets that need no custom DOM. * * @stability experimental */ - created?( - widget: WidgetHandle, - parentNode: NodeHandle | null - ): { render(container: HTMLElement): void; destroy?(): void } | void + mount?: WidgetMountFn } diff --git a/src/extension-api/widget.ts b/src/extension-api/widget.ts index 169a153175..73b6ffce06 100644 --- a/src/extension-api/widget.ts +++ b/src/extension-api/widget.ts @@ -8,6 +8,7 @@ */ import type { AsyncHandler, Handler, Unsubscribe } from './events' +import type { NodeHandle } from './node' import type { WidgetEntityId } from '@/world/entityIds' /** @@ -345,13 +346,16 @@ export interface WidgetHandle { */ readonly label: string - // ── HEIGHT — DOM widgets only ───────────────────────────────────────────── + // ── HEIGHT — reserved layout slot for mount-lifecycle widgets ──────────── /** - * Updates the reserved height for this DOM widget and triggers a node relayout. + * Updates the reserved height for this widget and triggers a node relayout. * - * Only meaningful for widgets registered via `NodeHandle.addDOMWidget()`. - * For non-DOM widgets this is a no-op. + * Meaningful for widgets registered via {@link defineWidget} with a + * {@link WidgetMountFn} `mount()` body — the reserved height bounds the + * runtime-owned host `
` that the mount body renders into. For widgets + * that render through the native widget renderer (no `mount`), this is a + * no-op. * * Replaces the v1 pattern of re-assigning `node.computeSize` to return a new * height whenever the embedded element resizes. @@ -388,14 +392,14 @@ export interface WidgetHandle { * object is `Readonly` — `widget.options.min = 0`, * `widget.options = {...}`, and `widget.options.values = [...]` all raise * TypeScript errors at compile time. To mutate, use - * {@link WidgetHandle.setOption} per-key. Bulk `setOptions(opts)` is - * tracked under W6.P8.UNMIGRATABLE / D-widget-converge. + * {@link WidgetHandle.setOption} per-key. * * Note: this is an accessor pair on the v2 surface. Reading is free; the * setter intentionally does not exist on the public type. v1 patterns like * `widget.options.serialize = false` should migrate to * {@link WidgetHandle.setSerializeEnabled}; `widget.options.values = [...]` - * (combo refresh) migrates to a future `setValues` mutator (W6.P3). + * (combo refresh) migrates to a future `setValues` mutator (tracked + * under W6.P8.UNMIGRATABLE). * * @example * ```ts @@ -554,6 +558,81 @@ export interface WidgetHandle { ): Unsubscribe } +// ── MOUNT LIFECYCLE — the sole DOM seam per D-widget-converge / Axiom A12 ── + +/** + * Cleanup function returned from a widget's `mount()`. Fires exactly once, + * when the widget entity is destroyed. **Does NOT fire on host remount** + * (graph↔app mode, subgraph promotion, `` shuffle) — use + * {@link WidgetMountContext.onBeforeRemount} / {@link WidgetMountContext.onAfterRemount} + * for those. + * + * @stability experimental + */ +export type WidgetCleanup = () => void + +/** + * Context passed to a widget's `mount()` function. + * + * Per **Axiom A12** (Mount-Lifecycle as the Sole DOM Seam), this is the only + * surface through which DOM enters a widget. Authors capture the host element + * and any constructed DOM via closure inside `mount()` — there is no + * `widget.element` / `widget.inputEl` accessor on the handle. + * + * @stability experimental + */ +export interface WidgetMountContext { + /** The widget being mounted. Use for `getValue` / `setValue` / `on(...)`. */ + readonly widget: WidgetHandle + /** The node hosting this widget. */ + readonly node: NodeHandle + + /** + * Register a callback that fires when the widget entity is destroyed. + * Equivalent to returning a cleanup function from `mount()`; provided as + * a hook for composition (e.g. inside helpers that own their own + * sub-resources). + */ + onUnmount(fn: () => void): void + + /** + * Register a callback that fires immediately **before** the widget's host + * `
` is moved to a new location (graph↔app mode, subgraph promotion, + * Vue `` shuffle). Use to detach observers, pause animations, + * or capture scroll position before the move. + * + * The widget's mount body is NOT re-invoked across a remount; only + * `onBeforeRemount` then `onAfterRemount` fire. + */ + onBeforeRemount(fn: () => void): void + + /** + * Register a callback that fires immediately **after** the widget's host + * `
` has been moved to a new location. Receives the new host element + * so authors can re-attach observers, restore scroll position, etc. + */ + onAfterRemount(fn: (newHost: HTMLElement) => void): void +} + +/** + * Mount function for a widget. Called once when the widget is first attached + * to a node host in the DOM. Returns an optional cleanup function that fires + * on widget destruction. + * + * @param host - A runtime-owned empty `
` for the widget to mount into. + * The widget MAY append children, set inline styles, attach event listeners, + * etc. It MUST NOT replace or remove the host itself. + * @param ctx - Mount context with the widget/node handles and remount hooks. + * @returns Optional cleanup function called on widget destruction. Host + * remount fires `ctx.onBeforeRemount` / `ctx.onAfterRemount` instead. + * + * @stability experimental + */ +export type WidgetMountFn = ( + host: HTMLElement, + ctx: WidgetMountContext +) => void | WidgetCleanup + /** * Options passed to `node.addWidget()` when creating a new widget. *