fix: unpacking a missing node causes it to disappear (#7341)

## 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`.

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #7279 -->

## 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)
This commit is contained in:
Taehoon Kim
2025-12-11 04:29:28 -07:00
committed by GitHub
parent a7de97470b
commit 8e28dda85c
5 changed files with 46 additions and 27 deletions

View File

@@ -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 () => {

View File

@@ -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()
}
},
{

View File

@@ -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<NodeId, NodeId>()
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)

View File

@@ -221,11 +221,13 @@ export function multiClone(nodes: Iterable<LGraphNode>): 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())

View File

@@ -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()
}
}
)