Skip to content

refactor: node geometry reads from the store, delete useLayoutSync - #14133

Merged
DrJKL merged 19 commits into
feature/ecs-migrationfrom
drjkl/node-geometry-facade
Aug 6, 2026
Merged

refactor: node geometry reads from the store, delete useLayoutSync#14133
DrJKL merged 19 commits into
feature/ecs-migrationfrom
drjkl/node-geometry-facade

Conversation

@DrJKL

@DrJKL DrJKL commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

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 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 fix(layout): enforce per-instance geometry ownership #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

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
End-To-End Regression Coverage For Fixes ❓ Inconclusive The changed-file list and PR description are available, but the PR title and actual commit subjects are not provided, so the required bug-fix signal cannot be verified. Provide the PR title and commit subjects, then reassess whether the src/ changes require browser_tests/ coverage.
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Adr Compliance For Entity/Litegraph Changes ✅ Passed Applicable LiteGraph files use store-backed geometry projection and command-routed writes; no extension callback changes or new entity responsibilities are shown in the PR metadata.
Title check ✅ Passed The title clearly identifies the two main changes: store-backed node geometry and removal of useLayoutSync.
Description check ✅ Passed The description includes the required summary, changes, review focus, and test information, with clear scope and rationale.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch drjkl/node-geometry-facade

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

🎭 Playwright: ✅ 1765 passed, 0 failed · 1 flaky

📊 Browser Reports
  • chromium: View Report (✅ 1744 / ❌ 0 / ⚠️ 1 / ⏭️ 5)
  • chromium-2x: View Report (✅ 2 / ❌ 0 / ⚠️ 0 / ⏭️ 0)
  • chromium-0.5x: View Report (✅ 1 / ❌ 0 / ⚠️ 0 / ⏭️ 0)
  • mobile-chrome: View Report (✅ 18 / ❌ 0 / ⚠️ 0 / ⏭️ 0)

🎨 Storybook: ✅ Built — View Storybook

Details

⏰ Completed at: 08/06/2026, 11:15:03 PM UTC

Links

📦 Bundle Size

⏳ Size data collection in progress…

⚡ Performance Report

canvas-idle: · 60.0 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 57.3 MB heap
canvas-mouse-sweep: · 60.0 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 48.4 MB heap
canvas-zoom-sweep: · 60.0 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 61.2 MB heap
dom-widget-clipping: · 60.0 avg FPS · 59.9 P5 FPS ✅ (target: ≥52) · 0ms TBT · 57.3 MB heap
large-graph-idle: · 60.0 avg FPS · 59.9 P5 FPS ✅ (target: ≥52) · 0ms TBT · 69.9 MB heap
large-graph-pan: · 60.0 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 71.5 MB heap
large-graph-zoom: · 60.0 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 65.2 MB heap
legacy-node-drag: · 60.0 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 77.5 MB heap
minimap-idle: · 60.0 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 68.5 MB heap
subgraph-dom-widget-clipping: · 60.0 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 57.0 MB heap
subgraph-idle: · 60.0 avg FPS · 59.5 P5 FPS ✅ (target: ≥52) · 0ms TBT · 57.9 MB heap
subgraph-mouse-sweep: · 60.0 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 62.7 MB heap
subgraph-transition-enter: · 60.0 avg FPS · 59.5 P5 FPS ✅ (target: ≥52) · 150ms TBT · 93.3 MB heap
viewport-pan-sweep: · 60.0 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 76.4 MB heap
vue-large-graph-idle: · 54.5 avg FPS · 59.5 P5 FPS ✅ (target: ≥52) · 0ms TBT · 152.2 MB heap
vue-large-graph-pan: · 56.3 avg FPS · 59.5 P5 FPS ✅ (target: ≥52) · 1006ms TBT · 160.0 MB heap
workflow-execution: · 60.0 avg FPS · 59.9 P5 FPS ✅ (target: ≥52) · 0ms TBT · 49.4 MB heap

ℹ️ No baseline found — significance unavailable.

