docs: update DOM widget value persistence best practices guide

- Restructure document to clearly separate simple vs complex widget patterns
- Add detailed explanation of ComfyUI's built-in callback mechanism
- Provide complete implementation examples for both patterns
- Remove outdated sync chain diagrams and replace with practical guidance
- Emphasize using DOM element as source of truth for simple widgets
- Document proper use of internal state with widget.callback for complex widgets
This commit is contained in:
Will Miao
2026-01-27 22:51:09 +08:00
parent 55fa31b144
commit 822ac046e0

View File

@@ -1,37 +1,45 @@
# DOM Widget Value Persistence - Best Practices # DOM Widget Value Persistence - Best Practices
## Problem ## Overview
DOM widgets with text inputs failed to persist values after: DOM widgets require different persistence patterns depending on their complexity. This document covers two patterns:
- Loading workflows
- Switching workflows
- Reloading pages
## Root Cause 1. **Simple Text Widgets**: DOM element as source of truth (e.g., textarea, input)
2. **Complex Widgets**: Internal value with `widget.callback` (e.g., LoraPoolWidget, RandomizerWidget)
**Multiple sources of truth** causing sync issues: ## Understanding ComfyUI's Built-in Callback Mechanism
- Internal state variable (`internalValue` in main.ts)
- Vue reactive ref (`textValue` in component)
- DOM element value (actual textarea)
- ComfyUI widget value (`props.widget.value`)
**Broken sync chains:** When `widget.value` is set (e.g., during workflow load), ComfyUI's `domWidget.ts` triggers this flow:
```
getValue() → internalValue (not actual DOM value)
setValue(v) → internalValue → onSetValue() → textValue.value (async chain)
serializeValue() → textValue.value (different from getValue)
watch() → another sync layer
```
## Solution
Follow ComfyUI built-in `addMultilineWidget` pattern:
### ✅ Do
1. **Single source of truth**: Use the DOM element directly
```typescript ```typescript
// main.ts // From ComfyUI_frontend/src/scripts/domWidget.ts:146-149
set value(v: V) {
this.options.setValue?.(v) // 1. Update internal state
this.callback?.(this.value) // 2. Notify listeners for UI updates
}
```
This means:
- `setValue()` handles storing the value
- `widget.callback()` is automatically called to notify the UI
- You don't need custom callback mechanisms like `onSetValue`
---
## Pattern 1: Simple Text Input Widgets
For widgets where the value IS the DOM element's text content (textarea, input fields).
### When to Use
- Single text input/textarea widgets
- Value is a simple string
- No complex state management needed
### Implementation
**main.ts:**
```typescript
const widget = node.addDOMWidget(name, type, container, { const widget = node.addDOMWidget(name, type, container, {
getValue() { getValue() {
return widget.inputEl?.value ?? '' return widget.inputEl?.value ?? ''
@@ -44,18 +52,14 @@ Follow ComfyUI built-in `addMultilineWidget` pattern:
}) })
``` ```
2. **Register DOM reference** when component mounts **Vue Component:**
```typescript ```typescript
// Vue component
onMounted(() => { onMounted(() => {
if (textareaRef.value) { if (textareaRef.value) {
props.widget.inputEl = textareaRef.value props.widget.inputEl = textareaRef.value
} }
}) })
```
3. **Clean up reference** on unmount
```typescript
onUnmounted(() => { onUnmounted(() => {
if (props.widget.inputEl === textareaRef.value) { if (props.widget.inputEl === textareaRef.value) {
props.widget.inputEl = undefined props.widget.inputEl = undefined
@@ -63,82 +67,159 @@ Follow ComfyUI built-in `addMultilineWidget` pattern:
}) })
``` ```
4. **Simplify interface** - only expose what's needed ### Why This Works
- Single source of truth: the DOM element
- `getValue()` reads directly from DOM
- `setValue()` writes directly to DOM
- No sync issues between multiple state variables
---
## Pattern 2: Complex Widgets
For widgets with structured data (JSON configs, arrays, objects) where the value cannot be stored in a DOM element.
### When to Use
- Value is a complex object/array (e.g., `{ loras: [...], settings: {...} }`)
- Multiple UI elements contribute to the value
- Vue reactive state manages the UI
### Implementation
**main.ts:**
```typescript ```typescript
export interface MyWidgetInterface { let internalValue: MyConfig | undefined
inputEl?: HTMLTextAreaElement
callback?: (v: string) => void const widget = node.addDOMWidget(name, type, container, {
getValue() {
return internalValue
},
setValue(v: MyConfig) {
internalValue = v
// NO custom onSetValue needed - widget.callback is called automatically
},
serialize: true // Ensure value is saved with workflow
})
```
**Vue Component:**
```typescript
const config = ref<MyConfig>(getDefaultConfig())
onMounted(() => {
// Set up callback for UI updates when widget.value changes externally
// (e.g., workflow load, undo/redo)
props.widget.callback = (newValue: MyConfig) => {
if (newValue) {
config.value = newValue
}
}
// Restore initial value if workflow was already loaded
if (props.widget.value) {
config.value = props.widget.value
}
})
// When UI changes, update widget value
function onConfigChange(newConfig: MyConfig) {
config.value = newConfig
props.widget.value = newConfig // This also triggers callback
} }
``` ```
### ❌ Don't ### Why This Works
1. **Clear separation**: `internalValue` stores the data, Vue ref manages the UI
2. **Built-in callback**: ComfyUI calls `widget.callback()` automatically after `setValue()`
3. **Bidirectional sync**:
- External → UI: `setValue()` updates `internalValue`, `callback()` updates Vue ref
- UI → External: User interaction updates Vue ref, which updates `widget.value`
---
## Common Mistakes
### ❌ Creating custom callback mechanisms
1. **Don't create internal state variables**
```typescript ```typescript
// Wrong // Wrong - unnecessary complexity
let internalValue = '' setValue(v: MyConfig) {
getValue() { return internalValue } internalValue = v
widget.onSetValue?.(v) // Don't add this - use widget.callback instead
}
``` ```
2. **Don't use v-model** for text inputs in DOM widgets Use the built-in `widget.callback` instead.
### ❌ Using v-model for simple text inputs in DOM widgets
```html ```html
<!-- Wrong --> <!-- Wrong - creates sync issues -->
<textarea v-model="textValue" /> <textarea v-model="textValue" />
<!-- Right --> <!-- Right for simple text widgets -->
<textarea ref="textareaRef" @input="onInput" /> <textarea ref="textareaRef" @input="onInput" />
``` ```
3. **Don't add serializeValue** - getValue/setValue handle it ### ❌ Watching props.widget.value
```typescript ```typescript
// Wrong // Wrong - creates race conditions
watch(() => props.widget.value, (newValue) => {
config.value = newValue
})
```
Use `widget.callback` instead - it's called at the right time in the lifecycle.
### ❌ Multiple sources of truth
```typescript
// Wrong - who is the source of truth?
let internalValue = '' // State 1
const textValue = ref('') // State 2
const domElement = textarea // State 3
props.widget.value // State 4
```
Choose ONE source of truth:
- **Simple widgets**: DOM element
- **Complex widgets**: `internalValue` (with Vue ref as derived UI state)
### ❌ Adding serializeValue for simple widgets
```typescript
// Wrong - getValue/setValue handle serialization
props.widget.serializeValue = async () => textValue.value props.widget.serializeValue = async () => textValue.value
``` ```
4. **Don't add onSetValue** callback ---
```typescript
// Wrong
setValue(v: string) {
internalValue = v
widget.onSetValue?.(v) // Unnecessary layer
}
```
5. **Don't watch props.widget.value** - creates race conditions ## Decision Guide
```typescript
// Wrong
watch(() => props.widget.value, (newValue) => {
textValue.value = newValue
})
```
6. **Don't restore from props.widget.value** in onMounted | Widget Type | Source of Truth | Use `widget.callback` | Example |
```typescript |-------------|-----------------|----------------------|---------|
// Wrong | Simple text input | DOM element (`inputEl`) | Optional | AutocompleteTextWidget |
onMounted(() => { | Complex config | `internalValue` | Yes, for UI sync | LoraPoolWidget |
if (props.widget.value) { | Vue component widget | Vue ref + `internalValue` | Yes | RandomizerWidget |
textValue.value = props.widget.value
}
})
```
## Key Principles ---
1. **One source of truth**: DOM element value only
2. **Direct sync**: getValue/setValue read/write DOM directly
3. **No async chains**: Eliminate intermediate variables
4. **Match built-in patterns**: Study ComfyUI's `addMultilineWidget` implementation
5. **Minimal interface**: Only expose `inputEl` and `callback`
## Testing Checklist ## Testing Checklist
- [ ] Load workflow - value restores correctly - [ ] Load workflow - value restores correctly
- [ ] Switch workflow - value persists - [ ] Switch workflow - value persists
- [ ] Reload page - value persists - [ ] Reload page - value persists
- [ ] Type in widget - callback fires - [ ] UI interaction - value updates
- [ ] Undo/redo - value syncs with UI
- [ ] No console errors - [ ] No console errors
---
## References ## References
- ComfyUI built-in: `/home/miao/code/ComfyUI_frontend/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts` - ComfyUI DOMWidget implementation: `ComfyUI_frontend/src/scripts/domWidget.ts`
- Example fix: `vue-widgets/src/components/AutocompleteTextWidget.vue` (after fix) - Simple text widget example: `ComfyUI_frontend/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts`