Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions docs/architecture/node-data-store.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,38 @@ synthetic `VueNodeData` today). `AppModeWidgetList` stops calling

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

- `LGraphNode` constructs its `_state: NodeState` at instantiation;
`registerNodeState(graph, node)` inserts it by reference and the class
adopts the returned reactive proxy; `node._graphScope` is the
registration-ownership marker.
- `LGraphNode` constructs its `_state: NodeState` at instantiation (via
`createNodeShellState`); `registerNodeState(graph, node)` inserts it by
reference and the class adopts the returned reactive proxy;
`node._graphId` (root id) is the registration-ownership marker.
- Chokepoints: `LGraph.add` / `LGraph.remove` (the canonical sites),
`unregisterAllNodeStates(graph)` on graph `clear()`, identity-checked
delete (`toRaw` compare) so only the registered state vacates its key.
- Two ways that lifecycle can silently drift are asserted rather than left to
the renderer to expose: re-registering an already-registered node under a
different root graph (its old bucket entry would strand), and unregistering a
state the bucket does not hold (`deleteNode` returning `false` — a ghost the
renderer keeps drawing). Re-registering the _same_ state object under the
_same_ root stays legal: `reactive()` returns a cached proxy, so
unregister→register sequences (`useNodeReplacement`) are idempotent.
- `assert` throws in DEV and reports (Sentry) elsewhere, so both paths repair
the store before reporting rather than relying on the throw to stop them:
`registerNodeState` deletes the stale entry from the previous root's bucket
and drops `node._graphScope` with it; `unregisterNodeState` clears
`_graphScope` regardless of the outcome. A production build is left
consistent, and a DEV throw cannot strand the node it names.
- Not asserted: a second `NodeState` object for a `(graphId, id)` the bucket
already holds. Deserialising a graph keeps its persisted id, so two live
`LGraph` instances round-tripped from one workflow share a bucket and collide
on every node id by design. Catching duplicate-id regressions needs bucket
identity to be per-instance rather than per-id — a separate change.
- The lifecycle coordination itself is app-owned and lives in
`src/core/graph/nodeShell/`: `nodeShellState.ts` (`createNodeShellState`,
`setTrackedNodeState`, `registerNodeState`, `unregisterNodeState`,
`unregisterAllNodeStates`) and `nodeShellLifecycle.ts`
(`attachNodeToStores`, `releaseGraphStores` — the single calls `LGraph.add`
and `LGraph.clear` make into the stores). None of these are re-exported from
the litegraph barrel; the registration surface is internal.
- Class fields become accessors reading through `_state`. Reads go
through the reactive proxy directly (there is no `_stateRaw` raw view),
so `node.title` / `node.mode` / … track inside Vue effects, matching a
Expand Down
102 changes: 102 additions & 0 deletions src/core/graph/nodeShell/nodeShellLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'

import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import {
createTestRootGraph,
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { getWidgetIds } from '@/lib/litegraph/src/utils/widget'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'

describe('node shell teardown', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})

function addWidgetedNode(graph: LGraph | Subgraph): LGraphNode {
const node = new LGraphNode('Node')
node.addWidget('text', 'prompt', 'a value', () => {})
graph.add(node)
return node
}

function widgetIdsOf(node: LGraphNode) {
return getWidgetIds(node.widgets ?? [])
}

it('drops the widget order a removed node registered, keeping its values', () => {
const subgraph = createTestSubgraph()
const rootGraphId = subgraph.rootGraph.id
const node = addWidgetedNode(subgraph)
const [widgetId] = widgetIdsOf(node)
const widgetValueStore = useWidgetValueStore()

expect(widgetValueStore.getNodeWidgetIds(rootGraphId, node.id)).toEqual([
widgetId
])

subgraph.remove(node)

expect(widgetValueStore.getNodeWidgetIds(rootGraphId, node.id)).toEqual([])
expect(widgetValueStore.getWidget(widgetId)?.value).toBe('a value')
})