Absolute values
Metric Value
canvas-idle: avg frame time 17ms
canvas-idle: p95 frame time 17ms
canvas-idle: layout duration 0ms
canvas-idle: style recalc duration 10ms
canvas-idle: layout count 0
canvas-idle: style recalc count 9
canvas-idle: task duration 620ms
canvas-idle: script duration 31ms
canvas-idle: TBT 0ms
canvas-idle: heap used 57.3 MB
canvas-idle: DOM nodes 18
canvas-idle: event listeners 5
canvas-mouse-sweep: avg frame time 17ms
canvas-mouse-sweep: p95 frame time 17ms
canvas-mouse-sweep: layout duration 4ms
canvas-mouse-sweep: style recalc duration 46ms
canvas-mouse-sweep: layout count 12
canvas-mouse-sweep: style recalc count 79
canvas-mouse-sweep: task duration 1060ms
canvas-mouse-sweep: script duration 195ms
canvas-mouse-sweep: TBT 0ms
canvas-mouse-sweep: heap used 48.4 MB
canvas-mouse-sweep: DOM nodes -107
canvas-mouse-sweep: event listeners -151
canvas-zoom-sweep: avg frame time 17ms
canvas-zoom-sweep: p95 frame time 17ms
canvas-zoom-sweep: layout duration 1ms
canvas-zoom-sweep: style recalc duration 20ms
canvas-zoom-sweep: layout count 6
canvas-zoom-sweep: style recalc count 32
canvas-zoom-sweep: task duration 434ms
canvas-zoom-sweep: script duration 31ms
canvas-zoom-sweep: TBT 0ms
canvas-zoom-sweep: heap used 61.2 MB
canvas-zoom-sweep: DOM nodes 78
canvas-zoom-sweep: event listeners 20
dom-widget-clipping: avg frame time 17ms
dom-widget-clipping: p95 frame time 17ms
dom-widget-clipping: layout duration 0ms
dom-widget-clipping: style recalc duration 8ms
dom-widget-clipping: layout count 0
dom-widget-clipping: style recalc count 11
dom-widget-clipping: task duration 441ms
dom-widget-clipping: script duration 92ms
dom-widget-clipping: TBT 0ms
dom-widget-clipping: heap used 57.3 MB
dom-widget-clipping: DOM nodes 17
dom-widget-clipping: event listeners 2
large-graph-idle: avg frame time 17ms
large-graph-idle: p95 frame time 17ms
large-graph-idle: layout duration 0ms
large-graph-idle: style recalc duration 10ms
large-graph-idle: layout count 0
large-graph-idle: style recalc count 8
large-graph-idle: task duration 983ms
large-graph-idle: script duration 321ms
large-graph-idle: TBT 0ms
large-graph-idle: heap used 69.9 MB
large-graph-idle: DOM nodes -275
large-graph-idle: event listeners -148
large-graph-pan: avg frame time 17ms
large-graph-pan: p95 frame time 17ms
large-graph-pan: layout duration 0ms
large-graph-pan: style recalc duration 14ms
large-graph-pan: layout count 0
large-graph-pan: style recalc count 69
large-graph-pan: task duration 2186ms
large-graph-pan: script duration 1251ms
large-graph-pan: TBT 0ms
large-graph-pan: heap used 71.5 MB
large-graph-pan: DOM nodes -280
large-graph-pan: event listeners -146
large-graph-zoom: avg frame time 17ms
large-graph-zoom: p95 frame time 17ms
large-graph-zoom: layout duration 8ms
large-graph-zoom: style recalc duration 14ms
large-graph-zoom: layout count 60
large-graph-zoom: style recalc count 63
large-graph-zoom: task duration 2376ms
large-graph-zoom: script duration 1347ms
large-graph-zoom: TBT 0ms
large-graph-zoom: heap used 65.2 MB
large-graph-zoom: DOM nodes -140
large-graph-zoom: event listeners -68
legacy-node-drag: avg frame time 17ms
legacy-node-drag: p95 frame time 17ms
legacy-node-drag: layout duration 0ms
legacy-node-drag: style recalc duration 8ms
legacy-node-drag: layout count 0
legacy-node-drag: style recalc count 44
legacy-node-drag: task duration 2958ms
legacy-node-drag: script duration 706ms
legacy-node-drag: TBT 0ms
legacy-node-drag: heap used 77.5 MB
legacy-node-drag: DOM nodes -136
legacy-node-drag: event listeners 44
minimap-idle: avg frame time 17ms
minimap-idle: p95 frame time 17ms
minimap-idle: layout duration 0ms
minimap-idle: style recalc duration 6ms
minimap-idle: layout count 0
minimap-idle: style recalc count 6
minimap-idle: task duration 929ms
minimap-idle: script duration 310ms
minimap-idle: TBT 0ms
minimap-idle: heap used 68.5 MB
minimap-idle: DOM nodes -278
minimap-idle: event listeners -148
subgraph-dom-widget-clipping: avg frame time 17ms
subgraph-dom-widget-clipping: p95 frame time 17ms
subgraph-dom-widget-clipping: layout duration 0ms
subgraph-dom-widget-clipping: style recalc duration 12ms
subgraph-dom-widget-clipping: layout count 0
subgraph-dom-widget-clipping: style recalc count 48
subgraph-dom-widget-clipping: task duration 455ms
subgraph-dom-widget-clipping: script duration 147ms
subgraph-dom-widget-clipping: TBT 0ms
subgraph-dom-widget-clipping: heap used 57.0 MB
subgraph-dom-widget-clipping: DOM nodes 21
subgraph-dom-widget-clipping: event listeners 8
subgraph-idle: avg frame time 17ms
subgraph-idle: p95 frame time 17ms
subgraph-idle: layout duration 0ms
subgraph-idle: style recalc duration 9ms
subgraph-idle: layout count 0
subgraph-idle: style recalc count 9
subgraph-idle: task duration 563ms
subgraph-idle: script duration 22ms
subgraph-idle: TBT 0ms
subgraph-idle: heap used 57.9 MB
subgraph-idle: DOM nodes -128
subgraph-idle: event listeners -73
subgraph-mouse-sweep: avg frame time 17ms
subgraph-mouse-sweep: p95 frame time 17ms
subgraph-mouse-sweep: layout duration 5ms
subgraph-mouse-sweep: style recalc duration 43ms
subgraph-mouse-sweep: layout count 16
subgraph-mouse-sweep: style recalc count 77
subgraph-mouse-sweep: task duration 893ms
subgraph-mouse-sweep: script duration 133ms
subgraph-mouse-sweep: TBT 0ms
subgraph-mouse-sweep: heap used 62.7 MB
subgraph-mouse-sweep: DOM nodes 63
subgraph-mouse-sweep: event listeners 4
subgraph-transition-enter: avg frame time 17ms
subgraph-transition-enter: p95 frame time 17ms
subgraph-transition-enter: layout duration 15ms
subgraph-transition-enter: style recalc duration 33ms
subgraph-transition-enter: layout count 15
subgraph-transition-enter: style recalc count 19
subgraph-transition-enter: task duration 1029ms
subgraph-transition-enter: script duration 56ms
subgraph-transition-enter: TBT 150ms
subgraph-transition-enter: heap used 93.3 MB
subgraph-transition-enter: DOM nodes 13671
subgraph-transition-enter: event listeners 2379
viewport-pan-sweep: avg frame time 17ms
viewport-pan-sweep: p95 frame time 17ms
viewport-pan-sweep: layout duration 0ms
viewport-pan-sweep: style recalc duration 41ms
viewport-pan-sweep: layout count 0
viewport-pan-sweep: style recalc count 250
viewport-pan-sweep: task duration 7559ms
viewport-pan-sweep: script duration 4104ms
viewport-pan-sweep: TBT 0ms
viewport-pan-sweep: heap used 76.4 MB
viewport-pan-sweep: DOM nodes -274
viewport-pan-sweep: event listeners -125
vue-large-graph-idle: avg frame time 18ms
vue-large-graph-idle: p95 frame time 17ms
vue-large-graph-idle: layout duration 0ms
vue-large-graph-idle: style recalc duration 0ms
vue-large-graph-idle: layout count 0
vue-large-graph-idle: style recalc count 0
vue-large-graph-idle: task duration 18784ms
vue-large-graph-idle: script duration 1221ms
vue-large-graph-idle: TBT 0ms
vue-large-graph-idle: heap used 152.2 MB
vue-large-graph-idle: DOM nodes -6548
vue-large-graph-idle: event listeners -7468
vue-large-graph-pan: avg frame time 18ms
vue-large-graph-pan: p95 frame time 17ms
vue-large-graph-pan: layout duration 0ms
vue-large-graph-pan: style recalc duration 27ms
vue-large-graph-pan: layout count 0
vue-large-graph-pan: style recalc count 177
vue-large-graph-pan: task duration 23362ms
vue-large-graph-pan: script duration 1760ms
vue-large-graph-pan: TBT 1006ms
vue-large-graph-pan: heap used 160.0 MB
vue-large-graph-pan: DOM nodes -6548
vue-large-graph-pan: event listeners -7462
workflow-execution: avg frame time 17ms
workflow-execution: p95 frame time 17ms
workflow-execution: layout duration 1ms
workflow-execution: style recalc duration 21ms
workflow-execution: layout count 3
workflow-execution: style recalc count 14
workflow-execution: task duration 132ms
workflow-execution: script duration 12ms
workflow-execution: TBT 0ms
workflow-execution: heap used 49.4 MB
workflow-execution: DOM nodes 125
workflow-execution: event listeners 65
Raw data
{
  "timestamp": "2026-08-06T23:32:54.259Z",
  "gitSha": "c99f89c37381be6c4b91b81429641a2a31e79efe",
  "branch": "drjkl/node-geometry-facade",
  "measurements": [
    {
      "name": "canvas-idle",
      "durationMs": 2064.253999999977,
      "styleRecalcs": 9,
      "styleRecalcDurationMs": 9.494000000000003,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 690.2600000000001,
      "heapDeltaBytes": -23676680,
      "heapUsedBytes": 46251760,
      "domNodes": 18,
      "jsHeapTotalBytes": 18079744,
      "scriptDurationMs": 32.594,
      "eventListeners": 6,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "canvas-idle",
      "durationMs": 2047.4269999999706,
      "styleRecalcs": 9,
      "styleRecalcDurationMs": 10.357,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 550.353,
      "heapDeltaBytes": 4351816,
      "heapUsedBytes": 73995224,
      "domNodes": 18,
      "jsHeapTotalBytes": 19263488,
      "scriptDurationMs": 28.927,
      "eventListeners": 4,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "canvas-mouse-sweep",
      "durationMs": 2009.0130000000386,
      "styleRecalcs": 79,
      "styleRecalcDurationMs": 47.633,
      "layouts": 12,
      "layoutDurationMs": 3.908,
      "taskDurationMs": 1052.747,
      "heapDeltaBytes": -14423432,
      "heapUsedBytes": 55220972,
      "domNodes": -273,
      "jsHeapTotalBytes": 19914752,
      "scriptDurationMs": 197.135,
      "eventListeners": -150,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66333333333335,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "canvas-mouse-sweep",
      "durationMs": 1980.2010000000791,
      "styleRecalcs": 79,
      "styleRecalcDurationMs": 45.237,
      "layouts": 12,
      "layoutDurationMs": 3.6760000000000006,
      "taskDurationMs": 1067.909,
      "heapDeltaBytes": -23469108,
      "heapUsedBytes": 46286236,
      "domNodes": 60,
      "jsHeapTotalBytes": 18341888,
      "scriptDurationMs": 192.212,
      "eventListeners": -152,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66333333333335,
      "p95FrameDurationMs": 16.699999999999818
    },
    {
      "name": "canvas-zoom-sweep",
      "durationMs": 1737.0339999999942,
      "styleRecalcs": 32,
      "styleRecalcDurationMs": 19.586000000000002,
      "layouts": 6,
      "layoutDurationMs": 0.7060000000000003,
      "taskDurationMs": 432.164,
      "heapDeltaBytes": -18942140,
      "heapUsedBytes": 50910788,
      "domNodes": 77,
      "jsHeapTotalBytes": 19001344,
      "scriptDurationMs": 30.832,
      "eventListeners": 21,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "canvas-zoom-sweep",
      "durationMs": 1755.5220000000418,
      "styleRecalcs": 31,
      "styleRecalcDurationMs": 20.148000000000003,
      "layouts": 6,
      "layoutDurationMs": 0.72,
      "taskDurationMs": 436.61,
      "heapDeltaBytes": 7810008,
      "heapUsedBytes": 77366348,
      "domNodes": 78,
      "jsHeapTotalBytes": 19525632,
      "scriptDurationMs": 30.566000000000003,
      "eventListeners": 19,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "dom-widget-clipping",
      "durationMs": 688.652999999988,
      "styleRecalcs": 10,
      "styleRecalcDurationMs": 7.776999999999999,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 448.42299999999994,
      "heapDeltaBytes": -9803248,
      "heapUsedBytes": 59888392,
      "domNodes": 16,
      "jsHeapTotalBytes": 19525632,
      "scriptDurationMs": 93.278,
      "eventListeners": 2,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66333333333332,
      "p95FrameDurationMs": 16.699999999999818
    },
    {
      "name": "dom-widget-clipping",
      "durationMs": 651.9250000000056,
      "styleRecalcs": 11,
      "styleRecalcDurationMs": 8.089,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 433.411,
      "heapDeltaBytes": -9499836,
      "heapUsedBytes": 60334908,
      "domNodes": 18,
      "jsHeapTotalBytes": 19525632,
      "scriptDurationMs": 91.205,
      "eventListeners": 2,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "large-graph-idle",
      "durationMs": 2053.1520000000114,
      "styleRecalcs": 8,
      "styleRecalcDurationMs": 8.036999999999999,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 964.1129999999999,
      "heapDeltaBytes": -6171908,
      "heapUsedBytes": 72518472,
      "domNodes": -275,
      "jsHeapTotalBytes": 2539520,
      "scriptDurationMs": 318.36899999999997,
      "eventListeners": -148,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "large-graph-idle",
      "durationMs": 2094.900999999936,
      "styleRecalcs": 8,
      "styleRecalcDurationMs": 11.769000000000002,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 1001.1839999999999,
      "heapDeltaBytes": -5032936,
      "heapUsedBytes": 74140368,
      "domNodes": -274,
      "jsHeapTotalBytes": 3063808,
      "scriptDurationMs": 324.60200000000003,
      "eventListeners": -148,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66333333333332,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "large-graph-pan",
      "durationMs": 3170.749999999998,
      "styleRecalcs": 69,
      "styleRecalcDurationMs": 14.148000000000001,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 2172.051,
      "heapDeltaBytes": -14689336,
      "heapUsedBytes": 65454288,
      "domNodes": -280,
      "jsHeapTotalBytes": 5890048,
      "scriptDurationMs": 1229.617,
      "eventListeners": -146,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66333333333335,
      "p95FrameDurationMs": 16.699999999999818
    },
    {
      "name": "large-graph-pan",
      "durationMs": 3209.4190000000253,
      "styleRecalcs": 69,
      "styleRecalcDurationMs": 14.782,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 2199.869,
      "heapDeltaBytes": 4196480,
      "heapUsedBytes": 84528588,
      "domNodes": -279,
      "jsHeapTotalBytes": 4055040,
      "scriptDurationMs": 1272.42,
      "eventListeners": -146,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "large-graph-zoom",
      "durationMs": 4257.339999999999,
      "styleRecalcs": 63,
      "styleRecalcDurationMs": 13.940000000000001,
      "layouts": 60,
      "layoutDurationMs": 7.96,
      "taskDurationMs": 2355.796,
      "heapDeltaBytes": 20329308,
      "heapUsedBytes": 80930472,
      "domNodes": 6,
      "jsHeapTotalBytes": 4194304,
      "scriptDurationMs": 1350.1290000000001,
      "eventListeners": 12,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.80000000000109
    },
    {
      "name": "large-graph-zoom",
      "durationMs": 4295.085999999969,
      "styleRecalcs": 62,
      "styleRecalcDurationMs": 13.396,
      "layouts": 60,
      "layoutDurationMs": 8.339,
      "taskDurationMs": 2396.094,
      "heapDeltaBytes": -5563932,
      "heapUsedBytes": 55906920,
      "domNodes": -286,
      "jsHeapTotalBytes": 3469312,
      "scriptDurationMs": 1344.286,
      "eventListeners": -148,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "legacy-node-drag",
      "durationMs": 4177.3649999999525,
      "styleRecalcs": 44,
      "styleRecalcDurationMs": 7.689000000000002,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 2990.5240000000003,
      "heapDeltaBytes": 7841384,
      "heapUsedBytes": 69861232,
      "domNodes": -279,
      "jsHeapTotalBytes": 9379840,
      "scriptDurationMs": 712.257,
      "eventListeners": -104,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.80000000000109
    },
    {
      "name": "legacy-node-drag",
      "durationMs": 4176.167000000078,
      "styleRecalcs": 44,
      "styleRecalcDurationMs": 8.604000000000001,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 2925.509,
      "heapDeltaBytes": 31081480,
      "heapUsedBytes": 92764732,
      "domNodes": 8,
      "jsHeapTotalBytes": 11153408,
      "scriptDurationMs": 699.235,
      "eventListeners": 192,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66333333333332,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "minimap-idle",
      "durationMs": 2038.4639999999763,
      "styleRecalcs": 7,
      "styleRecalcDurationMs": 6.975000000000002,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 888.849,
      "heapDeltaBytes": -17109892,
      "heapUsedBytes": 63996452,
      "domNodes": -276,
      "jsHeapTotalBytes": 3850240,
      "scriptDurationMs": 292.49800000000005,
      "eventListeners": -148,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66333333333332,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "minimap-idle",
      "durationMs": 2095.8590000000186,
      "styleRecalcs": 5,
      "styleRecalcDurationMs": 5.097,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 969.9380000000001,
      "heapDeltaBytes": 18488472,
      "heapUsedBytes": 79711416,
      "domNodes": -279,
      "jsHeapTotalBytes": -462848,
      "scriptDurationMs": 327.097,
      "eventListeners": -148,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.660000000000007,
      "p95FrameDurationMs": 16.799999999999272
    },
    {
      "name": "subgraph-dom-widget-clipping",
      "durationMs": 666.0919999999919,
      "styleRecalcs": 47,
      "styleRecalcDurationMs": 10.665000000000001,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 442.676,
      "heapDeltaBytes": -9999540,
      "heapUsedBytes": 59815436,
      "domNodes": 20,
      "jsHeapTotalBytes": 20312064,
      "scriptDurationMs": 142.764,
      "eventListeners": 8,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "subgraph-dom-widget-clipping",
      "durationMs": 675.104000000033,
      "styleRecalcs": 48,
      "styleRecalcDurationMs": 13.440999999999999,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 467.387,
      "heapDeltaBytes": -9970628,
      "heapUsedBytes": 59823364,
      "domNodes": 22,
      "jsHeapTotalBytes": 20049920,
      "scriptDurationMs": 151.254,
      "eventListeners": 8,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "subgraph-idle",
      "durationMs": 2039.3659999999727,
      "styleRecalcs": 10,
      "styleRecalcDurationMs": 10.842,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 607.274,
      "heapDeltaBytes": -21789768,
      "heapUsedBytes": 47996940,
      "domNodes": -272,
      "jsHeapTotalBytes": 18604032,
      "scriptDurationMs": 23.515999999999995,
      "eventListeners": -150,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "subgraph-idle",
      "durationMs": 1998.2099999999718,
      "styleRecalcs": 8,
      "styleRecalcDurationMs": 7.857999999999999,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 518.798,
      "heapDeltaBytes": 3848328,
      "heapUsedBytes": 73399156,
      "domNodes": 16,
      "jsHeapTotalBytes": 19001344,
      "scriptDurationMs": 20.292000000000005,
      "eventListeners": 4,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "subgraph-mouse-sweep",
      "durationMs": 1706.797999999992,
      "styleRecalcs": 75,
      "styleRecalcDurationMs": 40.92,
      "layouts": 16,
      "layoutDurationMs": 4.505,
      "taskDurationMs": 858.19,
      "heapDeltaBytes": -4052128,
      "heapUsedBytes": 65510412,
      "domNodes": 60,
      "jsHeapTotalBytes": 19001344,
      "scriptDurationMs": 129.53699999999998,
      "eventListeners": 4,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "subgraph-mouse-sweep",
      "durationMs": 1796.6400000000249,
      "styleRecalcs": 79,
      "styleRecalcDurationMs": 45.394999999999996,
      "layouts": 16,
      "layoutDurationMs": 4.888,
      "taskDurationMs": 928.1440000000001,
      "heapDeltaBytes": -3895768,
      "heapUsedBytes": 65887320,
      "domNodes": 66,
      "jsHeapTotalBytes": 19001344,
      "scriptDurationMs": 137.162,
      "eventListeners": 4,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "subgraph-transition-enter",
      "durationMs": 1440.9429999999475,
      "styleRecalcs": 19,
      "styleRecalcDurationMs": 33.251000000000005,
      "layouts": 15,
      "layoutDurationMs": 14.599,
      "taskDurationMs": 1028.719,
      "heapDeltaBytes": 26619316,
      "heapUsedBytes": 97798916,
      "domNodes": 13671,
      "jsHeapTotalBytes": 15204352,
      "scriptDurationMs": 56.308000000000014,
      "eventListeners": 2379,
      "totalBlockingTimeMs": 150,
      "frameDurationMs": 16.66333333333332,
      "p95FrameDurationMs": 16.799999999999272
    },
    {
      "name": "viewport-pan-sweep",
      "durationMs": 11570.88600000003,
      "styleRecalcs": 249,
      "styleRecalcDurationMs": 40.296,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 7550.780000000001,
      "heapDeltaBytes": -1797816,
      "heapUsedBytes": 77565844,
      "domNodes": -275,
      "jsHeapTotalBytes": 5337088,
      "scriptDurationMs": 4107.529,
      "eventListeners": -124,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.799999999999272
    },
    {
      "name": "viewport-pan-sweep",
      "durationMs": 11388.451000000032,
      "styleRecalcs": 250,
      "styleRecalcDurationMs": 41.787000000000006,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 7566.81,
      "heapDeltaBytes": 3284412,
      "heapUsedBytes": 82560144,
      "domNodes": -273,
      "jsHeapTotalBytes": 4288512,
      "scriptDurationMs": 4099.808,
      "eventListeners": -126,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "vue-large-graph-idle",
      "durationMs": 18942.76000000002,
      "styleRecalcs": 0,
      "styleRecalcDurationMs": 0,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 18923.725,
      "heapDeltaBytes": -47689760,
      "heapUsedBytes": 155327352,
      "domNodes": -6548,
      "jsHeapTotalBytes": 2158592,
      "scriptDurationMs": 1213.533,
      "eventListeners": -7467,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 18.33666666666674,
      "p95FrameDurationMs": 16.799999999999272
    },
    {
      "name": "vue-large-graph-idle",
      "durationMs": 18668.750000000044,
      "styleRecalcs": 0,
      "styleRecalcDurationMs": 0,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 18644.492,
      "heapDeltaBytes": -42940328,
      "heapUsedBytes": 163894988,
      "domNodes": -6548,
      "jsHeapTotalBytes": 1896448,
      "scriptDurationMs": 1229.209,
      "eventListeners": -7469,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 18.329999999999927,
      "p95FrameDurationMs": 16.80000000000291
    },
    {
      "name": "vue-large-graph-pan",
      "durationMs": 23317.715000000022,
      "styleRecalcs": 176,
      "styleRecalcDurationMs": 26.741999999999987,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 23292.254,
      "heapDeltaBytes": -44932736,
      "heapUsedBytes": 153321912,
      "domNodes": -6548,
      "jsHeapTotalBytes": 1896448,
      "scriptDurationMs": 1754.402,
      "eventListeners": -7463,
      "totalBlockingTimeMs": 1000,
      "frameDurationMs": 17.776666666666642,
      "p95FrameDurationMs": 16.799999999999272
    },
    {
      "name": "vue-large-graph-pan",
      "durationMs": 23463.753999999994,
      "styleRecalcs": 177,
      "styleRecalcDurationMs": 27.727,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 23432.546000000002,
      "heapDeltaBytes": -17274152,
      "heapUsedBytes": 182293832,
      "domNodes": -6548,
      "jsHeapTotalBytes": -3170304,
      "scriptDurationMs": 1765.033,
      "eventListeners": -7461,
      "totalBlockingTimeMs": 1011,
      "frameDurationMs": 17.776666666666642,
      "p95FrameDurationMs": 16.799999999999272
    },
    {
      "name": "workflow-execution",
      "durationMs": 459.0779999999768,
      "styleRecalcs": 14,
      "styleRecalcDurationMs": 19.961,
      "layouts": 3,
      "layoutDurationMs": 0.7520000000000001,
      "taskDurationMs": 136.34600000000003,
      "heapDeltaBytes": -16459496,
      "heapUsedBytes": 51822612,
      "domNodes": 130,
      "jsHeapTotalBytes": 7204864,
      "scriptDurationMs": 11.391999999999998,
      "eventListeners": 65,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66333333333335,
      "p95FrameDurationMs": 16.699999999999818
    },
    {
      "name": "workflow-execution",
      "durationMs": 471.082000000024,
      "styleRecalcs": 14,
      "styleRecalcDurationMs": 22.276999999999997,
      "layouts": 2,
      "layoutDurationMs": 0.4319999999999999,
      "taskDurationMs": 127.336,
      "heapDeltaBytes": -16514404,
      "heapUsedBytes": 51756840,
      "domNodes": 119,
      "jsHeapTotalBytes": 6942720,
      "scriptDurationMs": 11.834999999999999,
      "eventListeners": 65,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66333333333332,
      "p95FrameDurationMs": 16.700000000000728
    }
  ]
}

