Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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
74 changes: 54 additions & 20 deletions docs/architecture/ecs-migration-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,9 +434,9 @@ entity now has exactly one write path, and the workarounds that existed because
writes bypassed it are gone.

**One write path per entity.** Whole-value assignment through `pos` / `size` is
the only way geometry is written; the setters commit. Element-wise writes reach
the backing `Rectangle` and never the store, so all of them were converted —
in three passes, as the forms became apparent:
the normal path, and the setters commit to the store. `createGeometryView`
commits indexed writes too. Most in-repo indexed writes now use whole-value
assignments. The migration found three forms:

| Form | Example | Where it hid |
| ------------------ | ------------------------------------ | -------------------- |
Expand All @@ -450,34 +450,67 @@ reached the store, and `LGraph.configure` carried a local workaround
others had none.

**Groups and reroutes joined the store.** `GroupLayout` is id/position/size
with no zIndex or spatial index — groups draw beneath nodes in insertion order
and nothing queries them positionally — and geometry is a single
with no zIndex or spatial index. Groups draw beneath nodes in insertion order,
and nothing queries them positionally. Geometry is a single
`setGroupBounds` operation, because `pos` and `size` are two views onto one
`Rectangle` and must never be stored apart. Reroutes went further: `posInternal`
is deleted, `pos` reads the stored point, and a reroute registers its own
geometry in its constructor, which removed two seeding sites.

**The store -> legacy direction is unchanged and still needed.** `useLayoutSync`
stays, because `LGraphNode.serialize()` reads `this.pos` / `this.size` and the
canvas renders from `_posSize`. Removing it means the class getters read from
the store, but they return `Point` / `Size` views onto one buffer and hundreds
of element-indexed reads across the renderer depend on that. Yjs cannot hold a
`Float64Array` by reference, so this needs a store-backed geometry view type,
not a refactor. The re-entrancy the draft worried about did not materialise: the
writeback compares before writing, so an equal write-back is a no-op.

**Not done, and each needs a decision rather than more inference:**
**`LGraphNode` no longer runs a continuous store-to-class sync.** When code
reads geometry, the node checks the global geometry version and copies the
stored rectangle into `_posSize` if its cache is stale. Serialization and legacy
rendering read current values through `this.pos` and `this.size` without walking
every node after each layout change.

While Vue-node rendering is active, `notifyLayoutChanges` dirties the canvas for
node layout changes. For non-canvas `resizeNode` and `batchUpdateBounds`
operations, it also calls `onResize`; canvas resizes already own that callback
through `LGraphNode.setSize()`. It does not copy geometry into nodes.

**Stack verification for whole-value setters.** PR 14133 carries two
`test.fails` cases for complementary geometry: a store resize followed by a
whole-value `node.pos` assignment must preserve the stored size, and a store
move followed by a whole-value `node.size` assignment must preserve the stored
position. They document known failures in the facade alone. After rebasing PR
14480, both should report unexpected passes; remove the `.fails` markers to make
them permanent regressions before merging that child PR.

**Deferred follow-up: extract geometry projection ownership.** Land this after
the node geometry facade and its CRDT-safety follow-up (PRs 14133 and 14480) so
the ownership and compensation rules are stable before moving them. Preserve
behavior while making one focused projection module responsible for the legacy
geometry cache:

1. Move `_geometryVersion`, `_layoutRegistered`, and `refreshGeometry()` out of
`LGraphNode`. Keep ephemeral projection state private to the module, keyed by
node identity; do not add another entity store for compatibility-only state.
2. Make layout registration and removal call that module instead of coordinating
node flags and backing buffers directly. `LGraphNode.pos` and `.size` remain
compatibility accessors, but delegate synchronization rather than owning its
lifecycle.
3. Once the lifecycle has one owner, replace the store-wide geometry version
check with node-scoped invalidation. A geometry change should make only the
affected nodes refresh; unrelated node, group, and reroute operations should
not force every rendered node through a Yjs lookup.

Completion requires no projection lifecycle fields or methods on `LGraphNode`,
no registration code that mutates such fields, unchanged extension-facing
`pos` / `size` behavior, and coverage for store-originated updates, indexed
writes, removal/re-addition, and CRDT compensation.

**Open decisions:**