it('drops the preview exposures a removed host node owned', () => {
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({ rootGraph })
const hostNode = createTestSubgraphNode(subgraph)
rootGraph.add(hostNode)
const hostLocator = String(hostNode.id)
const previewExposureStore = usePreviewExposureStore()
previewExposureStore.addExposure(rootGraph.id, hostLocator, {
sourceNodeId: 1,
sourcePreviewName: 'images'
})

rootGraph.remove(hostNode)

expect(
previewExposureStore.getExposures(rootGraph.id, hostLocator)
).toEqual([])
})

it('releases widget and exposure entries when a root graph is cleared', () => {
const graph = new LGraph()
const graphId = graph.id

const node = addWidgetedNode(graph)
const [widgetId] = widgetIdsOf(node)
const hostLocator = String(node.id)
const previewExposureStore = usePreviewExposureStore()
previewExposureStore.addExposure(graphId, hostLocator, {
sourceNodeId: 1,
sourcePreviewName: 'images'
})
const widgetValueStore = useWidgetValueStore()

graph.clear()

expect(widgetValueStore.getNodeWidgetIds(graphId, node.id)).toEqual([])
expect(widgetValueStore.getWidget(widgetId)).toBeUndefined()
expect(previewExposureStore.getExposures(graphId, hostLocator)).toEqual([])
})

it('releases entries of a widget the node dropped without unregistering', () => {
const graph = new LGraph()
const graphId = graph.id
const node = addWidgetedNode(graph)
const [widgetId] = widgetIdsOf(node)
const widgetValueStore = useWidgetValueStore()
node.widgets = []

graph.clear()

expect(widgetValueStore.getNodeWidgetIds(graphId, node.id)).toEqual([])
expect(widgetValueStore.getWidget(widgetId)).toBeUndefined()
})
})
89 changes: 89 additions & 0 deletions src/core/graph/nodeShell/nodeShellLifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
import { getWidgetIds } from '@/lib/litegraph/src/utils/widget'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'

import { registerNodeState, unregisterNodeState } from './nodeShellState'

import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { NodeId } from '@/types/nodeId'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { UUID } from '@/utils/uuid'

/**
* Registers a node's shell state and its widget bindings with the app
* stores. Call once the node has a valid id and graph reference. Retries
* with a freshly minted id on a registration collision.
*/
export function attachNodeToStores(
graph: LGraph | Subgraph,
node: LGraphNode,
mintId: () => NodeId
): void {
while (!registerNodeState(graph, node)) node.id = mintId()

if (!node.widgets) return
for (const widget of node.widgets) {
if (isNodeBindable(widget)) widget.setNodeId(node.id)
}
useWidgetValueStore().setNodeWidgetOrder(
graph.rootGraph.id,
node.id,
getWidgetIds(node.widgets)
)
}

/**
* Whether a detached node's widget values leave the store with it. A node that
* may come back — undo of a deletion — keeps its values and drops only its
* ordering; a node whose whole graph is going away takes its values along.
*/
type WidgetDetachMode = 'keep-values' | 'discard-values'

function releaseNodePreviewExposures(
rootGraphId: UUID,
node: LGraphNode
): void {
const previewExposureStore = usePreviewExposureStore()
const hostNodeLocator = String(node.id)
if (!previewExposureStore.getExposures(rootGraphId, hostNodeLocator).length) {
return
}
previewExposureStore.setExposures(rootGraphId, hostNodeLocator, [])
}

/**
* The inverse of {@link attachNodeToStores}: drops the node's shell state, the
* widget order it registered, and the preview exposures it hosts.
*/
export function detachNodeFromStores(
graph: Pick<LGraph, 'rootGraph'>,
node: LGraphNode,
mode: WidgetDetachMode = 'keep-values'
): void {
const rootGraphId = graph.rootGraph.id
unregisterNodeState(node)
useWidgetValueStore().releaseNodeWidgets(rootGraphId, node.id, {
discardValues: mode === 'discard-values'
})
releaseNodePreviewExposures(rootGraphId, node)
}

