refactor(lora-pool-widget): adopt DOM widget value persistence best practices

- Replace custom onSetValue with ComfyUI's built-in widget.callback
- Remove widget.updateConfig, set widget.value directly
- Add isRestoring flag to break callback → watch → refreshPreview loop
- Update ComponentWidget types with callback and deprecate old methods

Refs: docs/dom-widgets/value-persistence-best-practices.md
This commit is contained in:
Will Miao
2026-01-27 23:49:44 +08:00
parent 822ac046e0
commit ad4574e02f
6 changed files with 123 additions and 116 deletions

View File

@@ -95,29 +95,29 @@ const openModal = (modal: ModalType) => {
// Lifecycle
onMounted(async () => {
// Setup serialization
props.widget.serializeValue = async () => {
const config = state.buildConfig()
console.log('[LoraPoolWidget] Serializing config:', config)
return config
// Setup callback for external value updates (e.g., workflow load, undo/redo)
// ComfyUI calls this automatically after setValue() in domWidget.ts
// NOTE: callback should NOT call refreshPreview() to avoid infinite loops:
// watch(filters) → refreshPreview() → buildConfig() → widget.value = v → callback → refreshPreview() → ...
props.widget.callback = (v: LoraPoolConfig | LegacyLoraPoolConfig) => {
if (v) {
console.log('[LoraPoolWidget] Restoring config from callback')
state.restoreFromConfig(v)
// Preview will refresh automatically via watch() when restoreFromConfig changes filter refs
}
}
// Handle external value updates (e.g., loading workflow, paste)
props.widget.onSetValue = (v) => {
state.restoreFromConfig(v as LoraPoolConfig | LegacyLoraPoolConfig)
state.refreshPreview()
}
// Restore from saved value
// Restore from saved value if workflow was already loaded
if (props.widget.value) {
console.log('[LoraPoolWidget] Restoring from saved value:', props.widget.value)
console.log('[LoraPoolWidget] Restoring from initial value')
state.restoreFromConfig(props.widget.value as LoraPoolConfig | LegacyLoraPoolConfig)
}
// Fetch filter options
await state.fetchFilterOptions()
// Initial preview
// Initial preview (only called once on mount)
// When workflow is loaded, callback restores config, then watch triggers this
await state.refreshPreview()
})
</script>

View File

@@ -99,8 +99,13 @@ export interface CyclerConfig {
}
export interface ComponentWidget {
/** @deprecated Use callback instead. Kept for backward compatibility with other widgets. */
serializeValue?: () => Promise<LoraPoolConfig | RandomizerConfig | CyclerConfig>
value?: LoraPoolConfig | LegacyLoraPoolConfig | RandomizerConfig | CyclerConfig
/** @deprecated Use callback instead. Kept for backward compatibility with other widgets. */
onSetValue?: (v: LoraPoolConfig | LegacyLoraPoolConfig | RandomizerConfig | CyclerConfig) => void
/** @deprecated Directly set widget.value instead. Kept for backward compatibility with other widgets. */
updateConfig?: (v: LoraPoolConfig | RandomizerConfig | CyclerConfig) => void
/** Called by ComfyUI automatically after setValue() - use this for UI sync */
callback?: (v: LoraPoolConfig | LegacyLoraPoolConfig | RandomizerConfig | CyclerConfig) => void
}

View File

@@ -13,6 +13,10 @@ import { useLoraPoolApi } from './useLoraPoolApi'
export function useLoraPoolState(widget: ComponentWidget) {
const api = useLoraPoolApi()
// Flag to prevent infinite loops during config restoration
// callback → restoreFromConfig → watch → refreshPreview → buildConfig → widget.value = config → callback → ...
let isRestoring = false
// Filter state
const selectedBaseModels = ref<string[]>([])
const includeTags = ref<string[]>([])
@@ -57,10 +61,10 @@ export function useLoraPoolState(widget: ComponentWidget) {
}
}
// Update widget value
if (widget.updateConfig) {
widget.updateConfig(config)
} else {
// Update widget value (this triggers callback for UI sync)
// Skip during restoration to prevent infinite loops:
// callback → restoreFromConfig → watch → refreshPreview → buildConfig → widget.value = config → callback → ...
if (!isRestoring) {
widget.value = config
}
return config
@@ -91,32 +95,40 @@ export function useLoraPoolState(widget: ComponentWidget) {
// Restore state from config
const restoreFromConfig = (rawConfig: LoraPoolConfig | LegacyLoraPoolConfig) => {
// Migrate if needed
const config = rawConfig.version === 1
? migrateConfig(rawConfig as LegacyLoraPoolConfig)
: rawConfig as LoraPoolConfig
// Set flag to prevent buildConfig from triggering widget.value updates during restoration
// This breaks the infinite loop: callback → restoreFromConfig → watch → refreshPreview → buildConfig → widget.value = config → callback
isRestoring = true
if (!config?.filters) return
try {
// Migrate if needed
const config = rawConfig.version === 1
? migrateConfig(rawConfig as LegacyLoraPoolConfig)
: rawConfig as LoraPoolConfig
const { filters, preview } = config
if (!config?.filters) return
// Helper to update ref only if value changed
const updateIfChanged = <T>(refValue: { value: T }, newValue: T) => {
if (JSON.stringify(refValue.value) !== JSON.stringify(newValue)) {
refValue.value = newValue
const { filters, preview } = config
// Helper to update ref only if value changed
const updateIfChanged = <T>(refValue: { value: T }, newValue: T) => {
if (JSON.stringify(refValue.value) !== JSON.stringify(newValue)) {
refValue.value = newValue
}
}
}
updateIfChanged(selectedBaseModels, filters.baseModels || [])
updateIfChanged(includeTags, filters.tags?.include || [])
updateIfChanged(excludeTags, filters.tags?.exclude || [])
updateIfChanged(includeFolders, filters.folders?.include || [])
updateIfChanged(excludeFolders, filters.folders?.exclude || [])
updateIfChanged(noCreditRequired, filters.license?.noCreditRequired ?? false)
updateIfChanged(allowSelling, filters.license?.allowSelling ?? false)
// matchCount doesn't trigger watchers, so direct assignment is fine
matchCount.value = preview?.matchCount || 0
updateIfChanged(selectedBaseModels, filters.baseModels || [])
updateIfChanged(includeTags, filters.tags?.include || [])
updateIfChanged(excludeTags, filters.tags?.exclude || [])
updateIfChanged(includeFolders, filters.folders?.include || [])
updateIfChanged(excludeFolders, filters.folders?.exclude || [])
updateIfChanged(noCreditRequired, filters.license?.noCreditRequired ?? false)
updateIfChanged(allowSelling, filters.license?.allowSelling ?? false)
// matchCount doesn't trigger watchers, so direct assignment is fine
matchCount.value = preview?.matchCount || 0
} finally {
isRestoring = false
}
}
// Fetch filter options from API

View File

@@ -90,9 +90,8 @@ function createLoraPoolWidget(node) {
},
setValue(v: LoraPoolConfig | LegacyLoraPoolConfig) {
internalValue = v
if (typeof widget.onSetValue === 'function') {
widget.onSetValue(v)
}
// ComfyUI automatically calls widget.callback after setValue
// No need for custom onSetValue mechanism
},
serialize: true,
// Per dev guide: providing getMinHeight via options allows the system to
@@ -103,10 +102,6 @@ function createLoraPoolWidget(node) {
}
)
widget.updateConfig = (v: LoraPoolConfig) => {
internalValue = v
}
const vueApp = createApp(LoraPoolWidget, {
widget,
node