feat: unify lifecycle event dispatcher callbacks

Amp-Thread-ID: https://ampcode.com/threads/T-019c98d1-7608-73e5-9da5-3ae48178b28b
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Alexander Brown
2026-02-25 23:30:37 -08:00
parent 6580500206
commit 34786a16c0
8 changed files with 341 additions and 120 deletions

View File

@@ -25,6 +25,7 @@ import { MapProxyHandler } from './MapProxyHandler'
import { Reroute } from './Reroute'
import type { RerouteId } from './Reroute'
import { CustomEventTarget } from './infrastructure/CustomEventTarget'
import { graphLifecycleEventDispatcher } from './infrastructure/GraphLifecycleEventDispatcher'
import { graphPersistenceAdapter } from './infrastructure/GraphPersistenceAdapter'
import type { LGraphEventMap } from './infrastructure/LGraphEventMap'
import type { SubgraphEventMap } from './infrastructure/SubgraphEventMap'
@@ -1687,15 +1688,15 @@ export class LGraph
output.links.push(link.id)
// connect in input
input.link = link.id
if (input.widget) {
this.trigger('node:slot-links:changed', {
nodeId: targetNode.id,
slotType: NodeSlotType.INPUT,
slotIndex: inputIndex,
connected: true,
linkId: link.id
})
}
graphLifecycleEventDispatcher.dispatchSlotLinkChanged({
graph: this,
nodeId: targetNode.id,
slotType: NodeSlotType.INPUT,
slotIndex: inputIndex,
connected: true,
linkId: link.id,
hasWidget: !!input.widget
})
this.finalizeConnectedLink(link)
return link

View File

@@ -162,8 +162,20 @@ describe('LGraphNode', () => {
const link = sourceNode.connect(0, targetNode, 0)
if (!link) throw new Error('Expected link')
targetNode.inputs[0].widget = { name: 'in-widget' }
const callbackOrder: string[] = []
graph.onTrigger = (event) => {
if (event.type !== 'node:slot-links:changed') return
callbackOrder.push(`trigger:${event.type}:${event.connected}`)
expect(event.type).toBe('node:slot-links:changed')
expect(event.nodeId).toBe(targetNode.id)
expect(event.slotType).toBe(NodeSlotType.INPUT)
expect(event.slotIndex).toBe(0)
expect(event.connected).toBe(false)
expect(event.linkId).toBe(link.id)
}
targetNode.onConnectionsChange = (
slotType,
@@ -197,6 +209,7 @@ describe('LGraphNode', () => {
expect(disconnected).toBe(true)
expect(callbackOrder).toEqual([
'trigger:node:slot-links:changed:false',
`target:${NodeSlotType.INPUT}:0:false`,
`source:${NodeSlotType.OUTPUT}:0:false`
])
@@ -269,8 +282,20 @@ describe('LGraphNode', () => {
const targetLink = sourceNode.connect(0, targetNode1, 0)
sourceNode.connect(0, targetNode2, 0)
if (!targetLink) throw new Error('Expected target link')
targetNode1.inputs[0].widget = { name: 'in-widget' }
const callbackOrder: string[] = []
graph.onTrigger = (event) => {
if (event.type !== 'node:slot-links:changed') return
callbackOrder.push(`trigger:${event.type}:${event.connected}`)
expect(event.type).toBe('node:slot-links:changed')
expect(event.nodeId).toBe(targetNode1.id)
expect(event.slotType).toBe(NodeSlotType.INPUT)
expect(event.slotIndex).toBe(0)
expect(event.connected).toBe(false)
expect(event.linkId).toBe(targetLink.id)
}
targetNode1.onConnectionsChange = (
slotType,
@@ -304,6 +329,7 @@ describe('LGraphNode', () => {
expect(disconnected).toBe(true)
expect(callbackOrder).toEqual([
'trigger:node:slot-links:changed:false',
`target:${NodeSlotType.INPUT}:0:false`,
`source:${NodeSlotType.OUTPUT}:0:false`
])
@@ -433,6 +459,18 @@ describe('LGraphNode', () => {
const callbackOrder: string[] = []
const sourceOutput = sourceNode.outputs[0]
const targetInput = targetNode.inputs[0]
targetInput.widget = { name: 'in-widget' }
graph.onTrigger = (event) => {
if (event.type !== 'node:slot-links:changed') return
callbackOrder.push(`trigger:${event.type}:${event.connected}`)
expect(event.type).toBe('node:slot-links:changed')
expect(event.nodeId).toBe(targetNode.id)
expect(event.slotType).toBe(NodeSlotType.INPUT)
expect(event.slotIndex).toBe(0)
expect(event.connected).toBe(true)
}
sourceNode.onConnectionsChange = (
slotType,
@@ -472,6 +510,7 @@ describe('LGraphNode', () => {
expect(link).toBeDefined()
expect(callbackOrder).toEqual([
'trigger:node:slot-links:changed:true',
`source:${NodeSlotType.OUTPUT}:0:true`,
`target:${NodeSlotType.INPUT}:0:true`
])

View File

@@ -23,6 +23,7 @@ import { LLink } from './LLink'
import type { Reroute, RerouteId } from './Reroute'
import { getNodeInputOnPos, getNodeOutputOnPos } from './canvas/measureSlots'
import type { IDrawBoundingOptions } from './draw'
import { graphLifecycleEventDispatcher } from './infrastructure/GraphLifecycleEventDispatcher'
import { NullGraphError } from './infrastructure/NullGraphError'
import type { ReadOnlyRectangle } from './infrastructure/Rectangle'
import { Rectangle } from './infrastructure/Rectangle'
@@ -2915,22 +2916,15 @@ export class LGraphNode
afterRerouteId
)
// link has been created now, so its updated
this.onConnectionsChange?.(
NodeSlotType.OUTPUT,
outputIndex,
true,
link,
output
)
inputNode.onConnectionsChange?.(
NodeSlotType.INPUT,
inputIndex,
true,
link,
input
)
graphLifecycleEventDispatcher.dispatchConnectNodePair({
sourceNode: this,
sourceSlotIndex: outputIndex,
sourceSlot: output,
targetNode: inputNode,
targetSlotIndex: inputIndex,
targetSlot: input,
link
})
this.setDirtyCanvas(false, true)
graph.afterChange()
@@ -3050,35 +3044,29 @@ export class LGraphNode
const input = target.inputs[link_info.target_slot]
// remove there
input.link = null
if (input.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: target.id,
slotType: NodeSlotType.INPUT,
slotIndex: link_info.target_slot,
connected: false,
linkId: link_info.id
})
}
graphLifecycleEventDispatcher.dispatchSlotLinkChanged({
graph,
nodeId: target.id,
slotType: NodeSlotType.INPUT,
slotIndex: link_info.target_slot,
connected: false,
linkId: link_info.id,
hasWidget: !!input.widget
})
// remove the link from the links pool
graph.disconnectLink(link_info, 'input')
graph._version++
// link_info hasn't been modified so its ok
target.onConnectionsChange?.(
NodeSlotType.INPUT,
link_info.target_slot,
false,
link_info,
input
)
this.onConnectionsChange?.(
NodeSlotType.OUTPUT,
slot,
false,
link_info,
output
)
graphLifecycleEventDispatcher.dispatchDisconnectNodePair({
sourceNode: this,
sourceSlotIndex: slot,
sourceSlot: output,
targetNode: target,
targetSlotIndex: link_info.target_slot,
targetSlot: input,
link: link_info
})
break
}
@@ -3099,13 +3087,14 @@ export class LGraphNode
slot,
link_info
)
this.onConnectionsChange?.(
NodeSlotType.OUTPUT,
slot,
false,
link_info,
output
)
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
node: this,
slotType: NodeSlotType.OUTPUT,
slotIndex: slot,
connected: false,
link: link_info,
slot: output
})
continue
} else {
console.error('Missing subgraphOutput slot when disconnecting link')
@@ -3119,35 +3108,36 @@ export class LGraphNode
const input = target.inputs[link_info.target_slot]
// remove other side link
input.link = null
if (input.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: target.id,
slotType: NodeSlotType.INPUT,
slotIndex: link_info.target_slot,
connected: false,
linkId: link_info.id
})
}
graphLifecycleEventDispatcher.dispatchSlotLinkChanged({
graph,
nodeId: target.id,
slotType: NodeSlotType.INPUT,
slotIndex: link_info.target_slot,
connected: false,
linkId: link_info.id,
hasWidget: !!input.widget
})
// link_info hasn't been modified so its ok
target.onConnectionsChange?.(
NodeSlotType.INPUT,
link_info.target_slot,
false,
link_info,
input
)
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
node: target,
slotType: NodeSlotType.INPUT,
slotIndex: link_info.target_slot,
connected: false,
link: link_info,
slot: input
})
}
// remove the link from the links pool
graph.disconnectLink(link_info, 'input')
this.onConnectionsChange?.(
NodeSlotType.OUTPUT,
slot,
false,
link_info,
output
)
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
node: this,
slotType: NodeSlotType.OUTPUT,
slotIndex: slot,
connected: false,
link: link_info,
slot: output
})
}
output.links = null
}
@@ -3197,15 +3187,15 @@ export class LGraphNode
const link_id = this.inputs[slot].link
if (link_id != null) {
this.inputs[slot].link = null
if (input.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: this.id,
slotType: NodeSlotType.INPUT,
slotIndex: slot,
connected: false,
linkId: link_id
})
}
graphLifecycleEventDispatcher.dispatchSlotLinkChanged({
graph,
nodeId: this.id,
slotType: NodeSlotType.INPUT,
slotIndex: slot,
connected: false,
linkId: link_id,
hasWidget: !!input.widget
})
// remove other side
const link_info = graph._links.get(link_id)
@@ -3243,20 +3233,15 @@ export class LGraphNode
graph.disconnectLink(link_info, keepReroutes ? 'output' : undefined)
if (graph) graph._version++
this.onConnectionsChange?.(
NodeSlotType.INPUT,
slot,
false,
link_info,
input
)
target_node.onConnectionsChange?.(
NodeSlotType.OUTPUT,
i,
false,
link_info,
output
)
graphLifecycleEventDispatcher.dispatchDisconnectNodePair({
sourceNode: target_node,
sourceSlotIndex: i,
sourceSlot: output,
targetNode: this,
targetSlotIndex: slot,
targetSlot: input,
link: link_info
})
}
}

View File

@@ -0,0 +1,126 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinkId, LLink } from '@/lib/litegraph/src/LLink'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type { SubgraphIO } from '@/lib/litegraph/src/types/serialisation'
type SlotConnection = INodeInputSlot | INodeOutputSlot | SubgraphIO
function dispatchSlotLinkChanged(params: {
graph: LGraph
nodeId: NodeId
slotType: NodeSlotType
slotIndex: number
connected: boolean
linkId: LinkId
hasWidget: boolean
}): void {
const { graph, nodeId, slotType, slotIndex, connected, linkId, hasWidget } =
params
if (!hasWidget) return
graph.trigger('node:slot-links:changed', {
nodeId,
slotType,
slotIndex,
connected,
linkId
})
}
function dispatchNodeConnectionChange(params: {
node: LGraphNode | undefined
slotType: NodeSlotType
slotIndex: number
connected: boolean
link: LLink | undefined
slot: SlotConnection
}): void {
const { node, slotType, slotIndex, connected, link, slot } = params
node?.onConnectionsChange?.(slotType, slotIndex, connected, link, slot)
}
function dispatchConnectNodePair(params: {
sourceNode: LGraphNode
sourceSlotIndex: number
sourceSlot: INodeOutputSlot
targetNode: LGraphNode
targetSlotIndex: number
targetSlot: INodeInputSlot
link: LLink
}): void {
const {
sourceNode,
sourceSlotIndex,
sourceSlot,
targetNode,
targetSlotIndex,
targetSlot,
link
} = params
dispatchNodeConnectionChange({
node: sourceNode,
slotType: NodeSlotType.OUTPUT,
slotIndex: sourceSlotIndex,
connected: true,
link,
slot: sourceSlot
})
dispatchNodeConnectionChange({
node: targetNode,
slotType: NodeSlotType.INPUT,
slotIndex: targetSlotIndex,
connected: true,
link,
slot: targetSlot
})
}
function dispatchDisconnectNodePair(params: {
sourceNode: LGraphNode
sourceSlotIndex: number
sourceSlot: INodeOutputSlot
targetNode: LGraphNode
targetSlotIndex: number
targetSlot: INodeInputSlot | SubgraphIO
link: LLink
}): void {
const {
sourceNode,
sourceSlotIndex,
sourceSlot,
targetNode,
targetSlotIndex,
targetSlot,
link
} = params
dispatchNodeConnectionChange({
node: targetNode,
slotType: NodeSlotType.INPUT,
slotIndex: targetSlotIndex,
connected: false,
link,
slot: targetSlot
})
dispatchNodeConnectionChange({
node: sourceNode,
slotType: NodeSlotType.OUTPUT,
slotIndex: sourceSlotIndex,
connected: false,
link,
slot: sourceSlot
})
}
export const graphLifecycleEventDispatcher = {
dispatchSlotLinkChanged,
dispatchNodeConnectionChange,
dispatchConnectNodePair,
dispatchDisconnectNodePair
}

View File

@@ -177,6 +177,63 @@ describe('SubgraphIO - Input Slot Dual-Nature Behavior', () => {
}
)
subgraphTest(
'connect lifecycle keeps input-connected event before node callback',
({ subgraphWithNode }) => {
const { subgraph } = subgraphWithNode
const internalNode = new LGraphNode('Internal Target')
internalNode.addInput('in', '*')
internalNode.addWidget('number', 'in-widget', 0, null)
internalNode.inputs[0].widget = { name: 'in-widget' }
subgraph.add(internalNode)
const subgraphInput = subgraph.inputNode.slots[0]
const callbackOrder: string[] = []
subgraphInput.events.addEventListener('input-connected', () => {
callbackOrder.push('event:input-connected')
})
internalNode.onConnectionsChange = () => {
callbackOrder.push('callback:node-connected')
}
subgraphInput.connect(internalNode.inputs[0], internalNode)
expect(callbackOrder).toEqual([
'event:input-connected',
'callback:node-connected'
])
}
)
subgraphTest(
'disconnect lifecycle keeps node callback before input-disconnected event',
({ subgraphWithNode }) => {
const { subgraph } = subgraphWithNode
const internalNode = new LGraphNode('Internal Target')
internalNode.addInput('in', '*')
subgraph.add(internalNode)
const subgraphInput = subgraph.inputNode.slots[0]
subgraphInput.connect(internalNode.inputs[0], internalNode)
const callbackOrder: string[] = []
internalNode.onConnectionsChange = (...args) => {
if (args[2]) return
callbackOrder.push('callback:node-disconnected')
}
subgraphInput.events.addEventListener('input-disconnected', () => {
callbackOrder.push('event:input-disconnected')
})
subgraphInput.disconnect()
expect(callbackOrder).toEqual([
'callback:node-disconnected',
'event:input-disconnected'
])
}
)
subgraphTest(
'input accepts external connections from parent graph',
({ subgraphWithNode }) => {

View File

@@ -2,6 +2,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LLink } from '@/lib/litegraph/src/LLink'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import { CustomEventTarget } from '@/lib/litegraph/src/infrastructure/CustomEventTarget'
import { graphLifecycleEventDispatcher } from '@/lib/litegraph/src/infrastructure/GraphLifecycleEventDispatcher'
import type { SubgraphInputEventMap } from '@/lib/litegraph/src/infrastructure/SubgraphInputEventMap'
import type {
INodeInputSlot,
@@ -102,7 +103,14 @@ export class SubgraphInput extends SubgraphSlot {
afterRerouteId
)
node.onConnectionsChange?.(NodeSlotType.INPUT, inputIndex, true, link, slot)
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
node,
slotType: NodeSlotType.INPUT,
slotIndex: inputIndex,
connected: true,
link,
slot
})
subgraph.afterChange()

View File

@@ -4,6 +4,7 @@ import { LLink } from '@/lib/litegraph/src/LLink'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/constants'
import { graphLifecycleEventDispatcher } from '@/lib/litegraph/src/infrastructure/GraphLifecycleEventDispatcher'
import type {
DefaultConnectionColors,
INodeInputSlot,
@@ -189,13 +190,14 @@ export class SubgraphInputNode
subgraph.disconnectSubgraphInputLink(subgraphInput, node, slotIndex, link)
subgraph.setDirtyCanvas(false, true)
node.onConnectionsChange?.(
NodeSlotType.INPUT,
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
node,
slotType: NodeSlotType.INPUT,
slotIndex,
false,
connected: false,
link,
subgraphInput
)
slot: subgraphInput
})
}
override drawProtected(

View File

@@ -1,6 +1,7 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LLink } from '@/lib/litegraph/src/LLink'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import { graphLifecycleEventDispatcher } from '@/lib/litegraph/src/infrastructure/GraphLifecycleEventDispatcher'
import type {
INodeInputSlot,
INodeOutputSlot,
@@ -73,13 +74,14 @@ export class SubgraphOutput extends SubgraphSlot {
afterRerouteId
)
node.onConnectionsChange?.(
NodeSlotType.OUTPUT,
outputIndex,
true,
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
node,
slotType: NodeSlotType.OUTPUT,
slotIndex: outputIndex,
connected: true,
link,
slot
)
})
subgraph.afterChange()
@@ -142,13 +144,14 @@ export class SubgraphOutput extends SubgraphSlot {
link
)
outputNode?.onConnectionsChange?.(
NodeSlotType.OUTPUT,
link.origin_slot,
false,
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
node: outputNode,
slotType: NodeSlotType.OUTPUT,
slotIndex: link.origin_slot,
connected: false,
link,
this
)
slot: this
})
}
}
}