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

View File

@@ -970,7 +970,7 @@ to { transform: rotate(360deg);
font-size: 13px;
}
.lora-pool-widget[data-v-7d3f681d] {
.lora-pool-widget[data-v-1cc8816c] {
padding: 12px;
background: rgba(40, 44, 52, 0.6);
border-radius: 4px;
@@ -1770,7 +1770,7 @@ to {
}
})();
var _a;
import { app } from "../../../scripts/app.js";
import { app as app$1 } from "../../../scripts/app.js";
/**
* @vue/shared v3.5.26
* (c) 2018-present Yuxi (Evan) You and Vue contributors
@@ -3317,7 +3317,7 @@ function onWatcherCleanup(cleanupFn, failSilently = false, owner = activeWatcher
cleanups.push(cleanupFn);
}
}
function watch$2(source, cb, options = EMPTY_OBJ) {
function watch$1(source, cb, options = EMPTY_OBJ) {
const { immediate, deep, once, scheduler, augmentJob, call } = options;
const reactiveGetter = (source2) => {
if (deep) return source2;
@@ -3914,7 +3914,7 @@ const useSSRContext = () => {
return ctx;
}
};
function watch$1(source, cb, options) {
function watch(source, cb, options) {
return doWatch(source, cb, options);
}
function doWatch(source, cb, options = EMPTY_OBJ) {
@@ -3964,7 +3964,7 @@ function doWatch(source, cb, options = EMPTY_OBJ) {
}
}
};
const watchHandle = watch$2(source, cb, baseWatchOptions);
const watchHandle = watch$1(source, cb, baseWatchOptions);
if (isInSSRComponentSetup) {
if (ssrCleanup) {
ssrCleanup.push(watchHandle);
@@ -5319,12 +5319,12 @@ function createWatcher(raw, ctx, publicThis, key) {
const handler = ctx[raw];
if (isFunction(handler)) {
{
watch$1(getter, handler);
watch(getter, handler);
}
}
} else if (isFunction(raw)) {
{
watch$1(getter, raw.bind(publicThis));
watch(getter, raw.bind(publicThis));
}
} else if (isObject(raw)) {
if (isArray(raw)) {
@@ -5332,7 +5332,7 @@ function createWatcher(raw, ctx, publicThis, key) {
} else {
const handler = isFunction(raw.handler) ? raw.handler.bind(publicThis) : ctx[raw.handler];
if (isFunction(handler)) {
watch$1(getter, handler, raw);
watch(getter, handler, raw);
}
}
} else ;
@@ -9649,7 +9649,7 @@ function useStyle(css3) {
onStyleMounted === null || onStyleMounted === void 0 || onStyleMounted(_name);
}
if (isLoaded.value) return;
stop = watch$1(cssRef, function(value) {
stop = watch(cssRef, function(value) {
styleRef.value.textContent = value;
onStyleUpdated === null || onStyleUpdated === void 0 || onStyleUpdated(_name);
}, {
@@ -10104,7 +10104,7 @@ function setupConfig(app2, PrimeVue2) {
isThemeChanged.value = true;
}
});
var stopConfigWatcher = watch$1(PrimeVue2.config, function(newValue, oldValue) {
var stopConfigWatcher = watch(PrimeVue2.config, function(newValue, oldValue) {
PrimeVueService.emit("config:change", {
newValue,
oldValue
@@ -10113,7 +10113,7 @@ function setupConfig(app2, PrimeVue2) {
immediate: true,
deep: true
});
var stopRippleWatcher = watch$1(function() {
var stopRippleWatcher = watch(function() {
return PrimeVue2.config.ripple;
}, function(newValue, oldValue) {
PrimeVueService.emit("config:ripple:change", {
@@ -10124,7 +10124,7 @@ function setupConfig(app2, PrimeVue2) {
immediate: true,
deep: true
});
var stopThemeWatcher = watch$1(function() {
var stopThemeWatcher = watch(function() {
return PrimeVue2.config.theme;
}, function(newValue, oldValue) {
if (!isThemeChanged.value) {
@@ -10142,7 +10142,7 @@ function setupConfig(app2, PrimeVue2) {
immediate: true,
deep: false
});
var stopUnstyledWatcher = watch$1(function() {
var stopUnstyledWatcher = watch(function() {
return PrimeVue2.config.unstyled;
}, function(newValue, oldValue) {
if (!newValue && PrimeVue2.config.theme) {
@@ -10730,7 +10730,7 @@ const _sfc_main$e = /* @__PURE__ */ defineComponent({
onUnmounted(() => {
document.removeEventListener("keydown", handleKeydown);
});
watch$1(() => props.visible, (isVisible) => {
watch(() => props.visible, (isVisible) => {
if (isVisible) {
document.body.style.overflow = "hidden";
} else {
@@ -10830,7 +10830,7 @@ const _sfc_main$d = /* @__PURE__ */ defineComponent({
searchQuery.value = "";
(_a2 = searchInputRef.value) == null ? void 0 : _a2.focus();
};
watch$1(() => props.visible, (isVisible) => {
watch(() => props.visible, (isVisible) => {
if (isVisible) {
nextTick(() => {
var _a2;
@@ -10957,7 +10957,7 @@ const _sfc_main$c = /* @__PURE__ */ defineComponent({
searchQuery.value = "";
(_a2 = searchInputRef.value) == null ? void 0 : _a2.focus();
};
watch$1(() => props.visible, (isVisible) => {
watch(() => props.visible, (isVisible) => {
if (isVisible) {
nextTick(() => {
var _a2;
@@ -11192,7 +11192,7 @@ const _sfc_main$a = /* @__PURE__ */ defineComponent({
const newSelected = props.selected.includes(key) ? props.selected.filter((k2) => k2 !== key) : [...props.selected, key];
emit2("update:selected", newSelected);
};
watch$1(() => props.visible, (isVisible) => {
watch(() => props.visible, (isVisible) => {
if (isVisible) {
searchQuery.value = "";
expandedKeys.value = /* @__PURE__ */ new Set();
@@ -11336,6 +11336,7 @@ function useLoraPoolApi() {
}
function useLoraPoolState(widget) {
const api = useLoraPoolApi();
let isRestoring = false;
const selectedBaseModels = ref([]);
const includeTags = ref([]);
const excludeTags = ref([]);
@@ -11372,9 +11373,7 @@ function useLoraPoolState(widget) {
lastUpdated: Date.now()
}
};
if (widget.updateConfig) {
widget.updateConfig(config);
} else {
if (!isRestoring) {
widget.value = config;
}
return config;
@@ -11403,22 +11402,27 @@ function useLoraPoolState(widget) {
};
const restoreFromConfig = (rawConfig) => {
var _a2, _b, _c, _d, _e2, _f;
const config = rawConfig.version === 1 ? migrateConfig(rawConfig) : rawConfig;
if (!(config == null ? void 0 : config.filters)) return;
const { filters, preview } = config;
const updateIfChanged = (refValue, newValue) => {
if (JSON.stringify(refValue.value) !== JSON.stringify(newValue)) {
refValue.value = newValue;
}
};
updateIfChanged(selectedBaseModels, filters.baseModels || []);
updateIfChanged(includeTags, ((_a2 = filters.tags) == null ? void 0 : _a2.include) || []);
updateIfChanged(excludeTags, ((_b = filters.tags) == null ? void 0 : _b.exclude) || []);
updateIfChanged(includeFolders, ((_c = filters.folders) == null ? void 0 : _c.include) || []);
updateIfChanged(excludeFolders, ((_d = filters.folders) == null ? void 0 : _d.exclude) || []);
updateIfChanged(noCreditRequired, ((_e2 = filters.license) == null ? void 0 : _e2.noCreditRequired) ?? false);
updateIfChanged(allowSelling, ((_f = filters.license) == null ? void 0 : _f.allowSelling) ?? false);
matchCount.value = (preview == null ? void 0 : preview.matchCount) || 0;
isRestoring = true;
try {
const config = rawConfig.version === 1 ? migrateConfig(rawConfig) : rawConfig;
if (!(config == null ? void 0 : config.filters)) return;
const { filters, preview } = config;
const updateIfChanged = (refValue, newValue) => {
if (JSON.stringify(refValue.value) !== JSON.stringify(newValue)) {
refValue.value = newValue;
}
};
updateIfChanged(selectedBaseModels, filters.baseModels || []);
updateIfChanged(includeTags, ((_a2 = filters.tags) == null ? void 0 : _a2.include) || []);
updateIfChanged(excludeTags, ((_b = filters.tags) == null ? void 0 : _b.exclude) || []);
updateIfChanged(includeFolders, ((_c = filters.folders) == null ? void 0 : _c.include) || []);
updateIfChanged(excludeFolders, ((_d = filters.folders) == null ? void 0 : _d.exclude) || []);
updateIfChanged(noCreditRequired, ((_e2 = filters.license) == null ? void 0 : _e2.noCreditRequired) ?? false);
updateIfChanged(allowSelling, ((_f = filters.license) == null ? void 0 : _f.allowSelling) ?? false);
matchCount.value = (preview == null ? void 0 : preview.matchCount) || 0;
} finally {
isRestoring = false;
}
};
const fetchFilterOptions = async () => {
const [baseModels, tags, folders] = await Promise.all([
@@ -11452,7 +11456,7 @@ function useLoraPoolState(widget) {
refreshPreview();
}, 300);
};
watch$1([
watch([
selectedBaseModels,
includeTags,
excludeTags,
@@ -11520,17 +11524,14 @@ const _sfc_main$9 = /* @__PURE__ */ defineComponent({
modalState.openModal(modal);
};
onMounted(async () => {
props.widget.serializeValue = async () => {
const config = state.buildConfig();
console.log("[LoraPoolWidget] Serializing config:", config);
return config;
};
props.widget.onSetValue = (v2) => {
state.restoreFromConfig(v2);
state.refreshPreview();
props.widget.callback = (v2) => {
if (v2) {
console.log("[LoraPoolWidget] Restoring config from callback");
state.restoreFromConfig(v2);
}
};
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);
}
await state.fetchFilterOptions();
@@ -11600,7 +11601,7 @@ const _sfc_main$9 = /* @__PURE__ */ defineComponent({
};
}
});
const LoraPoolWidget = /* @__PURE__ */ _export_sfc(_sfc_main$9, [["__scopeId", "data-v-7d3f681d"]]);
const LoraPoolWidget = /* @__PURE__ */ _export_sfc(_sfc_main$9, [["__scopeId", "data-v-1cc8816c"]]);
const _hoisted_1$8 = { class: "last-used-preview" };
const _hoisted_2$5 = { class: "last-used-preview__content" };
const _hoisted_3$3 = ["src", "onError"];
@@ -12473,7 +12474,7 @@ function useLoraRandomizerState(widget) {
};
const isClipStrengthDisabled = computed(() => !useCustomClipRange.value);
const isRecommendedStrengthEnabled = computed(() => useRecommendedStrength.value);
watch$1([
watch([
countMode,
countFixed,
countMin,
@@ -12598,7 +12599,7 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
state.rollMode.value = "fixed";
}
};
watch$1(() => {
watch(() => {
var _a2, _b;
return (_b = (_a2 = props.node.widgets) == null ? void 0 : _a2.find((w2) => w2.name === "loras")) == null ? void 0 : _b.value;
}, (newVal) => {
@@ -13000,12 +13001,12 @@ function useLoraCyclerState(widget) {
}
};
const isClipStrengthDisabled = computed(() => !useCustomClipRange.value);
watch$1(modelStrength, (newValue) => {
watch(modelStrength, (newValue) => {
if (!useCustomClipRange.value) {
clipStrength.value = newValue;
}
});
watch$1([
watch([
currentIndex,
totalCount,
poolConfigHash,
@@ -13499,7 +13500,7 @@ function createModeChangeCallback(node, updateDownstreamLoaders2, nodeSpecificCa
updateDownstreamLoaders2(node);
};
}
const app$1 = {};
const app = {};
const ROOT_GRAPH_ID = "root";
const LORA_PROVIDER_NODE_TYPES = [
"Lora Stacker (LoraManager)",
@@ -13519,7 +13520,7 @@ function getNodeGraphId(node) {
if (!node) {
return ROOT_GRAPH_ID;
}
return getGraphId(node.graph || app$1.graph);
return getGraphId(node.graph || app.graph);
}
function getNodeReference(node) {
if (!node) {
@@ -13720,7 +13721,7 @@ function forwardMiddleMouseToCanvas(container) {
if (!container) return;
container.addEventListener("pointerdown", (event) => {
if (event.button === 1) {
const canvas = app.canvas;
const canvas = app$1.canvas;
if (canvas && typeof canvas.processMouseDown === "function") {
canvas.processMouseDown(event);
}
@@ -13728,7 +13729,7 @@ function forwardMiddleMouseToCanvas(container) {
});
container.addEventListener("pointermove", (event) => {
if ((event.buttons & 4) === 4) {
const canvas = app.canvas;
const canvas = app$1.canvas;
if (canvas && typeof canvas.processMouseMove === "function") {
canvas.processMouseMove(event);
}
@@ -13736,7 +13737,7 @@ function forwardMiddleMouseToCanvas(container) {
});
container.addEventListener("pointerup", (event) => {
if (event.button === 1) {
const canvas = app.canvas;
const canvas = app$1.canvas;
if (canvas && typeof canvas.processMouseUp === "function") {
canvas.processMouseUp(event);
}
@@ -13765,9 +13766,6 @@ function createLoraPoolWidget(node) {
},
setValue(v2) {
internalValue = v2;
if (typeof widget.onSetValue === "function") {
widget.onSetValue(v2);
}
},
serialize: true,
// Per dev guide: providing getMinHeight via options allows the system to
@@ -13777,9 +13775,6 @@ function createLoraPoolWidget(node) {
}
}
);
widget.updateConfig = (v2) => {
internalValue = v2;
};
const vueApp = createApp(LoraPoolWidget, {
widget,
node
@@ -13995,11 +13990,11 @@ function createJsonDisplayWidget(node) {
const widgetInputOptions = /* @__PURE__ */ new Map();
const initVueDomModeListener = () => {
var _a2, _b;
if ((_b = (_a2 = app.ui) == null ? void 0 : _a2.settings) == null ? void 0 : _b.addEventListener) {
app.ui.settings.addEventListener("Comfy.VueNodes.Enabled.change", () => {
if ((_b = (_a2 = app$1.ui) == null ? void 0 : _a2.settings) == null ? void 0 : _b.addEventListener) {
app$1.ui.settings.addEventListener("Comfy.VueNodes.Enabled.change", () => {
requestAnimationFrame(() => {
var _a3, _b2, _c;
const isVueDomMode = ((_c = (_b2 = (_a3 = app.ui) == null ? void 0 : _a3.settings) == null ? void 0 : _b2.getSettingValue) == null ? void 0 : _c.call(_b2, "Comfy.VueNodes.Enabled")) ?? false;
const isVueDomMode = ((_c = (_b2 = (_a3 = app$1.ui) == null ? void 0 : _a3.settings) == null ? void 0 : _b2.getSettingValue) == null ? void 0 : _c.call(_b2, "Comfy.VueNodes.Enabled")) ?? false;
document.dispatchEvent(new CustomEvent("lora-manager:vue-mode-change", {
detail: { isVueDomMode }
}));
@@ -14007,12 +14002,12 @@ const initVueDomModeListener = () => {
});
}
};
if ((_a = app.ui) == null ? void 0 : _a.settings) {
if ((_a = app$1.ui) == null ? void 0 : _a.settings) {
initVueDomModeListener();
} else {
const checkAppReady = setInterval(() => {
var _a2;
if ((_a2 = app.ui) == null ? void 0 : _a2.settings) {
if ((_a2 = app$1.ui) == null ? void 0 : _a2.settings) {
initVueDomModeListener();
clearInterval(checkAppReady);
}
@@ -14053,7 +14048,7 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
}
}
);
const spellcheck = ((_c = (_b = (_a2 = app.ui) == null ? void 0 : _a2.settings) == null ? void 0 : _b.getSettingValue) == null ? void 0 : _c.call(_b, "Comfy.TextareaWidget.Spellcheck")) ?? false;
const spellcheck = ((_c = (_b = (_a2 = app$1.ui) == null ? void 0 : _a2.settings) == null ? void 0 : _b.getSettingValue) == null ? void 0 : _c.call(_b, "Comfy.TextareaWidget.Spellcheck")) ?? false;
const vueApp = createApp(AutocompleteTextWidget, {
widget,
node,
@@ -14078,7 +14073,7 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
};
return { widget };
}
app.registerExtension({
app$1.registerExtension({
name: "LoraManager.VueWidgets",
getCustomWidgets() {
return {

File diff suppressed because one or more lines are too long