mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
Ensure widgets always get a single callback (#7579)
The other side of reactivity. Ensure that vue mode always registers a callback on litegraph nodes and never registers more than one. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7579-Ensure-widgets-always-get-a-single-callback-2cc6d73d365081e2a488c38ae394efc0) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import { computed, provide } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useReactiveWidgetValue } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
@@ -71,7 +72,7 @@ const displayLabel = computed(
|
||||
<component
|
||||
:is="getWidgetComponent(widget)"
|
||||
:widget="widget"
|
||||
:model-value="widget.value"
|
||||
:model-value="useReactiveWidgetValue(widget)"
|
||||
:node-id="String(node.id)"
|
||||
:node-type="node.type"
|
||||
:class="cn('col-span-1', shouldExpand(widget.type) && 'min-h-36')"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Provides event-driven reactivity with performance optimizations
|
||||
*/
|
||||
import { reactiveComputed } from '@vueuse/core'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
import { customRef, reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
@@ -90,6 +90,23 @@ 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() {} }
|
||||
})
|
||||
}
|
||||
export 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'
|
||||
@@ -106,6 +123,37 @@ function getNodeType(node: LGraphNode, widget: IBaseWidget) {
|
||||
return subNode?.type
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a value is a valid WidgetValue type
|
||||
*/
|
||||
const normalizeWidgetValue = (value: unknown): WidgetValue => {
|
||||
if (value === null || value === undefined || value === void 0) {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
// Check if it's a File array
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((item): item is File => item instanceof File)
|
||||
) {
|
||||
return value
|
||||
}
|
||||
// Otherwise it's a generic object
|
||||
return value
|
||||
}
|
||||
// If none of the above, return undefined
|
||||
console.warn(`Invalid widget value type: ${typeof value}`, value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function safeWidgetMapper(
|
||||
node: LGraphNode,
|
||||
slotMetadata: Map<string, WidgetSlotMetadata>
|
||||
@@ -113,19 +161,6 @@ export function safeWidgetMapper(
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
return function (widget) {
|
||||
try {
|
||||
// TODO: Use widget.getReactiveData() once TypeScript types are updated
|
||||
let value = widget.value
|
||||
|
||||
// For combo widgets, if value is undefined, use the first option as default
|
||||
if (
|
||||
value === undefined &&
|
||||
widget.type === 'combo' &&
|
||||
widget.options?.values &&
|
||||
Array.isArray(widget.options.values) &&
|
||||
widget.options.values.length > 0
|
||||
) {
|
||||
value = widget.options.values[0]
|
||||
}
|
||||
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
const borderStyle = widget.promoted
|
||||
@@ -133,13 +168,18 @@ export function safeWidgetMapper(
|
||||
: widget.advanced
|
||||
? 'ring ring-component-node-widget-advanced'
|
||||
: undefined
|
||||
const callback = (v: unknown) => {
|
||||
const value = normalizeWidgetValue(v)
|
||||
widget.value = value ?? undefined
|
||||
widget.callback?.(value)
|
||||
}
|
||||
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: value,
|
||||
value: useReactiveWidgetValue(widget),
|
||||
borderStyle,
|
||||
callback: widget.callback,
|
||||
callback,
|
||||
controlWidget: getControlWidget(widget),
|
||||
isDOMWidget: isDOMWidget(widget),
|
||||
label: widget.label,
|
||||
@@ -286,128 +326,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
return nodeRefs.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a value is a valid WidgetValue type
|
||||
*/
|
||||
const validateWidgetValue = (value: unknown): WidgetValue => {
|
||||
if (value === null || value === undefined || value === void 0) {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
// Check if it's a File array
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((item): item is File => item instanceof File)
|
||||
) {
|
||||
return value
|
||||
}
|
||||
// Otherwise it's a generic object
|
||||
return value
|
||||
}
|
||||
// If none of the above, return undefined
|
||||
console.warn(`Invalid widget value type: ${typeof value}`, value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates Vue state when widget values change
|
||||
*/
|
||||
const updateVueWidgetState = (
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
value: unknown
|
||||
): void => {
|
||||
try {
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
if (!currentData?.widgets) return
|
||||
|
||||
const updatedWidgets = currentData.widgets.map((w) =>
|
||||
w.name === widgetName ? { ...w, value: validateWidgetValue(value) } : w
|
||||
)
|
||||
// Create a completely new object to ensure Vue reactivity triggers
|
||||
const updatedData = {
|
||||
...currentData,
|
||||
widgets: updatedWidgets
|
||||
}
|
||||
|
||||
vueNodeData.set(nodeId, updatedData)
|
||||
} catch (error) {
|
||||
// Ignore widget update errors to prevent cascade failures
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a wrapped callback for a widget that maintains LiteGraph/Vue sync
|
||||
*/
|
||||
const createWrappedWidgetCallback = (
|
||||
widget: IBaseWidget, // LiteGraph widget with minimal typing
|
||||
originalCallback: ((value: unknown) => void) | undefined,
|
||||
nodeId: string
|
||||
) => {
|
||||
let updateInProgress = false
|
||||
|
||||
return (value: unknown) => {
|
||||
if (updateInProgress) return
|
||||
updateInProgress = true
|
||||
|
||||
try {
|
||||
// 1. Update the widget value in LiteGraph (critical for LiteGraph state)
|
||||
// Validate that the value is of an acceptable type
|
||||
if (
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
typeof value !== 'string' &&
|
||||
typeof value !== 'number' &&
|
||||
typeof value !== 'boolean' &&
|
||||
typeof value !== 'object'
|
||||
) {
|
||||
console.warn(`Invalid widget value type: ${typeof value}`)
|
||||
updateInProgress = false
|
||||
return
|
||||
}
|
||||
|
||||
// Always update widget.value to ensure sync
|
||||
widget.value = value ?? undefined
|
||||
|
||||
// 2. Call the original callback if it exists
|
||||
if (originalCallback && widget.type !== 'asset') {
|
||||
originalCallback.call(widget, value)
|
||||
}
|
||||
|
||||
// 3. Update Vue state to maintain synchronization
|
||||
updateVueWidgetState(nodeId, widget.name, value)
|
||||
} finally {
|
||||
updateInProgress = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up widget callbacks for a node
|
||||
*/
|
||||
const setupNodeWidgetCallbacks = (node: LGraphNode) => {
|
||||
if (!node.widgets) return
|
||||
|
||||
const nodeId = String(node.id)
|
||||
|
||||
node.widgets.forEach((widget) => {
|
||||
const originalCallback = widget.callback
|
||||
widget.callback = createWrappedWidgetCallback(
|
||||
widget,
|
||||
originalCallback,
|
||||
nodeId
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const syncWithGraph = () => {
|
||||
if (!graph?._nodes) return
|
||||
|
||||
@@ -428,9 +346,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Store non-reactive reference
|
||||
nodeRefs.set(id, node)
|
||||
|
||||
// Set up widget callbacks BEFORE extracting data (critical order)
|
||||
setupNodeWidgetCallbacks(node)
|
||||
|
||||
// Extract and store safe data for Vue
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
})
|
||||
@@ -449,9 +364,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Store non-reactive reference to original node
|
||||
nodeRefs.set(id, node)
|
||||
|
||||
// Set up widget callbacks BEFORE extracting data (critical order)
|
||||
setupNodeWidgetCallbacks(node)
|
||||
|
||||
// Extract initial data for Vue (may be incomplete during graph configure)
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
|
||||
|
||||
@@ -211,11 +211,9 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
|
||||
}
|
||||
return Reflect.get(redirectedTarget, property, redirectedReceiver)
|
||||
},
|
||||
set(_t: IBaseWidget, property: string, value: unknown, receiver: object) {
|
||||
set(_t: IBaseWidget, property: string, value: unknown) {
|
||||
let redirectedTarget: object = backingWidget
|
||||
let redirectedReceiver = receiver
|
||||
if (property == 'value') redirectedReceiver = backingWidget
|
||||
else if (property == 'computedHeight') {
|
||||
if (property == 'computedHeight') {
|
||||
if (overlay.widgetName.startsWith('$$') && linkedNode) {
|
||||
updatePreviews(linkedNode)
|
||||
}
|
||||
@@ -228,9 +226,8 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
|
||||
redirectedTarget = overlay
|
||||
redirectedReceiver = overlay
|
||||
}
|
||||
return Reflect.set(redirectedTarget, property, value, redirectedReceiver)
|
||||
return Reflect.set(redirectedTarget, property, value, redirectedTarget)
|
||||
},
|
||||
getPrototypeOf() {
|
||||
return Reflect.getPrototypeOf(backingWidget)
|
||||
|
||||
@@ -284,6 +284,7 @@ export interface IBaseWidget<
|
||||
/** Widget type (see {@link TWidgetType}) */
|
||||
type: TType
|
||||
value?: TValue
|
||||
vueTrack?: () => void
|
||||
|
||||
/**
|
||||
* Whether the widget value should be serialized on node serialization.
|
||||
|
||||
Reference in New Issue
Block a user