Compare commits

..

1 Commits

Author SHA1 Message Date
CodeRabbit Fixer
a8db2739bc fix: Design clean public API for extension authors to integrate custom widgets with widgetValueStore (#9296)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:52:40 +01:00
8 changed files with 196 additions and 68 deletions

View File

@@ -0,0 +1,78 @@
import { nextTick } from 'vue'
import { describe, expect, it } from 'vitest'
import { useDomValueBridge } from './useDomValueBridge'
function createInput(initialValue = ''): HTMLInputElement {
const el = document.createElement('input')
el.value = initialValue
return el
}
function createTextarea(initialValue = ''): HTMLTextAreaElement {
const el = document.createElement('textarea')
el.value = initialValue
return el
}
describe('useDomValueBridge', () => {
it('initializes the ref with the current element value', () => {
const el = createInput('hello')
const bridged = useDomValueBridge(el)
expect(bridged.value).toBe('hello')
})
it('updates the ref when element.value is set programmatically', () => {
const el = createInput('')
const bridged = useDomValueBridge(el)
el.value = 'updated'
expect(bridged.value).toBe('updated')
})
it('updates the ref on user input events', () => {
const el = createInput('')
const bridged = useDomValueBridge(el)
// Simulate user typing by using the original descriptor to set value,
// then dispatching an input event
const proto = Object.getPrototypeOf(el)
const desc = Object.getOwnPropertyDescriptor(proto, 'value')
desc?.set?.call(el, 'typed')
el.dispatchEvent(new Event('input'))
expect(bridged.value).toBe('typed')
})
it('updates the DOM element when the ref is written to', async () => {
const el = createInput('initial')
const bridged = useDomValueBridge(el)
bridged.value = 'from-ref'
await nextTick()
expect(el.value).toBe('from-ref')
})
it('works with textarea elements', () => {
const el = createTextarea('initial')
const bridged = useDomValueBridge(el)
expect(bridged.value).toBe('initial')
el.value = 'new text'
expect(bridged.value).toBe('new text')
})
it('reads element value through the intercepted getter', async () => {
const el = createInput('start')
const bridged = useDomValueBridge(el)
// The getter on element.value should still work
expect(el.value).toBe('start')
bridged.value = 'changed'
await nextTick()
// The element getter should reflect the latest set
expect(el.value).toBe('changed')
})
})

View File

@@ -0,0 +1,79 @@
import { ref, watch } from 'vue'
import type { Ref } from 'vue'
type ValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
/**
* Bridges a DOM element's `.value` property to a Vue reactive ref.
*
* This composable provides a clean, public API for extension authors to
* synchronize DOM widget values with Vue reactivity (and by extension, the
* `widgetValueStore`). It works by:
*
* 1. Intercepting programmatic `.value` writes via `Object.defineProperty`
* 2. Listening for user-driven `input` events on the element
* 3. Exposing a reactive `Ref<string>` that stays in sync with the DOM
*
* When the returned ref is written to, the DOM element's value is updated.
* When the DOM element's value changes (programmatically or via user input),
* the ref is updated.
*
* @param element - The DOM element to bridge (input, textarea, or select)
* @returns A reactive ref that stays in sync with the element's value
*
* @example
* ```ts
* // In a custom widget's getValue/setValue:
* const bridgedValue = useDomValueBridge(inputEl)
* const widget = node.addDOMWidget(name, type, inputEl, {
* getValue: () => bridgedValue.value,
* setValue: (v) => { bridgedValue.value = v }
* })
* ```
*/
export function useDomValueBridge(element: ValueElement): Ref<string> {
const bridgedValue = ref(element.value)
// Capture the original property descriptor so we can chain through it
const proto = Object.getPrototypeOf(element)
const originalDescriptor =
Object.getOwnPropertyDescriptor(element, 'value') ??
Object.getOwnPropertyDescriptor(proto, 'value')
// Intercept programmatic .value writes on the element
// This catches cases where extensions or libraries set element.value directly
try {
Object.defineProperty(element, 'value', {
get() {
return originalDescriptor?.get?.call(this) ?? bridgedValue.value
},
set(newValue: string) {
originalDescriptor?.set?.call(this, newValue)
bridgedValue.value = newValue
},
configurable: true,
enumerable: true
})
} catch {
// If the descriptor is non-configurable, fall back to polling-free sync
// via input events only
}
// Listen for user-driven input events
element.addEventListener('input', () => {
// Read through the original descriptor to avoid infinite loops
const currentValue = originalDescriptor?.get?.call(element) ?? element.value
bridgedValue.value = currentValue
})
// When the ref is written to externally, update the DOM element
watch(bridgedValue, (newValue) => {
const currentDomValue =
originalDescriptor?.get?.call(element) ?? element.value
if (currentDomValue !== newValue) {
originalDescriptor?.set?.call(element, newValue)
}
})
return bridgedValue
}

View File

@@ -99,9 +99,7 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
useWorkflowThumbnail: () => ({
storeThumbnail: vi.fn(),
getThumbnail: vi.fn(),
clearThumbnail: vi.fn(),
moveWorkflowThumbnail: vi.fn()
getThumbnail: vi.fn()
})
}))
@@ -855,13 +853,12 @@ describe('useWorkflowService', () => {
const existing = createSaveableWorkflow('workflows/test.app.json')
vi.spyOn(workflowStore, 'getWorkflowByPath').mockReturnValue(existing)
vi.spyOn(workflowStore, 'removeWorkflowEntry').mockResolvedValue()
vi.spyOn(workflowStore, 'deleteWorkflow').mockResolvedValue()
mockConfirm.mockResolvedValue(true)
await service.saveWorkflow(workflow)
expect(mockConfirm).toHaveBeenCalled()
expect(workflowStore.removeWorkflowEntry).toHaveBeenCalledWith(existing)
expect(workflowStore.renameWorkflow).toHaveBeenCalledWith(
workflow,
'workflows/test.app.json'

View File

@@ -130,20 +130,11 @@ export const useWorkflowService = () => {
if (existingWorkflow && !existingWorkflow.isTemporary) {
if ((await confirmOverwrite(newPath)) !== true) return false
}
const needsOverwrite =
!!existingWorkflow && !existingWorkflow.isTemporary && !isSelfOverwrite
// Close and remove the old workflow entry before saving the new content.
// The file on disk is intentionally kept so that a save failure does not
// cause data loss. The subsequent save with overwrite: true will
// atomically replace it.
if (needsOverwrite) {
if (workflowStore.isOpen(existingWorkflow)) {
await closeWorkflow(existingWorkflow, { warnIfUnsaved: false })
if (!isSelfOverwrite) {
const deleted = await deleteWorkflow(existingWorkflow, true)
if (!deleted) return false
}
await workflowStore.removeWorkflowEntry(existingWorkflow)
}
workflow.changeTracker?.checkState()
@@ -152,19 +143,11 @@ export const useWorkflowService = () => {
await saveWorkflow(workflow)
} else if (workflow.isTemporary) {
await renameWorkflow(workflow, newPath)
if (needsOverwrite) {
await workflowStore.saveWorkflow(workflow, { overwrite: true })
} else {
await workflowStore.saveWorkflow(workflow)
}
await workflowStore.saveWorkflow(workflow)
} else {
const tempWorkflow = workflowStore.saveAs(workflow, newPath)
await openWorkflow(tempWorkflow)
if (needsOverwrite) {
await workflowStore.saveWorkflow(tempWorkflow, { overwrite: true })
} else {
await workflowStore.saveWorkflow(tempWorkflow)
}
await workflowStore.saveWorkflow(tempWorkflow)
}
return true
}
@@ -191,12 +174,7 @@ export const useWorkflowService = () => {
await workflowStore.saveWorkflow(workflow)
return
}
// Remove the old entry without deleting the file; the rename
// will atomically replace it, preventing data loss on failure.
if (workflowStore.isOpen(existing)) {
await closeWorkflow(existing, { warnIfUnsaved: false })
}
await workflowStore.removeWorkflowEntry(existing)
await deleteWorkflow(existing, true)
}
await renameWorkflow(workflow, expectedPath)
toastStore.add({

View File

@@ -151,16 +151,14 @@ export class ComfyWorkflow extends UserFile {
super.unload()
}
override async save({
overwrite
}: { force?: boolean; overwrite?: boolean } = {}) {
override async save() {
const { useWorkflowDraftStore } =
await import('@/platform/workflow/persistence/stores/workflowDraftStore')
const draftStore = useWorkflowDraftStore()
this.content = JSON.stringify(this.activeState)
// Force save to ensure the content is updated in remote storage incase
// the isModified state is screwed by changeTracker.
const ret = await super.save({ force: true, overwrite })
const ret = await super.save({ force: true })
this.changeTracker?.reset()
this.isModified = false
draftStore.removeDraft(this.path)

View File

@@ -63,11 +63,7 @@ interface WorkflowStore {
) => ComfyWorkflow
renameWorkflow: (workflow: ComfyWorkflow, newPath: string) => Promise<void>
deleteWorkflow: (workflow: ComfyWorkflow) => Promise<void>
removeWorkflowEntry: (workflow: ComfyWorkflow) => Promise<void>
saveWorkflow: (
workflow: ComfyWorkflow,
options?: { overwrite?: boolean }
) => Promise<void>
saveWorkflow: (workflow: ComfyWorkflow) => Promise<void>
workflows: ComfyWorkflow[]
bookmarkedWorkflows: ComfyWorkflow[]
@@ -543,32 +539,14 @@ export const useWorkflowStore = defineStore('workflow', () => {
}
}
/**
* Remove a workflow entry from the store without deleting the file on disk.
* Used during atomic overwrite to clear the old entry before saving the new
* content, so that the save can use overwrite: true to replace the file.
*/
const removeWorkflowEntry = async (workflow: ComfyWorkflow) => {
useWorkflowDraftStore().removeDraft(workflow.path)
if (bookmarkStore.isBookmarked(workflow.path)) {
await bookmarkStore.setBookmarked(workflow.path, false)
}
clearThumbnail(workflow.key)
delete workflowLookup.value[workflow.path]
}
/**
* Save a workflow.
* @param workflow The workflow to save.
* @param options.overwrite Force overwrite of existing file at the path.
*/
const saveWorkflow = async (
workflow: ComfyWorkflow,
options?: { overwrite?: boolean }
) => {
const saveWorkflow = async (workflow: ComfyWorkflow) => {
isBusy.value = true
try {
await workflow.save({ overwrite: options?.overwrite })
await workflow.save()
// Synchronously detach and re-attach to force refresh the tree objects
// without an async gap that would cause the tab to disappear.
const openIndex = detachWorkflow(workflow)
@@ -796,7 +774,6 @@ export const useWorkflowStore = defineStore('workflow', () => {
createNewTemporary,
renameWorkflow,
deleteWorkflow,
removeWorkflowEntry,
saveAs,
saveWorkflow,
reorderWorkflows,

View File

@@ -1,6 +1,7 @@
import _ from 'es-toolkit/compat'
import { type Component, toRaw } from 'vue'
import { useDomValueBridge } from '@/composables/element/useDomValueBridge'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import {
LGraphNode,
@@ -379,6 +380,16 @@ export const addWidget = <W extends BaseDOMWidget<object | string>>(
})
}
function isValueElement(
el: HTMLElement
): el is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement {
return (
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
el instanceof HTMLSelectElement
)
}
LGraphNode.prototype.addDOMWidget = function <
T extends HTMLElement,
V extends object | string
@@ -389,6 +400,19 @@ LGraphNode.prototype.addDOMWidget = function <
element: T,
options: DOMWidgetOptions<V> = {}
): DOMWidget<T, V> {
// Auto-bridge value-bearing elements when no getValue/setValue provided.
// This gives extension authors automatic widgetValueStore integration.
if (!options.getValue && !options.setValue && isValueElement(element)) {
const bridgedValue = useDomValueBridge(element)
options = {
...options,
getValue: (() => bridgedValue.value) as () => V,
setValue: ((v: V) => {
bridgedValue.value = String(v)
}) as (v: V) => void
}
}
const widget = new DOMWidgetImpl({
node: this,
name,

View File

@@ -140,14 +140,11 @@ export class UserFile {
* Saves the file to the remote storage.
* @param force Whether to force the save even if the file is not modified.
*/
async save({
force = false,
overwrite
}: { force?: boolean; overwrite?: boolean } = {}): Promise<UserFile> {
async save({ force = false }: { force?: boolean } = {}): Promise<UserFile> {
if (this.isPersisted && !this.isModified && !force) return this
const resp = await api.storeUserData(this.path, this.content, {
overwrite: overwrite ?? this.isPersisted,
overwrite: this.isPersisted,
throwOnError: true,
full_info: true
})