/**
* Detaches every node a graph owns, including those inside the subgraph
* definitions it holds. Used when a graph's nodes leave the stores without a
* whole-bucket wipe: subgraph-definition removal, and clearing a graph that
* shares its bucket with other graphs. The graph is going away, so its nodes'
* widget values go with it — the same reach as the wipe a root graph performs.
*/
export function detachAllNodesFromStores(
graph: Pick<LGraph, '_nodes' | '_subgraphs' | 'rootGraph'>
): void {
for (const node of graph._nodes) {
detachNodeFromStores(graph, node, 'discard-values')
}
for (const subgraph of graph._subgraphs.values()) {
detachAllNodesFromStores(subgraph)
}
}
120 changes: 120 additions & 0 deletions src/core/graph/nodeShell/nodeShellState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toRaw } from 'vue'

import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { useNodeDataStore } from '@/stores/nodeDataStore'
import { graphScopeOf } from '@/types/graphScopeId'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
import type { NodeState } from '@/types/nodeState'
import { createUuidv4, zeroUuid } from '@/utils/uuid'

import { createNodeShellState, unregisterNodeState } from './nodeShellState'

describe('node shell state', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})

function addNodeToSubgraph() {
const subgraph = createTestSubgraph()
const node = new LGraphNode('Node')
subgraph.add(node)
return { subgraph, node }
}

/** The states the store holds for a subgraph, within its root graph's bucket. */
function statesIn(subgraph: Subgraph): NodeState[] {
return useNodeDataStore().getGraphNodesFor(
subgraph.rootGraph.id,
subgraph.id
)
}

it('starts unregistered and unowned', () => {
const state = createNodeShellState('Node', 'some/type', undefined)

expect(state.id).toBe(UNASSIGNED_NODE_ID)
expect(state.graphId).toBe(zeroUuid)
expect(state.title).toBe('Node')
})

it('falls back to a placeholder title and an empty type', () => {
const state = createNodeShellState('', undefined, undefined)

expect(state.title).toBe('Unnamed')
expect(state.type).toBe('')
})

it('buckets by root graph and partitions by owning graph', () => {
const { subgraph, node } = addNodeToSubgraph()
const rootId = subgraph.rootGraph.id

expect(rootId).not.toBe(subgraph.id)
expect(node._graphScope).toEqual(graphScopeOf(subgraph))
expect(node._state.graphId).toBe(subgraph.id)

const [registered] = statesIn(subgraph)
expect(toRaw(node._state)).toBe(toRaw(registered))
expect(useNodeDataStore().getGraphNodesFor(rootId, rootId)).toEqual([])
})

it('vacates its store entry on remove', () => {
const { subgraph, node } = addNodeToSubgraph()

subgraph.remove(node)

expect(statesIn(subgraph)).toEqual([])
expect(node._graphScope).toBeUndefined()
})
})

describe('node registration invariants', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.stubEnv('DEV', true)
vi.spyOn(console, 'error').mockImplementation(() => {})
})

it('refuses to register a node under a second root graph', () => {
const first = new LGraph()
const second = new LGraph()
second.id = createUuidv4()
const node = new LGraphNode('Node')
first.add(node)

expect(() => second.add(node)).toThrow(/different root graph/)
expect(node._graphScope).toBeUndefined()
expect(useNodeDataStore().getGraphNodesFor(first.id, first.id)).toEqual([])
})

it('drops the previous root entry rather than stranding it', () => {
vi.stubEnv('DEV', false)
const first = new LGraph()
first.id = createUuidv4()
const second = new LGraph()
second.id = createUuidv4()
const node = new LGraphNode('Node')
first.add(node)

second.add(node)

const store = useNodeDataStore()
const owningGraphId = node._state.graphId
expect(store.getGraphNodesFor(first.id, owningGraphId)).toEqual([])
expect(store.getGraphNodesFor(second.id, owningGraphId)).toHaveLength(1)
})

it('reports a state that drifted out of its bucket before unregistering', () => {
const graph = new LGraph()
const node = new LGraphNode('Node')
graph.add(node)
node._state = createNodeShellState('Node', 'test', undefined)

expect(() => unregisterNodeState(node)).toThrow(/identity drift/)
expect(node._graphScope).toBeUndefined()
})
})
Loading
Loading