fix(layout): enforce per-instance geometry ownership - #14480
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThe PR introduces registration-aware layout storage for nodes, groups, and reroutes. It adds ownership validation, operation results, geometry notifications, rollback and compensation during graph lifecycle changes, subgraph configuration handling, and extensive test coverage. ChangesLayout storage and operation contracts
Graph lifecycle integration
Rendering and UI synchronization
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant Graph as LGraph
participant Registration as graphLayoutRegistration
participant Store as layoutStore
participant Canvas as LGraphCanvas
Graph->>Registration: register or detach entity layouts
Registration->>Store: apply ownership-aware operations
Store-->>Registration: applied, no-op, or rejected
Store->>Canvas: notify graph geometry changes
Canvas->>Canvas: mark rendering dirty or resize affected nodes
Possibly related PRs
Suggested labels: Suggested reviewers: Important Pre-merge checks failedPlease resolve all errors before merging. Addressing warnings is optional. ❌ Failed checks (1 inconclusive)
✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
🎨 Storybook: ✅ Built — View Storybook🎭 Playwright: ✅ 1767 passed, 0 failed · 1 flaky📊 Browser Reports
📦 Bundle Size
⚡ Performance Report
Absolute values
Raw data{
"timestamp": "2026-08-09T12:03:53.501Z",
"gitSha": "c998a58ccb13bcd778ac52325f93332694eabb36",
"branch": "drjkl/layout-crdt-safety",
"measurements": [
{
"name": "canvas-idle",
"durationMs": 2011.8499999999813,
"styleRecalcs": 9,
"styleRecalcDurationMs": 10.442000000000002,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 521.572,
"heapDeltaBytes": 4543876,
"heapUsedBytes": 74447424,
"domNodes": 18,
"jsHeapTotalBytes": 19001344,
"scriptDurationMs": 27.384999999999998,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66999999999998,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "canvas-idle",
"durationMs": 2046.0390000000075,
"styleRecalcs": 10,
"styleRecalcDurationMs": 10.445,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 517.734,
"heapDeltaBytes": 4472080,
"heapUsedBytes": 74262732,
"domNodes": 20,
"jsHeapTotalBytes": 19263488,
"scriptDurationMs": 26.581,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "canvas-mouse-sweep",
"durationMs": 2079.7430000000077,
"styleRecalcs": 80,
"styleRecalcDurationMs": 48.540000000000006,
"layouts": 12,
"layoutDurationMs": 4.131,
"taskDurationMs": 1089.9950000000001,
"heapDeltaBytes": 2147708,
"heapUsedBytes": 72029128,
"domNodes": 62,
"jsHeapTotalBytes": 20312064,
"scriptDurationMs": 202.53000000000003,
"eventListeners": 6,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "canvas-mouse-sweep",
"durationMs": 1904.2409999999563,
"styleRecalcs": 75,
"styleRecalcDurationMs": 41.749,
"layouts": 12,
"layoutDurationMs": 3.8189999999999995,
"taskDurationMs": 993.8609999999999,
"heapDeltaBytes": -22083856,
"heapUsedBytes": 47886360,
"domNodes": -277,
"jsHeapTotalBytes": 19390464,
"scriptDurationMs": 189.61299999999997,
"eventListeners": -152,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "canvas-zoom-sweep",
"durationMs": 1756.0740000000123,
"styleRecalcs": 33,
"styleRecalcDurationMs": 19.988,
"layouts": 6,
"layoutDurationMs": 0.6510000000000001,
"taskDurationMs": 429.229,
"heapDeltaBytes": -18782540,
"heapUsedBytes": 51043844,
"domNodes": 79,
"jsHeapTotalBytes": 18739200,
"scriptDurationMs": 31.392000000000003,
"eventListeners": 19,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "canvas-zoom-sweep",
"durationMs": 1744.401000000039,
"styleRecalcs": 31,
"styleRecalcDurationMs": 17.770999999999994,
"layouts": 6,
"layoutDurationMs": 0.6539999999999998,
"taskDurationMs": 455.878,
"heapDeltaBytes": -18845124,
"heapUsedBytes": 51066176,
"domNodes": 76,
"jsHeapTotalBytes": 18739200,
"scriptDurationMs": 37.986,
"eventListeners": 19,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "dom-widget-clipping",
"durationMs": 624.0349999999921,
"styleRecalcs": 11,
"styleRecalcDurationMs": 7.418000000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 413.811,
"heapDeltaBytes": -9992772,
"heapUsedBytes": 59988404,
"domNodes": 18,
"jsHeapTotalBytes": 20574208,
"scriptDurationMs": 92.32399999999998,
"eventListeners": 2,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333335,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "dom-widget-clipping",
"durationMs": 628.1460000000152,
"styleRecalcs": 11,
"styleRecalcDurationMs": 7.854000000000002,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 429.145,
"heapDeltaBytes": -9734944,
"heapUsedBytes": 60247968,
"domNodes": 18,
"jsHeapTotalBytes": 19263488,
"scriptDurationMs": 94.85700000000001,
"eventListeners": 2,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "large-graph-idle",
"durationMs": 2069.2339999999945,
"styleRecalcs": 9,
"styleRecalcDurationMs": 8.7,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 929.1099999999999,
"heapDeltaBytes": -27398592,
"heapUsedBytes": 55010856,
"domNodes": -271,
"jsHeapTotalBytes": 2277376,
"scriptDurationMs": 300.48900000000003,
"eventListeners": -148,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "large-graph-idle",
"durationMs": 2041.3849999999911,
"styleRecalcs": 7,
"styleRecalcDurationMs": 6.034999999999998,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 972.049,
"heapDeltaBytes": 17928924,
"heapUsedBytes": 78612896,
"domNodes": -274,
"jsHeapTotalBytes": -987136,
"scriptDurationMs": 306.556,
"eventListeners": -148,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "large-graph-pan",
"durationMs": 3014.1679999999837,
"styleRecalcs": 67,
"styleRecalcDurationMs": 11.919999999999996,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 2043.0410000000002,
"heapDeltaBytes": 23665436,
"heapUsedBytes": 85613080,
"domNodes": -279,
"jsHeapTotalBytes": 528384,
"scriptDurationMs": 1168.943,
"eventListeners": -146,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333335,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "large-graph-pan",
"durationMs": 3188.3209999999735,
"styleRecalcs": 68,
"styleRecalcDurationMs": 13.212000000000002,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 2130.402,
"heapDeltaBytes": 4412744,
"heapUsedBytes": 66447816,
"domNodes": -278,
"jsHeapTotalBytes": 1052672,
"scriptDurationMs": 1233.8120000000001,
"eventListeners": -146,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "large-graph-zoom",
"durationMs": 4187.2950000000055,
"styleRecalcs": 62,
"styleRecalcDurationMs": 11.382,
"layouts": 60,
"layoutDurationMs": 7.526000000000001,
"taskDurationMs": 2304.6530000000002,
"heapDeltaBytes": 12633936,
"heapUsedBytes": 78242728,
"domNodes": -284,
"jsHeapTotalBytes": 3207168,
"scriptDurationMs": 1287.4460000000001,
"eventListeners": -148,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "large-graph-zoom",
"durationMs": 4177.065999999968,
"styleRecalcs": 64,
"styleRecalcDurationMs": 13.689,
"layouts": 60,
"layoutDurationMs": 7.8069999999999995,
"taskDurationMs": 2231.678,
"heapDeltaBytes": 22428772,
"heapUsedBytes": 86475424,
"domNodes": 10,
"jsHeapTotalBytes": 4194304,
"scriptDurationMs": 1271.744,
"eventListeners": 12,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "legacy-node-drag",
"durationMs": 4164.017000000002,
"styleRecalcs": 44,
"styleRecalcDurationMs": 7.483999999999997,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 2899.839,
"heapDeltaBytes": 6710004,
"heapUsedBytes": 69938124,
"domNodes": -282,
"jsHeapTotalBytes": 9641984,
"scriptDurationMs": 689.661,
"eventListeners": -103,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "legacy-node-drag",
"durationMs": 4200.832999999989,
"styleRecalcs": 44,
"styleRecalcDurationMs": 6.857999999999996,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 3033.418,
"heapDeltaBytes": 7647536,
"heapUsedBytes": 70531756,
"domNodes": -285,
"jsHeapTotalBytes": 10690560,
"scriptDurationMs": 706.6320000000001,
"eventListeners": -104,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "minimap-idle",
"durationMs": 2046.0310000000277,
"styleRecalcs": 6,
"styleRecalcDurationMs": 5.4079999999999995,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 902.772,
"heapDeltaBytes": 18358588,
"heapUsedBytes": 80531140,
"domNodes": -278,
"jsHeapTotalBytes": -462848,
"scriptDurationMs": 288.93100000000004,
"eventListeners": -148,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "minimap-idle",
"durationMs": 2042.4270000000888,
"styleRecalcs": 6,
"styleRecalcDurationMs": 6.242000000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 936.51,
"heapDeltaBytes": 9633048,
"heapUsedBytes": 71952376,
"domNodes": -279,
"jsHeapTotalBytes": -200704,
"scriptDurationMs": 298.481,
"eventListeners": -148,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "subgraph-dom-widget-clipping",
"durationMs": 649.1479999999683,
"styleRecalcs": 47,
"styleRecalcDurationMs": 11.148,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 442.87999999999994,
"heapDeltaBytes": -9780132,
"heapUsedBytes": 60225224,
"domNodes": 20,
"jsHeapTotalBytes": 19525632,
"scriptDurationMs": 146.92200000000003,
"eventListeners": 8,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "subgraph-dom-widget-clipping",
"durationMs": 669.1260000000057,
"styleRecalcs": 46,
"styleRecalcDurationMs": 10.581999999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 444.5559999999999,
"heapDeltaBytes": -10121720,
"heapUsedBytes": 59830496,
"domNodes": 18,
"jsHeapTotalBytes": 19001344,
"scriptDurationMs": 153.81,
"eventListeners": 8,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "subgraph-idle",
"durationMs": 2040.5790000000366,
"styleRecalcs": 10,
"styleRecalcDurationMs": 9.937999999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 499.006,
"heapDeltaBytes": 3835372,
"heapUsedBytes": 73819204,
"domNodes": 20,
"jsHeapTotalBytes": 19001344,
"scriptDurationMs": 20.34,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333335,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "subgraph-idle",
"durationMs": 2004.9840000000358,
"styleRecalcs": 10,
"styleRecalcDurationMs": 10.795,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 554.008,
"heapDeltaBytes": -20517244,
"heapUsedBytes": 49505676,
"domNodes": -272,
"jsHeapTotalBytes": 18341888,
"scriptDurationMs": 20.873,
"eventListeners": -152,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-mouse-sweep",
"durationMs": 1741.0390000000007,
"styleRecalcs": 76,
"styleRecalcDurationMs": 39.797,
"layouts": 16,
"layoutDurationMs": 5.235,
"taskDurationMs": 892.1640000000001,
"heapDeltaBytes": -20689664,
"heapUsedBytes": 49337828,
"domNodes": -273,
"jsHeapTotalBytes": 18866176,
"scriptDurationMs": 126.284,
"eventListeners": -152,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.670000000000012,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-mouse-sweep",
"durationMs": 1721.8619999999873,
"styleRecalcs": 75,
"styleRecalcDurationMs": 39.046,
"layouts": 16,
"layoutDurationMs": 4.917000000000001,
"taskDurationMs": 875.146,
"heapDeltaBytes": -19637200,
"heapUsedBytes": 50402204,
"domNodes": -275,
"jsHeapTotalBytes": 18604032,
"scriptDurationMs": 122.20800000000001,
"eventListeners": -152,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-transition-enter",
"durationMs": 1402.5869999999827,
"styleRecalcs": 18,
"styleRecalcDurationMs": 31.125,
"layouts": 14,
"layoutDurationMs": 12.544,
"taskDurationMs": 1023.6160000000001,
"heapDeltaBytes": 4587220,
"heapUsedBytes": 96547048,
"domNodes": 13671,
"jsHeapTotalBytes": 10485760,
"scriptDurationMs": 56.641999999999996,
"eventListeners": 2377,
"totalBlockingTimeMs": 131,
"frameDurationMs": 16.66333333333338,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "viewport-pan-sweep",
"durationMs": 10617.895999999973,
"styleRecalcs": 250,
"styleRecalcDurationMs": 38.195,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 7283.721,
"heapDeltaBytes": 26895900,
"heapUsedBytes": 87559516,
"domNodes": -275,
"jsHeapTotalBytes": 499712,
"scriptDurationMs": 3997.198,
"eventListeners": -126,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "viewport-pan-sweep",
"durationMs": 10845.394000000055,
"styleRecalcs": 249,
"styleRecalcDurationMs": 36.25599999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 7362.995,
"heapDeltaBytes": 9678040,
"heapUsedBytes": 92259668,
"domNodes": -274,
"jsHeapTotalBytes": 4550656,
"scriptDurationMs": 4050.8889999999997,
"eventListeners": -126,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "vue-large-graph-idle",
"durationMs": 17521.634000000005,
"styleRecalcs": 0,
"styleRecalcDurationMs": 0,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 17500.879999999997,
"heapDeltaBytes": -42380184,
"heapUsedBytes": 165435704,
"domNodes": -6548,
"jsHeapTotalBytes": 5828608,
"scriptDurationMs": 1162.3709999999999,
"eventListeners": -7471,
"totalBlockingTimeMs": 0,
"frameDurationMs": 17.776666666666763,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "vue-large-graph-idle",
"durationMs": 17899.871000000076,
"styleRecalcs": 0,
"styleRecalcDurationMs": 0,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 17879.426000000003,
"heapDeltaBytes": -24189324,
"heapUsedBytes": 164801184,
"domNodes": -6552,
"jsHeapTotalBytes": 323584,
"scriptDurationMs": 1299.564,
"eventListeners": -7469,
"totalBlockingTimeMs": 0,
"frameDurationMs": 17.776666666666642,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "vue-large-graph-pan",
"durationMs": 21429.883000000016,
"styleRecalcs": 152,
"styleRecalcDurationMs": 20.71500000000004,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 21405.408000000003,
"heapDeltaBytes": -36375060,
"heapUsedBytes": 168908128,
"domNodes": -6552,
"jsHeapTotalBytes": 1372160,
"scriptDurationMs": 1707.699,
"eventListeners": -7465,
"totalBlockingTimeMs": 430,
"frameDurationMs": 18.333333333333332,
"p95FrameDurationMs": 16.80000000000291
},
{
"name": "vue-large-graph-pan",
"durationMs": 21748.080999999955,
"styleRecalcs": 156,
"styleRecalcDurationMs": 21.780999999999995,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 21724.951,
"heapDeltaBytes": -21329368,
"heapUsedBytes": 167330948,
"domNodes": -6548,
"jsHeapTotalBytes": 1372160,
"scriptDurationMs": 1812.0529999999999,
"eventListeners": -7463,
"totalBlockingTimeMs": 505,
"frameDurationMs": 17.776666666666642,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "workflow-execution",
"durationMs": 453.9310000000114,
"styleRecalcs": 13,
"styleRecalcDurationMs": 19.871,
"layouts": 3,
"layoutDurationMs": 0.6519999999999998,
"taskDurationMs": 134.01100000000002,
"heapDeltaBytes": -15739292,
"heapUsedBytes": 52827652,
"domNodes": 130,
"jsHeapTotalBytes": 6418432,
"scriptDurationMs": 11.622,
"eventListeners": 65,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "workflow-execution",
"durationMs": 455.1370000000361,
"styleRecalcs": 13,
"styleRecalcDurationMs": 23.113,
"layouts": 4,
"layoutDurationMs": 1.3579999999999999,
"taskDurationMs": 131.45800000000003,
"heapDeltaBytes": -15908924,
"heapUsedBytes": 52496672,
"domNodes": 119,
"jsHeapTotalBytes": 6418432,
"scriptDurationMs": 11.151,
"eventListeners": 65,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
}
]
} |
2e3020c to
6946098
Compare
5e85c8f to
48073f7
Compare
6946098 to
513d33f
Compare
f3e0c79 to
885b8db
Compare
513d33f to
d52bd10
Compare
885b8db to
ef7dae4
Compare
d52bd10 to
b6c1c5c
Compare
ef7dae4 to
0c4bb12
Compare
0c4bb12 to
65ffdc3
Compare
2f38faa to
eb06b67
Compare
65ffdc3 to
b7ca00a
Compare
eb06b67 to
1d5cb40
Compare
c95b8eb to
65f0318
Compare
329456c to
b481407
Compare
…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>
Preserve concurrent node geometry updates and project remote CRDT updates through the store instead of overwriting local state.
Make layout command outcomes explicit and guard ownership teardown so a rejected claim cannot tear down layout it does not own.
Preserve layout state when a teardown claim is rejected instead of applying a partial removal.
…llocation module Amp-Thread-ID: https://ampcode.com/threads/T-019fe0df-168d-72fb-9ce6-b64cd5911165 Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019fe0df-168d-72fb-9ce6-b64cd5911165 Co-authored-by: Amp <amp@ampcode.com>
Introduce branded RootGraphId/OwningGraphId and GraphScope in a dependency-free leaf module and thread (root, owner) scope through the link and reroute store APIs and all call sites. Storage stays keyed by root graph only; the owning graph is retained but not yet load-bearing, in preparation for owner-scoped topology buckets. - NodeSlots resolves the owner from the rendered node's graphId, not the active canvas graph, so previews and off-screen graphs cannot read another graph's same-ID slots once storage is owner-keyed - LLink retains a single _graphScope registration identity (legacy _graphId removed) - Private store maps are keyed by RootGraphId so the root-only storage invariant is compiler-enforced - Tests query each entity through the scope of the graph that directly contains it, and capture scopes before clear()/configure() so teardown assertions survive graph id reassignment Amp-Thread-ID: https://ampcode.com/threads/T-019fe0df-168d-72fb-9ce6-b64cd5911165 Co-authored-by: Amp <amp@ampcode.com>
Partition link and reroute topology stores into per-owner buckets (Map<RootGraphId, Map<OwningGraphId, bucket>>) so sibling subgraph definitions can hold identical link IDs and endpoints without collision. getLink is now O(1) via an owner-local byId index. - add owner-local clearOwner alongside root-wide clearGraph - prune empty owner buckets on deletion; reads never create state - advance the shared link ID counter when floating links are minted or restored, and remint explicit floating IDs that collide with live or floating links (legacy workflows may contain duplicates) - derive reroute membership caches per owner bucket - remove expected-failure markers from the subgraph link identity repros, which now pass Amp-Thread-ID: https://ampcode.com/threads/T-019fe0df-168d-72fb-9ce6-b64cd5911165 Co-authored-by: Amp <amp@ampcode.com>
…lisions configure now clears the configured graph's owner-scoped link/reroute topology before materializing the payload, making serialized data authoritative: - Remove collision-based adoption from registerLinkTopology and registerRerouteChain; delete hasSameEndpoints. Serialized parentId (reroute chains) can no longer be silently discarded in favor of stale persisted topology. - keep_old retains graph identity, counters, and node semantics but rebuilds all topology from the payload: old registrations are detached, owner store buckets cleared, graph link/reroute/floating maps reset, and attached canvases cleared to drop stale selections. - Reserve reroute IDs only for root-partition owners that are NOT being rebuilt (retained sibling definitions), so incoming definitions deduplicate against them while the configured owner's own IDs are free to be reused. - _addLink is first-wins on duplicate serialized link IDs, matching the store policy and preventing graph-map/store desync. - _addReroute's remaining flag is layout-only (adoptLayout). The keep_old reroute-chain reload repro (LLink.adoptionIdentity) now passes and the file pins rebuild semantics: stale-topology removal, sibling-owner isolation, and deterministic repeated configure. Amp-Thread-ID: https://ampcode.com/threads/T-019fe0df-168d-72fb-9ce6-b64cd5911165 Co-authored-by: Amp <amp@ampcode.com>
…dpoints Amp-Thread-ID: https://ampcode.com/threads/T-019fe0df-168d-72fb-9ce6-b64cd5911165 Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019fe0df-168d-72fb-9ce6-b64cd5911165 Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019fe0df-168d-72fb-9ce6-b64cd5911165 Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019fe0df-168d-72fb-9ce6-b64cd5911165 Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019fe0df-168d-72fb-9ce6-b64cd5911165 Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019fe0df-168d-72fb-9ce6-b64cd5911165 Co-authored-by: Amp <amp@ampcode.com>
… store deletion Amp-Thread-ID: https://ampcode.com/threads/T-019fe0df-168d-72fb-9ce6-b64cd5911165 Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019fe0df-168d-72fb-9ce6-b64cd5911165 Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019fe0df-168d-72fb-9ce6-b64cd5911165 Co-authored-by: Amp <amp@ampcode.com>
Adds a behavioral test freezing first-wins restore semantics: a stale detach restore yields to an intervening entry at the same key. Amp-Thread-ID: https://ampcode.com/threads/T-019fe0df-168d-72fb-9ce6-b64cd5911165 Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019fe0df-168d-72fb-9ce6-b64cd5911165 Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019fe0df-168d-72fb-9ce6-b64cd5911165 Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019fe0df-168d-72fb-9ce6-b64cd5911165 Co-authored-by: Amp <amp@ampcode.com>
Split 1/6 of #14480 (see that PR for the full map). Collapses the copy-pasted `lastLinkId`/`lastNodeId`/`lastGroupId`/`lastRerouteId` increment-and-sync idioms across `LGraph`, `LGraphNode`, subgraph slots, and `subgraphDeduplication` into `mint*`/`observe*` helpers in `src/lib/litegraph/src/idAllocation.ts`. No behavior change. Notes for review: - Module lives in `src/lib/litegraph/src/` (litegraph-only behavior), not `src/types/` as in #14480. - `snapshotIdState`/`restoreIdState` from #14480 are omitted — no callers until the layout rollback PR; they land there. - `LGraphState` is re-exported from `LGraph.ts` so existing importers are unaffected.
|
Superseded — split into reviewable PRs after the net diff was mapped and pruned:
Branch kept for reference. |
Split 2/6 of #14480. Stacked on #15001. Fixes cross-subgraph-definition topology contamination: `linkStore`/`rerouteStore` buckets were keyed only by root graph id, but every subgraph definition numbers links from 1 and shares the IO pseudo-node ids (−10/−20). Two definitions under one root therefore shared an origin index and reroute membership — one definition's links joined another definition's reroute chains, driving wrong reroute pruning on configure. Repro: `browser_tests/tests/subgraph/subgraphLinkIdentity.spec.ts` + fixture. Changes: - Adds `GraphScope` (`rootGraphId`, `owningGraphId`) and scopes topology queries, owner-local iteration, and teardown to the graph that directly owns each entity. Root-wide identity lookup remains keyed by `rootGraphId`. - Makes `linkStore`, `rerouteStore`, and `nodeDataStore` the authoritative collision boundaries. Registrations are first-wins; rejected entities remain detached and are not assigned to the requesting owner. - Shares link and reroute ID allocation across a root and its subgraphs. Serialized subgraphs are deduplicated before registration so link, reroute, node, widget, and promoted-widget references are remapped together. - Keeps endpoint swaps, rotations, and removals atomic within `linkStore`: an entire batch is validated before indexes or topology fields change. `LLink` retains the store-held reactive topology object across ID and endpoint updates. - Treats configure payloads as authoritative for their owner by clearing owner-local registrations before rebuilding them, with empty root-bucket pruning. - Derives topology scope from the directly owning graph at callers such as `NodeSlots.vue` and `useProcessedWidgets.ts`. - Positions promoted DOM widgets immediately on mount instead of waiting for a watched value to change. - Removes redundant graph-local collision checks, floating-link ID bookkeeping, unreachable tuple deduplication, obsolete compatibility types, and mock-only tests. - Documents root-vs-owner query guidance, topology transaction boundaries, and the deferred measurement of root-wide group scans before introducing another persistent index. Behavior notes: - Constructing or configuring an `LGraph` from serialized data requires an active Pinia because entities register with topology stores during deserialization. - Runtime duplicate IDs are rejected by the root-scoped store; persisted collisions are remapped before object registration. - Registration failures remain explicit return values and do not throw errors. --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: github-actions <github-actions@github.com>
Split 3/6 of #14480. Stacked on #15009. The original version replaced every rewired link with a fresh ID. #15009 now addresses that root cause more simply: endpoint changes are validated and applied atomically at the link-store mutation boundary while preserving link identity. This PR has therefore been reduced to the remaining independent cleanup. `LGraph._unpackSubgraphImpl()` previously rewrote the registered links in the shared subgraph definition while calculating their parent-graph endpoints. Unpacking one instance could transiently reindex or partially mutate topology still used by another instance. This change computes mapped origin/target endpoints in local variables, leaving the source definition and its store registration untouched. The behavioral test keeps another instance of the definition alive, unpacks one instance, and verifies both the original topology and copied parent link. Deliberately omitted: - no `linkReplacement.ts` - no fresh IDs for endpoint changes - no immutable/warn-and-ignore endpoint setters - no second topology mutation boundary outside `linkStore` Test plan: - `pnpm test:unit src/lib/litegraph/src/subgraph/SubgraphConversion.test.ts` - typecheck, changed-file lint/format, and diff checks --------- Co-authored-by: Amp <amp@ampcode.com>
Split 5/6 of #14480. Stacked on #15017. Node replacement bypasses `graph.add()`. Copying `pos` and `size` preserved legacy canvas geometry only; the replacement did not own the existing layout attachment and could lose its position when Vue Nodes read from the layout store. This change centralizes replacement ownership transfer in the existing node-replacement subsystem: - Preflights graph indices, node-state store ownership, same-ID identity, and layout attachment before mutation. - Runs the existing `onRemoved` callback while ownership is unchanged, then revalidates before committing. - Moves the existing registered node-state proxy and layout attachment to the replacement without delete/recreate windows. - Reads canonical geometry before stamping the replacement’s geometry version. - Detaches the old instance from graph, state, and layout ownership so retained stale references cannot mutate the replacement. - Keeps orchestration private to `useNodeReplacement`; no new public `LGraph` mutation API. The browser regression performs replacement, drags the node on the legacy canvas, enables Vue Nodes, and verifies the rendered position remains within 2px. Unit coverage verifies exact state transfer, graph identity preservation, stale-instance isolation, mismatched ownership rejection, and latest-geometry adoption. Test plan: - 178 focused node replacement, node store, LGraph, and LGraphNode tests - application and browser typechecks - changed-file lint/format and diff checks --------- Co-authored-by: Amp <amp@ampcode.com>
Summary
Protects layout geometry from stale LiteGraph instances: only the instance that registered a node/group/reroute layout may mutate or delete it, ownership transfers explicitly during node replacement, and every layout operation reports
applied/no-op/rejected. Node position and size are stored as independent Yjs registers.An earlier revision of this PR also shipped retry/compensation sagas, two-phase prepare/revalidate mutations, pending-registration reconciliation, and schema-migration fallbacks aimed at a multiplayer transport that does not exist yet. The layout store is a private, synchronous, in-memory
Y.Docwith no provider, no remote writer, and no persistence — so that machinery defended unreachable states and has been removed (−877 net production lines, −849 net test lines vs. that revision). It should return together with the ingress path (provider/update application) when multiplayer actually ships.Changes
no-op/rejectedinstead of clobbering live geometry.undefined= legacy tokenless; non-empty string = owned;''is invalid).position/sizesplit into independent Yjs registers (no legacyrectfallback — the doc is never persisted, so no old documents exist).withDeferredNotificationsduring multi-entity operations.Review Focus
LGraph— fallible callbacks run before any layout unregistration, which is what made compensation machinery unnecessary.hasSameEndpointsguard inLLink.registerLinkTopology: adoption of persisted topology is now conditional on origin/target node+slot equality.beforeTransaction, and callback-failure rollback. Synthetic tests that patched Yjs internals to fabricate impossible failures were deleted along with the code they justified.Test Plan
pnpm test:unit --changed origin/feature/ecs-migration: 9,479 passed; the 5 persistent failures reproduce identically on the base branch (pre-existing, verified via detached checkout)pnpm typecheck,pnpm knip, ESLint, Oxfmt,git diff --checknodeReplacement.spec.ts,rerouteGeometry.spec.ts) via CIStack notes
Builds on #14133 (store-backed node geometry) and #14128 (attach/detach layout lifetime), both already in the
feature/ecs-migrationbase. The publicpos/sizefacade from #14133 is preserved; no registration token is exposed through the public LiteGraph geometry API.