feat(extension-api): converge DOMWidget ↔ Widget via mount-lifecycle (D-widget-converge, A12)

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.
This commit is contained in:
Connor Byrne
2026-05-18 14:46:51 -07:00
parent df921f3512
commit 2f102353fa
4 changed files with 126 additions and 57 deletions

View File

@@ -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'

View File

@@ -202,20 +202,6 @@ export interface NodeBeforeSerializeEvent {
replace(fn: (orig: Record<string, unknown>) => Record<string, unknown>): 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<WidgetOptions>
): 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 `<div>` it owns. See `WidgetMountFn` and
// `WidgetMountContext` in `./widget` for the lifecycle contract.
// ── SLOTS ─────────────────────────────────────────────────────────────────

View File

@@ -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 `<div>` 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
}

View File

@@ -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<T = WidgetValue> {
*/
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 `<div>` 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<T = WidgetValue> {
* object is `Readonly<WidgetOptions>` — `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<T = WidgetValue> {
): 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, `<KeepAlive>` 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
* `<div>` is moved to a new location (graph↔app mode, subgraph promotion,
* Vue `<KeepAlive>` 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
* `<div>` 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 `<div>` 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.
*