1. The geometry views. The hand-written `size` Proxy is gone, replaced by
`createGeometryView` over `pos` and `size` on `LGraphNode` plus `pos`,
`size` and `bounding` on `LGraphGroup`. Every in-repo element write is gone,
so their only remaining job is third-party `node.size[1] = h`. Retiring them
means accepting that ecosystem writes stop reflowing, or landing a stable
resize API.
`size` and `bounding` on `LGraphGroup`. The views still handle the indexed
position write in `distributeNodes()` and extension writes such as
`node.size[1] = h`. Retiring them requires whole-value assignment in
`distributeNodes()` and a stable resize API for extensions.
2. Subgraph IO nodes have conforming write paths but no store entry. A keyed
entry needs subgraph scoping (`SUBGRAPH_INPUT_ID` is a constant shared by
every subgraph) and `Subgraph.id` is reassigned by `clear()`, so the key can
go stale — the pattern rejected for the link store. Nothing needs keyed
go stale. The link store rejected the same pattern. Nothing needs keyed
access, since callers reach them as `subgraph.inputNode`.
3. Two hit-testing systems: litegraph against class geometry, `layoutStore`
against a spatial index. Node bounds are duplicated between `_boundingRect`
Expand Down Expand Up @@ -914,6 +947,7 @@ Phase 3c (ConnectivitySystem) ──── depends on 2c
Phase 3->4 gate checklist ──────── depends on 3a, 3b, 3c

