fix: decouple subgraph preview/output display from the promotion system - #14569
fix: decouple subgraph preview/output display from the promotion system#14569claude[bot] wants to merge 15 commits into
Conversation
…or preview Two KSamplers in one subgraph, only one wired into previewExposureStore (the other simulates a node added after the subgraph's one-shot auto-expose already ran). The unit test targets the not-yet-existing useAmbientSubgraphPreviews composable; the e2e spec drives two live b_preview WS frames at distinct interior execution IDs and asserts both show on the collapsed SubgraphNode host.
…t exposures Subgraph previews were 1:1 with previewExposureStore, so a second interior node producing output concurrently (e.g. a 2nd KSampler with no exposure) had nowhere to render and stayed invisible on the collapsed host. Add useAmbientSubgraphPreviews, a computed rollup that mirrors how executionStore's nodeLocationProgressStates bubbles up progress regardless of promotion: it reads each direct interior node's own output/preview state from nodeOutputStore, independent of exposures. LGraphNode.vue merges it underneath the existing exposure-driven promotedPreviews (an explicit exposure still wins for that node), so every concurrently-executing interior node shows and updates on its own, not just the promoted one.
🎭 Playwright: ✅ 1989 passed, 0 failed · 1 flaky📊 Browser Reports
🎨 Storybook: ✅ Built — View Storybook📦 Bundle: 9.11 MB gzip 🔴 +1.04 kBDetailsSummary
Category Glance App Entry Points — 3.71 kB (baseline 3.71 kB) • ⚪ 0 BMain entry bundles and manifests
Status: 1 added / 1 removed Graph Workspace — 1.38 MB (baseline 1.38 MB) • 🔴 +3.44 kBGraph editor runtime, canvas, workflow orchestration
Status: 1 added / 1 removed / 2 unchanged Views & Navigation — 124 kB (baseline 124 kB) • ⚪ 0 BTop-level views, pages, and routed surfaces Status: 17 unchanged Panels & Settings — 591 kB (baseline 591 kB) • ⚪ 0 BConfiguration panels, inspectors, and settings screens Status: 27 unchanged User & Accounts — 27.5 kB (baseline 27.5 kB) • ⚪ 0 BAuthentication, profile, and account management bundles Status: 11 unchanged Editors & Dialogs — 125 kB (baseline 125 kB) • ⚪ 0 BModals, dialogs, drawers, and in-app editors Status: 8 unchanged UI Components — 67.1 kB (baseline 67.1 kB) • ⚪ 0 BReusable component library chunks Status: 14 unchanged Data & Services — 3.53 MB (baseline 3.53 MB) • ⚪ 0 BStores, services, APIs, and repositories Status: 17 unchanged Utilities & Hooks — 549 kB (baseline 549 kB) • ⚪ 0 BHelpers, composables, and utility bundles Status: 37 unchanged Vendor & Third-Party — 18.1 MB (baseline 18.1 MB) • ⚪ 0 BExternal libraries and shared vendor chunks Status: 18 unchanged Other — 14.1 MB (baseline 14.1 MB) • 🔴 +183 BBundles that do not match a named category
Status: 2 added / 2 removed / 283 unchanged ⚡ Performance Report
Show regressions
All metrics
Historical variance (last 15 runs)
Trend (last 15 commits on main)
Raw data{
"timestamp": "2026-08-23T08:19:03.141Z",
"gitSha": "37218003baa53910a2f6516210ff9ed58a37f5d7",
"branch": "fix/decouple-subgraph-preview-outputs",
"measurements": [
{
"name": "canvas-idle",
"durationMs": 2001.0839999999916,
"styleRecalcs": 9,
"styleRecalcDurationMs": 5.047,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 394.05199999999996,
"heapDeltaBytes": 6821972,
"heapUsedBytes": 68828492,
"domNodes": -282,
"jsHeapTotalBytes": 4448256,
"scriptDurationMs": 5.1709999999999985,
"eventListeners": -151,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "canvas-idle",
"durationMs": 2052.993000000015,
"styleRecalcs": 10,
"styleRecalcDurationMs": 5.421,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 379.802,
"heapDeltaBytes": 2855652,
"heapUsedBytes": 64807512,
"domNodes": -281,
"jsHeapTotalBytes": 3661824,
"scriptDurationMs": 5.387000000000001,
"eventListeners": -183,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "canvas-mouse-sweep",
"durationMs": 1742.1769999999697,
"styleRecalcs": 72,
"styleRecalcDurationMs": 25.522,
"layouts": 12,
"layoutDurationMs": 2.641,
"taskDurationMs": 685.917,
"heapDeltaBytes": -3662152,
"heapUsedBytes": 58848036,
"domNodes": -282,
"jsHeapTotalBytes": 4710400,
"scriptDurationMs": 72.95100000000001,
"eventListeners": -151,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "canvas-mouse-sweep",
"durationMs": 1700.3479999999627,
"styleRecalcs": 71,
"styleRecalcDurationMs": 22.875,
"layouts": 12,
"layoutDurationMs": 2.818,
"taskDurationMs": 633.1719999999999,
"heapDeltaBytes": -2029800,
"heapUsedBytes": 60089492,
"domNodes": -282,
"jsHeapTotalBytes": 4186112,
"scriptDurationMs": 70.15599999999999,
"eventListeners": -151,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "canvas-zoom-sweep",
"durationMs": 1666.565999999989,
"styleRecalcs": 29,
"styleRecalcDurationMs": 12.439,
"layouts": 6,
"layoutDurationMs": 0.6300000000000001,
"taskDurationMs": 283.839,
"heapDeltaBytes": 2633012,
"heapUsedBytes": 65336104,
"domNodes": 76,
"jsHeapTotalBytes": 4194304,
"scriptDurationMs": 8.858,
"eventListeners": 19,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "canvas-zoom-sweep",
"durationMs": 1666.291000000001,
"styleRecalcs": 29,
"styleRecalcDurationMs": 10.301000000000002,
"layouts": 6,
"layoutDurationMs": 0.535,
"taskDurationMs": 263.667,
"heapDeltaBytes": 2945588,
"heapUsedBytes": 65355940,
"domNodes": 76,
"jsHeapTotalBytes": 4718592,
"scriptDurationMs": 7.055000000000001,
"eventListeners": 19,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "dom-widget-clipping",
"durationMs": 436.56399999997575,
"styleRecalcs": 13,
"styleRecalcDurationMs": 5.7189999999999985,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 254.72100000000003,
"heapDeltaBytes": 9549580,
"heapUsedBytes": 72012260,
"domNodes": 22,
"jsHeapTotalBytes": 5242880,
"scriptDurationMs": 34.043,
"eventListeners": 0,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "dom-widget-clipping",
"durationMs": 428.99899999997615,
"styleRecalcs": 12,
"styleRecalcDurationMs": 5.613999999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 254.703,
"heapDeltaBytes": 9680688,
"heapUsedBytes": 71729172,
"domNodes": 20,
"jsHeapTotalBytes": 5242880,
"scriptDurationMs": 34.923,
"eventListeners": 0,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.663333333333338,
"p95FrameDurationMs": 16.700000000000273
},
{
"name": "large-graph-idle",
"durationMs": 2012.399999999957,
"styleRecalcs": 10,
"styleRecalcDurationMs": 5.190000000000002,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 472.43600000000004,
"heapDeltaBytes": -13365920,
"heapUsedBytes": 62777340,
"domNodes": -280,
"jsHeapTotalBytes": 4452352,
"scriptDurationMs": 13.028999999999998,
"eventListeners": -179,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "large-graph-idle",
"durationMs": 2023.8750000000891,
"styleRecalcs": 9,
"styleRecalcDurationMs": 7.616,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 489.159,
"heapDeltaBytes": -1380208,
"heapUsedBytes": 75263612,
"domNodes": -268,
"jsHeapTotalBytes": -1576960,
"scriptDurationMs": 12.188000000000004,
"eventListeners": -147,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "large-graph-pan",
"durationMs": 2111.013000000071,
"styleRecalcs": 70,
"styleRecalcDurationMs": 12.924000000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 876.5049999999999,
"heapDeltaBytes": -2429656,
"heapUsedBytes": 74835100,
"domNodes": -238,
"jsHeapTotalBytes": -1089536,
"scriptDurationMs": 291.839,
"eventListeners": -149,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "large-graph-pan",
"durationMs": 2078.2110000000102,
"styleRecalcs": 68,
"styleRecalcDurationMs": 11.873999999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 910.6179999999999,
"heapDeltaBytes": 11489440,
"heapUsedBytes": 88032084,
"domNodes": -284,
"jsHeapTotalBytes": 4677632,
"scriptDurationMs": 242.37800000000001,
"eventListeners": -179,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333335,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "large-graph-zoom",
"durationMs": 3093.9260000000104,
"styleRecalcs": 65,
"styleRecalcDurationMs": 14.745999999999999,
"layouts": 60,
"layoutDurationMs": 8.248999999999999,
"taskDurationMs": 1113.785,
"heapDeltaBytes": 17814588,
"heapUsedBytes": 96722520,
"domNodes": 12,
"jsHeapTotalBytes": 6328320,
"scriptDurationMs": 305.026,
"eventListeners": 8,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333335,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "large-graph-zoom",
"durationMs": 3055.511000000024,
"styleRecalcs": 66,
"styleRecalcDurationMs": 14.256999999999996,
"layouts": 60,
"layoutDurationMs": 7.257,
"taskDurationMs": 1060.6299999999999,
"heapDeltaBytes": -1736260,
"heapUsedBytes": 77252336,
"domNodes": -271,
"jsHeapTotalBytes": -2363392,
"scriptDurationMs": 281.45799999999997,
"eventListeners": -145,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "legacy-node-drag",
"durationMs": 2144.916999999964,
"styleRecalcs": 47,
"styleRecalcDurationMs": 9.814,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 1140.9099999999999,
"heapDeltaBytes": -23370852,
"heapUsedBytes": 63829192,
"domNodes": -244,
"jsHeapTotalBytes": 4976640,
"scriptDurationMs": 345.281,
"eventListeners": 33,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "legacy-node-drag",
"durationMs": 2131.799000000001,
"styleRecalcs": 48,
"styleRecalcDurationMs": 9.811999999999998,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 1107.713,
"heapDeltaBytes": -22667556,
"heapUsedBytes": 64082352,
"domNodes": -242,
"jsHeapTotalBytes": 3665920,
"scriptDurationMs": 339.88599999999997,
"eventListeners": 31,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66999999999998,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "minimap-idle",
"durationMs": 2049.74500000003,
"styleRecalcs": 8,
"styleRecalcDurationMs": 6.308999999999998,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 526.408,
"heapDeltaBytes": -12133600,
"heapUsedBytes": 72594956,
"domNodes": -277,
"jsHeapTotalBytes": 2617344,
"scriptDurationMs": 18.698000000000004,
"eventListeners": -149,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333335,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "minimap-idle",
"durationMs": 2052.632000000017,
"styleRecalcs": 8,
"styleRecalcDurationMs": 6.170999999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 464.667,
"heapDeltaBytes": -12839068,
"heapUsedBytes": 70287728,
"domNodes": -284,
"jsHeapTotalBytes": 3403776,
"scriptDurationMs": 10.602999999999998,
"eventListeners": -149,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-dom-widget-clipping",
"durationMs": 532.8499999999394,
"styleRecalcs": 48,
"styleRecalcDurationMs": 9.153999999999998,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 318.976,
"heapDeltaBytes": 11516912,
"heapUsedBytes": 74217588,
"domNodes": 22,
"jsHeapTotalBytes": 5242880,
"scriptDurationMs": 92.759,
"eventListeners": 6,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666682,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-dom-widget-clipping",
"durationMs": 508.27700000002096,
"styleRecalcs": 47,
"styleRecalcDurationMs": 7.527000000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 300.75100000000003,
"heapDeltaBytes": 11524248,
"heapUsedBytes": 73789496,
"domNodes": 20,
"jsHeapTotalBytes": 4718592,
"scriptDurationMs": 87.92099999999999,
"eventListeners": 6,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-idle",
"durationMs": 2009.510999999975,
"styleRecalcs": 10,
"styleRecalcDurationMs": 5.763000000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 384.14699999999993,
"heapDeltaBytes": 2191756,
"heapUsedBytes": 65033008,
"domNodes": -282,
"jsHeapTotalBytes": 4186112,
"scriptDurationMs": 5.063999999999999,
"eventListeners": -183,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333335,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "subgraph-idle",
"durationMs": 1992.3539999999775,
"styleRecalcs": 9,
"styleRecalcDurationMs": 5.552,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 380.24399999999997,
"heapDeltaBytes": 17835140,
"heapUsedBytes": 80374236,
"domNodes": -282,
"jsHeapTotalBytes": 4448256,
"scriptDurationMs": 5.073000000000001,
"eventListeners": -151,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-mouse-sweep",
"durationMs": 1692.9450000000088,
"styleRecalcs": 75,
"styleRecalcDurationMs": 26.255999999999993,
"layouts": 16,
"layoutDurationMs": 3.522,
"taskDurationMs": 608.107,
"heapDeltaBytes": -5646752,
"heapUsedBytes": 56078788,
"domNodes": -280,
"jsHeapTotalBytes": 5234688,
"scriptDurationMs": 54.589999999999996,
"eventListeners": -151,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-mouse-sweep",
"durationMs": 1689.4750000000158,
"styleRecalcs": 75,
"styleRecalcDurationMs": 24.532999999999998,
"layouts": 16,
"layoutDurationMs": 3.175,
"taskDurationMs": 628.185,
"heapDeltaBytes": -6837952,
"heapUsedBytes": 55625240,
"domNodes": -280,
"jsHeapTotalBytes": 4448256,
"scriptDurationMs": 58.984,
"eventListeners": -151,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333335,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "subgraph-transition-enter",
"durationMs": 898.7060000000611,
"styleRecalcs": 20,
"styleRecalcDurationMs": 23.96700000000001,
"layouts": 15,
"layoutDurationMs": 9.228000000000002,
"taskDurationMs": 663.538,
"heapDeltaBytes": -5895900,
"heapUsedBytes": 88438356,
"domNodes": 13673,
"jsHeapTotalBytes": 11796480,
"scriptDurationMs": 11.643999999999995,
"eventListeners": 2373,
"totalBlockingTimeMs": 99,
"frameDurationMs": 16.66333333333335,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "viewport-pan-sweep",
"durationMs": 8098.973999999998,
"styleRecalcs": 249,
"styleRecalcDurationMs": 37.816,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 3235.4210000000003,
"heapDeltaBytes": 15924040,
"heapUsedBytes": 91636612,
"domNodes": -282,
"jsHeapTotalBytes": 5201920,
"scriptDurationMs": 801.608,
"eventListeners": -163,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "viewport-pan-sweep",
"durationMs": 8121.497999999974,
"styleRecalcs": 251,
"styleRecalcDurationMs": 34.84,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 3102.152,
"heapDeltaBytes": 2990440,
"heapUsedBytes": 79963064,
"domNodes": -234,
"jsHeapTotalBytes": 1007616,
"scriptDurationMs": 764.0790000000001,
"eventListeners": -133,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.80000000000109
},
{
"name": "vue-large-graph-idle",
"durationMs": 13568.864000000076,
"styleRecalcs": 0,
"styleRecalcDurationMs": 0,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 12457.071000000002,
"heapDeltaBytes": -48680068,
"heapUsedBytes": 176209800,
"domNodes": -8312,
"jsHeapTotalBytes": -17866752,
"scriptDurationMs": 103.925,
"eventListeners": -16393,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333326,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "vue-large-graph-idle",
"durationMs": 10816.919999999982,
"styleRecalcs": 0,
"styleRecalcDurationMs": 0,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 10164.797,
"heapDeltaBytes": -47408556,
"heapUsedBytes": 178724460,
"domNodes": -8312,
"jsHeapTotalBytes": -14954496,
"scriptDurationMs": 63.98100000000001,
"eventListeners": -16395,
"totalBlockingTimeMs": 0,
"frameDurationMs": 17.220000000000073,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "vue-large-graph-pan",
"durationMs": 15471.435000000041,
"styleRecalcs": 85,
"styleRecalcDurationMs": 15.360999999999986,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 14706.85,
"heapDeltaBytes": -16744212,
"heapUsedBytes": 195931576,
"domNodes": -8312,
"jsHeapTotalBytes": -18198528,
"scriptDurationMs": 385.872,
"eventListeners": -16387,
"totalBlockingTimeMs": 0,
"frameDurationMs": 17.220000000000073,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "vue-large-graph-pan",
"durationMs": 14646.376999999917,
"styleRecalcs": 70,
"styleRecalcDurationMs": 14.069999999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 13952.967999999999,
"heapDeltaBytes": -19211940,
"heapUsedBytes": 193300952,
"domNodes": -8312,
"jsHeapTotalBytes": -16625664,
"scriptDurationMs": 363.1569999999999,
"eventListeners": -16391,
"totalBlockingTimeMs": 0,
"frameDurationMs": 17.220000000000073,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "workflow-execution",
"durationMs": 453.81299999996827,
"styleRecalcs": 21,
"styleRecalcDurationMs": 17.962,
"layouts": 5,
"layoutDurationMs": 1.586,
"taskDurationMs": 105.11099999999999,
"heapDeltaBytes": 5307696,
"heapUsedBytes": 67184204,
"domNodes": 152,
"jsHeapTotalBytes": 524288,
"scriptDurationMs": 9.536999999999999,
"eventListeners": 97,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666682,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "workflow-execution",
"durationMs": 466.86899999986053,
"styleRecalcs": 20,
"styleRecalcDurationMs": 14.791000000000002,
"layouts": 2,
"layoutDurationMs": 0.846,
"taskDurationMs": 94.16199999999999,
"heapDeltaBytes": 5055688,
"heapUsedBytes": 67011468,
"domNodes": 149,
"jsHeapTotalBytes": 0,
"scriptDurationMs": 5.514999999999999,
"eventListeners": 99,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.669999999999998,
"p95FrameDurationMs": 16.700000000000728
}
]
} |
Codecov Report❌ Patch coverage is
@@ Coverage Diff @@
## main #14569 +/- ##
==========================================
+ Coverage 81.32% 81.90% +0.57%
==========================================
Files 1882 1890 +8
Lines 107571 107310 -261
Branches 33984 33000 -984
==========================================
+ Hits 87483 87887 +404
+ Misses 19727 19076 -651
+ Partials 361 347 -14
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 215 files with indirect coverage changes 🚀 New features to boost your workflow:
|
useAmbientSubgraphPreviews treated any interior node with images in nodeOutputStore as a live preview candidate, including nodes whose only "output" is an input-type reference (e.g. an unpromoted LoadImage node's own selected file). That made every such node's thumbnail always visible on the collapsed subgraph host regardless of promotion state, breaking promotion exclusivity. CI caught this: browser_tests/tests/subgraph/subgraphPromotion.spec.ts "Can promote multiple previews" > "Promote both image previews" failed consistently (all 4 attempts) on PR #14569 - after promoting only one of two Load Image previews, the host showed 2 preview elements instead of 1, because the unpromoted node's own file leaked in via the new ambient rollup. Export isInputPreviewOutput from nodeOutputStore and use it to skip input-only outputs in the ambient computed, so only genuine live execution results (or streaming preview frames) surface ambiently - matching the feature's stated intent (a second concurrently-executing node with no exposure), not static input previews. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FCL4Meqh8o8VYh9xV1h1Ky
📄 Knowledge reviewDosu skipped reviewing this PR because your organization has used its |
|
Ported the three artifacts from #14551 per Christian's closing comment (commit 42b7c92):
Generated by Claude Code |
|
Hey, that sounds like an elegant solution. Is there any news on when this could be merged ? |
christian-byrne
left a comment
There was a problem hiding this comment.
Multi-agent review: 10 reviewer agents, a verification gate over the compiled findings, and an adversarial cut pass. Re-verified against 62b52fd after the two commits that landed mid-review; the SamplerCustomAdvanced port from #14551 is here and appreciated, and the comment cleanup did not change any of the findings below.
One item that does not anchor to a line, and one correction of my own:
Scope. This changes the Vue node renderer only. The classic litegraph canvas resolves host previews from previewExposureStore alone (litegraphService.ts:193), and Comfy.VueNodes.Enabled is defaultValue: false with defaultsByInstallVersion: { '1.41.0': isCloud || isDesktop }, so OSS local installs are still on the unfixed path. Classic-canvas parity is the other half of this incident.
Correction, mine. I added Fixes #9859 to this PR body earlier and have since downgraded it. #9859 says verbatim "In LG mode, live previews ... can no longer be shown ... In nodes 2.0 it works" — it is a classic-mode report that this PR provably does not fix, and the keyword would have auto-closed it. #14597 does not state its renderer either, so it is now Refs as well. Worth confirming the renderer on both before closing anything.
Follow-ups worth their own PRs rather than changes here: previews are keyed by subgraph definition id throughout nodeOutputStore, so two host instances of one definition share previews (pre-existing, and not fixed by the change suggested below either); nested subgraph hosts never bubble to an outer host even though the ancestor fan-out at app.ts:861 already writes that data to the nested host's locator.
|
Addressed all 8 line-level findings from the latest review round:
Also saw your scope note on classic-canvas parity and the Generated by Claude Code |
|
Status for whoever picks this up next. @DrJKL adding you as co-assignee since you own this area ( Two things worth deciding before this merges, both of which outlive this PR: Bug 2a is still unowned. The Classic canvas was scoped out on 2026-08-04 and never revisited. Also worth linking, both unfiled against this incident: https://linear.app/comfyorg/issue/FE-616 ("Multiple subgraphs on one canvas get same previews from each other", closed as Duplicate with no target) is the same definition-scoped locator bleed that still exists in |
|
Per Christian's Slack ask, expanded e2e (+1 unit) coverage for pathways beyond the two original specs. Summary of what I audited and added: New e2e specs (
Unit: Audited, not covered (flagging rather than guessing):
|
Adds e2e coverage for `Convert to Subgraph` with a **partial** selection. ## The gap `browser_tests/tests/subgraph/subgraphCrud.spec.ts:68` is the only e2e test of subgraph creation. It calls `comfyPage.keyboard.selectAll()` first, so every node is inside the selection, **there are zero boundary links**, and the entire rewiring block in `_convertToSubgraphImpl` (`src/lib/litegraph/src/LGraph.ts:1861-1939`) never executes. It then asserts node counts, never link topology. Partial selection — what users actually do — had no e2e coverage at all. That block reconnects boundary links by positional index (`subgraphNode.inputs[i - 1]`, `subgraphNode.outputs[i - 1]`) against a slot array built independently in `mapSubgraphInputsAndLinks`, so a drift there is silent. ## What it does Packs `KSampler` + `VAE Decode` out of the default workflow and asserts: - the exact post-conversion link table, with the new subgraph node rendered as `HOST` (five boundary inputs rewired onto it, one boundary output to SaveImage, and the two `CheckpointLoaderSimple → CLIP` links that lie entirely outside the selection left untouched) - every rewired link lands on a type-compatible slot - the result survives a serialize/reload round trip ## Status — currently red, cause not yet established Please do not read the red as a confirmed product bug yet; I do not want to overstate it. The first run failed, but **my expected link count was wrong** (I asserted six root links and forgot the two external-only CLIP links; the correct total is eight). That error is fixed in `0566c82c5a`, which now asserts the full topology rather than a count. What the first run reported that is *not* explained by my arithmetic error, and is still open: - the `4:2` VAE boundary link was absent from the links targeting the host - one host input slot was left unconnected - SaveImage's input was not fed by the host - nine root links, where the corrected expectation is eight Against that, a faithful unit-level reduction of the same graph shape (same node/slot/type layout, same selection) **passes**: all five host inputs connect, SaveImage is fed by the host, and the link count is eight. So either the trigger is something the reduction lacks (real node defs, widget-backed inputs, the app's `SubgraphNode` registration path), or the browser selection was not the two nodes I assumed. The corrected assertion prints the actual table on failure, which should settle it on the next run. I have deliberately **not** written a fix. Test-only PR. ## Related - Does **not** overlap #15002 (subgraph **unpack**, FE-1539 — different action, different code path `_unpackSubgraphImpl`). - Does **not** overlap #15009 / #15018, which are based on `feature/ecs-migration` and change `src/stores/linkStore.ts` / `rerouteStore.ts` — files that do not exist on `main`. - Does **not** overlap #14569 (preview/output display only). --------- Co-authored-by: Connor Byrne <c.byrne@comfy.org> Co-authored-by: Alexander Brown <drjkl@comfy.org> Co-authored-by: DrJKL <DrJKL0424@gmail.com> Co-authored-by: Amp <amp@ampcode.com>
useAmbientSubgraphPreviews treated any interior node with images in nodeOutputStore as a live preview candidate, including nodes whose only "output" is an input-type reference (e.g. an unpromoted LoadImage node's own selected file). That made every such node's thumbnail always visible on the collapsed subgraph host regardless of promotion state, breaking promotion exclusivity. CI caught this: browser_tests/tests/subgraph/subgraphPromotion.spec.ts "Can promote multiple previews" > "Promote both image previews" failed consistently (all 4 attempts) on PR #14569 - after promoting only one of two Load Image previews, the host showed 2 preview elements instead of 1, because the unpromoted node's own file leaked in via the new ambient rollup. Export isInputPreviewOutput from nodeOutputStore and use it to skip input-only outputs in the ambient computed, so only genuine live execution results (or streaming preview frames) surface ambiently - matching the feature's stated intent (a second concurrently-executing node with no exposure), not static input previews. Claude-Session: https://claude.ai/code/session_01FCL4Meqh8o8VYh9xV1h1Ky
#14551 was closed in favor of this PR's ambient-rollup mechanism, but it carried three artifacts not otherwise covered here: - browser_tests/assets/nodes/sampler_custom_advanced_in_subgraph.json + browser_tests/tests/subgraph/subgraphPreviewAllowlistGap.spec.ts: this PR's own e2e fixture uses two KSamplers, both allowlisted, so it would pass even if a node-type gate were reintroduced into the ambient path. This fixture pins a non-allowlisted node type (SamplerCustomAdvanced) instead. - Two incident-94 expected-fail regression tests (LGraphNode.test.ts, imagePreview.spec.ts) pinning the still-open `!lgraphNode?.isSubgraphNode()` guard at LGraphNode.vue:172, which neither PR touches.
Remove comments that restate what the code does or reference the incident/PR that motivated the change. Keep only comments that capture a genuine non-obvious invariant, rewritten to describe the invariant itself instead of pointing at an incident or ticket number.
Admitting committed nodeOutputs alongside nodePreviewImages made demoting an exposure a permanent no-op (the ambient rollup kept re-rendering what removeExposure just deleted, and queueStore repopulates nodeOutputs on history load so it survived reload), stacked a preview on the host for every interior output node after every run, and fed committed-output nodes into buildImageUrls, which appends a fresh &rand= per call and re-fetches at latent-preview frame rate. Narrow the gate to nodePreviewImages only. isInputPreviewOutput was exported from nodeOutputStore solely for the dropped branch and has no other external callers, so drop the export too - nodeOutputStore.ts's diff against main is now empty. Also correct two docblock claims: previews render when the host is expanded (v-else-if="!isCollapsed" in LGraphNode.vue), not collapsed, and this composable bails at depth 1 instead of recursing like nodeLocationProgressStates.
exposedNodeIds was built from promotedPreviews[].sourceNodeId, which is the chain-resolved leaf id for exposures that come from a nested subgraph. useAmbientSubgraphPreviews emits immediate-interior ids instead. Since last_node_id is per-LGraph, ids restart at 1 in every subgraph, so a nested exposure resolving to leaf id N could collide with and suppress the host's own interior node N. Build the exclusion set from previewExposureStore.getExposures( rootGraphId, hostLocator) instead - those are, by schema, direct children of the host's own subgraph, so both sides of the dedup are in the same id space. Also extract the promoted/ambient merge into a pure mergeSubgraphPreviews function with unit tests covering no overlap, an overlapping exposure winning over its ambient duplicate, and the namespace-collision regression above.
getNodeImageUrls is an unstubbed vi.fn(), reset to undefined by vi.resetAllMocks(), so `if (!urls?.length) return []` short-circuited before the isDetached bail, the nested-SubgraphNode skip, or the hideOutputImages guard ever ran - deleting any of the three still left every test green. Add mockReturnValue stubs so the detached and nested-SubgraphNode tests actually reach their guard, and add a test for the previously uncovered hideOutputImages guard. Also dedupe the byte-identical "no output" test by differentiating it into a test for the getNodeImageUrls-returns-nothing case, add a regression test locking in the nodeOutputs exclusion from the previous commit, and assert media type via getPreviewMediaType instead of a hardcoded 'image' literal so a change to that derivation isn't invisible to the suite.
A prior comment-cleanup pass reworded these it.fails/test.fail markers to drop the incident name and exact guard line. Without that pointer, whoever eventually fixes LGraphNode.vue:172's !lgraphNode?.isSubgraphNode() guard hits a bare "Error: Expect test to fail" with no context. Add the breadcrumb back.
Adds e2e coverage for pathways the two existing preview specs don't reach: an ambient preview nested two subgraph levels deep bubbles to its immediate host only, not the outermost one; a committed (non-live) interior output never gets conflated with the ambient rollup; and un-promoting a still-live sampler's exposed preview falls back to its ambient preview instead of disappearing. Also fills a small unit gap: useAmbientSubgraphPreviews derives video/audio media types the same way the promoted path already does.
removeExposure had no tombstone, so useAmbientSubgraphPreviews would re-surface a preview the next frame after a user explicitly demoted it, as long as the interior node kept producing live output. Add a suppressedAmbientNodeIds tombstone set to previewExposureStore, keyed per (rootGraphId, hostNodeLocator): removeExposure adds the removed source node to it, addExposure clears it, and clearGraph drops it with the rest of the host's state. useAmbientSubgraphPreviews now excludes suppressed node ids from its interior-node scan. Also documents two invariants raised in review: nodePreviewImages is only ever written by live execution/streaming frames, never static input data, so the single-branch gate needs no isInputPreviewOutput guard; and subgraph.nodes is a plain non-reactive array, so structural add/remove while collapsed isn't tracked until the next preview frame.
… own regression test b6bc646 added a suppressedAmbientNodeIds tombstone so removeExposure would permanently hide an interior node's ambient preview. That directly reverses this PR's stated design ("any interior node currently producing output shows on the host, whether or not anyone promoted it") and broke the e2e regression test added earlier in this branch (subgraphConcurrentPreviews.spec.ts: "un-promoting a still-live sampler falls back to its ambient preview instead of disappearing"), which was failing in CI. Un-promoting a node only removes its explicit exposure (name/ordering in the promoted slot); it was never meant to blacklist the node from the ambient rollup while it keeps producing live output. Revert the tombstone mechanism and its tests, restoring the pre-b6bc646 behavior that the existing regression test expects.
4613373 to
e36ee9d
Compare
Requested by Christian Byrne · Slack thread
Summary
Fixes incident-94 bug #3: when a subgraph host has multiple interior nodes producing live previews concurrently (e.g. two
KSamplers in one subgraph), only one of them showed on the host, and it could go stale.Before: the only path from an interior node's live output to the
SubgraphNodehost waspreviewExposureStore. The root cause is not the exposure record itself but how exposures get created: a one-shotautoExposeKnownPreviewNodespass that runs once, the first time the subgraph'spreviewExposuresproperty is undefined. If a second interior node (a secondKSampler, or one added after that one-shot pass already ran) never got an exposure, its live preview had nowhere to render on the host — invisible, even while executing.After: the host also derives an ambient rollup, independent of promotion, by reading each direct interior node's own output/preview state straight from
nodeOutputStore— the same store every regular node's own preview already reads from. Any interior node currently producing output shows on the host, whether or not anyone promoted it. An explicit exposure for a node still wins (keeps its promoted name/ordering); the ambient rollup only fills in nodes that have live output but no exposure. Two concurrently-executingKSamplers in one subgraph now both show and update independently instead of one overwriting/hiding the other.How
src/composables/node/useAmbientSubgraphPreviews.ts(new): acomputedthat mirrors the shape ofexecutionStore'snodeLocationProgressStates— which already bubbles up execution progress to a subgraph host independent of promotion — but for preview images. For aSubgraphNodehost it walkshost.subgraph.nodes(direct interior nodes only; nestedSubgraphNodes derive their own previews independently) and, for each one with live output/preview data innodeOutputStore, builds a preview entry via the samegetNodeImageUrlsa regular node's own preview already uses. Deliberately acomputed, not awatch(), per this repo's existing review pushback on watcher-based state derivation.src/composables/node/usePromotedPreviews.ts: exported the existingPromotedPreviewtype andgetPreviewMediaTypehelper (previously private) so the new ambient composable can share them — no behavior change to the exposure-driven path itself.src/renderer/extensions/vueNodes/components/LGraphNode.vue: addeduseAmbientSubgraphPreviewsalongside the existingusePromotedPreviews, merged into asubgraphPreviewscomputed (exposure-driven entries first, then any ambient entries for interior nodes not already exposed, deduped by source node id), and swapped the renderv-for/hasCustomContentcheck frompromotedPreviewstosubgraphPreviews. The render list already supported an arbitrary number of previews — no new render component needed.previewExposureStore's schema, the promotion/exposure mechanism, and thecanvasImagePreviewTypes.tsallowlist are untouched — this is purely additive.Test plan
src/composables/node/useAmbientSubgraphPreviews.test.ts(new, red→green): unit tests the new rollup directly — empty/detached/no-output cases, a single exposed-free interior node with live output, and the core regression case of two concurrently-executing interior nodes producing distinct previews without either being registered as an exposure.browser_tests/tests/subgraph/subgraphConcurrentPreviews.spec.ts+browser_tests/assets/subgraphs/subgraph-with-two-live-samplers.json(new): e2e regression with twoKSamplers in one subgraph where only one has a baked-inpreviewExposuresentry (simulating a node added after the subgraph's one-shot auto-expose already ran). Drives two liveb_previewWS frames at distinct interior execution IDs and asserts both show on the host, and that each updates independently as new frames arrive.main: before the fix, the unit test fails becauseuseAmbientSubgraphPreviewsdoesn't exist yet (module-not-found), and the e2e scenario only shows the exposed sampler's preview.usePromotedPreviews,previewExposureStore,nodeOutputStore,executionStore,nodeIdentification,LGraphNode/LGraphNode.subgraph,promotionUtils— 31 files / 550 tests) all pass, so single-KSampler-in-subgraph and the SamplerCustomAdvanced allowlist-gap fix (fix: derive subgraph preview promotion for any interior node type #14551) are unaffected.pnpm typecheck,pnpm typecheck:browser, andoxlint/eslint/oxfmton all touched files are clean.comfyanonymous/ComfyUIalongside this session'sComfy-Orgsources) — the e2e spec was verified viaplaywright test --list(compiles, discovers under@vue-nodes/@subgraph) andvue-tsc --project browser_tests/tsconfig.json, but not executed end-to-end. Recommend running it in CI/a full local checkout before merge.Linked issues
This PR changes only the Vue nodes renderer. The classic litegraph canvas still resolves host previews from
previewExposureStorealone (litegraphService.ts:193), andComfy.VueNodes.Enableddefaults tofalsefor OSS local installs, so classic-canvas parity is the other half of this incident and is not addressed here.Note: #14551 was closed in favour of this PR (same mechanism, this one better guarded). Its
SamplerCustomAdvancede2e fixture and spec still need porting here so the non-allowlisted node type stays pinned.Generated by Claude Code