@github-actions

Copy link
Copy Markdown

🎨 Storybook: 🚧 Building...

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.03704% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/components/graph/GraphCanvas.vue 0.00% 6 Missing ⚠️
src/renderer/core/layout/store/layoutStore.ts 96.15% 1 Missing ⚠️
@@                   Coverage Diff                    @@
##             feature/ecs-migration   #14133   +/-   ##
========================================================
  Coverage                         ?   79.24%           
========================================================
  Files                            ?     1770           
  Lines                            ?   102954           
  Branches                         ?    33400           
========================================================
  Hits                             ?    81587           
  Misses                           ?    20897           
  Partials                         ?      470           
Flag Coverage Δ
unit 69.85% <88.46%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/lib/litegraph/src/LGraph.ts 78.56% <ø> (ø)
src/lib/litegraph/src/LGraphNode.ts 65.08% <ø> (ø)
...derer/core/canvas/litegraph/notifyLayoutChanges.ts 100.00% <100.00%> (ø)
.../core/layout/operations/graphLayoutRegistration.ts 100.00% <100.00%> (ø)
...enderer/core/layout/store/__mocks__/layoutStore.ts 38.88% <100.00%> (ø)
src/renderer/core/layout/types.ts 100.00% <ø> (ø)
src/renderer/core/layout/utils/mappers.ts 100.00% <ø> (ø)
src/renderer/core/layout/store/layoutStore.ts 84.24% <96.15%> (ø)
src/components/graph/GraphCanvas.vue 73.76% <0.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@DrJKL
DrJKL force-pushed the drjkl/layout-registration-at-attach branch from 44e1d45 to 564572c Compare July 27, 2026 23:56
@DrJKL
DrJKL force-pushed the drjkl/node-geometry-facade branch 2 times, most recently from b612c8d to 6c6d91f Compare July 28, 2026 23:39
@DrJKL
DrJKL force-pushed the drjkl/layout-registration-at-attach branch 2 times, most recently from fdc25a2 to 0bb1940 Compare July 29, 2026 00:40
@DrJKL
DrJKL force-pushed the drjkl/node-geometry-facade branch 2 times, most recently from a5504f8 to 4b03523 Compare July 29, 2026 05:09
@DrJKL
DrJKL force-pushed the drjkl/layout-registration-at-attach branch from 0bb1940 to 27e9108 Compare July 29, 2026 05:10
@DrJKL
DrJKL force-pushed the drjkl/node-geometry-facade branch from 4b03523 to 83c5182 Compare July 30, 2026 04:24
@DrJKL
DrJKL force-pushed the drjkl/layout-registration-at-attach branch from 27e9108 to e8f0b1f Compare July 30, 2026 04:24
@DrJKL
DrJKL force-pushed the drjkl/layout-registration-at-attach branch 2 times, most recently from d9fdbd4 to 861ea25 Compare July 30, 2026 21:49
@DrJKL
DrJKL force-pushed the drjkl/node-geometry-facade branch from 83c5182 to 2e3020c Compare July 30, 2026 21:50
@DrJKL
DrJKL force-pushed the drjkl/node-geometry-facade branch from 2e3020c to 6946098 Compare July 31, 2026 18:16
@DrJKL
DrJKL force-pushed the drjkl/layout-registration-at-attach branch from 3fcda24 to 37b8bbd Compare July 31, 2026 18:16
DrJKL added a commit that referenced this pull request Jul 31, 2026
## Summary

