Comfy-Org / Comfy-Org/ComfyUI_frontend
DOM widget values: architectural mismatch between store ownership and DOM delegation
- Dominant language
- TypeScript
- Stars
- 2k
- Forks
- 699
- Avg merge
- 1d 7h
- Merged PRs (30d)
- 490
Description
## DOM widget values: architectural mismatch between store ownership and DOM delegation
PR #9166 hotfixed the immediate bug (DOM widgets showing `undefined` in Vue nodes), but the underlying architectural tension remains. This issue tracks the design work to resolve it properly.
### Context
When #8594 introduced `WidgetValueStore`, it created a centralized reactive store where Vue nodes read widget values. This works perfectly for standard widgets (number, combo, toggle) because their values live directly in `_state.value`. But DOM widgets (textarea/customtext, markdown, audio) store their values in the DOM element itself, accessed through `options.getValue()`/`options.setValue()` — the store never gets the real value.
The root cause is in [`BaseWidget.setNodeId()`](https://github.com/Comfy-Org/ComfyUI_frontend/blob/8c3738fb/src/lib/litegraph/src/widgets/BaseWidget.ts#L137-L145) which spreads `this._state` (where `.value` is `undefined` for DOM widgets) into `registerWidget()`. Meanwhile, the actual value lives behind an [`Object.defineProperty` hack](https://github.com/Comfy-Org/ComfyUI_frontend/blob/8c3738fb/src/scripts/domWidget.ts#L419-L427) on the widget instance that delegates to `options.getValue()`.
```mermaid
flowchart LR
subgraph "What Vue reads"
store["widgetValueStore\n.value = undefined ❌"]
end
subgraph "What execution reads"
odp["widget.value\n(Object.defineProperty)\n→ options.getValue()\n→ inputEl.value ✅"]
end
vue["NodeWidgets.vue"] --> store
exec["executionUtil.ts"] --> odp
```
### Current state (hotfix)
#9166 [overrides `setNodeId`](https://github.com/Comfy-Org/ComfyUI_frontend/blob/8c3738fb/src/scripts/domWidget.ts#L155-L166) in `BaseDOMWidgetImpl` to snapshot the DOM-resolved value into the store at registration time. This fixes the bug but the value is a point-in-time copy — the store and DOM element can drift.
### The deeper issue
There are currently **two parallel rendering paths** for the same widget type, and they disagree about who owns the value.
**Path A** — `useStringWidget.ts` [creates a raw ``](https://github.com/Comfy-Org/ComfyUI_frontend/blob/8c3738fb/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts#L20-L40) via `document.createElement` and registers it through `addDOMWidget`. The DOM element owns the value. The widget [manually syncs with the store](https://github.com/Comfy-Org/ComfyUI_frontend/blob/8c3738fb/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts#L28-L39) in `getValue`/`setValue` (there's even a TODO on line 12 about this).
**Path B** — The widget registry [maps `customtext` → `WidgetTextarea`](https://github.com/Comfy-Org/ComfyUI_frontend/blob/8c3738fb/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts#L120-L125), a Vue component that uses [`defineModel<string>()`](https://github.com/Comfy-Org/ComfyUI_frontend/blob/8c3738fb/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue#L58). `NodeWidgets.vue` [binds it with `v-model`](https://github.com/Comfy-Org/ComfyUI_frontend/blob/8c3738fb/src/renderer/extensions/vueNodes/components/NodeWidgets.vue#L59) reading from the store. Here the store owns the value and the DOM is just a view.
So both a raw `<textarea>` AND `WidgetTextarea.vue` exist for the same widget. Path B is already the correct architecture — the store owns the value, the Vue component reads via `modelValue`, user input writes back via `update:modelValue`. The problem is that Path A creates the raw element first and registers `undefined` in the store, which Path B then faithfully renders.
```mermaid
flowchart TD
subgraph pathA["Path A: Legacy DOM widget"]
usw["useStringWidget.ts"] -->|"document.createElement('textarea')"| raw["Raw <textarea>\n(owns value)"]
raw -->|"addDOMWidget()"| odp2["Object.defineProperty hack"]
odp2 -->|"options.getValue()"| raw
end
subgraph pathB["Path B: Vue component"]
reg["widgetRegistry.ts"] -->|"customtext → textarea"| wt["WidgetTextarea.vue\ndefineModel<string>()"]
wt -->|"v-model"| store2["widgetValueStore\n(owns value)"]
end
nw["NodeWidgets.vue"] -->|"getComponent('customtext')"| wt
nw -->|"widgetState?.value"| store2
pathA -.->|"setNodeId registers undefined"| store2
```
### Possible approaches
I've been thinking about a few directions, roughly ordered from least to most aligned with where we want the architecture to go (ECS-style centralized store, consistent with the `widgetValueStore` and `proxy-widget-v2` / #8856 direction).
**1. Delegate value source on store entries (~30 lines, 2 files)**
Add a discriminated union to `WidgetState` that tells the store *how* to resolve a value:
```typescript
type ValueSource<T = unknown> =
| { type: 'delegate'; get: () => T; set: (v: T) => void }
// in registerWidget:
if (state.valueSource?.type === 'delegate') {
Object.defineProperty(registered, 'value', {
get: () => state.valueSource.get(),
set: (v) => state.valueSource.set(v),
enumerable: true, configurable: true
})
}
```
DOM widgets would pass `{ type: 'delegate', get: () => options.getValue(), set: (v) => options.setValue(v) }` at registration. The store resolves at read time instead of snapshotting. This is structurally similar to [Vue's `ComputedRefImpl`](https://github.com/vuejs/core/blob/355d60624a6d3a06330e09a75daf0d572ead35e0/packages/reactivity/src/computed.ts#L131-L142) — both delegate `.value` to a pluggable getter/setter — but without dependency tracking since DOM values aren't reactive sources Vue can track.
Tradeoff: the store becomes a proxy/router for delegate widgets rather than a data owner. It fixes the symptom robustly but the store still doesn't own DOM widget values. Not true ECS.
**2. Migrate DOM widgets to Vue components with `modelValue` (the north star)**
Since `WidgetTextarea.vue` already exists and already uses `defineModel`, the cleanest path is to stop creating raw `<textarea>` elements in `useStringWidget.ts` for the Nodes 2.0 path. Instead, create a `ComponentWidgetImpl` and let the store own the value from the start. The raw element path stays for legacy canvas rendering only.
This eliminates the `getValue`/`setValue` indirection, the `Object.defineProperty` hack, and the manual store sync. The store is the single source of truth, the Vue component reads via `modelValue`, user input writes back. True ECS: entities are just keys, the store owns all data, Vue's reactivity is the system.
The complexity here is feature parity — `useStringWidget.ts` adds wheel event handling, middle-click panning, trackpad gestures, and spellcheck config that `WidgetTextarea.vue` would need to absorb. Also need to handle the legacy canvas path which still needs the raw DOM element.
**3. The approach we explicitly don't want**
For completeness: the v1 design explored a `WidgetValueComponent` interface with `PlainValueComponent` and `DOMValueComponent` classes, factory methods on `BaseWidget`, and `subscribe()`/`dispose()` lifecycle. This is Strategy pattern, not ECS — smart objects with behavior attached to widget entities instead of dumb data in a centralized store. ~200 lines across 6 new files for something the delegate approach does in 30 lines and the north star eliminates entirely.
### My recommendation
Ship approach 1 (delegate) as a follow-up to the hotfix — it's small, handles edge cases the snapshot misses, and is independently valuable. Then pursue approach 2 (migrate to Vue components) per-widget-type, starting with textarea/customtext since `WidgetTextarea.vue` already exists. The delegate type can be removed once all our DOM widgets have Vue component equivalents. Extension-created DOM widgets continue to use `addDOMWidget` → `WidgetDOM.vue` unchanged.
This aligns with the direction established by `widgetValueStore` (#8594) and the subgraph proxy-widget-v2 work (#8856) — centralizing widget state in queryable stores rather than scattering it across widget instances and DOM elements.
┆Issue is synchronized with this [Notion page](https://www.notion.so/Issue-9194-DOM-widget-values-architectural-mismatch-between-store-ownership-and-DOM-delegation-3126d73d365081b69555d7d033b7dfd7) by [Unito](https://www.unito.io)
Contributor guide
Assessment
This issue has not been assessed yet.