Files
ComfyUI_frontend/src/extension-api/widget.ts
Connor Byrne 3d09f89251 docs(ext-api): refresh @example blocks + deprecate notify (W17.C/D/E)
- W17.C: refresh 5 stale @example blocks (types.ts, lifecycle.ts, node.ts,
  imperatives.ts ×2) — replace deprecated `async setup()` with
  `setup() { onMounted(...) }`; remove A1-removed `node.getWidget` from
  defineNode + NodeHandle.on migration examples; align imperatives examples
  with W17.B notify-toast verdict.
- W17.D: author ~37 new @example blocks across 9 files for sparse exports
  surfaced by W17.A audit. Pure JSDoc — zero exported-type diff.
- W17.E: add @deprecated JSDoc to notify() + NotifyOptions per
  D-notify-toast-consolidation (W17.B verdict (a) soft-deprecate).

Pure JSDoc — no signature, type, or export changes.
Phase A surface remains FROZEN at ee0537fdb5 (foundation surface SHA
unchanged; only @example / @deprecated text differs).

See research/dashboard-snippet-audit-2026-05-21.md (W17.A) for full
per-edit rationale.
2026-05-20 20:07:13 -07:00

659 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* WidgetHandle — the controlled surface for widget access in v2 extensions.
*
* All state reads and writes go through this interface. Internal ECS
* components and World references are never exposed.
*
* @packageDocumentation
*/
import type { AsyncHandler, Handler, Unsubscribe } from './events'
import type { NodeHandle } from './node'
import type { WidgetEntityId } from '@/world/entityIds'
/**
* Branded entity ID for widgets. Prevents mixing widget IDs with node IDs
* at compile time. Re-exported from the world layer so the entire codebase
* shares a single brand. The underlying value is `string` in Phase A.
*
* @internal Per D20 — extension authors use `widget.id: string` and
* `widget.equals(other)`. The branded type is reserved for internal package
* modules and is intentionally absent from the published barrel.
*/
export type { WidgetEntityId }
/**
* The union of all legal widget scalar values. Complex widgets (DOM, canvas)
* may return their own serializable shapes.
*
* @example
* ```ts
* import type { WidgetValue } from '@comfyorg/extension-api'
*
* // `WidgetValue` is `string | number | boolean | null` — the four
* // primitive-widget value shapes.
* const val: WidgetValue = 42
* ```
*/
export type WidgetValue = string | number | boolean | null
/**
* Payload for `widget.on('valueChange', handler)`.
*
* Replaces the v1 `widget.callback` pattern.
*
* @typeParam T - The widget's value type.
* @example
* ```ts
* widget.on('valueChange', (e) => {
* console.log('changed from', e.oldValue, 'to', e.newValue)
* })
* ```
*/
export interface WidgetValueChangeEvent<T = WidgetValue> {
/** Value before the change. */
readonly oldValue: T
/** Value after the change. */
readonly newValue: T
}
/**
* Payload for `widget.on('optionChange', handler)`.
*
* Fires when a type-specific option is mutated via `setOption(key, value)`.
* The exact set of observable option keys is type-dependent (e.g. `min`,
* `max`, `step` for numeric widgets; `multiline` for strings).
*
* This event covers the options-bag tier (type-specific, not every-widget).
*
* @stability experimental
* @example
* ```ts
* widget.on('optionChange', (e) => {
* if (e.key === 'min') clampValue(e.newValue as number)
* })
* ```
*/
export interface WidgetOptionChangeEvent {
/** The option key that changed (e.g. `'min'`, `'max'`, `'multiline'`). */
readonly key: string
/** Value before the change. */
readonly oldValue: unknown
/** Value after the change. */
readonly newValue: unknown
}
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16:
// `WidgetPropertyChangeEvent` is vacuous after `'serialize'` was removed from
// the property union (A16: authors cannot disable serialization). The other
// historical members ('hidden', 'disabled') were already A14-deferred.
// Restoration requires a new first-class property to surface that satisfies
// A1A16 and an axiom amendment.
//
// export interface WidgetPropertyChangeEvent {
// readonly property: 'serialize'
// readonly oldValue: boolean
// readonly newValue: boolean
// }
/**
* Payload for `widget.on('beforeSerialize', handler)`.
*
* This is the **only async-allowed event** in the API and, per AXIOMS.md
* §A16 (Unified Serialization Target), the **sole** extension-author
* interface to serialization. Replaces every v1 serialization hook:
* `widget.serializeValue = fn`, `widget.options.serialize = false`,
* `nodeType.prototype.serialize`.
*
* The hook fires **once per serialization**. The framework writes the
* resulting payload to every transport (workflow JSON `widgets_values[i]`,
* API prompt, clone target, subgraph promotion). Extensions do not see and
* cannot branch on the transport — that is a framework concern (A16).
*
* Call `event.setSerializedValue(v)` to override what is written. Do not
* call it to pass through the widget's current `getValue()` unchanged.
*
* Per A16 there is no `skip()` and no `context` discriminator. Per A15
* (Widget Declarativity) there is no way to exclude a widget from
* serialization — if a widget should not contribute to the payload, it
* should not be a widget (use a boxed widget, a non-widget UI primitive,
* or a schema input).
*
* @typeParam T - The widget's value type.
* @example
* ```ts
* // Dynamic prompts: replace value at serialize time
* widget.on('beforeSerialize', (e) => {
* e.setSerializedValue(processDynamicPrompt(widget.getValue()))
* })
*
* // Async: webcam capture — materialize frame before serialization
* widget.on('beforeSerialize', async (e) => {
* const frame = await captureFrame()
* e.setSerializedValue(frame)
* })
* ```
*/
export interface WidgetBeforeSerializeEvent<T = WidgetValue> {
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16:
// The 4-way transport discriminator inverted the direction of knowledge
// flow — framework owns transport, extensions own value. Workflow JSON
// and API prompt converge to a single serialized payload; clone and
// subgraph-promote are framework concerns. Restoration requires an
// axiom amendment to A16.
//
// readonly context: 'workflow' | 'prompt' | 'clone' | 'subgraph-promote'
/**
* The widget's current value at the time of serialization (before any override).
* Equivalent to calling `widget.getValue()`.
*/
readonly value: T
/**
* Override the serialized value. The provided value is written to every
* transport (workflow JSON `widgets_values[i]`, API prompt, clone target,
* subgraph promotion). Calling this multiple times keeps the last call's
* value.
*
* @param v - The value to serialize. Must be JSON-serializable.
*/
setSerializedValue(v: unknown): void
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16:
// `skip()` IS a per-call disable — authors cannot disable serialization
// (A16). If a widget should not contribute to the payload, it should not
// be a widget (A15). Restoration requires axiom amendments to A15 + A16.
//
// skip(): void
}
/**
* Payload for `widget.on('beforeQueue', handler)`.
*
* Fires when the user triggers a prompt queue (before `graphToPrompt` runs).
* Call `event.reject(message)` to cancel the queue attempt with a user-visible
* error. Do not call `reject` to allow the queue to proceed.
*
* Replaces the v1 `app.queuePrompt` monkey-patching pattern (S6.A4/S6.A5)
* for per-widget validation (e.g. required field empty, value out of range).
* For cross-node/graph-wide rejection, see the app-level `beforePrompt` event
* (I-UWF.4 — not yet in the API).
*
* @stability experimental
* @example
* ```ts
* // Reject if a required field is empty
* widget.on('beforeQueue', (e) => {
* if (!widget.getValue()) {
* e.reject('Prompt text is required before queueing.')
* }
* })
*
* // Reject with a dynamic message
* widget.on('beforeQueue', (e) => {
* const val = widget.getValue<number>()
* const min = widget.getOption<number>('min') ?? 0
* if (val < min) {
* e.reject(`Value ${val} is below the minimum of ${min}.`)
* }
* })
* ```
*/
export interface WidgetBeforeQueueEvent {
/**
* Reject the queue attempt, showing `message` to the user.
* Once any handler calls `reject`, the queue is cancelled — subsequent
* handlers still run but their `reject` calls are no-ops.
*
* @param message - Human-readable reason shown in the UI toast.
*/
reject(message: string): void
}
/**
* Controlled surface for widget access. Backed by ECS `WidgetValue` and
* `WidgetIdentity` components in the World. Reads query components directly;
* writes dispatch commands (undo-able, serializable, validatable).
*
* All views (node, properties panel, promoted copy) share the same backing
* `WidgetEntityId`, so mutations from any source trigger `valueChange`.
*
* @typeParam T - The type of `getValue()` / `setValue()`. Defaults to `WidgetValue`.
* @example
* ```ts
* import { defineWidget } from '@comfyorg/extension-api'
*
* // Per AXIOMS.md A1, nodes cannot
* // enumerate or reference widgets — `node.getWidget(name)` was removed.
* // To react to a specific widget's lifecycle and value changes, register
* // a widget type and use the `mount` context's `ctx.widget` handle:
*
* export default defineWidget({
* name: 'my-extension',
* type: 'INT',
* mount(_host, { widget }) {
* widget.on('valueChange', (e) => console.log(widget.name, '=', e.newValue))
* widget.setOption('min', 1)
* widget.setOption('max', 150)
* }
* })
* ```
*/
export interface WidgetHandle<T = WidgetValue> {
/**
* Opaque identifier for this widget. Stable for the lifetime of the
* widget entity. Treat as a string token: do not parse, slice, or compare
* its internal structure. Use {@link WidgetHandle.equals} to compare with
* another handle.
*
* @remarks
* The underlying value is a branded `WidgetEntityId` at runtime
* but is narrowed to `string` on the public surface so authors never need
* to import a brand to type a local variable.
*/
readonly id: string
/**
* Returns `true` if `other` represents the same widget entity as this
* one. Equivalent to `this.id === other.id` but the canonical comparator
* — prefer `equals` over manual string comparison so future changes to
* the identity scheme remain transparent.
*/
equals(other: WidgetHandle): boolean
/**
* The widget's name as registered in the node's `INPUT_TYPES` schema.
* Stable for the lifetime of the node; never changes after creation.
*
*/
readonly name: string
/**
* The widget's type string (e.g. `'INT'`, `'STRING'`, `'COMBO'`,
* `'MARKDOWN'`). Read-only invariant set at creation.
*
*/
readonly widgetType: string
/**
* Returns the widget's current user-edited value.
*
* @typeParam T - Narrows the return type when you know the widget type.
* @example
* ```ts
* // Inside `defineWidget({mount})` — `ctx.widget` is the only legal
* // path to a `WidgetHandle` (nodes cannot enumerate widgets per A1).
* const value = widget.getValue<number>()
* ```
*/
getValue(): T
/**
* Sets the widget's value. Dispatches a `SetWidgetValue` command (undo-able).
* Triggers `valueChange` handlers on all views.
*
*/
setValue(value: T): void
// PHASE_A_EXCLUDED per AXIOMS.md A14: Deferred pending serialization convergence.
// isHidden(): boolean
// setHidden(hidden: boolean): void
// PHASE_A_EXCLUDED per AXIOMS.md A14: Deferred pending serialization convergence.
// isDisabled(): boolean
// setDisabled(disabled: boolean): void
/**
* The widget's display label shown to the user. Defaults to the widget name.
* Read-only invariant (set at creation, never changes after).
*
* The label is set by the Python node's `INPUT_TYPES` schema (e.g. via
* the `label` key on the input options dict).
*/
readonly label: string
/**
* Updates the reserved height for this widget and triggers a node relayout.
*
* 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.
*
* @param px - New reserved height in pixels.
* @stability experimental
*/
setHeight(px: number): void
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16:
// Authors cannot disable serialization at the widget level (A16). If a
// widget should not contribute to the serialized payload, it should not
// be a widget (A15) — use a boxed/composed widget (BBOX-style), a
// non-widget UI primitive, or a schema input. The sole serialization
// interface is `widget.on('beforeSerialize', handler)`. Restoration
// requires axiom amendments to A15 + A16 + a validated ecosystem use
// case that no boxed/composed pattern can serve.
//
// isSerializeEnabled(): boolean
// setSerializeEnabled(enabled: boolean): void
/**
* Read-only snapshot of the full options bag for this widget.
*
* **Immutable.** The returned
* 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.
*
* Note: this is an accessor pair on the v2 surface. Reading is free; the
* setter intentionally does not exist on the public type. Per AXIOMS.md
* §A16, `serialize` is no longer a writable option (and no longer a key
* on this bag) — there is no widget-level serialization disable.
* `widget.options.values = [...]` (combo refresh) migrates to a future
* `setValues` mutator.
*
* @example
* ```ts
* // ❌ TS-ERR — every option write raises a compile-time error
* widget.options.min = 0
* widget.options = { min: 0, max: 100 }
*
* // ✅ Read freely
* const min = widget.options.min ?? 0
*
* // ✅ Mutate via typed setters
* widget.setOption('min', 0)
* ```
*/
readonly options: Readonly<WidgetOptions>
/**
* Returns the per-instance override for `key`, or the class-default value
* from `INPUT_TYPES` if no override has been set, or `undefined` if the key
* is unknown for this widget type.
*
* Type-specific option names: `min`, `max`, `step` (INT/FLOAT); `multiline`,
* `dynamicPrompts` (STRING); `image_folder`, `upload_to` (upload widgets).
*
* @example
* ```ts
* const min = widget.getOption<number>('min') ?? 0
* ```
*/
getOption<K = unknown>(key: string): K | undefined
/**
* Set a per-instance option override. Persisted as a `widget_options` sidecar
* in the workflow JSON (additive, backward-compatible). Does not change the
* backend prompt schema unless the extension explicitly opts in via
* `beforeSerialize`.
*
* @example
* ```ts
* // Primitive Int/Float per-instance config (replaces node.properties anti-pattern)
* widget.setOption('min', 0)
* widget.setOption('max', 100)
* widget.setOption('step', 1)
* ```
*/
setOption(key: string, value: unknown): void
/**
* The widget's current `serializeValue` function (or `undefined` if none is
* registered).
*
* **Accessor-only.** The setter
* intentionally does not exist on the public type — assignment
* (`widget.serializeValue = fn`) raises a TypeScript error. The v2
* migration target is the {@link WidgetHandle.on | `on('beforeSerialize', fn)`}
* event, which is typed, async-capable, and composable across
* multiple extensions on the same widget.
*
* @deprecated v1 callers reading `widget.serializeValue` to invoke the
* function directly should subscribe to `'beforeSerialize'` instead. This
* read-only accessor exists for debugging / introspection only and may be
* removed once the v1 surface is fully retired.
*
* @example
* ```ts
* // ❌ TS-ERR — direct assignment no longer compiles
* widget.serializeValue = () => 'static value'
*
* // ✅ Subscribe to the typed event (D5). Per A16 the hook fires once
* // and the framework writes the resulting payload to every transport;
* // there is no transport discriminator.
* widget.on('beforeSerialize', (e) => {
* e.setSerializedValue('static value')
* })
* ```
*
* @stability experimental
*/
readonly serializeValue: ((...args: unknown[]) => unknown) | undefined
/**
* Subscribe to the widget's value changes.
*
* Replaces the v1 `widget.callback` pattern.
* Fires synchronously after the value is committed.
*
* @returns A cleanup function to remove the listener.
*/
on(
event: 'valueChange',
handler: Handler<WidgetValueChangeEvent<T>>
): Unsubscribe
/**
* Subscribe to type-specific option mutations (`setOption(key, value)`).
*
* Fires for options-bag changes (e.g. `min`, `max`, `step`, `multiline`).
* Does NOT fire for value changes or first-class field changes.
*
* @returns A cleanup function to remove the listener.
* @stability experimental
*/
on(
event: 'optionChange',
handler: Handler<WidgetOptionChangeEvent>
): Unsubscribe
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16:
// `WidgetPropertyChangeEvent` is vacuous — the only property the event
// ever surfaced was `'serialize'`, which is gone per A16. `setHidden` /
// `setDisabled` were already A14-deferred. Restoration requires a new
// first-class property to surface that satisfies A1A16.
//
// on(
// event: 'propertyChange',
// handler: Handler<WidgetPropertyChangeEvent>
// ): Unsubscribe
/**
* Subscribe to widget serialization. The only async-allowed event.
*
* Per AXIOMS.md §A16 this is the **sole** extension-author interface
* to serialization. The hook fires once per serialization and the
* framework writes the resulting payload to every transport (workflow
* JSON `widgets_values`, API prompt, clone target, subgraph promotion).
* Replaces the v1 `widget.serializeValue = fn` /
* `widget.options.serialize` patterns.
*
* The handler may be sync or async; async handlers are awaited before
* the serialization payload is sent.
*
* @returns A cleanup function to remove the listener.
*/
on(
event: 'beforeSerialize',
handler: AsyncHandler<WidgetBeforeSerializeEvent<T>>
): Unsubscribe
/**
* Subscribe to pre-queue validation. Fires before `graphToPrompt` runs.
*
* Call `event.reject(message)` to cancel the queue with a user-visible error.
* Replaces the v1 `app.queuePrompt` monkey-patching pattern (S6.A4/S6.A5)
* for per-widget validation use cases.
*
* Handlers are sync-only — use for validation logic only, not I/O.
*
* @returns A cleanup function to remove the listener.
* @stability experimental
*/
on(
event: 'beforeQueue',
handler: Handler<WidgetBeforeQueueEvent>
): Unsubscribe
}
/**
* 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
* @example
* ```ts
* import { defineWidget, type WidgetCleanup } from '@comfyorg/extension-api'
*
* defineWidget({
* name: 'my-ext',
* type: 'STRING',
* mount(host): WidgetCleanup {
* const input = document.createElement('input')
* host.appendChild(input)
* return () => input.remove()
* }
* })
* ```
*/
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
* @example
* ```ts
* import type { WidgetMountFn } from '@comfyorg/extension-api'
*
* const mount: WidgetMountFn = (host, ctx) => {
* const el = document.createElement('div')
* el.textContent = String(ctx.widget.getValue() ?? '')
* host.appendChild(el)
* return () => el.remove()
* }
* ```
*/
export type WidgetMountFn = (
host: HTMLElement,
ctx: WidgetMountContext
) => void | WidgetCleanup
/**
* Options surfaced on each widget instance. Type-specific keys (e.g. `min`,
* `max`, `step` for numeric widgets; `multiline`, `dynamicPrompts` for
* strings) are passed through from the node's `INPUT_TYPES` schema as-is.
*
* Runtime widget addition is forbidden per AXIOMS.md A15 (Widget
* Declarativity) / `decisions/D-ban-runtime-addwidget.md` — every widget
* originates from the Python `INPUT_TYPES` declaration; this type
* describes the options surfaced on the resulting `WidgetHandle`, not a
* constructor argument bag.
*/
export interface WidgetOptions {
/** If `true`, the widget is hidden from the node UI on creation. */
hidden?: boolean
/** If `true`, the widget is rendered read-only (no user editing). */
readonly?: boolean
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16:
// `serialize` contradicted A16 even as a read-only key — there is no
// widget-level serialization disable. Removed from the type entirely.
//
// serialize?: boolean
/** Display label override. Defaults to the widget `name`. */
label?: string
/** Toggle label shown when value is `true` (BOOLEAN widgets). */
labelOn?: string
/** Toggle label shown when value is `false` (BOOLEAN widgets). */
labelOff?: string
/** Multiline text input (STRING widgets). */
multiline?: boolean
/**
* When `true`, the widget value is processed for dynamic prompt syntax
* at serialize time. (STRING widgets with `dynamicPrompts: true`.)
*/
dynamicPrompts?: boolean
/** Min value for numeric widgets (INT, FLOAT). */
min?: number
/** Max value for numeric widgets. */
max?: number
/** Step size for numeric widgets. */
step?: number
/** Default value at construction time. */
default?: unknown
/** Any additional type-specific option. */
[key: string]: unknown
}