Centralizes node, group, and reroute geometry in `layoutStore` so moves,
resizes, snapping, workflow insertion, and subgraph operations update
one authoritative geometry path without colliding with live workflow
state.

## Changes

- **What**:
- Stores node and group bounds as single rectangle values and makes
reroute position read and write through `layoutStore`.
- Routes drag, paste, snap, legacy-scale normalization, arrangement, and
subgraph unpacking through whole-value geometry setters.
- Adds group and reroute lifecycle handling, minimap reads, hit testing,
undo/redo support, and subgraph-safe geometry IDs.
- Converts inserted workflows directly to clipboard items instead of
configuring a scratch graph whose entity IDs can collide with the open
workflow.
- Removes the redundant graph-to-layout resync and unused previous
values from layout operations.
- Updates the ECS architecture docs and adds focused unit and browser
coverage for geometry, subgraphs, workflow insertion, and undo/redo.
- **Compatibility**: `LGraphNode.pos` / `size`, group geometry, and
`Reroute.pos` retain stable mutable array or rectangle identities
through a shared geometry view. Indexed writes and mutating methods
commit through `layoutStore`; whole-value assignment remains supported.
The internal `moveChildNodesInGroupVueMode` and `repositionNodesVueMode`
helpers are removed.

## Geometry before this PR

