mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-03 11:54:07 +00:00
Compare commits
5 Commits
feat/node-
...
pysssss/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d5cff4519 | ||
|
|
1fb24d8927 | ||
|
|
aeb3b49ff2 | ||
|
|
48907bdcb1 | ||
|
|
c29dd37de4 |
@@ -1,5 +1,7 @@
|
||||
import type { ConsoleMessage } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
@@ -90,4 +92,173 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
|
||||
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Detach Race Repro', { tag: ['@vue-nodes'] }, () => {
|
||||
const SUBGRAPH_NODE_TITLE = 'New Subgraph'
|
||||
|
||||
// Capture-and-defer the legacy onNodeRemoved/onSelectionChange handlers
|
||||
// so the test can drive unpack to completion before they run. Widens
|
||||
// the race window so a guard regression deterministically surfaces; on
|
||||
// fast environments the legacy cleanup runs in time and masks the bug.
|
||||
const DEFERRED_HANDLERS_KEY = '__deferredHandlers'
|
||||
|
||||
async function deferLegacyHandlers(comfyPage: ComfyPage) {
|
||||
await comfyPage.page.evaluate((key) => {
|
||||
const w = window as unknown as Record<string, unknown>
|
||||
const graph = window.app!.graph!
|
||||
const canvas = window.app!.canvas!
|
||||
const queue: Array<() => void> = []
|
||||
const originalNodeRemoved = graph.onNodeRemoved
|
||||
const originalSelectionChange = canvas.onSelectionChange
|
||||
w[key] = { queue, originalNodeRemoved, originalSelectionChange }
|
||||
graph.onNodeRemoved = function (node) {
|
||||
queue.push(() => originalNodeRemoved?.call(this, node))
|
||||
}
|
||||
canvas.onSelectionChange = function (selected) {
|
||||
queue.push(() => originalSelectionChange?.call(this, selected))
|
||||
}
|
||||
}, DEFERRED_HANDLERS_KEY)
|
||||
}
|
||||
|
||||
async function runDeferredHandlers(comfyPage: ComfyPage) {
|
||||
await comfyPage.page.evaluate((key) => {
|
||||
const stash = (window as unknown as Record<string, unknown>)[key] as
|
||||
| { queue: Array<() => void> }
|
||||
| undefined
|
||||
if (!stash) return
|
||||
for (const fn of stash.queue.splice(0)) fn()
|
||||
}, DEFERRED_HANDLERS_KEY)
|
||||
}
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate((key) => {
|
||||
const w = window as unknown as Record<string, unknown>
|
||||
const graph = window.app?.graph
|
||||
const canvas = window.app?.canvas
|
||||
const stash = w[key] as
|
||||
| {
|
||||
originalNodeRemoved?: NonNullable<typeof graph>['onNodeRemoved']
|
||||
originalSelectionChange?: NonNullable<
|
||||
typeof canvas
|
||||
>['onSelectionChange']
|
||||
}
|
||||
| undefined
|
||||
if (stash) {
|
||||
if (graph) graph.onNodeRemoved = stash.originalNodeRemoved
|
||||
if (canvas) canvas.onSelectionChange = stash.originalSelectionChange
|
||||
}
|
||||
delete w[key]
|
||||
}, DEFERRED_HANDLERS_KEY)
|
||||
})
|
||||
|
||||
function isNullGraphErrorText(text: string): boolean {
|
||||
return text.includes('NullGraphError') || /has no graph/.test(text)
|
||||
}
|
||||
|
||||
// Vue's default errorHandler routes render throws to console.error,
|
||||
// not pageerror - listen to both.
|
||||
function captureNullGraphErrors(comfyPage: ComfyPage) {
|
||||
const captured: string[] = []
|
||||
const onPageError = (err: Error) => {
|
||||
if (
|
||||
err.name === 'NullGraphError' ||
|
||||
isNullGraphErrorText(err.message ?? '')
|
||||
) {
|
||||
captured.push(`pageerror ${err.name}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
const onConsoleMessage = (msg: ConsoleMessage) => {
|
||||
if (msg.type() !== 'error') return
|
||||
const text = msg.text()
|
||||
if (isNullGraphErrorText(text)) {
|
||||
captured.push(`console.error: ${text}`)
|
||||
}
|
||||
}
|
||||
comfyPage.page.on('pageerror', onPageError)
|
||||
comfyPage.page.on('console', onConsoleMessage)
|
||||
return {
|
||||
getErrors: () => [...captured],
|
||||
stop: () => {
|
||||
comfyPage.page.off('pageerror', onPageError)
|
||||
comfyPage.page.off('console', onConsoleMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function unpackViaContextMenu(comfyPage: ComfyPage, title: string) {
|
||||
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
|
||||
await comfyPage.contextMenu.openForVueNode(fixture.header)
|
||||
await comfyPage.contextMenu.clickMenuItemExact('Unpack Subgraph')
|
||||
}
|
||||
|
||||
async function unpackAndCaptureErrors(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<string[]> {
|
||||
const subgraphNode =
|
||||
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
|
||||
const errors = captureNullGraphErrors(comfyPage)
|
||||
try {
|
||||
await deferLegacyHandlers(comfyPage)
|
||||
await unpackViaContextMenu(comfyPage, SUBGRAPH_NODE_TITLE)
|
||||
await expect(subgraphNode).toHaveCount(0)
|
||||
await runDeferredHandlers(comfyPage)
|
||||
// Let drained-handler reactive flushes settle before stop().
|
||||
await comfyPage.nextFrame()
|
||||
return errors.getErrors()
|
||||
} finally {
|
||||
errors.stop()
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
const subgraphNode =
|
||||
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
const fixture =
|
||||
await comfyPage.vueNodes.getFixtureByTitle(SUBGRAPH_NODE_TITLE)
|
||||
await fixture.header.click()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
|
||||
).toBeVisible()
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('unpack does not surface NullGraphError on the LGraphNode render path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nullGraphErrors = await unpackAndCaptureErrors(comfyPage)
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'LGraphNode render path: detach race must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('unpack does not surface NullGraphError from the TabSubgraphInputs panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nullGraphErrors = await unpackAndCaptureErrors(comfyPage)
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'TabSubgraphInputs panel: detach race must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('unpack with subgraph editor open does not surface NullGraphError from the SubgraphEditor panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nullGraphErrors = await unpackAndCaptureErrors(comfyPage)
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'SubgraphEditor panel: detach race must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -872,3 +872,55 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
|
||||
expect(subgraphNode.has_errors).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Pre-remove vueNodeData drain', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('drops vueNodeData entry before node.onRemoved fires', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
expect(vueNodeData.has(String(node.id))).toBe(true)
|
||||
|
||||
let dataPresentInOnRemoved: boolean | undefined
|
||||
node.onRemoved = () => {
|
||||
dataPresentInOnRemoved = vueNodeData.has(String(node.id))
|
||||
}
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(
|
||||
dataPresentInOnRemoved,
|
||||
'vueNodeData entry must be cleared before node.onRemoved fires so reactive consumers cannot observe the detached node'
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('clears vueNodeData via the onNodeRemoved fallback when LGraph.clear() bypasses node:before-removed', () => {
|
||||
const graph = new LGraph()
|
||||
const nodeA = new LGraphNode('a')
|
||||
const nodeB = new LGraphNode('b')
|
||||
graph.add(nodeA)
|
||||
graph.add(nodeB)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
expect(vueNodeData.size).toBe(2)
|
||||
|
||||
const beforeRemovedSpy = vi.fn()
|
||||
graph.events.addEventListener('node:before-removed', beforeRemovedSpy)
|
||||
|
||||
graph.clear()
|
||||
|
||||
expect(
|
||||
beforeRemovedSpy,
|
||||
'clear() does not dispatch node:before-removed - cleanup comes from the onNodeRemoved fallback'
|
||||
).not.toHaveBeenCalled()
|
||||
expect(
|
||||
vueNodeData.size,
|
||||
'onNodeRemoved fallback must clear vueNodeData when the event path is bypassed'
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -637,27 +637,24 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles node removal from the graph - cleans up all references
|
||||
*/
|
||||
// Drop refs while node is still attached, before reactive store writes
|
||||
// in node.onRemoved can invalidate computeds holding the node.
|
||||
const handleBeforeNodeRemoved = (node: LGraphNode) => {
|
||||
const id = String(node.id)
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
}
|
||||
|
||||
const handleNodeRemoved = (
|
||||
node: LGraphNode,
|
||||
originalCallback?: (node: LGraphNode) => void
|
||||
) => {
|
||||
// Fallback for LGraph.clear() which bypasses node:before-removed
|
||||
handleBeforeNodeRemoved(node)
|
||||
const id = String(node.id)
|
||||
|
||||
// Remove node from layout store
|
||||
setSource(LayoutSource.Canvas)
|
||||
void deleteNode(id)
|
||||
|
||||
// Clean up all tracking references
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
|
||||
// Call original callback if provided
|
||||
if (originalCallback) {
|
||||
originalCallback(node)
|
||||
}
|
||||
originalCallback?.(node)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -680,9 +677,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event listeners - now simplified with extracted handlers
|
||||
*/
|
||||
const setupEventListeners = (): (() => void) => {
|
||||
// Store original callbacks
|
||||
const originalOnNodeAdded = graph.onNodeAdded
|
||||
@@ -698,6 +692,16 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
handleNodeRemoved(node, originalOnNodeRemoved)
|
||||
}
|
||||
|
||||
const beforeNodeRemovedListener = (
|
||||
e: CustomEvent<{ node: LGraphNode }>
|
||||
) => {
|
||||
handleBeforeNodeRemoved(e.detail.node)
|
||||
}
|
||||
graph.events.addEventListener(
|
||||
'node:before-removed',
|
||||
beforeNodeRemovedListener
|
||||
)
|
||||
|
||||
const triggerHandlers: {
|
||||
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
|
||||
} = {
|
||||
@@ -846,12 +850,19 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Initialize state
|
||||
syncWithGraph()
|
||||
|
||||
// Return cleanup function
|
||||
return createCleanupFunction(
|
||||
const cleanup = createCleanupFunction(
|
||||
originalOnNodeAdded || undefined,
|
||||
originalOnNodeRemoved || undefined,
|
||||
originalOnTrigger || undefined
|
||||
)
|
||||
|
||||
return () => {
|
||||
graph.events.removeEventListener(
|
||||
'node:before-removed',
|
||||
beforeNodeRemovedListener
|
||||
)
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
// Set up event listeners immediately
|
||||
|
||||
@@ -106,6 +106,18 @@ describe(usePromotedPreviews, () => {
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array (does not throw) when SubgraphNode is detached', () => {
|
||||
const setup = createSetup()
|
||||
const parentGraph = setup.subgraphNode.graph!
|
||||
parentGraph.add(setup.subgraphNode)
|
||||
parentGraph.remove(setup.subgraphNode)
|
||||
|
||||
expect(setup.subgraphNode.graph).toBeNull()
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(() => promotedPreviews.value).not.toThrow()
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when no $$ promotions exist', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10 })
|
||||
|
||||
@@ -28,6 +28,7 @@ export function usePromotedPreviews(
|
||||
const promotedPreviews = computed((): PromotedPreview[] => {
|
||||
const node = toValue(lgraphNode)
|
||||
if (!(node instanceof SubgraphNode)) return []
|
||||
if (!node.graph) return []
|
||||
|
||||
const entries = promotionStore.getPromotions(node.rootGraph.id, node.id)
|
||||
const pseudoEntries = entries.filter((e) =>
|
||||
|
||||
@@ -334,6 +334,22 @@ describe('hasUnpromotedWidgets', () => {
|
||||
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false (does not throw) when SubgraphNode is detached', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const parentGraph = subgraphNode.graph!
|
||||
parentGraph.add(subgraphNode)
|
||||
const interiorNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(interiorNode)
|
||||
interiorNode.addWidget('text', 'seed', '123', () => {})
|
||||
|
||||
parentGraph.remove(subgraphNode)
|
||||
|
||||
expect(subgraphNode.graph).toBeNull()
|
||||
expect(() => hasUnpromotedWidgets(subgraphNode)).not.toThrow()
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLinkedPromotion', () => {
|
||||
|
||||
@@ -360,6 +360,7 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
|
||||
}
|
||||
|
||||
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
|
||||
if (!subgraphNode.graph) return false
|
||||
const promotionStore = usePromotionStore()
|
||||
const { id: subgraphNodeId, rootGraph, subgraph } = subgraphNode
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { NodeId, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraph,
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
LLink,
|
||||
@@ -329,6 +330,96 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('node:before-removed event', () => {
|
||||
it('fires node:before-removed for a successful node removal', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
|
||||
const events: { node: LGraphNode; graphAtDispatch: unknown }[] = []
|
||||
graph.events.addEventListener('node:before-removed', (e) => {
|
||||
events.push({
|
||||
node: e.detail.node,
|
||||
graphAtDispatch: e.detail.node.graph
|
||||
})
|
||||
})
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].node).toBe(node)
|
||||
expect(events[0].graphAtDispatch).toBe(graph)
|
||||
expect(node.graph).toBeNull()
|
||||
})
|
||||
|
||||
it('does not fire node:before-removed for a node not in the graph', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
|
||||
const fired = vi.fn()
|
||||
graph.events.addEventListener('node:before-removed', fired)
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(fired).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fire node:before-removed when removing an LGraphGroup', () => {
|
||||
const graph = new LGraph()
|
||||
const group = new LGraphGroup('test-group')
|
||||
graph.add(group)
|
||||
|
||||
const fired = vi.fn()
|
||||
graph.events.addEventListener('node:before-removed', fired)
|
||||
|
||||
graph.remove(group)
|
||||
|
||||
expect(fired).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fire node:before-removed when ignore_remove is set', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
node.ignore_remove = true
|
||||
|
||||
const fired = vi.fn()
|
||||
graph.events.addEventListener('node:before-removed', fired)
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(fired).not.toHaveBeenCalled()
|
||||
expect(graph.nodes).toContain(node)
|
||||
})
|
||||
|
||||
it('fires node:before-removed before node.onRemoved and detach', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
|
||||
const order: string[] = []
|
||||
graph.events.addEventListener('node:before-removed', () => {
|
||||
order.push(
|
||||
`before-removed(graph=${node.graph === graph ? 'set' : 'null'})`
|
||||
)
|
||||
})
|
||||
node.onRemoved = () => {
|
||||
order.push(`onRemoved(graph=${node.graph === graph ? 'set' : 'null'})`)
|
||||
}
|
||||
graph.onNodeRemoved = (n) => {
|
||||
order.push(`onNodeRemoved(graph=${n.graph === null ? 'null' : 'set'})`)
|
||||
}
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(order).toEqual([
|
||||
'before-removed(graph=set)',
|
||||
'onRemoved(graph=set)',
|
||||
'onNodeRemoved(graph=null)'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Definition Garbage Collection', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -381,6 +472,53 @@ describe('Subgraph Definition Garbage Collection', () => {
|
||||
expect(graphRemovedNodeIds.size).toBe(2)
|
||||
})
|
||||
|
||||
it('subgraph-definition GC dispatches node:before-removed on the inner subgraph for each inner node', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const { subgraph, innerNodes } = createSubgraphWithNodes(rootGraph, 2)
|
||||
|
||||
const dispatched: { node: LGraphNode; graphAtDispatch: unknown }[] = []
|
||||
subgraph.events.addEventListener('node:before-removed', (e) => {
|
||||
dispatched.push({
|
||||
node: e.detail.node,
|
||||
graphAtDispatch: e.detail.node.graph
|
||||
})
|
||||
})
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
rootGraph.remove(subgraphNode)
|
||||
|
||||
expect(dispatched.map((e) => e.node)).toEqual(innerNodes)
|
||||
for (const entry of dispatched) {
|
||||
expect(entry.graphAtDispatch).toBe(subgraph)
|
||||
}
|
||||
})
|
||||
|
||||
it('subgraph-definition GC dispatches node:before-removed before each inner node onRemoved', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const { subgraph, innerNodes } = createSubgraphWithNodes(rootGraph, 1)
|
||||
const innerNode = innerNodes[0]
|
||||
|
||||
const order: string[] = []
|
||||
subgraph.events.addEventListener('node:before-removed', () => {
|
||||
order.push('before-removed')
|
||||
})
|
||||
innerNode.onRemoved = () => {
|
||||
order.push('onRemoved')
|
||||
}
|
||||
subgraph.onNodeRemoved = () => {
|
||||
order.push('onNodeRemoved')
|
||||
}
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
rootGraph.remove(subgraphNode)
|
||||
|
||||
expect(order).toEqual(['before-removed', 'onRemoved', 'onNodeRemoved'])
|
||||
})
|
||||
|
||||
it('subgraph definition is removed when SubgraphNode is removed', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const { subgraph } = createSubgraphWithNodes(rootGraph, 1)
|
||||
|
||||
@@ -1058,6 +1058,8 @@ export class LGraph
|
||||
// sure? - almost sure is wrong
|
||||
this.beforeChange()
|
||||
|
||||
this.events.dispatch('node:before-removed', { node })
|
||||
|
||||
const { inputs, outputs } = node
|
||||
|
||||
// disconnect inputs
|
||||
@@ -1094,6 +1096,11 @@ export class LGraph
|
||||
|
||||
if (!hasRemainingReferences) {
|
||||
forEachNode(node.subgraph, (innerNode) => {
|
||||
if (innerNode.graph) {
|
||||
;(
|
||||
innerNode.graph.events as CustomEventTarget<LGraphEventMap>
|
||||
).dispatch('node:before-removed', { node: innerNode })
|
||||
}
|
||||
innerNode.onRemoved?.()
|
||||
innerNode.graph?.onNodeRemoved?.(innerNode)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LLink, ResolvedConnection } from '@/lib/litegraph/src/LLink'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
@@ -48,4 +49,11 @@ export interface LGraphEventMap {
|
||||
subgraph: Subgraph
|
||||
closingGraph: LGraph | Subgraph
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires on the owning graph before per-node teardown begins
|
||||
*/
|
||||
'node:before-removed': {
|
||||
node: LGraphNode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,19 @@ describe('SubgraphNode Construction', () => {
|
||||
expect(subgraphNode.graph).toBeNull()
|
||||
})
|
||||
|
||||
it('should return empty widgets array (not throw) after removal', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const parentGraph = subgraphNode.graph!
|
||||
parentGraph.add(subgraphNode)
|
||||
|
||||
parentGraph.remove(subgraphNode)
|
||||
|
||||
expect(subgraphNode.graph).toBeNull()
|
||||
expect(() => subgraphNode.widgets).not.toThrow()
|
||||
expect(subgraphNode.widgets).toEqual([])
|
||||
})
|
||||
|
||||
subgraphTest(
|
||||
'should synchronize slots with subgraph definition',
|
||||
({ subgraphWithNode }) => {
|
||||
|
||||
@@ -257,6 +257,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
private _getPromotedViews(): PromotedWidgetView[] {
|
||||
if (!this.graph) return []
|
||||
const store = usePromotionStore()
|
||||
const entries = store.getPromotionsRef(this.rootGraph.id, this.id)
|
||||
const hasMissingBoundSourceWidget = this._hasMissingBoundSourceWidget()
|
||||
@@ -302,6 +303,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
private _syncPromotions(): void {
|
||||
if (this.id === -1) return
|
||||
if (!this.graph) return
|
||||
|
||||
const store = usePromotionStore()
|
||||
const entries = store.getPromotionsRef(this.rootGraph.id, this.id)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphCanvas, Positionable } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
@@ -85,6 +88,42 @@ describe('useCanvasStore', () => {
|
||||
expect(originalHandler).toHaveBeenCalledWith(2.0, app.canvas.ds.offset)
|
||||
})
|
||||
})
|
||||
|
||||
describe('node:before-removed selection cleanup', () => {
|
||||
it('removes the node from store.selectedItems before its onRemoved fires', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
|
||||
const selectedItems = new Set<Positionable>([node])
|
||||
const fakeCanvas = {
|
||||
canvas: document.createElement('canvas'),
|
||||
graph,
|
||||
selectedItems,
|
||||
deselect: vi.fn((item: Positionable) => {
|
||||
selectedItems.delete(item)
|
||||
})
|
||||
}
|
||||
store.canvas = fakeCanvas as unknown as LGraphCanvas
|
||||
await nextTick()
|
||||
store.updateSelectedItems()
|
||||
expect(store.selectedItems).toContain(node)
|
||||
|
||||
let stillSelectedInOnRemoved: boolean | undefined
|
||||
node.onRemoved = () => {
|
||||
stillSelectedInOnRemoved = store.selectedItems.includes(node)
|
||||
}
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(
|
||||
stillSelectedInOnRemoved,
|
||||
'selectedItems must not contain the node when onRemoved fires'
|
||||
).toBe(false)
|
||||
expect(store.selectedItems).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('Does not include groups in selected nodeIds', async () => {
|
||||
store.selectedItems = [new LGraphGroup()]
|
||||
|
||||
|
||||
@@ -131,6 +131,15 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
whenever(
|
||||
() => canvas.value,
|
||||
(newCanvas) => {
|
||||
useEventListener(
|
||||
() => (currentGraph.value ?? newCanvas.graph)?.events,
|
||||
'node:before-removed',
|
||||
(e: CustomEvent<{ node: LGraphNode }>) => {
|
||||
newCanvas.deselect(e.detail.node)
|
||||
updateSelectedItems()
|
||||
}
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
newCanvas.canvas,
|
||||
'litegraph:set-graph',
|
||||
|
||||
Reference in New Issue
Block a user