Skip to content

Commit a80a5e1

Browse files
authored
feat(nodeShell): assert node registration-identity invariants (#14443)
**STACKED — merging lands on `matt/be-5050-node-shell-state` (#14257, which itself targets `feature/ecs-migration`), NOT `main`.** ## ELI-5 When a node joins or leaves a graph, the app files its "shell state" into a store so the renderer can draw it. Two ways that filing can go wrong today fail silently: filing a node that is already filed under a *different* workflow strands the old entry, and un-filing a state the store never held leaves a ghost the renderer keeps drawing. Both now trip an assertion (throws in DEV, reports via the assert reporter in prod) instead of drifting quietly. No behavior changes when the invariants hold. ## What changed `src/core/graph/nodeShell/nodeShellState.ts` - `registerNodeState` asserts `node._graphId` is unset or already equals `graph.rootGraph.id` before overwriting it. Membership is by state identity, so a node re-registered under a second root graph leaves its first bucket entry behind with nothing to remove it. - `unregisterNodeState` captures `deleteNode`'s boolean instead of discarding it and asserts it. `false` means the bucket did not contain `node._state` — identity drift, e.g. `_state` reassigned after registration. Both messages name the node id. `_graphId` is cleared before the assertion so a failing teardown still leaves the node detached rather than half-registered. ## Deliberately not shipped: the duplicate-`NodeState` assertion The third invariant asked for — asserting the store never holds two distinct `NodeState` objects for one `(graphId, id)` — was implemented (an auxiliary forward `Map<string, NodeState>` plus a `Map<NodeState, string>` reverse map per bucket, so a renumbered node still de-indexes by identity) and then **reverted**, because it is not a regression net on this base: it fires on legitimate existing behavior. Evidence, all from the suite on this branch: - `LGraph.serialise.test.ts > can (de)serialise node / group titles` — `new LGraph(data)` (`LGraph.ts:476` → `configure:2636` → `add:1092`) keeps the *persisted* graph id, so a round-tripped copy shares a bucket with the graph it was serialised from and collides on every node id. - `LGraph.test.ts > Link serialization goldens` (4 tests) and `LGraph.test.ts > deduplicateSubgraphNodeIds (via configure) > warns when configuring a host with legacy proxyWidgets…` fail the same way. That is 6 tests, green on this base and green again with the assertion removed — so this is the assertion's premise being wrong, not a latent bug those tests were hiding. `subgraphDeduplication.ts` guarantees id-uniqueness *within* one root graph; it says nothing about two live `LGraph` instances that carry the same id. Making duplicate ids assertable means giving each `LGraph` instance its own bucket identity rather than keying buckets by graph id — a separate change with real blast radius (`getGraphNodesFor` callers, `clearGraph`, the `canvasStore.rootGraphId` readers), not a rider on this one. `docs/architecture/node-data-store.md` records both the two shipped invariants and this gap. ## Tests `nodeShellState.test.ts` gains the two cases; both are red without the production change (verified by stashing it). The cross-root case needs an explicit `second.id = createUuidv4()` — two fresh `LGraph`s both sit at `zeroUuid` until `configure`, so they share a bucket and the invariant genuinely holds. ## Verification Targeted: `nodeShellState.test.ts`, `nodeDataStore.test.ts`, `useNodeReplacement.test.ts`, `LGraph.test.ts`, `LGraph.serialise.test.ts` — 82 tests pass, 0 fail. `oxfmt --check` clean. Full `vitest run` was executed and is the reason the third assertion was reverted (it caught the 6 failures above). That run is **not** a clean-suite claim: this worktree borrows the parent clone's `node_modules`, which predates the `minisearch` dependency this branch adds, so 222 suites failed to resolve imports for reasons unrelated to the diff. Of the tests that did run, the only failures attributable to the diff were the 6 named above; the rest (`assetService`, `useAssetGridSelection`, `onboardingCloudRoutes`, …) are the same load-dependent set #14257 reported. A reviewer should treat repo-wide CI as the authority here, not this local run. Repo-wide `pnpm typecheck` was likewise not run locally — the diff is 11 lines of source plus tests, and both changed files typecheck under vitest's transform.
1 parent af6567c commit a80a5e1

3 files changed

Lines changed: 97 additions & 6 deletions

File tree

docs/architecture/node-data-store.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,24 @@ Follows the shipped trio convention (`LLink` / `Reroute`):
132132
- Chokepoints: `LGraph.add` / `LGraph.remove` (the canonical sites),
133133
`unregisterAllNodeStates(graph)` on graph `clear()`, identity-checked
134134
delete (`toRaw` compare) so only the registered state vacates its key.
135+
- Two ways that lifecycle can silently drift are asserted rather than left to
136+
the renderer to expose: re-registering an already-registered node under a
137+
different root graph (its old bucket entry would strand), and unregistering a
138+
state the bucket does not hold (`deleteNode` returning `false` — a ghost the
139+
renderer keeps drawing). Re-registering the _same_ state object under the
140+
_same_ root stays legal: `reactive()` returns a cached proxy, so
141+
unregister→register sequences (`useNodeReplacement`) are idempotent.
142+
- `assert` throws in DEV and reports (Sentry) elsewhere, so both paths repair
143+
the store before reporting rather than relying on the throw to stop them:
144+
`registerNodeState` deletes the stale entry from the previous root's bucket
145+
and drops `node._graphScope` with it; `unregisterNodeState` clears
146+
`_graphScope` regardless of the outcome. A production build is left
147+
consistent, and a DEV throw cannot strand the node it names.
148+
- Not asserted: a second `NodeState` object for a `(graphId, id)` the bucket
149+
already holds. Deserialising a graph keeps its persisted id, so two live
150+
`LGraph` instances round-tripped from one workflow share a bucket and collide
151+
on every node id by design. Catching duplicate-id regressions needs bucket
152+
identity to be per-instance rather than per-id — a separate change.
135153
- The lifecycle coordination itself is app-owned and lives in
136154
`src/core/graph/nodeShell/`: `nodeShellState.ts` (`createNodeShellState`,
137155
`setTrackedNodeState`, `registerNodeState`, `unregisterNodeState`,

src/core/graph/nodeShell/nodeShellState.test.ts

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
import { createTestingPinia } from '@pinia/testing'
22
import { setActivePinia } from 'pinia'
3-
import { beforeEach, describe, expect, it } from 'vitest'
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
44
import { toRaw } from 'vue'
55

6-
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
6+
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
77
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
88
import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
99
import { useNodeDataStore } from '@/stores/nodeDataStore'
1010
import { graphScopeOf } from '@/types/graphScopeId'
1111
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
1212
import type { NodeState } from '@/types/nodeState'
13-
import { zeroUuid } from '@/utils/uuid'
13+
import { createUuidv4, zeroUuid } from '@/utils/uuid'
1414

15-
import { createNodeShellState } from './nodeShellState'
15+
import { createNodeShellState, unregisterNodeState } from './nodeShellState'
1616

1717
describe('node shell state', () => {
1818
beforeEach(() => {
@@ -71,3 +71,55 @@ describe('node shell state', () => {
7171
expect(node._graphScope).toBeUndefined()
7272
})
7373
})
74+
75+
describe('node registration invariants', () => {
76+
beforeEach(() => {
77+
setActivePinia(createTestingPinia({ stubActions: false }))
78+
vi.stubEnv('DEV', true)
79+
vi.spyOn(console, 'error').mockImplementation(() => {})
80+
})
81+
82+
afterEach(() => {
83+
vi.unstubAllEnvs()
84+
vi.restoreAllMocks()
85+
})
86+
87+
it('refuses to register a node under a second root graph', () => {
88+
const first = new LGraph()
89+
const second = new LGraph()
90+
second.id = createUuidv4()
91+
const node = new LGraphNode('Node')
92+
first.add(node)
93+
94+
expect(() => second.add(node)).toThrow(/different root graph/)
95+
expect(node._graphScope).toBeUndefined()
96+
expect(useNodeDataStore().getGraphNodesFor(first.id, first.id)).toEqual([])
97+
})
98+
99+
it('drops the previous root entry rather than stranding it', () => {
100+
vi.stubEnv('DEV', false)
101+
const first = new LGraph()
102+
first.id = createUuidv4()
103+
const second = new LGraph()
104+
second.id = createUuidv4()
105+
const node = new LGraphNode('Node')
106+
first.add(node)
107+
108+
second.add(node)
109+
110+
const store = useNodeDataStore()
111+
const owningGraphId = node._state.graphId
112+
expect(store.getGraphNodesFor(first.id, owningGraphId)).toEqual([])
113+
expect(store.getGraphNodesFor(second.id, owningGraphId)).toHaveLength(1)
114+
})
115+
116+
it('reports a state that drifted out of its bucket before unregistering', () => {
117+
const graph = new LGraph()
118+
const node = new LGraphNode('Node')
119+
graph.add(node)
120+
node._state = createNodeShellState('Node', 'test', undefined)
121+
122+
expect(() => unregisterNodeState(node)).toThrow(/identity drift/)
123+
expect(node._graphScope).toBeUndefined()
124+
})
125+
})

src/core/graph/nodeShell/nodeShellState.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { shallowReactive } from 'vue'
22

3+
import { assert } from '@/base/assert'
34
import {
45
canTransferLayoutAttachment,
56
transferLayoutAttachment
@@ -70,8 +71,24 @@ export function registerNodeState(
7071
node: LGraphNode
7172
): boolean {
7273
const graphScope = graphScopeOf(graph)
74+
const store = useNodeDataStore()
75+
const strandedScope =
76+
node._graphScope === undefined ||
77+
node._graphScope.rootGraphId === graphScope.rootGraphId
78+
? undefined
79+
: node._graphScope
80+
81+
if (strandedScope !== undefined) {
82+
store.deleteNode(strandedScope, node._state)
83+
node._graphScope = undefined
84+
}
85+
assert(
86+
strandedScope === undefined,
87+
`registerNodeState: node ${node.id} already registered under a different root graph (${strandedScope?.rootGraphId})`
88+
)
89+
7390
node._state.graphId = graph.id
74-
const registered = useNodeDataStore().registerNode(graphScope, node._state)
91+
const registered = store.registerNode(graphScope, node._state)
7592
if (!registered) return false
7693
node._state = registered
7794
node._graphScope = graphScope
@@ -85,8 +102,12 @@ export function registerNodeState(
85102
*/
86103
export function unregisterNodeState(node: LGraphNode): void {
87104
if (!node._graphScope) return
88-
useNodeDataStore().deleteNode(node._graphScope, node._state)
105+
const deleted = useNodeDataStore().deleteNode(node._graphScope, node._state)
89106
node._graphScope = undefined
107+
assert(
108+
deleted,
109+
`unregisterNodeState: state for node ${node.id} not found in bucket (identity drift)`
110+
)
90111
}
91112

92113
/**

0 commit comments

Comments
 (0)