feat: add WidgetValueStore for centralized widget value management (#8594)

## Summary

Implements Phase 1 of the **Vue-owns-truth** pattern for widget values.
Widget values are now canonical in a Pinia store; `widget.value`
delegates to the store while preserving full backward compatibility.

## Changes

- **New store**: `src/stores/widgetValueStore.ts` - centralized widget
value storage with `get/set/remove/removeNode` API
- **BaseWidget integration**: `widget.value` getter/setter now delegates
to store when widget is associated with a node
- **LGraphNode wiring**: `addCustomWidget()` automatically calls
`widget.setNodeId(this.id)` to wire widgets to their nodes
- **Test fixes**: Added Pinia setup to test files that use widgets

## Why

This foundation enables:
- Vue components to reactively bind to widget values via `computed(() =>
store.get(...))`
- Future Yjs/CRDT backing for real-time collaboration
- Cleaner separation between Vue state and LiteGraph rendering

## Backward Compatibility

| Extension Pattern | Status |
|-------------------|--------|
| `widget.value = x` |  Works unchanged |
| `node.widgets[i].value` |  Works unchanged |
| `widget.callback` |  Still fires |
| `node.onWidgetChanged` |  Still fires |

## Testing

-  4252 unit tests pass
-  Build succeeds

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8594-feat-add-WidgetValueStore-for-centralized-widget-value-management-2fc6d73d36508160886fcb9f3ebd941e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2026-02-10 19:37:17 -08:00
committed by GitHub
parent d044bed9b2
commit a7c2115166
36 changed files with 814 additions and 186 deletions

View File

@@ -1,49 +1,76 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, it, vi } from 'vitest'
import { nextTick, watch } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
setActivePinia(createTestingPinia())
function createTestGraph() {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('input', 'INT')
node.addWidget('number', 'testnum', 2, () => undefined, {})
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const onReactivityUpdate = vi.fn()
watch(vueNodeData, onReactivityUpdate)
return [node, graph, onReactivityUpdate] as const
}
import { useWidgetValueStore } from '@/stores/widgetValueStore'
describe('Node Reactivity', () => {
it('should trigger on callback', async () => {
const [node, , onReactivityUpdate] = createTestGraph()
node.widgets![0].callback!(2)
await nextTick()
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('should remain reactive after a connection is made', async () => {
const [node, graph, onReactivityUpdate] = createTestGraph()
function createTestGraph() {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('input', 'INT')
node.addWidget('number', 'testnum', 2, () => undefined, {})
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
return { node, graph, vueNodeData }
}
it('widget values are reactive through the store', async () => {
const { node } = createTestGraph()
const store = useWidgetValueStore()
const widget = node.widgets![0]
// Verify widget is a BaseWidget with correct value and node assignment
expect(widget).toBeInstanceOf(BaseWidget)
expect(widget.value).toBe(2)
expect((widget as BaseWidget).node.id).toBe(node.id)
// Initial value should be in store after setNodeId was called
expect(store.getWidget(node.id, 'testnum')?.value).toBe(2)
const onValueChange = vi.fn()
const widgetValue = computed(
() => store.getWidget(node.id, 'testnum')?.value
)
watch(widgetValue, onValueChange)
widget.value = 42
await nextTick()
expect(widgetValue.value).toBe(42)
expect(onValueChange).toHaveBeenCalledTimes(1)
})
it('widget values remain reactive after a connection is made', async () => {
const { node, graph } = createTestGraph()
const store = useWidgetValueStore()
const onValueChange = vi.fn()
graph.trigger('node:slot-links:changed', {
nodeId: '1',
nodeId: String(node.id),
slotType: NodeSlotType.INPUT
})
await nextTick()
onReactivityUpdate.mockClear()
node.widgets![0].callback!(2)
const widgetValue = computed(
() => store.getWidget(node.id, 'testnum')?.value
)
watch(widgetValue, onValueChange)
node.widgets![0].value = 99
await nextTick()
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)
expect(onValueChange).toHaveBeenCalledTimes(1)
expect(widgetValue.value).toBe(99)
})
})

View File

@@ -3,7 +3,7 @@
* Provides event-driven reactivity with performance optimizations
*/
import { reactiveComputed } from '@vueuse/core'
import { customRef, reactive, shallowReactive } from 'vue'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
@@ -11,10 +11,7 @@ import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
@@ -41,19 +38,37 @@ export interface WidgetSlotMetadata {
linked: boolean
}
/**
* Minimal render-specific widget data extracted from LiteGraph widgets.
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
nodeId?: NodeId
name: string
type: string
value: WidgetValue
borderStyle?: string
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined
/** Control widget for seed randomization/increment/decrement */
controlWidget?: SafeControlWidget
/** Whether widget has custom layout size computation */
hasLayoutSize?: boolean
/** Whether widget is a DOM widget */
isDOMWidget?: boolean
label?: string
/** Node type (for subgraph promoted widgets) */
nodeType?: string
options?: IWidgetOptions
/**
* Widget options needed for render decisions.
* Note: Most metadata should be accessed via widgetValueStore.getWidget().
*/
options?: {
canvasOnly?: boolean
advanced?: boolean
hidden?: boolean
read_only?: boolean
}
/** Input specification from node definition */
spec?: InputSpec
/** Input slot metadata (index and link status) */
slotMetadata?: WidgetSlotMetadata
}
@@ -95,23 +110,6 @@ export interface GraphNodeManager {
cleanup(): void
}
function widgetWithVueTrack(
widget: IBaseWidget
): asserts widget is IBaseWidget & { vueTrack: () => void } {
if (widget.vueTrack) return
customRef((track, trigger) => {
widget.callback = useChainCallback(widget.callback, trigger)
widget.vueTrack = track
return { get() {}, set() {} }
})
}
function useReactiveWidgetValue(widget: IBaseWidget) {
widgetWithVueTrack(widget)
widget.vueTrack()
return widget.value
}
function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
const cagWidget = widget.linkedWidgets?.find(
(w) => w.name == 'control_after_generate'
@@ -133,26 +131,18 @@ function getNodeType(node: LGraphNode, widget: IBaseWidget) {
* Shared widget enhancements used by both safeWidgetMapper and Right Side Panel
*/
interface SharedWidgetEnhancements {
/** Reactive widget value that updates when the widget changes */
value: WidgetValue
/** Control widget for seed randomization/increment/decrement */
controlWidget?: SafeControlWidget
/** Input specification from node definition */
spec?: InputSpec
/** Node type (for subgraph promoted widgets) */
nodeType?: string
/** Border style for promoted/advanced widgets */
borderStyle?: string
/** Widget label */
label?: string
/** Widget options */
options?: IWidgetOptions
}
/**
* Extracts common widget enhancements shared across different rendering contexts.
* This function centralizes the logic for extracting metadata and reactive values
* from widgets, ensuring consistency between Nodes 2.0 and Right Side Panel.
* This function centralizes the logic for extracting metadata from widgets.
* Note: Value and metadata (label, options, hidden, etc.) are accessed via widgetValueStore.
*/
export function getSharedWidgetEnhancements(
node: LGraphNode,
@@ -161,17 +151,9 @@ export function getSharedWidgetEnhancements(
const nodeDefStore = useNodeDefStore()
return {
value: useReactiveWidgetValue(widget),
controlWidget: getControlWidget(widget),
spec: nodeDefStore.getInputSpecForWidget(node, widget.name),
nodeType: getNodeType(node, widget),
borderStyle: widget.promoted
? 'ring ring-component-node-widget-promoted'
: widget.advanced
? 'ring ring-component-node-widget-advanced'
: undefined,
label: widget.label,
options: widget.options as IWidgetOptions
nodeType: getNodeType(node, widget)
}
}
@@ -212,7 +194,7 @@ function safeWidgetMapper(
): (widget: IBaseWidget) => SafeWidgetData {
return function (widget) {
try {
// Get shared enhancements used by both Nodes 2.0 and Right Side Panel
// Get shared enhancements (controlWidget, spec, nodeType)
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
const slotInfo = slotMetadata.get(widget.name)
@@ -228,20 +210,41 @@ function safeWidgetMapper(
node.widgets?.forEach((w) => w.triggerDraw?.())
}
// Extract only render-critical options (canvasOnly, advanced, read_only)
const options = widget.options
? {
canvasOnly: widget.options.canvasOnly,
advanced: widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
: undefined
const subgraphId = node.isSubgraphNode() && node.subgraph.id
const localId = isProxyWidget(widget)
? widget._overlay?.nodeId
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
const name = isProxyWidget(widget)
? widget._overlay.widgetName
: widget.name
return {
name: widget.name,
nodeId,
name,
type: widget.type,
...sharedEnhancements,
callback,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: isDOMWidget(widget),
options,
slotMetadata: slotInfo
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined
type: widget.type || 'text'
}
}
}