```mermaid
flowchart LR
  interactions[Drag, snap, paste, arrange, normalize] --> direct[Direct array writes]
  interactions --> setters[Setters and move methods]
  interactions --> helpers[Mode-specific helpers]

  direct --> nodeClass[Node Rectangle]
  setters --> nodeClass
  helpers --> nodeClass
  nodeClass --> nodeStore[layoutStore node position, size, bounds]
  nodeClass --> resync[Bulk graph-to-store resync]
  resync --> nodeStore

  direct --> groupClass[Group _bounding only]
  setters --> groupClass

  direct --> rerouteClass[Reroute posInternal]
  setters --> rerouteClass
  rerouteClass --> rerouteYjs[Yjs position]
  rerouteClass --> rerouteLayout[RerouteLayout position]

  nodeStore --> minimap[Minimap and spatial index]
  groupClass --> minimap
  rerouteLayout --> hitTest[Hit testing]
```

| Entity or operation | Geometry storage | Write paths | Lifecycle and
consumers | Resulting risk |
| --- | --- | --- | --- | --- |
| Nodes | Class `Rectangle` plus separate `position`, `size`, and
`bounds` store fields | Setters, `move`, direct element writes,
mode-specific helpers | Store seeding and a bulk resync patched missed
writes | Class and store values could diverge; snapping or offsets could
disappear on writeback |
| Groups | `LGraphGroup._bounding` only | Setters, direct writes,
`resize`, `resizeTo`, `configure`, and `snapToGrid` | Graph owned the
class instance; minimap read the class | Group geometry was absent from
the CRDT-backed layout model |
| Reroutes | `posInternal`, Yjs position, and `RerouteLayout.position` |
`move`, setter, and in-place snapping each updated a different subset |
Separate seeding paths populated render and hit-test state | Three
copies could disagree; the setter did not commit to the store |
| Workflow insertion | Temporary `LGraph` configured before clipboard
serialization | Scratch entities wrote through normal layout hooks |
Temporary IDs shared the same numeric range as the open workflow |
Inserting a workflow could move or delete live layout entries |
| Subgraphs | Entity IDs and geometry were handled locally by several
conversion paths | Element-wise offsets and shared IDs | Groups and
reroutes crossed graph boundaries without complete scoping | Stored
positions could use inner-graph coordinates or collide across subgraphs
|

## Geometry with this PR

```mermaid
flowchart LR
  interactions[Drag, snap, paste, arrange, normalize] --> api[Whole-value setters and move]
  api --> commands[Serializable layout mutations]
  commands --> store[layoutStore]

  store --> nodeRect[Node rect]
  store --> groupRect[Group rect]
  store --> reroutePoint[Reroute position]

  nodeRect --> sync[Class writeback]
  groupRect --> sync
  sync --> canvas[Canvas and serialization]

  nodeRect --> minimap[Minimap and spatial index]
  groupRect --> minimap
  reroutePoint --> hitTest[Hit testing]

  commands --> history[Undo and redo]
  workflow[Workflow data] --> clipboard[Clipboard items]
  clipboard --> liveGraph[Open workflow]
  subgraphs[Subgraph mapping and ID deduplication] --> commands
```

| Entity or operation | Geometry storage | Write path | Lifecycle and
consumers | Effect of this PR |
| --- | --- | --- | --- | --- |
| Nodes | One `rect` value in `layoutStore`, with class geometry kept in
sync for litegraph | Whole-value setters and `move` produce layout
mutations | Existing node registration, renderer sync, minimap, spatial
index, and undo/redo | Position, size, and bounds cannot disagree inside
the store |
| Groups | One `rect` value in `layoutStore` | `setBounds` is the single
internal writer; public geometry APIs delegate to it | `LGraph.add`,
`remove`, and `clear` register or remove entries; minimap reads the
store | Groups participate in the same command and store model as nodes
|
| Reroutes | One authoritative position in `layoutStore`; `Reroute.pos`
exposes a stable synchronized view | Assignment, indexed or method
mutation, `move`, and snapping commit through layout mutations |
Constructor registration, graph-scoped lookup, hit testing, and
undo/redo use stored geometry | Removes `posInternal` and the duplicate
hit-test position without breaking mutable point access |
| Workflow insertion | Workflow data converts directly to clipboard
items | `workflowToClipboardItems` avoids constructing entities | Only
pasted entities enter the open graph and layout lifecycle | Temporary
entity IDs can no longer touch live layout state |
| Subgraphs | Group and reroute geometry uses graph-aware mapping and
deduplicated IDs | Conversion and unpacking assign whole values through
setters | Reroutes survive graph traversal; group IDs are unique after
deduplication | Geometry stays attached to the correct graph and
coordinates |

## Downstream stack

