mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 05:19:03 +00:00
feat(extension-api): apply Hybrid C Readonly typing per D-immutability-enforcement
Implements the W6.P8.C foundation slice of D-immutability-enforcement
(ACCEPTED 2026-05-14, Hybrid C):
* Point/Size are now readonly tuples — getPosition()[0] = X and
getSize()[0] = X raise TS-ERR at compile time.
* getWidgets() now returns ReadonlyArray<Readonly<WidgetHandle>>
— node.getWidgets().push(...) and []= raise TS-ERR.
* inputs()/outputs() are renamed to getInputs()/getOutputs() returning
ReadonlyArray<Readonly<SlotInfo>>. Old names kept as @deprecated
aliases for one minor release; SlotInfo fields were already readonly.
* New WidgetHandle.options: Readonly<WidgetOptions> accessor — bulk read
of the options bag; widget.options.min = 0 and widget.options = {...}
raise TS-ERR. Mutate via setOption(key, value).
* New WidgetHandle.serializeValue accessor-only — direct assignment
raises TS-ERR; the v2 path is on('beforeSerialize') per D5.
* widget.value (setValue/getValue pair) is unchanged — already ZERO row
in R3 per D14.
Zero runtime cost — no Object.freeze, no Proxy. The 'as any' /
'@ts-ignore' / JS-not-TS escape gap is the explicit trade per ADR; the
follow-up W6.P8.FREEZE closes that gap when prioritized.
Refs: decisions/D-immutability-enforcement.md (ACCEPTED 2026-05-14)
research/architecture/D-immutability-enforcement-blast-radius.md
W6.P8.B PICK (handoff-6, captured in tasks/W6-P8-B.lock)
Phase A — gates: lint OK / format:check OK / knip OK.
typecheck/test failures on the v2 stack are EXPECTED per AGENTS.md
Rule #8 until rebased onto Alex's ECS branch (PR #11939).
This commit is contained in:
@@ -26,13 +26,21 @@ export type { NodeEntityId }
|
||||
|
||||
/**
|
||||
* A 2D point as `[x, y]`.
|
||||
*
|
||||
* **Immutable tuple per D-immutability-enforcement (Hybrid C).** Attempts to
|
||||
* mutate via `node.getPosition()[0] = X` raise a TypeScript error. Use
|
||||
* {@link NodeHandle.setPosition} to move the node.
|
||||
*/
|
||||
export type Point = [x: number, y: number]
|
||||
export type Point = readonly [x: number, y: number]
|
||||
|
||||
/**
|
||||
* A 2D size as `[width, height]`.
|
||||
*
|
||||
* **Immutable tuple per D-immutability-enforcement (Hybrid C).** Attempts to
|
||||
* mutate via `node.getSize()[0] = X` raise a TypeScript error. Use
|
||||
* {@link NodeHandle.setSize} to resize the node.
|
||||
*/
|
||||
export type Size = [width: number, height: number]
|
||||
export type Size = readonly [width: number, height: number]
|
||||
|
||||
/**
|
||||
* LiteGraph node execution mode.
|
||||
@@ -374,8 +382,25 @@ export interface NodeHandle {
|
||||
/**
|
||||
* Returns all widgets on this node as `WidgetHandle` instances.
|
||||
*
|
||||
* **Immutable view per D-immutability-enforcement (Hybrid C).** The returned
|
||||
* array cannot be mutated (`push`, `splice`, `length =`, index assignment
|
||||
* all raise TS errors). Each `WidgetHandle` is also surface-frozen — use
|
||||
* the `WidgetHandle` setter methods (`setValue`, `setHidden`, etc.) to
|
||||
* mutate widget state. To add or remove widgets, use
|
||||
* {@link NodeHandle.addWidget} / future `removeWidget` (W6.P8.UNMIGRATABLE).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // ❌ TS-ERR — readonly array; v1 patterns no longer compile
|
||||
* node.getWidgets().push(newWidget)
|
||||
* node.getWidgets()[0] = newWidget
|
||||
*
|
||||
* // ✅ Iterate / read freely
|
||||
* for (const w of node.getWidgets()) console.log(w.name)
|
||||
* const labels = node.getWidgets().map((w) => w.label)
|
||||
* ```
|
||||
*/
|
||||
getWidgets(): readonly WidgetHandle[]
|
||||
getWidgets(): ReadonlyArray<Readonly<WidgetHandle>>
|
||||
|
||||
/**
|
||||
* Adds a new widget to this node.
|
||||
@@ -417,14 +442,47 @@ export interface NodeHandle {
|
||||
/**
|
||||
* Returns all input slots on this node.
|
||||
*
|
||||
* **Immutable view per D-immutability-enforcement (Hybrid C).** The returned
|
||||
* array and each slot are `Readonly` — `node.getInputs().push(...)`,
|
||||
* `node.getInputs()[i] = X`, and `node.getInputs()[i].name = "x"` all raise
|
||||
* TypeScript errors at compile time. Per-slot mutators (`setInputName`,
|
||||
* `replaceInput`, bulk field setters) are tracked under
|
||||
* W6.P8.UNMIGRATABLE / D-input-output-shape.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // ❌ TS-ERR — readonly array; v1 patterns no longer compile
|
||||
* node.getInputs().push({ name: 'x', type: 'INT' })
|
||||
* node.getInputs()[0].name = 'renamed'
|
||||
*
|
||||
* // ✅ Read / iterate freely
|
||||
* const types = node.getInputs().map((s) => s.type)
|
||||
* ```
|
||||
*/
|
||||
inputs(): readonly SlotInfo[]
|
||||
getInputs(): ReadonlyArray<Readonly<SlotInfo>>
|
||||
|
||||
/**
|
||||
* Returns all output slots on this node.
|
||||
*
|
||||
* **Immutable view per D-immutability-enforcement (Hybrid C).** Same
|
||||
* read-only semantics as {@link NodeHandle.getInputs}. Per-slot mutators
|
||||
* tracked under W6.P8.UNMIGRATABLE / D-input-output-shape.
|
||||
*/
|
||||
outputs(): readonly SlotInfo[]
|
||||
getOutputs(): ReadonlyArray<Readonly<SlotInfo>>
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link NodeHandle.getInputs} instead. Renamed to align
|
||||
* with the `getX()` accessor convention (D11/D-immutability-enforcement).
|
||||
* Will be removed in v1.0.
|
||||
*/
|
||||
inputs(): ReadonlyArray<Readonly<SlotInfo>>
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link NodeHandle.getOutputs} instead. Renamed to align
|
||||
* with the `getX()` accessor convention (D11/D-immutability-enforcement).
|
||||
* Will be removed in v1.0.
|
||||
*/
|
||||
outputs(): ReadonlyArray<Readonly<SlotInfo>>
|
||||
|
||||
// ── EVENTS ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -381,6 +381,39 @@ export interface WidgetHandle<T = WidgetValue> {
|
||||
|
||||
// ── OPTIONS BAG — type-specific overrides ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read-only snapshot of the full options bag for this widget.
|
||||
*
|
||||
* **Immutable per D-immutability-enforcement (Hybrid C).** 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. Bulk `setOptions(opts)` is
|
||||
* tracked under W6.P8.UNMIGRATABLE / D-widget-converge.
|
||||
*
|
||||
* 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).
|
||||
*
|
||||
* @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>
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -412,6 +445,39 @@ export interface WidgetHandle<T = WidgetValue> {
|
||||
*/
|
||||
setOption(key: string, value: unknown): void
|
||||
|
||||
// ── SERIALIZE VALUE — read-only accessor; D5 is the write path ───────────
|
||||
|
||||
/**
|
||||
* The widget's current `serializeValue` function (or `undefined` if none is
|
||||
* registered).
|
||||
*
|
||||
* **Accessor-only per D-immutability-enforcement (Hybrid C).** 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 (per D5), 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)
|
||||
* widget.on('beforeSerialize', (e) => {
|
||||
* if (e.context === 'prompt') e.setSerializedValue('static value')
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @stability experimental
|
||||
*/
|
||||
readonly serializeValue: ((...args: unknown[]) => unknown) | undefined
|
||||
|
||||
// ── EVENTS ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -56,7 +56,7 @@ import type {
|
||||
Size,
|
||||
DOMWidgetOptions
|
||||
} from '@/extension-api/node'
|
||||
import type { WidgetHandle } from '@/extension-api/widget'
|
||||
import type { WidgetHandle, WidgetOptions } from '@/extension-api/widget'
|
||||
import type { Unsubscribe } from '@/extension-api/events'
|
||||
import { stampBrand } from '@/extension-api/brand'
|
||||
import type {
|
||||
@@ -314,6 +314,25 @@ function createWidgetHandle(widgetId: WidgetEntityId): WidgetHandle {
|
||||
})
|
||||
},
|
||||
|
||||
// D-immutability-enforcement (Hybrid C): read-only snapshot of options
|
||||
// bag. Public type is Readonly<WidgetOptions> — TS-ERR on any assignment.
|
||||
// Mutate via setOption(key, value).
|
||||
get options() {
|
||||
const opts =
|
||||
world.getComponent(widgetId, WidgetComponentSchema)?.options ?? {}
|
||||
return opts as Readonly<WidgetOptions>
|
||||
},
|
||||
// D-immutability-enforcement (Hybrid C): accessor only — no setter. v2
|
||||
// migration target is on('beforeSerialize') per D5.
|
||||
get serializeValue() {
|
||||
const schema = world.getComponent(widgetId, WidgetComponentSchema)
|
||||
const fn = (schema?.options as Record<string, unknown> | undefined)?.[
|
||||
'serializeValue'
|
||||
]
|
||||
return typeof fn === 'function'
|
||||
? (fn as (...args: unknown[]) => unknown)
|
||||
: undefined
|
||||
},
|
||||
getOption<K = unknown>(key: string): K | undefined {
|
||||
const opts = world.getComponent(widgetId, WidgetComponentSchema)?.options
|
||||
return (opts as Record<string, unknown> | undefined)?.[key] as
|
||||
@@ -410,13 +429,20 @@ function createNodeHandle(nodeId: NodeEntityId): NodeHandle {
|
||||
getPosition(): Point {
|
||||
// Position is centralized in layoutStore (Yjs CRDT-backed)
|
||||
// See D13 §4 — layoutStore.ts is the source of truth for position/size
|
||||
// D-immutability-enforcement: Point is a readonly tuple, so the literal
|
||||
// must be widened via `as const`.
|
||||
const layout = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
return layout ? [layout.position.x, layout.position.y] : [0, 0]
|
||||
return layout
|
||||
? ([layout.position.x, layout.position.y] as const)
|
||||
: ([0, 0] as const)
|
||||
},
|
||||
getSize(): Size {
|
||||
// Size is centralized in layoutStore (Yjs CRDT-backed)
|
||||
// D-immutability-enforcement: Size is a readonly tuple, see getPosition.
|
||||
const layout = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
return layout ? [layout.size.width, layout.size.height] : [0, 0]
|
||||
return layout
|
||||
? ([layout.size.width, layout.size.height] as const)
|
||||
: ([0, 0] as const)
|
||||
},
|
||||
getTitle() {
|
||||
return world.getComponent(nodeId, NodeVisualKey)?.title ?? ''
|
||||
@@ -540,7 +566,7 @@ function createNodeHandle(nodeId: NodeEntityId): NodeHandle {
|
||||
return createWidgetHandle(widgetId)
|
||||
},
|
||||
|
||||
inputs() {
|
||||
getInputs() {
|
||||
const conn = world.getComponent(nodeId, ConnectivityKey)
|
||||
return (conn?.inputSlotIds ?? []).map((slotId: SlotEntityId) => {
|
||||
const slot = world.getComponent(
|
||||
@@ -559,7 +585,7 @@ function createNodeHandle(nodeId: NodeEntityId): NodeHandle {
|
||||
} satisfies SlotInfo
|
||||
})
|
||||
},
|
||||
outputs() {
|
||||
getOutputs() {
|
||||
const conn = world.getComponent(nodeId, ConnectivityKey)
|
||||
return (conn?.outputSlotIds ?? []).map((slotId: SlotEntityId) => {
|
||||
const slot = world.getComponent(
|
||||
@@ -578,6 +604,14 @@ function createNodeHandle(nodeId: NodeEntityId): NodeHandle {
|
||||
} satisfies SlotInfo
|
||||
})
|
||||
},
|
||||
/** @deprecated D-immutability-enforcement: use getInputs(). */
|
||||
inputs() {
|
||||
return this.getInputs()
|
||||
},
|
||||
/** @deprecated D-immutability-enforcement: use getOutputs(). */
|
||||
outputs() {
|
||||
return this.getOutputs()
|
||||
},
|
||||
|
||||
on: ((event: string, fn: (...args: unknown[]) => unknown): Unsubscribe => {
|
||||
if (event === 'positionChanged') {
|
||||
|
||||
Reference in New Issue
Block a user