From 8e28dda85c2b92fcb788316d3535caa834d663a2 Mon Sep 17 00:00:00 2001 From: Taehoon Kim <144179915+taehk98@users.noreply.github.com> Date: Thu, 11 Dec 2025 04:29:28 -0700 Subject: [PATCH] fix: unpacking a missing node causes it to disappear (#7341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes the issue where unpacking a subgraph containing missing nodes causes those nodes to disappear. Missing nodes are now automatically restored as placeholder nodes that preserve their original data, allowing them to be recovered when the node types are installed later. ## Changes - **What**: - Modified `multiClone()` to preserve missing nodes as serialized data when creating subgraphs - Added `skipMissingNodes` option to `unpackSubgraph()` method to restore missing nodes as placeholder nodes instead of throwing errors - Updated `useSubgraphOperations.unpackSubgraph()` to automatically restore missing nodes as placeholders (removed confirmation dialog) - Replaced deprecated `LiteGraph.cloneObject()` with `structuredClone()` - Removed unused i18n keys and debugging logs ## Review Focus - **Placeholder node restoration**: Missing nodes are restored using the same mechanism as `LGraph.configure()` (creating `LGraphNode` with `last_serialization` and `has_errors` flags). This ensures compatibility with the existing missing node manager. - **Performance**: Optimized `getMissingNodeTypes()` to check `registered_node_types` first before attempting node creation, and uses Set for O(1) duplicate checking. - **Data preservation**: Missing nodes preserve their original type, title, and serialized data in `last_serialization`, allowing automatic recovery when node types are installed. - **Backward compatibility**: The `skipMissingNodes` option defaults to `false`, maintaining original behavior for other code paths. Only the UI-level `unpackSubgraph()` always uses `skipMissingNodes: true`. ## Demo Before: https://github.com/user-attachments/assets/e0327d05-802d-4a64-a9db-4d174e185d82 After: https://github.com/user-attachments/assets/37ab3140-0ada-480e-b9d5-fef8856f8b27 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7341-fix-unpacking-a-missing-node-causes-it-to-disappear-2c66d73d36508151ac6be70a7b2bc56d) by [Unito](https://www.unito.io) --- .../graph/useSubgraphOperations.ts | 27 +++++++++++-------- src/composables/useCoreCommands.ts | 13 +++------ src/lib/litegraph/src/LGraph.ts | 24 ++++++++++++++--- .../litegraph/src/subgraph/subgraphUtils.ts | 4 ++- src/services/litegraphService.ts | 5 ++-- 5 files changed, 46 insertions(+), 27 deletions(-) diff --git a/src/composables/graph/useSubgraphOperations.ts b/src/composables/graph/useSubgraphOperations.ts index b42cc2ec4..45a665d4b 100644 --- a/src/composables/graph/useSubgraphOperations.ts +++ b/src/composables/graph/useSubgraphOperations.ts @@ -37,6 +37,21 @@ export function useSubgraphOperations() { workflowStore.activeWorkflow?.changeTracker?.checkState() } + const doUnpack = ( + subgraphNodes: SubgraphNode[], + skipMissingNodes: boolean + ) => { + const canvas = canvasStore.getCanvas() + const graph = canvas.subgraph ?? canvas.graph + if (!graph) return + + for (const subgraphNode of subgraphNodes) { + nodeOutputStore.revokeSubgraphPreviews(subgraphNode) + graph.unpackSubgraph(subgraphNode, { skipMissingNodes }) + } + workflowStore.activeWorkflow?.changeTracker?.checkState() + } + const unpackSubgraph = () => { const canvas = canvasStore.getCanvas() const graph = canvas.subgraph ?? canvas.graph @@ -53,17 +68,7 @@ export function useSubgraphOperations() { if (subgraphNodes.length === 0) { return } - - subgraphNodes.forEach((subgraphNode) => { - // Revoke any image previews for the subgraph - nodeOutputStore.revokeSubgraphPreviews(subgraphNode) - - // Unpack the subgraph - graph.unpackSubgraph(subgraphNode) - }) - - // Trigger change tracking - workflowStore.activeWorkflow?.changeTracker?.checkState() + doUnpack(subgraphNodes, true) } const addSubgraphToLibrary = async () => { diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts index 1cbfec909..b582a0b64 100644 --- a/src/composables/useCoreCommands.ts +++ b/src/composables/useCoreCommands.ts @@ -1,6 +1,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser' import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems' +import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations' import { useExternalLink } from '@/composables/useExternalLink' import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog' import { @@ -14,7 +15,6 @@ import { LGraphGroup, LGraphNode, LiteGraph, - SubgraphNode } from '@/lib/litegraph/src/litegraph' import type { Point } from '@/lib/litegraph/src/litegraph' import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog' @@ -41,7 +41,6 @@ import { useLitegraphService } from '@/services/litegraphService' import type { ComfyCommand } from '@/stores/commandStore' import { useExecutionStore } from '@/stores/executionStore' import { useHelpCenterStore } from '@/stores/helpCenterStore' -import { useNodeOutputStore } from '@/stores/imagePreviewStore' import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore' import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore' import { useSubgraphStore } from '@/stores/subgraphStore' @@ -1010,14 +1009,8 @@ export function useCoreCommands(): ComfyCommand[] { label: 'Unpack the selected Subgraph', versionAdded: '1.26.3', function: () => { - const canvas = canvasStore.getCanvas() - const graph = canvas.subgraph ?? canvas.graph - if (!graph) throw new TypeError('Canvas has no graph or subgraph set.') - - const subgraphNode = app.canvas.selectedItems.values().next().value - if (!(subgraphNode instanceof SubgraphNode)) return - useNodeOutputStore().revokeSubgraphPreviews(subgraphNode) - graph.unpackSubgraph(subgraphNode) + const { unpackSubgraph } = useSubgraphOperations() + unpackSubgraph() } }, { diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts index d5d6c1820..478a2a784 100644 --- a/src/lib/litegraph/src/LGraph.ts +++ b/src/lib/litegraph/src/LGraph.ts @@ -1726,9 +1726,15 @@ export class LGraph return { subgraph, node: subgraphNode as SubgraphNode } } - unpackSubgraph(subgraphNode: SubgraphNode) { + unpackSubgraph( + subgraphNode: SubgraphNode, + options?: { skipMissingNodes?: boolean } + ) { if (!(subgraphNode instanceof SubgraphNode)) throw new Error('Can only unpack Subgraph Nodes') + + const skipMissingNodes = options?.skipMissingNodes ?? false + this.beforeChange() //NOTE: Create bounds can not be called on positionables directly as the subgraph is not being displayed and boundingRect is not initialized. //NOTE: NODE_TITLE_HEIGHT is explicitly excluded here @@ -1750,9 +1756,21 @@ export class LGraph const movedNodes = multiClone(subgraphNode.subgraph.nodes) const nodeIdMap = new Map() for (const n_info of movedNodes) { - const node = LiteGraph.createNode(String(n_info.type), n_info.title) + let node = LiteGraph.createNode(String(n_info.type), n_info.title) if (!node) { - throw new Error('Node not found') + if (skipMissingNodes) { + console.warn( + `Cannot unpack node of type "${n_info.type}" - node type not found. Creating placeholder node.` + ) + node = new LGraphNode(n_info.title || n_info.type || 'Missing Node') + node.last_serialization = n_info + node.has_errors = true + node.type = String(n_info.type) + } else { + throw new Error( + `Cannot unpack: node type "${n_info.type}" is not registered` + ) + } } nodeIdMap.set(n_info.id, ++this.last_node_id) diff --git a/src/lib/litegraph/src/subgraph/subgraphUtils.ts b/src/lib/litegraph/src/subgraph/subgraphUtils.ts index 70ffc7a5d..38f74efaa 100644 --- a/src/lib/litegraph/src/subgraph/subgraphUtils.ts +++ b/src/lib/litegraph/src/subgraph/subgraphUtils.ts @@ -221,11 +221,13 @@ export function multiClone(nodes: Iterable): ISerialisedNode[] { const newNode = LiteGraph.createNode(node.type) if (!newNode) { console.warn('Failed to create node', node.type) + const serializedData = structuredClone(node.serialize()) + clonedNodes.push(serializedData) continue } // Must be cloned; litegraph "serialize" is mostly shallow clone - const data = LiteGraph.cloneObject(node.serialize()) + const data = structuredClone(node.serialize()) newNode.configure(data) clonedNodes.push(newNode.serialize()) diff --git a/src/services/litegraphService.ts b/src/services/litegraphService.ts index 7cc451c03..8ad39f84a 100644 --- a/src/services/litegraphService.ts +++ b/src/services/litegraphService.ts @@ -2,6 +2,7 @@ import _ from 'es-toolkit/compat' import { downloadFile } from '@/base/common/downloadUtil' import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems' +import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations' import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage' import { useNodeCanvasImagePreview } from '@/composables/node/useNodeCanvasImagePreview' import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage' @@ -661,8 +662,8 @@ export const useLitegraphService = () => { { content: 'Unpack Subgraph', callback: () => { - useNodeOutputStore().revokeSubgraphPreviews(this) - this.graph.unpackSubgraph(this) + const { unpackSubgraph } = useSubgraphOperations() + unpackSubgraph() } } )