Skip to content

Commit 867ac42

Browse files
DrJKLampagentactions-userclaude
authored
refactor: node geometry reads from the store, delete useLayoutSync (#14133)
## Summary Make `layoutStore` authoritative for node geometry and remove the Layout → LiteGraph writeback loop. `LGraphNode.pos` / `size` remain stable compatibility views backed by a lazily refreshed `Rectangle` projection. ## Why a projection At 1000 nodes, a `Rectangle` read measured ~6 ns versus ~63.5 ns for `ynodes.get(id)` → rect. The legacy canvas reads node geometry ~44 times per frame, making direct Yjs reads cost roughly 2.5 ms/frame. A geometry-version check keeps the common path local and refreshes each accessed projection once after invalidation. ## Changes - Project `pos` / `size` from the store while preserving indexed mutation compatibility and command-based writes. - Register and unregister projection lifetime with node layout lifetime. - Delete `useLayoutSync`, including its RAF/microtask batching and writeback machinery. - Observe layout changes through a Vue-scoped effect: invalidate the canvas for node changes and forward actual non-Canvas size changes to legacy `onResize` callbacks. Canvas resizes retain synchronous callback ownership through `setSize()`. - Share the stored `[x, y, width, height]` tuple type between Yjs mappers and validation. ## Review Focus - The projection stays inert until the node's own layout entry exists, preventing accidental adoption of geometry from an ID collision during attach. - Two `test.fails` cases pin known whole-value setter cache gaps in this PR alone. Child PR [#14480](../pull/14480) fixes both; rebasing it should produce unexpected passes, after which the `.fails` markers must be removed. - Projection lifecycle extraction and node-scoped invalidation are deferred until after #14480 and documented in the ECS migration plan. ## Tests - Stable-view refresh and indexed mutation behavior - Removal/re-addition and graph-scoped layout state - Exact-once Canvas resize callbacks and store-originated resize callbacks - Layout notification graph filtering and cleanup --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c7b84f4 commit 867ac42

15 files changed

Lines changed: 499 additions & 430 deletions

File tree

docs/architecture/ecs-migration-plan.md

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -434,9 +434,9 @@ entity now has exactly one write path, and the workarounds that existed because
434434
writes bypassed it are gone.
435435
436436
**One write path per entity.** Whole-value assignment through `pos` / `size` is
437-
the only way geometry is written; the setters commit. Element-wise writes reach
438-
the backing `Rectangle` and never the store, so all of them were converted —
439-
in three passes, as the forms became apparent:
437+
the normal path, and the setters commit to the store. `createGeometryView`
438+
commits indexed writes too. Most in-repo indexed writes now use whole-value
439+
assignments. The migration found three forms:
440440
441441
| Form | Example | Where it hid |
442442
| ------------------ | ------------------------------------ | -------------------- |
@@ -450,34 +450,66 @@ reached the store, and `LGraph.configure` carried a local workaround
450450
others had none.
451451
452452
**Groups and reroutes joined the store.** `GroupLayout` is id/position/size
453-
with no zIndex or spatial index — groups draw beneath nodes in insertion order
454-
and nothing queries them positionally — and geometry is a single
453+
with no zIndex or spatial index. Groups draw beneath nodes in insertion order,
454+
and nothing queries them positionally. Geometry is a single
455455
`setGroupBounds` operation, because `pos` and `size` are two views onto one
456456
`Rectangle` and must never be stored apart. Reroutes went further: `posInternal`
457457
is deleted, `pos` reads the stored point, and a reroute registers its own
458458
geometry in its constructor, which removed two seeding sites.
459459
460-
**The store -> legacy direction is unchanged and still needed.** `useLayoutSync`
461-
stays, because `LGraphNode.serialize()` reads `this.pos` / `this.size` and the
462-
canvas renders from `_posSize`. Removing it means the class getters read from
463-
the store, but they return `Point` / `Size` views onto one buffer and hundreds
464-
of element-indexed reads across the renderer depend on that. Yjs cannot hold a
465-
`Float64Array` by reference, so this needs a store-backed geometry view type,
466-
not a refactor. The re-entrancy the draft worried about did not materialise: the
467-
writeback compares before writing, so an equal write-back is a no-op.
468-
469-
**Not done, and each needs a decision rather than more inference:**
460+
**`LGraphNode` no longer runs a continuous store-to-class sync.** When code
461+
reads geometry, the node checks the global geometry version and copies the
462+
stored rectangle into `_posSize` if its cache is stale. Serialization and legacy
463+
rendering read current values through `this.pos` and `this.size` without walking
464+
every node after each layout change.
465+
466+
While Vue-node rendering is active, `notifyLayoutChanges` dirties the canvas for
467+
node layout changes. For non-canvas `resizeNode` and `batchUpdateBounds`
468+
operations, it also calls `onResize`; canvas resizes already own that callback
469+
through `LGraphNode.setSize()`. It does not copy geometry into nodes.
470+
471+
**Whole-value setters re-read after committing.** `_positionUpdated` and
472+
`_sizeUpdated` write only their own half of `_posSize`, so stamping
473+
`_geometryVersion` to the post-commit store version would mark the untouched
474+
half fresh while it was still stale. The next commit of that half would then
475+
publish the stale value to the store and to CRDT peers. Both invalidate and
476+
refresh instead, which also picks up any value the store clamped or merged.
477+
478+
**Deferred follow-up: extract geometry projection ownership.** Land this after
479+
the node geometry facade and its CRDT-safety follow-up (PRs 14133 and 14480) so
480+
the ownership and compensation rules are stable before moving them. Preserve
481+
behavior while making one focused projection module responsible for the legacy
482+
geometry cache:
483+
484+
1. Move `_geometryVersion`, `_layoutRegistered`, and `refreshGeometry()` out of
485+
`LGraphNode`. Keep ephemeral projection state private to the module, keyed by
486+
node identity; do not add another entity store for compatibility-only state.
487+
2. Make layout registration and removal call that module instead of coordinating
488+
node flags and backing buffers directly. `LGraphNode.pos` and `.size` remain
489+
compatibility accessors, but delegate synchronization rather than owning its
490+
lifecycle.
491+
3. Once the lifecycle has one owner, replace the store-wide geometry version
492+
check with node-scoped invalidation. A geometry change should make only the
493+
affected nodes refresh; unrelated node, group, and reroute operations should
494+
not force every rendered node through a Yjs lookup.
495+
496+
Completion requires no projection lifecycle fields or methods on `LGraphNode`,
497+
no registration code that mutates such fields, unchanged extension-facing
498+
`pos` / `size` behavior, and coverage for store-originated updates, indexed
499+
writes, removal/re-addition, and CRDT compensation.
500+
501+
**Open decisions:**
470502
471503
1. The geometry views. The hand-written `size` Proxy is gone, replaced by
472504
`createGeometryView` over `pos` and `size` on `LGraphNode` plus `pos`,
473-
`size` and `bounding` on `LGraphGroup`. Every in-repo element write is gone,
474-
so their only remaining job is third-party `node.size[1] = h`. Retiring them
475-
means accepting that ecosystem writes stop reflowing, or landing a stable
476-
resize API.
505+
`size` and `bounding` on `LGraphGroup`. The views still handle the indexed
506+
position write in `distributeNodes()` and extension writes such as
507+
`node.size[1] = h`. Retiring them requires whole-value assignment in
508+
`distributeNodes()` and a stable resize API for extensions.
477509
2. Subgraph IO nodes have conforming write paths but no store entry. A keyed
478510
entry needs subgraph scoping (`SUBGRAPH_INPUT_ID` is a constant shared by
479511
every subgraph) and `Subgraph.id` is reassigned by `clear()`, so the key can
480-
go stale — the pattern rejected for the link store. Nothing needs keyed
512+
go stale. The link store rejected the same pattern. Nothing needs keyed
481513
access, since callers reach them as `subgraph.inputNode`.
482514
3. Two hit-testing systems: litegraph against class geometry, `layoutStore`
483515
against a spatial index. Node bounds are duplicated between `_boundingRect`
@@ -914,6 +946,7 @@ Phase 3c (ConnectivitySystem) ──── depends on 2c
914946
Phase 3->4 gate checklist ──────── depends on 3a, 3b, 3c
915947
916948
Phase 4a (Position writes) ────── depends on 2a, 3b
949+
Geometry projection ownership ── after PRs 14133, 14480; depends on 4a
917950
Phase 4b (Connectivity mutations) ─ depends on 3c, 3->4 gate
918951
Phase 4c (Widget writes) ─────── ✅ largely shipped; depends on 2b
919952
Phase 4d (Layout decoupling) ─── depends on 2a, 3->4 gate

src/components/graph/GraphCanvas.vue

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,6 @@
113113
import { until, useEventListener } from '@vueuse/core'
114114
import {
115115
computed,
116-
nextTick,
117116
onMounted,
118117
onUnmounted,
119118
ref,
@@ -174,8 +173,8 @@ import { useNodeDataStore } from '@/stores/nodeDataStore'
174173
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
175174
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
176175
import { arrangeForLegacyRender } from '@/renderer/core/canvas/litegraph/arrangeForLegacyRender'
176+
import { notifyLayoutChanges } from '@/renderer/core/canvas/litegraph/notifyLayoutChanges'
177177
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
178-
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
179178
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
180179
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
181180
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
@@ -255,8 +254,6 @@ const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
255254
// Feature flags
256255
const { shouldRenderVueNodes } = useVueFeatureFlags()
257256
258-
const { startSync, stopSync } = useLayoutSync()
259-
260257
// Error-clearing hooks run regardless of rendering mode (Vue or legacy canvas).
261258
let cleanupErrorHooks: (() => void) | null = null
262259
watch(
@@ -267,35 +264,31 @@ watch(
267264
}
268265
)
269266
270-
async function enterVueRendering(graph: LGraph | null) {
271-
stopSync()
272-
layoutStore.clearViewGeometry()
273-
await nextTick()
274-
275-
// Revalidate after nextTick: rendering mode or the current graph may change.
276-
if (!shouldRenderVueNodes.value || canvasStore.currentGraph !== graph) return
277-
startSync(canvasStore.canvas)
278-
}
279-
280267
function exitToLegacyRendering(graph: LGraph | null) {
281-
stopSync()
282268
layoutStore.clearViewGeometry()
283269
if (graph) arrangeForLegacyRender(graph)
284270
canvasStore.canvas?.setDirty(true, true)
285271
}
286272
287273
watch(
288274
[shouldRenderVueNodes, () => canvasStore.currentGraph],
289-
async ([enabled, graph], previous) => {
275+
([enabled, graph], previous) => {
290276
if (enabled) {
291-
await enterVueRendering(graph)
277+
layoutStore.clearViewGeometry()
292278
} else if (previous?.[0]) {
293279
exitToLegacyRendering(graph)
294280
}
295281
},
296282
{ immediate: true }
297283
)
298284
285+
watchEffect((onCleanup) => {
286+
if (!shouldRenderVueNodes.value) return
287+
288+
const canvas = canvasStore.canvas
289+
if (canvas) onCleanup(notifyLayoutChanges(canvas))
290+
})
291+
299292
watch(
300293
() => canvasStore.isInSubgraph,
301294
(newValue, oldValue) => {
@@ -604,7 +597,6 @@ onMounted(async () => {
604597
onUnmounted(() => {
605598
cleanupErrorHooks?.()
606599
cleanupErrorHooks = null
607-
stopSync()
608600
})
609601
function forwardPointerDownPanEvent(e: PointerEvent) {
610602
forwardPanEvent(e, isMiddlePointerInput)

src/lib/litegraph/src/LGraph.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import {
1212
canvasLayoutMutations,
1313
registerGroupLayout,
1414
registerNodeLayout,
15-
unregisterAllGraphLayout
15+
unregisterAllGraphLayout,
16+
unregisterNodeLayout
1617
} from '@/renderer/core/layout/operations/graphLayoutRegistration'
1718
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
1819
import { toLinkId } from '@/types/linkId'
@@ -1247,7 +1248,7 @@ export class LGraph
12471248
node.onRemoved?.()
12481249

12491250
unregisterNodeState(node)
1250-
canvasLayoutMutations().deleteNode(this.rootGraph.id, node.id)
1251+
unregisterNodeLayout(this, node)
12511252

12521253
node.graph = null
12531254
this.incrementVersion()

src/lib/litegraph/src/LGraphNode.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -872,6 +872,161 @@ describe('snapToGrid', () => {
872872
})
873873
})
874874

875+
describe('layout geometry projection', () => {
876+
beforeEach(() => {
877+
setActivePinia(createTestingPinia({ stubActions: false }))
878+
layoutStore.resetForTests()
879+
})
880+
881+
test('moves from the latest stored position', () => {
882+
const graph = new LGraph()
883+
const node = new LGraphNode('test')
884+
graph.add(node)
885+
layoutStore.batchUpdateNodeBounds(graph.rootGraph.id, [
886+
{
887+
nodeId: node.id,
888+
bounds: { x: 30, y: 40, width: 200, height: 80 }
889+
}
890+
])
891+
892+
node.move(5, 10)
893+
894+
expect(
895+
layoutStore.getNodeLayoutRef(graph.rootGraph.id, node.id).value?.position
896+
).toEqual({ x: 35, y: 50 })
897+
})
898+
899+
test('snaps the latest stored position', () => {
900+
const graph = new LGraph()
901+
const node = new LGraphNode('test')
902+
graph.add(node)
903+
layoutStore.batchUpdateNodeBounds(graph.rootGraph.id, [
904+
{
905+
nodeId: node.id,
906+
bounds: { x: 103, y: 97, width: 200, height: 80 }
907+
}
908+
])
909+
910+
node.snapToGrid(20)
911+
912+
expect(
913+
layoutStore.getNodeLayoutRef(graph.rootGraph.id, node.id).value?.position
914+
).toEqual({ x: 100, y: 100 })
915+
})
916+
917+
test('preserves stored geometry when removed and re-added', () => {
918+
const graph = new LGraph()
919+
const node = new LGraphNode('test')
920+
graph.add(node)
921+
layoutStore.batchUpdateNodeBounds(graph.rootGraph.id, [
922+
{
923+
nodeId: node.id,
924+
bounds: { x: 30, y: 40, width: 200, height: 80 }
925+
}
926+
])
927+
928+
graph.remove(node)
929+
graph.add(node)
930+
931+
expect(
932+
layoutStore.getNodeLayoutRef(graph.rootGraph.id, node.id).value
933+
).toMatchObject({
934+
position: { x: 30, y: 40 },
935+
size: { width: 200, height: 80 }
936+
})
937+
})
938+
939+
test('refreshes stable views before indexed mutations', () => {
940+
const graph = new LGraph()
941+
const node = new LGraphNode('test')
942+
node.pos = [10, 20]
943+
node.size = [100, 50]
944+
graph.add(node)
945+
const pos = node.pos
946+
const size = node.size
947+
948+
layoutStore.batchUpdateNodeBounds(graph.rootGraph.id, [
949+
{
950+
nodeId: node.id,
951+
bounds: { x: 30, y: 40, width: 200, height: 80 }
952+
}
953+
])
954+
pos[0] = 50
955+
size[1] = 90
956+
957+
expect(node.pos).toBe(pos)
958+
expect(node.size).toBe(size)
959+
expect([...pos]).toEqual([50, 40])
960+
expect([...size]).toEqual([200, 90])
961+
expect(
962+
layoutStore.getNodeLayoutRef(graph.rootGraph.id, node.id).value
963+
).toMatchObject({
964+
position: { x: 50, y: 40 },
965+
size: { width: 200, height: 90 }
966+
})
967+
})
968+
969+
test('preserves stored size when assigning position', () => {
970+
const graph = new LGraph()
971+
const node = new LGraphNode('test')
972+
node.pos = [10, 20]
973+
node.size = [100, 50]
974+
graph.add(node)
975+
layoutStore.batchUpdateNodeBounds(graph.rootGraph.id, [
976+
{
977+
nodeId: node.id,
978+
bounds: { x: 30, y: 40, width: 200, height: 80 }
979+
}
980+
])
981+
982+
node.pos = [50, 60]
983+
984+
expect([...node.size]).toEqual([200, 80])
985+
})
986+
987+
test('preserves stored position when assigning size', () => {
988+
const graph = new LGraph()
989+
const node = new LGraphNode('test')
990+
node.pos = [10, 20]
991+
node.size = [100, 50]
992+
graph.add(node)
993+
layoutStore.batchUpdateNodeBounds(graph.rootGraph.id, [
994+
{
995+
nodeId: node.id,
996+
bounds: { x: 30, y: 40, width: 200, height: 80 }
997+
}
998+
])
999+
1000+
node.size = [300, 90]
1001+
1002+
expect([...node.pos]).toEqual([30, 40])
1003+
})
1004+
1005+
test('does not write a stale width back to the store', () => {
1006+
const graph = new LGraph()
1007+
const node = new LGraphNode('test')
1008+
node.pos = [10, 20]
1009+
node.size = [100, 50]
1010+
graph.add(node)
1011+
layoutStore.batchUpdateNodeBounds(graph.rootGraph.id, [
1012+
{
1013+
nodeId: node.id,
1014+
bounds: { x: 30, y: 40, width: 200, height: 80 }
1015+
}
1016+
])
1017+
1018+
node.pos = [50, 60]
1019+
node.setSize([node.size[0], 120])
1020+
1021+
expect(
1022+
layoutStore.getNodeLayoutRef(graph.rootGraph.id, node.id).value
1023+
).toMatchObject({
1024+
position: { x: 50, y: 60 },
1025+
size: { width: 200, height: 120 }
1026+
})
1027+
})
1028+
})
1029+
8751030
describe('_setConcreteSlots', () => {
8761031
beforeEach(() => {
8771032
setActivePinia(createTestingPinia({ stubActions: false }))

0 commit comments

Comments
 (0)