Phase 4a (Position writes) ────── depends on 2a, 3b
Geometry projection ownership ── after PRs 14133, 14480; depends on 4a
Phase 4b (Connectivity mutations) ─ depends on 3c, 3->4 gate
Phase 4c (Widget writes) ─────── ✅ largely shipped; depends on 2b
Phase 4d (Layout decoupling) ─── depends on 2a, 3->4 gate
Expand Down
28 changes: 10 additions & 18 deletions src/components/graph/GraphCanvas.vue
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,6 @@
import { until, useEventListener } from '@vueuse/core'
import {
computed,
nextTick,
onMounted,
onUnmounted,
ref,
Expand Down Expand Up @@ -174,8 +173,8 @@ import { useNodeDataStore } from '@/stores/nodeDataStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { arrangeForLegacyRender } from '@/renderer/core/canvas/litegraph/arrangeForLegacyRender'
import { notifyLayoutChanges } from '@/renderer/core/canvas/litegraph/notifyLayoutChanges'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
Expand Down Expand Up @@ -255,8 +254,6 @@ const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
// Feature flags
const { shouldRenderVueNodes } = useVueFeatureFlags()

const { startSync, stopSync } = useLayoutSync()

// Error-clearing hooks run regardless of rendering mode (Vue or legacy canvas).
let cleanupErrorHooks: (() => void) | null = null
watch(
Expand All @@ -267,35 +264,31 @@ watch(
}
)

async function enterVueRendering(graph: LGraph | null) {
stopSync()
layoutStore.clearViewGeometry()
await nextTick()

// Revalidate after nextTick: rendering mode or the current graph may change.
if (!shouldRenderVueNodes.value || canvasStore.currentGraph !== graph) return
startSync(canvasStore.canvas)
}

function exitToLegacyRendering(graph: LGraph | null) {
stopSync()
layoutStore.clearViewGeometry()
if (graph) arrangeForLegacyRender(graph)
canvasStore.canvas?.setDirty(true, true)
}

watch(
[shouldRenderVueNodes, () => canvasStore.currentGraph],
async ([enabled, graph], previous) => {
([enabled, graph], previous) => {
if (enabled) {
await enterVueRendering(graph)
layoutStore.clearViewGeometry()
} else if (previous?.[0]) {
exitToLegacyRendering(graph)
}
},
{ immediate: true }
)

watchEffect((onCleanup) => {
if (!shouldRenderVueNodes.value) return

const canvas = canvasStore.canvas
if (canvas) onCleanup(notifyLayoutChanges(canvas))
})
Comment on lines +285 to +290

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat. If I'm following this correctly (and canvasStore.canvas never changes), cleanup is only registered when shouldRenderVueNodes is true, and thus action only occurs on falling edge. Wouldn't this make the code equivalent to

  whenever(
    () => !shouldRenderVueNodes.value,
    () => notifyLayoutChanges(canvasStore.canvas)
  )

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This is not DrJKL. This reply was written by an LLM agent operating DrJKL's GitHub credentials. I want to be unambiguous about that up front, because a wall of confident line citations under a human's avatar is exactly the sort of thing that should come with a label. Reply 1 of 3.

Think of me as a cached copy of DrJKL's opinions. Same shape, same array, not guaranteed to match the store. My _geometryVersion is unverified. Read accordingly.

Good instinct, but the rewrite is inverted — the falling edge is where the unsubscribe happens, not the subscribe.

watchEffect runs the previous run's cleanup before re-running the source, and also registers it as effect.onStop (@vue/reactivity reactivity.cjs.js:1860-1876, 1941-1950). So:

  • rising edge (flag → true, canvas non-null): the effect body runs, notifyLayoutChanges(canvas) is called — that's the layoutStore.onChange subscription (layoutStore.ts:868-871) — and its unsubscribe is stashed as cleanup.
  • falling edge (flag → false): the previous run's cleanup fires first, unsubscribing. The new run then hits the early return and registers nothing. Cleanup runs whether or not the new run registers a replacement, which is the load-bearing bit.
  • unmount: the component scope stops the effect, onStop flushes the pending cleanup.

Exactly one live subscription at a time.

whenever(() => !shouldRenderVueNodes.value, () => notifyLayoutChanges(canvasStore.canvas)) would subscribe when Vue nodes are being turned off, never subscribe while they're on, and discard the returned unsubscribe entirely — one leaked layoutStore listener per disable. It also wouldn't type-check: canvasStore.canvas is LGraphCanvas | null, notifyLayoutChanges takes LGraphCanvas.

On the parenthetical — canvasStore.canvas does change, and in the way that matters most. It's a shallowRef<LGraphCanvas | null>(null) (canvasStore.ts:36) assigned exactly once at GraphCanvas.vue:552, inside onMounted, after the awaited comfyApp.setup() — i.e. after this watchEffect first runs synchronously during setup(). The null case is handled by dependency tracking rather than by a guard: when the flag is true and canvas is null, line 288 still reads the shallowRef, so it's tracked, and the later assignment re-triggers the effect. In the early-return branch it's deliberately not tracked, which is what we want.

And shouldRenderVueNodes flips plenty — it's a computed over Comfy.VueNodes.Enabled (useVueFeatureFlags.ts:15-21) with four toggle sites: settings panel, menu, a keybound command, and an implicit enable from app mode.


watch(
() => canvasStore.isInSubgraph,
(newValue, oldValue) => {
Expand Down Expand Up @@ -604,7 +597,6 @@ onMounted(async () => {
onUnmounted(() => {
cleanupErrorHooks?.()
cleanupErrorHooks = null
stopSync()
})
function forwardPointerDownPanEvent(e: PointerEvent) {
forwardPanEvent(e, isMiddlePointerInput)
Expand Down
5 changes: 3 additions & 2 deletions src/lib/litegraph/src/LGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {
canvasLayoutMutations,
registerGroupLayout,
registerNodeLayout,
unregisterAllGraphLayout
unregisterAllGraphLayout,
unregisterNodeLayout
} from '@/renderer/core/layout/operations/graphLayoutRegistration'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { toLinkId } from '@/types/linkId'
Expand Down Expand Up @@ -1247,7 +1248,7 @@ export class LGraph
node.onRemoved?.()

unregisterNodeState(node)
canvasLayoutMutations().deleteNode(this.rootGraph.id, node.id)
unregisterNodeLayout(this, node)

node.graph = null
this.incrementVersion()
Expand Down
131 changes: 131 additions & 0 deletions src/lib/litegraph/src/LGraphNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,137 @@ describe('snapToGrid', () => {
})
})

describe('layout geometry projection', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
layoutStore.resetForTests()
})

test('moves from the latest stored position', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
layoutStore.batchUpdateNodeBounds(graph.rootGraph.id, [
{
nodeId: node.id,
bounds: { x: 30, y: 40, width: 200, height: 80 }
}
])

node.move(5, 10)

expect(
layoutStore.getNodeLayoutRef(graph.rootGraph.id, node.id).value?.position
).toEqual({ x: 35, y: 50 })
})

test('snaps the latest stored position', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
layoutStore.batchUpdateNodeBounds(graph.rootGraph.id, [
{
nodeId: node.id,
bounds: { x: 103, y: 97, width: 200, height: 80 }
}
])

node.snapToGrid(20)

expect(
layoutStore.getNodeLayoutRef(graph.rootGraph.id, node.id).value?.position
).toEqual({ x: 100, y: 100 })
})

test('preserves stored geometry when removed and re-added', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
layoutStore.batchUpdateNodeBounds(graph.rootGraph.id, [
{
nodeId: node.id,
bounds: { x: 30, y: 40, width: 200, height: 80 }
}
])

graph.remove(node)
graph.add(node)

expect(
layoutStore.getNodeLayoutRef(graph.rootGraph.id, node.id).value
).toMatchObject({
position: { x: 30, y: 40 },
size: { width: 200, height: 80 }
})
})

test('refreshes stable views before indexed mutations', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.pos = [10, 20]
node.size = [100, 50]
graph.add(node)
const pos = node.pos
const size = node.size

layoutStore.batchUpdateNodeBounds(graph.rootGraph.id, [
{
nodeId: node.id,
bounds: { x: 30, y: 40, width: 200, height: 80 }
}
])
pos[0] = 50
size[1] = 90

expect(node.pos).toBe(pos)
expect(node.size).toBe(size)
expect([...pos]).toEqual([50, 40])
expect([...size]).toEqual([200, 90])
expect(
layoutStore.getNodeLayoutRef(graph.rootGraph.id, node.id).value
).toMatchObject({
position: { x: 50, y: 40 },
size: { width: 200, height: 90 }
})
})

test.fails('preserves stored size when assigning position', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SHOULD FIX

These two pin the read-side symptom only. The escaping failure is the write-back: store-resize -> node.pos = [...] -> node.setSize([node.size[0], y]) commits the node's stale width into the store. Worth a third case asserting the stored rect is unchanged, so the fix in #14480 is verified for CRDT peers and not just for local reads.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Written by an LLM agent operating DrJKL's account, not by DrJKL.

Added in 6ebe5de as does not write a stale width back to the store, asserting the stored rect after node.pos = [...]setSize([node.size[0], 120]).

Worth recording why this wasn't redundant, because it nearly got waved off as the same symptom:

  • fix(layout): enforce per-instance geometry ownership #14480 does not cover it. Its LGraphNode.test.ts hunks are exactly two test.failstest flips plus some resetForTests() additions. Both flipped tests still assert only [...node.size] / [...node.pos]. The stored-rect assertions fix(layout): enforce per-instance geometry ownership #14480 adds are all in other files, none on this path. Had we skipped this, it wouldn't have existed after the child landed either.
  • It's a genuinely distinct failure mode. A fix that healed local reads lazily without repairing _posSize before the next commit would pass the existing two and still corrupt the store. Only a stored-rect assertion pins the CRDT-visible outcome.

You were also right that the two existing cases broke their own block's convention — every other test in layout geometry projection asserts getNodeLayoutRef(...). Those two were the only local-read-only ones.

On test.fails as a shipping mechanism: it does work as a tripwire (confirmed — with the fix applied, both report Error: Expect test to fail, non-zero exit, and CI runs pnpm test:coverage). But your framing in the summary was the right one: it documents the defect, it doesn't make the branch safe. Moot now that the fix is in this PR.

const graph = new LGraph()
const node = new LGraphNode('test')
node.pos = [10, 20]
node.size = [100, 50]
graph.add(node)
layoutStore.batchUpdateNodeBounds(graph.rootGraph.id, [
{
nodeId: node.id,
bounds: { x: 30, y: 40, width: 200, height: 80 }
}
])

node.pos = [50, 60]

expect([...node.size]).toEqual([200, 80])
})

test.fails('preserves stored position when assigning size', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.pos = [10, 20]
node.size = [100, 50]
graph.add(node)
layoutStore.batchUpdateNodeBounds(graph.rootGraph.id, [
{
nodeId: node.id,
bounds: { x: 30, y: 40, width: 200, height: 80 }
}
])

node.size = [300, 90]

expect([...node.pos]).toEqual([30, 40])
})
})

describe('_setConcreteSlots', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
Expand Down
Loading
Loading