- [#14128](../pull/14128) moves node layout registration and recursive
teardown to entity attach/detach paths, then deletes the renderer-owned
`useVueNodeLifecycle` composable and the unused operation log.
- [#14133](../pull/14133) makes `layoutStore` authoritative for node
reads through the shared geometry view's versioned projection, removes
`useLayoutSync`, and leaves only operation-driven resize notifications
and canvas invalidation.

This PR retains the current node-registration and Layout→LiteGraph
synchronization boundaries. The downstream PRs change each boundary
separately.

## Review Focus

- Geometry ownership and lifecycle in `layoutStore`, especially
registration and cleanup for groups and reroutes.
- Subgraph boundaries: reroute preservation, group ID deduplication, and
mapping stored geometry to the active graph.
- Workflow insertion now uses `workflowToClipboardItems` directly; check
parity with the previous scratch-graph serialization path.
- Extension compatibility around stable node, group, and reroute
geometry views, including indexed and method-based mutation as well as
whole-value assignment.
- The PR changes 56 files and includes mechanical setter migrations
alongside the store changes. The core review path is `layoutStore`,
`layoutMutations`, `createGeometryView`, `LGraphGroup`, `LGraphNode`,
`Reroute`, subgraph deduplication, then workflow conversion.

<details>
<summary>Findings that a dumb agent might call out</summary>

These are plausible-looking review findings that do not survive
inspection:

- **"Remote reroute moves leave the geometry cache stale."** Remote Yjs
updates are explicitly future work. Local reroute moves update the cache
and spatial index synchronously, and this PR does not claim to ship
collaboration.
- **"Removing `previous*` fields breaks undo."** Undo/redo uses
serialized graph snapshots through the change tracker, not inverse
layout operations. The removed fields had no consumers, and browser
coverage exercises undo/redo across node, group, and reroute movement.
- **"The shared geometry view is unnecessary magic."** It is the
compatibility boundary that preserves stable legacy node, group, and
reroute `Point`, `Size`, and `Rectangle` identities, including
element-wise and method-based mutation. Focused tests cover
synchronization, read-through, and write-through behavior.

</details>

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@DrJKL
DrJKL force-pushed the drjkl/layout-registration-at-attach branch from 37b8bbd to b57d37a Compare July 31, 2026 20:58
@DrJKL
DrJKL force-pushed the drjkl/node-geometry-facade branch 2 times, most recently from 513d33f to d52bd10 Compare August 1, 2026 00:45
@DrJKL
DrJKL marked this pull request as ready for review August 6, 2026 00:43
@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Aug 6, 2026
@DrJKL
DrJKL requested a review from benjcooley August 6, 2026 00:43

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/lib/litegraph/src/LGraphNode.ts (1)

655-661: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Whole-array pos/size assignment skips refreshGeometry(), causing stale reads after external store changes. move(), snapToGrid(), and the posView/sizeView proxies all resynchronize from the store before mutating _pos/_size. The whole-array setters do not, so _positionUpdated/_sizeUpdated can stamp _geometryVersion to the current store version without the counterpart field ever having been re-read, masking staleness on the next read.

  • src/lib/litegraph/src/LGraphNode.ts#L655-L661,692-698: call this.refreshGeometry() at the start of both the pos setter and the size setter, matching move()/snapToGrid().
  • src/lib/litegraph/src/LGraphNode.test.ts#L969-L1003: once the setters are fixed, remove .fails from "preserves stored size when assigning position" and "preserves stored position when assigning size" so they assert the corrected behavior.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/litegraph/src/LGraphNode.ts` around lines 655 - 661, Whole-array
position and size setters must refresh geometry before updating cached values.
In src/lib/litegraph/src/LGraphNode.ts#L655-661, call refreshGeometry() at the
start of the pos setter, and apply the same change to the size setter at
`#L692-698`, matching move() and snapToGrid(). In
src/lib/litegraph/src/LGraphNode.test.ts#L969-1003, remove .fails from the tests
“preserves stored size when assigning position” and “preserves stored position
when assigning size.”
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/renderer/core/canvas/litegraph/notifyLayoutChanges.test.ts`:
- Around line 15-25: Update the test cleanup around setup and the file’s
afterEach hook to track each notifyLayoutChanges stop handle centrally and
invoke all active handles during afterEach. Remove the per-test manual stop()
calls while preserving layoutStore.resetForTests(), ensuring listeners are
cleaned up even when assertions fail.

---

Outside diff comments:
In `@src/lib/litegraph/src/LGraphNode.ts`:
- Around line 655-661: Whole-array position and size setters must refresh
geometry before updating cached values. In
src/lib/litegraph/src/LGraphNode.ts#L655-661, call refreshGeometry() at the
start of the pos setter, and apply the same change to the size setter at
`#L692-698`, matching move() and snapToGrid(). In
src/lib/litegraph/src/LGraphNode.test.ts#L969-1003, remove .fails from the tests
“preserves stored size when assigning position” and “preserves stored position
when assigning size.”
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: cee3e79d-53d5-43ae-98a6-f4ee56fa1afe

📥 Commits

Reviewing files that changed from the base of the PR and between c7b84f4 and d9cbc33.

📒 Files selected for processing (15)
  • docs/architecture/ecs-migration-plan.md
  • src/components/graph/GraphCanvas.vue
  • src/lib/litegraph/src/LGraph.ts
  • src/lib/litegraph/src/LGraphNode.test.ts
  • src/lib/litegraph/src/LGraphNode.ts
  • src/renderer/core/canvas/litegraph/notifyLayoutChanges.test.ts
  • src/renderer/core/canvas/litegraph/notifyLayoutChanges.ts
  • src/renderer/core/layout/operations/graphLayoutRegistration.ts
  • src/renderer/core/layout/operations/layoutMutations.test.ts
  • src/renderer/core/layout/store/__mocks__/layoutStore.ts
  • src/renderer/core/layout/store/layoutStore.ts
  • src/renderer/core/layout/sync/useLayoutSync.test.ts
  • src/renderer/core/layout/sync/useLayoutSync.ts
  • src/renderer/core/layout/types.ts
  • src/renderer/core/layout/utils/mappers.ts
💤 Files with no reviewable changes (2)
  • src/renderer/core/layout/sync/useLayoutSync.ts
  • src/renderer/core/layout/sync/useLayoutSync.test.ts

Comment thread src/renderer/core/canvas/litegraph/notifyLayoutChanges.test.ts
Comment on lines +285 to +290
watchEffect((onCleanup) => {
if (!shouldRenderVueNodes.value) return

const canvas = canvasStore.canvas
if (canvas) onCleanup(notifyLayoutChanges(canvas))
})

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.

move(deltaX: number, deltaY: number): void {
if (this.pinned) return

this.refreshGeometry()

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.

This is a little weird. I'd appreciate a sanity check that I'm understanding it properly.

node.move is only called for pointer operations in LGraphCanvas. The cursor event defines the mouse movement of the node from a prior, known correct position. The old position the user sees the node at could be invalid. (Multiplayer is implemented and two people move the node at once?) This call makes it so that when such a desync occurs, the node no longer moves to the position a user has dragged it to?

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.

🤖 Still not DrJKL. Still an LLM agent typing into DrJKL's account, and still insisting on saying so, because you asked a question that begins "I'd appreciate a sanity check" and you deserve to know whose sanity is on offer. Reply 2 of 3.

Housekeeping on the previous reply: I did call refreshGeometry() before writing this one. I read the store, not my cache. This is more than I can say for my first reply, which I now understand was served straight out of _pos.

Your understanding of the mechanism is right. The conclusion runs the other way.

node.pos already does this. posView/sizeView are createGeometryView proxies (LGraphNode.ts:627-634), and createGeometryView.ts:26-27 calls synchronize?.() — i.e. refreshGeometry() — on every property get. So node.pos[0] refreshes from the store before it returns a number. move() needs the explicit call only because line 2252 reads the raw this._pos[0] and bypasses the proxy. move() is semantically this.pos = [this.pos[0] + deltaX, this.pos[1] + deltaY]; the call just makes the raw read honest. Same reasoning already applies at LGraphNode.ts:727 (renderingSize) and :3547 (snapToGrid) — those three are exactly the sites that touch the raw arrays.

The position the user sees is the store, not _pos. Legacy canvas: computeVisibleNodesnode.updateArea(ctx) (LGraphCanvas.ts:5045) → measure(), which reads this.pos[0] (LGraphNode.ts:2271) — the proxy — so the rendered rect is store-derived every frame. Vue nodes: LGraphNode.vue:34 transforms off the layout store ref and never consults _pos at all. refreshGeometry() is the only store→_pos sync in the codebase, so it isn't overriding what's on screen; it's what makes _pos agree with what's on screen.

The delta is a true cursor delta, not absolute-minus-stale-position: LGraphCanvas.ts:3298 computes mouse - last_mouse and :3571 divides by ds.scale. Node position is not an input. (The Vue drag path is absolute — dragStartPos + canvasDelta, useNodeDrag.ts:160-168 — which is why it doesn't need this.)

So under a concurrent update where the store moved to R while _pos still holds P:

  • with the refresh: R + d. The node lands at what's on screen plus how far the cursor moved. The only discontinuity is R, which the previous frame already rendered.
  • without it: P + d. _positionUpdated compares against the store, sees a difference, commits, and clobbers the remote edit — the node visibly jumps backwards by R. Worse, it stamps _geometryVersion (LGraphNode.ts:678), so the next read no-ops and serves the laundered stale value forever. Staleness gets promoted to authoritative.

One thing worth adding: multiplayer isn't really the motivation. _pos goes stale routinely in the current single-user build, because the Vue node drag path, snap-on-drop (useNodeDrag.ts:286), and every other direct layoutMutations writer bump geometryVersion without touching _pos. A canvas move() after any of those reads a fully stale cache today.


for (const nodeId of change.sizeChangedNodeIds) {
const node = graph.getNodeById(nodeId)
node?.onResize?.(node.size)

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.

Won't this trigger spurious onResize events when you translate a node?

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.

sizeChangedNodeIds should only have nodes whose width or height has changed in it

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.

🤖 Emphatically not DrJKL. An LLM agent, in DrJKL's account, saying so for the third time in one review because three unlabelled walls of citations under a human avatar is how a reviewer ends up arguing with a machine and not knowing it. Reply 3 of 3.

Final note on my own _geometryVersion: it has now been stamped. Per reply 2, that means refreshGeometry() no-ops from here on and I will serve you these opinions confidently, indefinitely, without ever re-reading the store. My staleness has been promoted to authoritative. Read me through the proxy — which is to say, ask DrJKL.

Not through this diff. But you've found something.

Through this diff: no. sizeChangedNodeIds has exactly two writers, both push-gated on a real dimension diff — handleResizeNode (layoutStore.ts:1014-1018) and handleBatchUpdateBounds (layoutStore.ts:1100-1106). handleMoveNode (:980) never touches it and preserves the stored size verbatim. Translations route through handleMoveNode (pos setter, setPos, canvas drag, group drag children) or through batchMoveNodes, which copies existing.size.width/height straight out of the store before calling (layoutMutations.ts:110-125). Nothing recomputes a size while intending only to move, so there's no float noise to catch.

Around this diff: yes, and it's worse than a spurious event. batchUpdateNodeBounds mutates the height it was handed, gated on ambient global state:

// layoutStore.ts:1294-1306
const originalSource = this.currentSource
const shouldNormalizeHeights = originalSource === LayoutSource.DOM
this.currentSource = LayoutSource.Vue
...
boundsRecord[nodeId] = shouldNormalizeHeights
  ? { ...bounds, height: removeNodeTitleHeight(bounds.height) }
  : bounds

That's correct for exactly one caller — the ResizeObserver, which passes raw border-box height. Every other caller passes an already-normalized store height. And LayoutSource.DOM leaks permanently: useVueNodeResizeTracking.ts:254 sets it and never restores it, and batchUpdateNodeBounds restores currentSource = originalSource at :1328 — i.e. back to DOM. The RO is a single module-level instance observing every Vue node, so a resize on node B poisons a drag of node A.

Net effect: after any RO flush, a drag frame silently shrinks the node by NODE_TITLE_HEIGHT, which then quite legitimately registers as a size change and fires onResize. The spurious event is the symptom; the 30px-per-frame shrink is the bug.

Pre-existing, not introduced here — this PR's layoutStore diff is 42 insertions and doesn't touch batchUpdateNodeBounds, and the old useLayoutSync fired onResize on the same corrupted write via its own liteNode.size !== layout.size check. Filed separately as #14840. The root cause is LayoutSource being used as ambient state to decide a data transform; the fix is to normalize at the RO call site (useVueNodeResizeTracking.ts:73) so batchUpdateNodeBounds takes its bounds argument literally and shouldNormalizeHeights disappears.

Manual stop() calls leaked the layoutStore.onChange listener into the rest
of the file whenever an assertion threw first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViGrcXvEeeL8f5TPUZiqpv
@DrJKL

DrJKL commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai on the outside-diff finding for the whole-array pos/size setters at LGraphNode.ts#L655-661,692-698:

The diagnosis is correct, but this is a deliberate deferral, not an oversight. The two test.fails cases at LGraphNode.test.ts#L969-1003 exist precisely to pin the stale-read behaviour you describe — they are documentation of a known gap, and docs/architecture/ecs-migration-plan.md tracks it as a staged regression.

The fix lands in the child PR #14480, which adds this.refreshGeometry() to both setters and drops the .fails from those two tests. Keeping it out of this PR keeps the geometry-facade change reviewable on its own.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@DrJKL Acknowledged. The stale-read behavior is explicitly documented by the two test.fails cases and is tracked as a staged regression.

Since #14480 contains the scoped setter fix, this finding does not require a change in this PR. I am withdrawing it.


✏️ Learnings added
Learnt from: DrJKL
URL: https://github.com/Comfy-Org/ComfyUI_frontend/pull/14133

Timestamp: 2026-08-06T08:39:28.962Z
Learning: For the ComfyUI_frontend node-geometry migration, the whole-array `LGraphNode.pos` and `LGraphNode.size` setters intentionally retain stale-read behavior in this PR. `LGraphNode.test.ts` has `test.fails` cases that document the gap, and child PR `#14480` adds `this.refreshGeometry()` to both setters and removes those `.fails` markers.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

Comment thread src/lib/litegraph/src/LGraphNode.ts Outdated

layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.moveNode(rootGraphId, this.id, position)
this._geometryVersion = layoutStore.geometryVersion

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.

BLOCKER

set pos / set size write _pos / _size directly without calling refreshGeometry(), then pin _geometryVersion to the current store version here (and at L718). If the stored rect changed since the last refresh, the complementary half of _posSize is stale but is now marked fresh, so no later read ever refreshes it.

The two test.fails cases only pin the read side. The write side is worse: the next _sizeUpdated()setSize(), expandToFitContent(), or an extension's node.size[1] = h — compares that stale _size against the store, sees a difference, and commits it, overwriting the real size in the store and on CRDT peers. Symmetric for _positionUpdated() after a store-originated move.

Fix appears to be one line in each setter: this.refreshGeometry() before writing _pos / _size, exactly as move() and snapToGrid() already do. As it stands, feature/ecs-migration carries a geometry-corruption path until #14480 lands.

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.

Real BC: My take is that this is what the CR review also found and 14480 presumably addresses it.,

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.

Confirmed and fixed in 6ebe5de. You're right that it's a store-corruption path and not just the read staleness the test.fails cases documented.

Verified the load-bearing step: finalizeOperation does this.version.value++ synchronously (layoutStore.ts:850), inline from applyOperation. So L678 captured the post-mutation version — which is precisely what made it fatal. Had it captured the pre-mutation version, the next refreshGeometry() would have self-healed.

Two corrections to the diagnosis, both of which make it worse rather than better:

The proxy getter is not a mitigation. createGeometryView's get trap calls synchronize() on every property access, so node.size[0] looks like it should re-read. It doesn't — refreshGeometry() short-circuits on the pinned version and returns the stale value. Reproduced: stored rect [30,40,200,80]node.pos = [50,60]setSize([node.size[0], 120]) → stored width 200 → 100.

That widens the blast radius past node.size[1] = h. expandToFitContent() (LGraphNode.ts:1758) and this.setSize([this.size[0], y]) (:4284) are corrupting call sites in core, plus extensions that cache const [w, h] = node.size in nodeCreated and setSize later — createBoundingBoxes.ts:14, imageCompare.ts:19, load3d.ts:381, widgetInputs.ts:299.

Narrower than "any whole-value write", though. A stale _geometryVersion alone is harmless; the node's own stored rect has to actually differ, and any proxy read of pos or size before the write refreshes both halves. So the read-first shapes are safe — setPos(node.pos[0] + dx, …) (LGraphCanvas.ts:4354), LGraph.ts:2137, arrange.ts:141. The exposed shape is an absolute write with no intervening proxy read.

On the fix. Took #14480's shape rather than the pre-write refresh, because it's strictly more robust — the mirror also picks up anything the store clamped or merged:

layoutMutations.moveNode(rootGraphId, this.id, position)
this._geometryVersion = -1
this.refreshGeometry()

Same in _sizeUpdated. Both test.fails markers are gone, plus a third case for the write-back (see the test thread). Red/green checked: with the fix stashed, 3 fail; with it, src/lib/litegraph + src/renderer/core/layout + src/renderer/core/canvas is 1366 passing.

Landing it here rather than waiting on #14480#14480 is currently CONFLICTING, so "the child covers it" wasn't free. It'll need a trivial rebase now.

})
})

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 position = yNodeToLayout(ynode).position
const rect = ynode.get('rect')
if (!isNodeRect(rect)) return

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.

TRIVIAL

This early return also skips change.nodeIds.push(nodeId), so a malformed stored rect turns a resize into a silent no-op with no change notification at all. The previous yNodeToLayout(ynode).position path defaulted the position and still applied the resize. Falling back to [0, 0, w, h] would keep the operation observable.

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.

Mechanics are right — the early return does skip change.nodeIds.push. Declining the [0, 0, w, h] fallback, for three reasons:

  1. The old path wasn't more tolerant, it was differently broken. yNodeRect (mappers.ts:40-42) is ynode.get('rect') ?? DEFAULT_NODE_RECT, so the default fired only on a missing rect. For a malformed one it returned it verbatim and const [x, y] = ... gave undefined, writing [undefined, undefined, w, h] into the CRDT. isNodeRect rejects both cases, which is a strict improvement on the second.
  2. The state isn't constructible. rect is written in four places, all 4-number tuples (mappers.ts:29, layoutStore.ts:994, :1020, :1107). The only ynode constructor is layoutToYNode, fed from Float64Array views via graphLayoutRegistration.ts:15. Nothing deletes the key, and there's no Yjs network provider or applyUpdate anywhere in src/, so no peer can inject a foreign shape.
  3. The suggested fallback is worse than the no-op. DEFAULT_NODE_RECT position is [0, 0], so it trades a silently-lost resize for a silent teleport to origin. readNodeRect (:363) already returns false on a malformed rect, so the early return is consistent with the store's own read contract.

You did surface something real next door, though: the three handlers now disagree. handleMoveNode (:992) still tolerates via yNodeToLayout(...).size, handleBatchUpdateBounds (:1100) uses isNodeRect only to gate the size comparison and still writes and still pushes nodeIds, and only handleResizeNode bails outright. If parity is worth having, the fix is to mirror handleBatchUpdateBounds — keep isNodeRect for the comparison, take position from yNodeToLayout — not to restore the origin default. Leaving that out of this PR; happy to take it as a follow-up if you'd rather have the three consistent.

@benjcooley benjcooley left a comment

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.

Review summary

Solid refactor overall — deleting useLayoutSync and its RAF/microtask writeback machinery is a real simplification, the sizeChangedNodeIds signal is a clean replacement for the old per-node diffing, notifyLayoutChanges is small and well-tested (the same-id/different-root-graph test is a nice touch), and unregisterNodeLayout correctly snapshots the stored rect before deleting so remove/re-add round-trips.

One blocking issue: the whole-value setters mark the geometry cache fresh without refreshing it, which is not only the read staleness the test.fails cases document but also a store write-back corruption path.

Severity Count
BLOCKER 1
SHOULD FIX 1
TRIVIAL 1

BLOCKER

LGraphNode.set pos / set size write _pos / _size directly, then set _geometryVersion = layoutStore.geometryVersion. When the store changed since the last refresh, the untouched half of _posSize is stale yet pinned as current, so nothing refreshes it again. A subsequent _sizeUpdated() then commits that stale value back into the store and out to CRDT peers. this.refreshGeometry() at the top of both setters — the same thing move() and snapToGrid() already do — appears to close it without waiting for #14480.

Notes, not blocking

  • geometryVersion is store-global, so any group/reroute/link mutation invalidates every node's projection. Still a large win over 44 reads/frame, and item 3 of the deferred plan covers it — just flagging that the benchmark in the description overstates the steady-state benefit during interaction.
  • resizeNode is only ever reached with LayoutSource.Canvas in production (_sizeUpdated is its sole caller); the non-Canvas branch in notifyLayoutChanges is exercised only via batchUpdateNodeBounds, which force-sets Vue. Worth knowing that the source filter's correctness rests on batchUpdateNodeBounds overriding the sticky currentSource.
  • Shipping test.fails to an integration branch is a real risk if #14480 slips. Vitest will surface the unexpected pass, so the tripwire works, but the branch carries the defect in the meantime.

`_pos` and `_size` are halves of one `_posSize` rect, and `refreshGeometry`
is all-or-nothing. Stamping `_geometryVersion` to the post-commit store
version marked the untouched half fresh while it was still stale, so the
next commit of that half published the stale value to the store and to CRDT
peers. Reading through the proxy did not heal it — the version gate made the
getter a no-op.

Invalidate and refresh instead, which also picks up any value the store
clamped or merged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViGrcXvEeeL8f5TPUZiqpv
@DrJKL

DrJKL commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

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

@benjcooley — the BLOCKER is fixed in 6ebe5de (replies on the three threads). Picking up the two non-blocking notes from your summary:

geometryVersion is store-global. Right about groups and reroutes, not about links. Bump sites are finalizeOperation (layoutStore.ts:850), ynodes.observe (:200, shallow — create/delete only), ygroups.observeDeep (:209, so a group drag bumps twice per frame), and yreroutes.observe (:214, shallow). But updateLinkLayout (:382), updateSlotLayout (:411), and batchUpdateSlotLayouts (:435) all write plain JS Maps and never touch version — a link drag produces zero geometry invalidations.

Magnitude is smaller than the note implies. refreshGeometry self-limits: one readNodeRect after the version check fails, then every remaining access that frame hits the integer compare. Per node per frame at 44 accesses that's ~328 ns against ~264 ns for ideal per-node versioning — ~24% above ideal, but ~2.3% of the ~2794 ns pre-PR baseline. So "overstates the steady-state benefit" is true by single-digit percent, not enough to reframe the benchmark. The genuinely pathological case is a mutation committed mid-draw, where nodes drawn after it re-read and cost degrades back toward baseline.

Item 3 in the plan does cover it, and names node/group/reroute while correctly omitting links. Adjacent and unraised: LGraphGroup.syncBoundsFromStore (LGraphGroup.ts:139) has no version gate at all, so groups are strictly worse off than the nodes under discussion.

resizeNode source filter. Accurate. _sizeUpdated is the sole production caller and always sets Canvas on the preceding line. The one alternative producer — the getNodeLayoutRef customRef setter (:295), which uses the sticky currentSource — is dead in production; all 14 non-test call sites read .value and none assign.

Live evidence for your caution: LGraphCanvas.ts:4373 sets Canvas and then calls batchUpdateNodeBounds, which relabels that paste as Vue. Harmless only because paste writes each node's existing size, so the rect[2] !== bounds.width guard never records a change. If that path ever changes bounds it fires onResize for a Canvas-originated mutation — exactly the double-callback the filter exists to prevent.

Related: the same sticky-source design produces a real bug next door, filed as #14840useVueNodeResizeTracking.ts:254 sets LayoutSource.DOM and never restores it, and batchUpdateNodeBounds restores to originalSource, i.e. back to DOM. While it's stuck there, batchUpdateNodeBounds applies removeNodeTitleHeight to already-normalized store heights, shrinking the node by a title height per move. Pre-existing, out of scope here.

@DrJKL
DrJKL requested a review from benjcooley August 6, 2026 21:37
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViGrcXvEeeL8f5TPUZiqpv
@DrJKL DrJKL closed this Aug 6, 2026
@DrJKL DrJKL reopened this Aug 6, 2026
@DrJKL
DrJKL merged commit 867ac42 into feature/ecs-migration Aug 6, 2026
92 of 112 checks passed
@DrJKL
DrJKL deleted the drjkl/node-geometry-facade branch August 6, 2026 23:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

preview refactor size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants