mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 06:35:10 +00:00
feat(ext-api): A16 closure — D-widget-serialization-simplification (wave-9)
Codify A15 (Widget Declarativity) + A16 (Unified Serialization Target) by
collapsing widget-level serialization to its irreducible core.
Per D-widget-serialization-simplification (wave-9, 2026-05-20) — comment out
in src/extension-api/widget.ts (A14 pattern):
- WidgetHandle.setSerializeEnabled(enabled) / .isSerializeEnabled()
- WidgetBeforeSerializeEvent.context: 'workflow'|'prompt'|'clone'|'subgraph-promote'
- WidgetBeforeSerializeEvent.skip()
- WidgetPropertyChangeEvent (vacuous union after 'serialize' removed)
- WidgetHandle.on('propertyChange', handler) overload
- WidgetOptions.serialize?: boolean key
Per A16, the sole extension-author interface to serialization is
`widget.on('beforeSerialize', handler)`. The framework writes one payload
to every transport (workflow JSON, API prompt, clone, subgraph-promote);
authors do not branch on transport and cannot disable serialization. Per
A15, runtime widget addition was the underlying reason for the disable
matrix — declarativity removes the cause, A16 removes the escape hatch.
Removed framework-side adapters in src/services/extension-api-service.ts:
isSerializeEnabled / setSerializeEnabled blocks (lines 302-315) and the
propertyChange branch of the event dispatcher (line 362). Dropped
WidgetComponentSerialize import (now unused).
Removed WidgetPropertyChangeEvent from src/extension-api/index.ts barrel.
Updated JSDoc: stripped 4-context narrative + skip() examples from
WidgetBeforeSerializeEvent, dropped widget.options.serialize references
from the options accessor, dropped e.context === 'prompt' from the
serializeValue accessor example.
node.on('beforeSerialize') (separate surface) remains `@deprecated` +
runtime-warned per ADR-0010 — NOT banned by this commit (per user
direction 2026-05-20).
Blast radius: 0 v2 ext call sites for any removed surface (verified via
rg sweep across restack-ext-v2/src and foundation extensions/core).
v1 path untouched per D6 parallel-paths migration.
Refs:
- decisions/D-widget-serialization-simplification.md (ACCEPTED 2026-05-20)
- AXIOMS.md §A15 (Widget Declarativity), §A16 (Unified Serialization Target)
- AXIOMS.md §A14 (7 new rows in COMMENTED OUT table)
- D14-serialization-convergence.md (predecessor — promised this end-state)
- D-no-node-widget-access.md (wave-8, structural sibling)
- design-review-12142.md row #11
Verified: scripts/check-axiom-compliance.sh ✅ all checks pass
(A1, A2, A3, A4, A6, A15, A16 — strict-fail on re-introduction)
This commit is contained in:
@@ -120,7 +120,7 @@ export type {
|
||||
WidgetOptions,
|
||||
WidgetValueChangeEvent,
|
||||
WidgetOptionChangeEvent,
|
||||
WidgetPropertyChangeEvent,
|
||||
// WidgetPropertyChangeEvent removed per A16 (D-widget-serialization-simplification, wave-9)
|
||||
WidgetBeforeSerializeEvent,
|
||||
WidgetBeforeQueueEvent,
|
||||
// Mount-lifecycle surface per D-widget-converge / Axiom A12 ────────────
|
||||
|
||||
@@ -74,85 +74,66 @@ export interface WidgetOptionChangeEvent {
|
||||
readonly newValue: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for `widget.on('propertyChange', handler)`.
|
||||
*
|
||||
* Fires when a first-class every-widget property is mutated — specifically
|
||||
* `hidden`, `disabled`, and `serialize`. Does NOT fire for `value` changes
|
||||
* (use `valueChange`) or for options-bag mutations (use `optionChange`).
|
||||
*
|
||||
* @stability experimental
|
||||
* @example
|
||||
* ```ts
|
||||
* widget.on('propertyChange', (e) => {
|
||||
* if (e.property === 'hidden') updateLayout(e.newValue as boolean)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export interface WidgetPropertyChangeEvent {
|
||||
/**
|
||||
* Which first-class property changed.
|
||||
* - `'serialize'` — serialization opt-in/out via `setSerializeEnabled()`
|
||||
*
|
||||
* PHASE_A_EXCLUDED per AXIOMS.md A14:
|
||||
* - `'hidden'` — visibility toggled via `setHidden()` (deferred)
|
||||
* - `'disabled'` — enabled/disabled via `setDisabled()` (deferred)
|
||||
*/
|
||||
readonly property: 'serialize' // Phase A: 'hidden' | 'disabled' deferred
|
||||
/** Value before the change. */
|
||||
readonly oldValue: boolean
|
||||
/** Value after the change. */
|
||||
readonly newValue: boolean
|
||||
}
|
||||
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16 (D-widget-serialization-simplification, wave-9):
|
||||
// `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
|
||||
// A1–A16 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.
|
||||
* Replaces `widget.serializeValue`, `widget.options.serialize = false`, and
|
||||
* the v1 `widget.serializeValue = (workflowNode, widgetIndex) => ...` pattern.
|
||||
* 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`.
|
||||
*
|
||||
* Call `event.setSerializedValue(v)` to override what is written to
|
||||
* `widgets_values[i]` and the API prompt. Call `event.skip()` to exclude this
|
||||
* widget from the prompt entirely. Do not call either to pass through the
|
||||
* widget's current `getValue()` unchanged.
|
||||
* 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) => {
|
||||
* if (e.context === 'prompt') {
|
||||
* e.setSerializedValue(processDynamicPrompt(widget.getValue()))
|
||||
* }
|
||||
* e.setSerializedValue(processDynamicPrompt(widget.getValue()))
|
||||
* })
|
||||
*
|
||||
* // Preview widget: exclude from prompt
|
||||
* widget.on('beforeSerialize', (e) => {
|
||||
* if (e.context === 'prompt') e.skip()
|
||||
* })
|
||||
*
|
||||
* // Async: webcam capture — materialize frame before prompt builds
|
||||
* // Async: webcam capture — materialize frame before serialization
|
||||
* widget.on('beforeSerialize', async (e) => {
|
||||
* if (e.context === 'prompt') {
|
||||
* const frame = await captureFrame()
|
||||
* e.setSerializedValue(frame)
|
||||
* }
|
||||
* const frame = await captureFrame()
|
||||
* e.setSerializedValue(frame)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export interface WidgetBeforeSerializeEvent<T = WidgetValue> {
|
||||
/**
|
||||
* Which serialization path triggered this handler.
|
||||
*
|
||||
* - `'workflow'` — user is saving the workflow to disk (full round-trip).
|
||||
* - `'prompt'` — user is queueing a run (only prompt-relevant data sent to backend).
|
||||
* - `'clone'` — a copy/paste is happening; the framework already populated the
|
||||
* cloned entity's widget value from the source. Override only if the clone should
|
||||
* differ from the source.
|
||||
* - `'subgraph-promote'` — the widget is being promoted to a subgraph IO slot.
|
||||
*/
|
||||
readonly context: 'workflow' | 'prompt' | 'clone' | 'subgraph-promote'
|
||||
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16 (D-widget-serialization-simplification, wave-9):
|
||||
// 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).
|
||||
@@ -161,20 +142,21 @@ export interface WidgetBeforeSerializeEvent<T = WidgetValue> {
|
||||
readonly value: T
|
||||
|
||||
/**
|
||||
* Override the serialized value. The provided value is written to
|
||||
* `widgets_values[i]` (and to the API prompt for `context='prompt'`).
|
||||
* Calling this multiple times keeps the last call's value.
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Exclude this widget from the API prompt entirely.
|
||||
* Only meaningful for `context='prompt'`; no-ops on other contexts.
|
||||
* Replaces `widget.options.serialize = false` and `() => undefined` patterns.
|
||||
*/
|
||||
skip(): void
|
||||
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16 (D-widget-serialization-simplification, wave-9):
|
||||
// `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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -349,23 +331,17 @@ export interface WidgetHandle<T = WidgetValue> {
|
||||
*/
|
||||
setHeight(px: number): void
|
||||
|
||||
// ── SERIALIZATION OPT-OUT — first-class, every-widget ────────────────────
|
||||
|
||||
/**
|
||||
* Returns `true` if this widget is included in workflow and prompt
|
||||
* serialization. Defaults to `true` for all widget types.
|
||||
*
|
||||
*/
|
||||
isSerializeEnabled(): boolean
|
||||
|
||||
/**
|
||||
* Enable or disable serialization for this widget. When disabled, the widget
|
||||
* is excluded from both `widgets_values` in the workflow JSON and the API
|
||||
* prompt payload. Equivalent to the v1 `widget.options.serialize = false`
|
||||
* pattern.
|
||||
*
|
||||
*/
|
||||
setSerializeEnabled(enabled: boolean): void
|
||||
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16 (D-widget-serialization-simplification, wave-9):
|
||||
// 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
|
||||
|
||||
// ── OPTIONS BAG — type-specific overrides ─────────────────────────────────
|
||||
|
||||
@@ -379,25 +355,23 @@ export interface WidgetHandle<T = WidgetValue> {
|
||||
* {@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 (tracked
|
||||
* under W6.P8.UNMIGRATABLE).
|
||||
* 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 (tracked under W6.P8.UNMIGRATABLE).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // ❌ TS-ERR — every option write raises a compile-time error
|
||||
* widget.options.min = 0
|
||||
* widget.options = { min: 0, max: 100 }
|
||||
* widget.options.serialize = false
|
||||
*
|
||||
* // ✅ Read freely
|
||||
* const min = widget.options.min ?? 0
|
||||
*
|
||||
* // ✅ Mutate via typed setters
|
||||
* widget.setOption('min', 0)
|
||||
* widget.setSerializeEnabled(false)
|
||||
* ```
|
||||
*/
|
||||
readonly options: Readonly<WidgetOptions>
|
||||
@@ -456,9 +430,11 @@ export interface WidgetHandle<T = WidgetValue> {
|
||||
* // ❌ TS-ERR — direct assignment no longer compiles
|
||||
* widget.serializeValue = () => 'static value'
|
||||
*
|
||||
* // ✅ Subscribe to the typed event (D5)
|
||||
* // ✅ 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) => {
|
||||
* if (e.context === 'prompt') e.setSerializedValue('static value')
|
||||
* e.setSerializedValue('static value')
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
@@ -495,28 +471,28 @@ export interface WidgetHandle<T = WidgetValue> {
|
||||
handler: Handler<WidgetOptionChangeEvent>
|
||||
): Unsubscribe
|
||||
|
||||
/**
|
||||
* Subscribe to first-class property mutations (`setSerializeEnabled`).
|
||||
*
|
||||
* PHASE_A_EXCLUDED per AXIOMS.md A14: `setHidden`, `setDisabled` deferred
|
||||
* pending serialization convergence.
|
||||
*
|
||||
* Does NOT fire for `setValue` (use `valueChange`) or options-bag mutations
|
||||
* (use `optionChange`).
|
||||
*
|
||||
* @returns A cleanup function to remove the listener.
|
||||
* @stability experimental
|
||||
*/
|
||||
on(
|
||||
event: 'propertyChange',
|
||||
handler: Handler<WidgetPropertyChangeEvent>
|
||||
): Unsubscribe
|
||||
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16 (D-widget-serialization-simplification, wave-9):
|
||||
// `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 A1–A16.
|
||||
//
|
||||
// on(
|
||||
// event: 'propertyChange',
|
||||
// handler: Handler<WidgetPropertyChangeEvent>
|
||||
// ): Unsubscribe
|
||||
|
||||
/**
|
||||
* Subscribe to widget serialization. The only async-allowed event.
|
||||
*
|
||||
* Replaces `widget.serializeValue = fn` and the v1 `widget.options.serialize`
|
||||
* flag. The handler may be sync or async; async handlers are awaited before
|
||||
* 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.
|
||||
@@ -630,8 +606,12 @@ export interface WidgetOptions {
|
||||
hidden?: boolean
|
||||
/** If `true`, the widget is rendered read-only (no user editing). */
|
||||
readonly?: boolean
|
||||
/** If `false`, this widget is excluded from workflow/prompt serialization. */
|
||||
serialize?: boolean
|
||||
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16 (D-widget-serialization-simplification, wave-9):
|
||||
// `serialize` contradicted A16 even as a read-only key — there is no
|
||||
// widget-level serialization disable. Already write-blocked by
|
||||
// D-immutability-enforcement; now 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). */
|
||||
|
||||
@@ -42,7 +42,8 @@ import {
|
||||
WidgetComponentContainer,
|
||||
WidgetComponentDisplay,
|
||||
WidgetComponentSchema,
|
||||
WidgetComponentSerialize,
|
||||
// WidgetComponentSerialize import removed per A16
|
||||
// (D-widget-serialization-simplification, wave-9) — adapter blocks gone.
|
||||
WidgetComponentValue
|
||||
} from '@/world/widgets/widgetComponents'
|
||||
import type { NodeEntityId, WidgetEntityId } from '@/world/entityIds'
|
||||
@@ -299,20 +300,12 @@ function createWidgetHandle(widgetId: WidgetEntityId): WidgetHandle {
|
||||
})
|
||||
},
|
||||
|
||||
isSerializeEnabled() {
|
||||
return (
|
||||
world.getComponent(widgetId, WidgetComponentSerialize)?.serialize ??
|
||||
true
|
||||
)
|
||||
},
|
||||
setSerializeEnabled(enabled: boolean) {
|
||||
dispatch({
|
||||
type: 'SetWidgetOption',
|
||||
widgetId,
|
||||
key: 'serialize',
|
||||
value: enabled
|
||||
})
|
||||
},
|
||||
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16 (D-widget-serialization-simplification, wave-9):
|
||||
// Authors cannot disable serialization at the widget level (A16).
|
||||
// Restoration requires axiom amendments to A15 + A16.
|
||||
//
|
||||
// isSerializeEnabled(): boolean { ... }
|
||||
// setSerializeEnabled(enabled: boolean): void { ... }
|
||||
|
||||
// D-immutability-enforcement (Hybrid C): read-only snapshot of options
|
||||
// bag. Public type is Readonly<WidgetOptions> — TS-ERR on any assignment.
|
||||
@@ -359,7 +352,10 @@ function createWidgetHandle(widgetId: WidgetEntityId): WidgetHandle {
|
||||
() => world.getComponent(widgetId, WidgetComponentValue)?.value,
|
||||
(newValue, oldValue) => fn({ newValue, oldValue })
|
||||
)
|
||||
} else if (event === 'optionChange' || event === 'propertyChange') {
|
||||
} else if (event === 'optionChange') {
|
||||
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16 (D-widget-serialization-simplification, wave-9):
|
||||
// `propertyChange` event removed alongside `setSerializeEnabled`
|
||||
// (vacuous union after `'serialize'` was the sole member).
|
||||
// TODO(#11939): wire through ECS event bus when available
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
|
||||
Reference in New Issue
Block a user