Skip to content

Commit 1b41949

Browse files
mattmilleraiampagent
authored andcommitted
refactor: extract node shell-state lifecycle into src/core/graph/nodeShell
Move the app-owned node shell-state lifecycle coordination out of the litegraph folder into an app-layer module, leaving thin call sites in litegraph. No behaviour change. - createNodeShellState / setTrackedNodeState / registerNodeState / unregisterNodeState / unregisterAllNodeStates now live in src/core/graph/nodeShell/nodeShellState.ts - attachNodeToStores / releaseGraphStores in nodeShellLifecycle.ts are the single calls LGraph.add and LGraph.clear make into the app stores - LGraphNode no longer imports useNodeDataStore - the litegraph barrel no longer re-exports the registration functions Amp-Thread-ID: https://ampcode.com/threads/T-01a027af-1c32-7532-84f9-b94aa90620fa
1 parent 7ec4ffc commit 1b41949

11 files changed

Lines changed: 326 additions & 210 deletions

File tree

docs/architecture/node-data-store.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,13 +126,20 @@ synthetic `VueNodeData` today). `AppModeWidgetList` stops calling
126126

127127
Follows the shipped trio convention (`LLink` / `Reroute`):
128128

129-
- `LGraphNode` constructs its `_state: NodeState` at instantiation;
130-
`registerNodeState(graph, node)` inserts it by reference and the class
131-
adopts the returned reactive proxy; `node._graphScope` is the
132-
registration-ownership marker.
129+
- `LGraphNode` constructs its `_state: NodeState` at instantiation (via
130+
`createNodeShellState`); `registerNodeState(graph, node)` inserts it by
131+
reference and the class adopts the returned reactive proxy;
132+
`node._graphId` (root id) is the registration-ownership marker.
133133
- Chokepoints: `LGraph.add` / `LGraph.remove` (the canonical sites),
134134
`unregisterAllNodeStates(graph)` on graph `clear()`, identity-checked
135135
delete (`toRaw` compare) so only the registered state vacates its key.
136+
- The lifecycle coordination itself is app-owned and lives in
137+
`src/core/graph/nodeShell/`: `nodeShellState.ts` (`createNodeShellState`,
138+
`setTrackedNodeState`, `registerNodeState`, `unregisterNodeState`,
139+
`unregisterAllNodeStates`) and `nodeShellLifecycle.ts`
140+
(`attachNodeToStores`, `releaseGraphStores` — the single calls `LGraph.add`
141+
and `LGraph.clear` make into the stores). None of these are re-exported from
142+
the litegraph barrel; the registration surface is internal.
136143
- Class fields become accessors reading through `_state`. Reads go
137144
through the reactive proxy directly (there is no `_stateRaw` raw view),
138145
so `node.title` / `node.mode` / … track inside Vue effects, matching a
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
2+
import { getWidgetIds } from '@/lib/litegraph/src/utils/widget'
3+
import { useWidgetValueStore } from '@/stores/widgetValueStore'
4+
5+
import { registerNodeState } from './nodeShellState'
6+
7+
import type { LGraph } from '@/lib/litegraph/src/LGraph'
8+
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
9+
import type { NodeId } from '@/types/nodeId'
10+
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
11+
12+
/**
13+
* Registers a node's shell state and its widget bindings with the app
14+
* stores. Call once the node has a valid id and graph reference. Retries
15+
* with a freshly minted id on a registration collision.
16+
*/
17+
export function attachNodeToStores(
18+
graph: LGraph | Subgraph,
19+
node: LGraphNode,
20+
mintId: () => NodeId
21+
): void {
22+
while (!registerNodeState(graph, node)) node.id = mintId()
23+
24+
if (!node.widgets) return
25+
for (const widget of node.widgets) {
26+
if (isNodeBindable(widget)) widget.setNodeId(node.id)
27+
}
28+
useWidgetValueStore().setNodeWidgetOrder(
29+
graph.rootGraph.id,
30+
node.id,
31+
getWidgetIds(node.widgets)
32+
)
33+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { createTestingPinia } from '@pinia/testing'
2+
import { setActivePinia } from 'pinia'
3+
import { beforeEach, describe, expect, it } from 'vitest'
4+
import { toRaw } from 'vue'
5+
6+
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
7+
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
8+
import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
9+
import { useNodeDataStore } from '@/stores/nodeDataStore'
10+
import { graphScopeOf } from '@/types/graphScopeId'
11+
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
12+
import type { NodeState } from '@/types/nodeState'
13+
import { zeroUuid } from '@/utils/uuid'
14+
15+
import { createNodeShellState } from './nodeShellState'
16+
17+
describe('node shell state', () => {
18+
beforeEach(() => {
19+
setActivePinia(createTestingPinia({ stubActions: false }))
20+
})
21+
22+
function addNodeToSubgraph() {
23+
const subgraph = createTestSubgraph()
24+
const node = new LGraphNode('Node')
25+
subgraph.add(node)
26+
return { subgraph, node }
27+
}
28+
29+
/** The states the store holds for a subgraph, within its root graph's bucket. */
30+
function statesIn(subgraph: Subgraph): NodeState[] {
31+
return useNodeDataStore().getGraphNodesFor(
32+
subgraph.rootGraph.id,
33+
subgraph.id
34+
)
35+
}
36+
37+
it('starts unregistered and unowned', () => {
38+
const state = createNodeShellState('Node', 'some/type', undefined)
39+
40+
expect(state.id).toBe(UNASSIGNED_NODE_ID)
41+
expect(state.graphId).toBe(zeroUuid)
42+
expect(state.title).toBe('Node')
43+
})
44+
45+
it('falls back to a placeholder title and an empty type', () => {
46+
const state = createNodeShellState('', undefined, undefined)
47+
48+
expect(state.title).toBe('Unnamed')
49+
expect(state.type).toBe('')
50+
})
51+
52+
it('buckets by root graph and partitions by owning graph', () => {
53+
const { subgraph, node } = addNodeToSubgraph()
54+
const rootId = subgraph.rootGraph.id
55+
56+
expect(rootId).not.toBe(subgraph.id)
57+
expect(node._graphScope).toEqual(graphScopeOf(subgraph))
58+
expect(node._state.graphId).toBe(subgraph.id)
59+
60+
const [registered] = statesIn(subgraph)
61+
expect(toRaw(node._state)).toBe(toRaw(registered))
62+
expect(useNodeDataStore().getGraphNodesFor(rootId, rootId)).toEqual([])
63+
})
64+
65+
it('vacates its store entry on remove', () => {
66+
const { subgraph, node } = addNodeToSubgraph()
67+
68+
subgraph.remove(node)
69+
70+
expect(statesIn(subgraph)).toEqual([])
71+
expect(node._graphScope).toBeUndefined()
72+
})
73+
})
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { shallowReactive } from 'vue'
2+
3+
import {
4+
canTransferLayoutAttachment,
5+
transferLayoutAttachment
6+
} from '@/renderer/core/layout/operations/graphLayoutAttachment'
7+
import { useNodeDataStore } from '@/stores/nodeDataStore'
8+
import { graphScopeOf } from '@/types/graphScopeId'
9+
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
10+
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
11+
import { zeroUuid } from '@/utils/uuid'
12+
13+
import type {
14+
INodeInputSlot,
15+
INodeOutputSlot
16+
} from '@/lib/litegraph/src/interfaces'
17+
import type { LGraph } from '@/lib/litegraph/src/LGraph'
18+
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
19+
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
20+
import type { NodeState } from '@/types/nodeState'
21+
22+
/**
23+
* Builds the shell state a node carries from construction until it adopts the
24+
* {@link useNodeDataStore} proxy in {@link registerNodeState}.
25+
*/
26+
export function createNodeShellState(
27+
title: string,
28+
type: string | undefined,
29+
titleMode: TitleMode | undefined
30+
): NodeState {
31+
return {
32+
flags: {},
33+
graphId: zeroUuid,
34+
id: UNASSIGNED_NODE_ID,
35+
inputs: shallowReactive<INodeInputSlot[]>([]),
36+
mode: LGraphEventMode.ALWAYS,
37+
outputs: shallowReactive<INodeOutputSlot[]>([]),
38+
title: title || 'Unnamed',
39+
type: type ?? '',
40+
titleMode
41+
}
42+
}
43+
44+
/** Writes a shell-state field, emitting `node:property:changed` on change. */
45+
export function setTrackedNodeState<K extends keyof NodeState>(
46+
node: LGraphNode,
47+
property: K,
48+
value: NodeState[K]
49+
): void {
50+
const oldValue = node._state[property]
51+
if (oldValue === value) return
52+
53+
node._state[property] = value
54+
node.graph?.trigger('node:property:changed', {
55+
nodeId: node.id,
56+
property,
57+
oldValue,
58+
newValue: value
59+
})
60+
}
61+
62+
/**
63+
* Registers a node's shell state into {@link useNodeDataStore} and adopts the
64+
* store's proxy as {@link LGraphNode._state}. Call wherever a node joins a
65+
* graph. Returns `false` on an id collision within the owning root graph —
66+
* the caller must mint a new id and retry.
67+
*/
68+
export function registerNodeState(
69+
graph: Pick<LGraph, 'rootGraph' | 'id'>,
70+
node: LGraphNode
71+
): boolean {
72+
const graphScope = graphScopeOf(graph)
73+
node._state.graphId = graph.id
74+
const registered = useNodeDataStore().registerNode(graphScope, node._state)
75+
if (!registered) return false
76+
node._state = registered
77+
node._graphScope = graphScope
78+
return true
79+
}
80+
81+
/**
82+
* Removes a node's shell state from {@link useNodeDataStore} and detaches the
83+
* node. No-op for nodes that were never registered.
84+
* @param node The node to unregister
85+
*/
86+
export function unregisterNodeState(node: LGraphNode): void {
87+
if (!node._graphScope) return
88+
useNodeDataStore().deleteNode(node._graphScope, node._state)
89+
node._graphScope = undefined
90+
}
91+
92+
/**
93+
* Unregisters every node a graph owns, including those inside the subgraph
94+
* definitions it holds. Used when a graph's nodes leave the store without a
95+
* whole-bucket wipe: subgraph-definition removal, and clearing a graph that
96+
* shares its bucket with other graphs.
97+
* @param graph The graph whose nodes should be unregistered
98+
*/
99+
export function unregisterAllNodeStates(
100+
graph: Pick<LGraph, '_nodes' | '_subgraphs'>
101+
): void {
102+
for (const node of graph._nodes) unregisterNodeState(node)
103+
for (const subgraph of graph._subgraphs.values()) {
104+
unregisterAllNodeStates(subgraph)
105+
}
106+
}
107+
108+
function canTransferNodeState(
109+
node: LGraphNode,
110+
replacement: LGraphNode
111+
): boolean {
112+
return (
113+
node.id === replacement.id &&
114+
replacement._graphScope === undefined &&
115+
node._graphScope !== undefined &&
116+
useNodeDataStore().ownsNode(node._graphScope, node._state)
117+
)
118+
}
119+
120+
function transferNodeState(node: LGraphNode, replacement: LGraphNode): void {
121+
const registeredState = node._state
122+
const detachedState = { ...registeredState }
123+
const { graphId: _graphId, id: _id, ...replacementState } = replacement._state
124+
Object.assign(registeredState, {
125+
bgcolor: undefined,
126+
color: undefined,
127+
resizable: undefined,
128+
shape: undefined,
129+
showAdvanced: undefined,
130+
titleMode: undefined,
131+
...replacementState
132+
} satisfies {
133+
[K in Exclude<keyof NodeState, 'graphId' | 'id'>]-?:
134+
| NodeState[K]
135+
| undefined
136+
})
137+
replacement._state = registeredState
138+
replacement._graphScope = node._graphScope
139+
node._state = detachedState
140+
node._graphScope = undefined
141+
}
142+
143+
/**
144+
* Whether `replacement` may adopt `node`'s registered shell state and layout
145+
* attachment during an in-place node-type replacement.
146+
*/
147+
export function canTransferReplacementOwnership(
148+
node: LGraphNode,
149+
replacement: LGraphNode
150+
): boolean {
151+
return (
152+
canTransferNodeState(node, replacement) &&
153+
canTransferLayoutAttachment(node, replacement)
154+
)
155+
}
156+
157+
/**
158+
* Transfers `node`'s registered shell state and layout attachment to
159+
* `replacement`, detaching `node`. Returns `false` (no-op) when the transfer
160+
* preconditions no longer hold.
161+
*/
162+
export function transferReplacementOwnership(
163+
node: LGraphNode,
164+
replacement: LGraphNode
165+
): boolean {
166+
if (!canTransferReplacementOwnership(node, replacement)) return false
167+
if (!transferLayoutAttachment(node, replacement)) return false
168+
transferNodeState(node, replacement)
169+
return true
170+
}

src/lib/litegraph/src/LGraph.ts

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import {
55
SUBGRAPH_INPUT_ID,
66
SUBGRAPH_OUTPUT_ID
77
} from '@/lib/litegraph/src/constants'
8-
import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
8+
import { attachNodeToStores } from '@/core/graph/nodeShell/nodeShellLifecycle'
9+
import {
10+
unregisterAllNodeStates,
11+
unregisterNodeState
12+
} from '@/core/graph/nodeShell/nodeShellState'
913
import type { UUID } from '@/utils/uuid'
1014
import { createUuidv4, zeroUuid } from '@/utils/uuid'
1115
import {
@@ -18,6 +22,9 @@ import {
1822
materializeRerouteLayout
1923
} from '@/renderer/core/layout/operations/graphLayoutAttachment'
2024
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
25+
import { useLinkStore } from '@/stores/linkStore'
26+
import { useNodeDataStore } from '@/stores/nodeDataStore'
27+
import { useRerouteStore } from '@/stores/rerouteStore'
2128
import { toLinkId } from '@/types/linkId'
2229
import { isFloatingTopology } from '@/types/linkTopology'
2330
import { toRerouteId } from '@/types/rerouteId'
@@ -34,9 +41,6 @@ import {
3441
observeRerouteId
3542
} from './idAllocation'
3643
import type { LGraphState } from './idAllocation'
37-
import { useLinkStore } from '@/stores/linkStore'
38-
import { useNodeDataStore } from '@/stores/nodeDataStore'
39-
import { useRerouteStore } from '@/stores/rerouteStore'
4044
import {
4145
inputHasLink,
4246
inputLink,
@@ -63,12 +67,7 @@ import type { DragAndScaleState } from './DragAndScale'
6367
import { LGraphCanvas } from './LGraphCanvas'
6468
import { Rectangle } from './infrastructure/Rectangle'
6569
import { LGraphGroup } from './LGraphGroup'
66-
import {
67-
LGraphNode,
68-
registerNodeState,
69-
unregisterAllNodeStates,
70-
unregisterNodeState
71-
} from './LGraphNode'
70+
import { LGraphNode } from './LGraphNode'
7271
import {
7372
LLink,
7473
registerLinkTopology,
@@ -111,7 +110,6 @@ import {
111110
snapPoint
112111
} from './measure'
113112
import { warnDeprecated } from './utils/feedback'
114-
import { getWidgetIds } from './utils/widget'
115113
import { SubgraphInput } from './subgraph/SubgraphInput'
116114
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
117115
import { SubgraphOutput } from './subgraph/SubgraphOutput'
@@ -1168,21 +1166,7 @@ export class LGraph
11681166
normalizeWidgetsView(node)
11691167
node.graph = this
11701168

1171-
while (!registerNodeState(this, node)) node.id = mintNodeId(state)
1172-
1173-
// Register all widgets with the WidgetValueStore now that node has a
1174-
// valid ID and graph reference.
1175-
if (node.widgets) {
1176-
const widgetValueStore = useWidgetValueStore()
1177-
for (const widget of node.widgets) {
1178-
if (isNodeBindable(widget)) widget.setNodeId(node.id)
1179-
}
1180-
widgetValueStore.setNodeWidgetOrder(
1181-
this.rootGraph.id,
1182-
node.id,
1183-
getWidgetIds(node.widgets)
1184-
)
1185-
}
1169+
attachNodeToStores(this, node, () => mintNodeId(state))
11861170

11871171
this._nodes.push(node)
11881172
this._nodes_by_id[node.id] = node

0 commit comments

Comments
 (0)