fix: detect painter canvas emptiness from pixel data, not stroke flags#12009
fix: detect painter canvas emptiness from pixel data, not stroke flags#12009christian-byrne wants to merge 2 commits intomainfrom
Conversation
…ount The painter's serializeValue closure captured hasStrokes and isDirty as local variables that reset on every WidgetPainter.vue mount. After a remount (e.g. NodeWidgets re-render driven by useProcessedWidgets, added in #10966), serializeValue saw isCanvasEmpty()=true and returned '' even when modelValue still held a valid mask reference from a workflow load or a prior upload — backend then received an empty mask, and on cloud this surfaced as ImageDownloadError. Reorder the short-circuits so a non-dirty painter returns its existing modelValue.value before the canvas-empty guard, and fall back to the prior modelValue.value if the canvas element is no longer mounted.
📝 WalkthroughWalkthroughserializeValue now uses a pixel-based emptiness check and short-circuits to the existing modelValue when the canvas is missing or empty; handleClear clears modelValue; tests add a regression ensuring remounted painters preserve restored mask references. ChangesPainter serialization & emptiness detection
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
Caution Pre-merge checks failedPlease resolve all errors before merging. Addressing warnings is optional.
❌ Failed checks (1 error, 1 warning)
✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
🎨 Storybook: ✅ Built — View Storybook |
🎭 Playwright: ✅ 1529 passed, 0 failed · 3 flaky📊 Browser Reports
|
📦 Bundle: 5.26 MB gzip 🔴 +292 BDetailsSummary
Category Glance App Entry Points — 22.6 kB (baseline 22.6 kB) • ⚪ 0 BMain entry bundles and manifests
Status: 1 added / 1 removed Graph Workspace — 1.24 MB (baseline 1.24 MB) • ⚪ 0 BGraph editor runtime, canvas, workflow orchestration
Status: 1 added / 1 removed Views & Navigation — 82.3 kB (baseline 82.3 kB) • ⚪ 0 BTop-level views, pages, and routed surfaces
Status: 9 added / 9 removed / 2 unchanged Panels & Settings — 489 kB (baseline 489 kB) • ⚪ 0 BConfiguration panels, inspectors, and settings screens
Status: 10 added / 10 removed / 11 unchanged User & Accounts — 17.6 kB (baseline 17.6 kB) • ⚪ 0 BAuthentication, profile, and account management bundles
Status: 5 added / 5 removed / 2 unchanged Editors & Dialogs — 112 kB (baseline 112 kB) • ⚪ 0 BModals, dialogs, drawers, and in-app editors
Status: 4 added / 4 removed UI Components — 62.9 kB (baseline 62.9 kB) • ⚪ 0 BReusable component library chunks
Status: 5 added / 5 removed / 9 unchanged Data & Services — 3.05 MB (baseline 3.05 MB) • ⚪ 0 BStores, services, APIs, and repositories
Status: 13 added / 13 removed / 4 unchanged Utilities & Hooks — 365 kB (baseline 365 kB) • ⚪ 0 BHelpers, composables, and utility bundles
Status: 13 added / 13 removed / 18 unchanged Vendor & Third-Party — 9.94 MB (baseline 9.94 MB) • ⚪ 0 BExternal libraries and shared vendor chunks Status: 16 unchanged Other — 8.86 MB (baseline 8.86 MB) • 🔴 +463 BBundles that do not match a named category
Status: 57 added / 57 removed / 79 unchanged ⚡ Performance Report
No regressions detected. All metrics
Historical variance (last 15 runs)
Trend (last 15 commits on main)
Raw data{
"timestamp": "2026-05-06T07:33:52.122Z",
"gitSha": "6e42253cc32ee8bb291be938e03c27f5a830e2d9",
"branch": "fix/painter-serialize-preserves-modelvalue-on-remount",
"measurements": [
{
"name": "canvas-idle",
"durationMs": 2065.4069999999933,
"styleRecalcs": 8,
"styleRecalcDurationMs": 7.356000000000002,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 340.775,
"heapDeltaBytes": 23460152,
"heapUsedBytes": 72789820,
"domNodes": 16,
"jsHeapTotalBytes": 14942208,
"scriptDurationMs": 15.462000000000002,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "canvas-idle",
"durationMs": 2013.417000000004,
"styleRecalcs": 8,
"styleRecalcDurationMs": 6.256000000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 329.92999999999995,
"heapDeltaBytes": 22955056,
"heapUsedBytes": 71726960,
"domNodes": 16,
"jsHeapTotalBytes": 14680064,
"scriptDurationMs": 14.775999999999998,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "canvas-idle",
"durationMs": 1999.1710000000467,
"styleRecalcs": 10,
"styleRecalcDurationMs": 7.793999999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 329.94800000000004,
"heapDeltaBytes": 22956780,
"heapUsedBytes": 71768012,
"domNodes": 20,
"jsHeapTotalBytes": 14942208,
"scriptDurationMs": 14.264000000000001,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "canvas-mouse-sweep",
"durationMs": 1902.1459999999877,
"styleRecalcs": 74,
"styleRecalcDurationMs": 40.06700000000001,
"layouts": 12,
"layoutDurationMs": 3.5060000000000002,
"taskDurationMs": 845.3510000000001,
"heapDeltaBytes": -89664,
"heapUsedBytes": 48606284,
"domNodes": -265,
"jsHeapTotalBytes": 16117760,
"scriptDurationMs": 117.43500000000002,
"eventListeners": -131,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "canvas-mouse-sweep",
"durationMs": 2023.8630000000057,
"styleRecalcs": 83,
"styleRecalcDurationMs": 40.485,
"layouts": 12,
"layoutDurationMs": 3.278,
"taskDurationMs": 965.813,
"heapDeltaBytes": 4778760,
"heapUsedBytes": 53453380,
"domNodes": -262,
"jsHeapTotalBytes": 15855616,
"scriptDurationMs": 111.96300000000001,
"eventListeners": -131,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "canvas-mouse-sweep",
"durationMs": 2013.6009999999942,
"styleRecalcs": 79,
"styleRecalcDurationMs": 37.463,
"layouts": 12,
"layoutDurationMs": 3.28,
"taskDurationMs": 947.5539999999999,
"heapDeltaBytes": 6797132,
"heapUsedBytes": 55507000,
"domNodes": -263,
"jsHeapTotalBytes": 16117760,
"scriptDurationMs": 115.837,
"eventListeners": -129,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "canvas-zoom-sweep",
"durationMs": 1727.6350000000207,
"styleRecalcs": 32,
"styleRecalcDurationMs": 16.174999999999997,
"layouts": 6,
"layoutDurationMs": 0.5519999999999999,
"taskDurationMs": 288.863,
"heapDeltaBytes": 259612,
"heapUsedBytes": 47961176,
"domNodes": 79,
"jsHeapTotalBytes": 14942208,
"scriptDurationMs": 24.67799999999999,
"eventListeners": 21,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "canvas-zoom-sweep",
"durationMs": 1716.8019999999728,
"styleRecalcs": 30,
"styleRecalcDurationMs": 14.498999999999999,
"layouts": 6,
"layoutDurationMs": 0.6329999999999999,
"taskDurationMs": 271.154,
"heapDeltaBytes": 229500,
"heapUsedBytes": 48606608,
"domNodes": 74,
"jsHeapTotalBytes": 15204352,
"scriptDurationMs": 17.201,
"eventListeners": 21,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.660000000000007,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "canvas-zoom-sweep",
"durationMs": 1745.8710000000792,
"styleRecalcs": 33,
"styleRecalcDurationMs": 16.312,
"layouts": 6,
"layoutDurationMs": 0.554,
"taskDurationMs": 292.71899999999994,
"heapDeltaBytes": 207684,
"heapUsedBytes": 48003296,
"domNodes": 80,
"jsHeapTotalBytes": 14680064,
"scriptDurationMs": 23.37,
"eventListeners": 19,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "dom-widget-clipping",
"durationMs": 522.5700000000018,
"styleRecalcs": 11,
"styleRecalcDurationMs": 7.183,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 314.959,
"heapDeltaBytes": 9394948,
"heapUsedBytes": 57709904,
"domNodes": 18,
"jsHeapTotalBytes": 15990784,
"scriptDurationMs": 53.275999999999996,
"eventListeners": 0,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.669999999999998,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "dom-widget-clipping",
"durationMs": 550.2629999999726,
"styleRecalcs": 10,
"styleRecalcDurationMs": 6.879999999999997,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 320.35999999999996,
"heapDeltaBytes": 8744788,
"heapUsedBytes": 57392656,
"domNodes": 16,
"jsHeapTotalBytes": 16515072,
"scriptDurationMs": 53.895,
"eventListeners": 0,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.663333333333338,
"p95FrameDurationMs": 16.700000000000273
},
{
"name": "dom-widget-clipping",
"durationMs": 603.7900000000036,
"styleRecalcs": 12,
"styleRecalcDurationMs": 8.181000000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 342.525,
"heapDeltaBytes": 9354900,
"heapUsedBytes": 57318460,
"domNodes": 20,
"jsHeapTotalBytes": 14942208,
"scriptDurationMs": 59.88299999999999,
"eventListeners": 0,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.663333333333338,
"p95FrameDurationMs": 16.799999999999727
},
{
"name": "large-graph-idle",
"durationMs": 2033.996000000002,
"styleRecalcs": 10,
"styleRecalcDurationMs": 10.984999999999998,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 531.015,
"heapDeltaBytes": 14849984,
"heapUsedBytes": 72671648,
"domNodes": -255,
"jsHeapTotalBytes": -233472,
"scriptDurationMs": 84.10000000000001,
"eventListeners": -129,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "large-graph-idle",
"durationMs": 2033.9889999999627,
"styleRecalcs": 8,
"styleRecalcDurationMs": 6.768,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 520.9970000000001,
"heapDeltaBytes": 2365696,
"heapUsedBytes": 60886752,
"domNodes": -265,
"jsHeapTotalBytes": 4485120,
"scriptDurationMs": 77.64999999999998,
"eventListeners": -129,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "large-graph-idle",
"durationMs": 2020.8800000000338,
"styleRecalcs": 11,
"styleRecalcDurationMs": 8.839999999999996,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 530.3549999999999,
"heapDeltaBytes": 5611836,
"heapUsedBytes": 62723384,
"domNodes": -260,
"jsHeapTotalBytes": 1077248,
"scriptDurationMs": 84.77699999999999,
"eventListeners": -129,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "large-graph-pan",
"durationMs": 2108.5559999999646,
"styleRecalcs": 67,
"styleRecalcDurationMs": 16.703000000000003,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 1067.557,
"heapDeltaBytes": 2482996,
"heapUsedBytes": 61780788,
"domNodes": -267,
"jsHeapTotalBytes": 1282048,
"scriptDurationMs": 396.61,
"eventListeners": -129,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "large-graph-pan",
"durationMs": 2105.8340000000157,
"styleRecalcs": 69,
"styleRecalcDurationMs": 18.316000000000003,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 994.605,
"heapDeltaBytes": 7080024,
"heapUsedBytes": 66178460,
"domNodes": -261,
"jsHeapTotalBytes": 1282048,
"scriptDurationMs": 353.781,
"eventListeners": -127,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "large-graph-pan",
"durationMs": 2115.4089999999997,
"styleRecalcs": 67,
"styleRecalcDurationMs": 17.453,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 1075.4830000000002,
"heapDeltaBytes": -5302848,
"heapUsedBytes": 53909216,
"domNodes": -266,
"jsHeapTotalBytes": 7892992,
"scriptDurationMs": 395.464,
"eventListeners": -127,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333335,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "large-graph-zoom",
"durationMs": 3168.332000000021,
"styleRecalcs": 66,
"styleRecalcDurationMs": 18.464,
"layouts": 60,
"layoutDurationMs": 7.726,
"taskDurationMs": 1266.3719999999998,
"heapDeltaBytes": 8827428,
"heapUsedBytes": 69627424,
"domNodes": -266,
"jsHeapTotalBytes": 552960,
"scriptDurationMs": 467.823,
"eventListeners": -125,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66999999999998,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "large-graph-zoom",
"durationMs": 3135.6040000000007,
"styleRecalcs": 65,
"styleRecalcDurationMs": 18.291,
"layouts": 60,
"layoutDurationMs": 7.7219999999999995,
"taskDurationMs": 1262.0529999999999,
"heapDeltaBytes": 7245956,
"heapUsedBytes": 69382884,
"domNodes": -265,
"jsHeapTotalBytes": 3756032,
"scriptDurationMs": 462.569,
"eventListeners": -125,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "large-graph-zoom",
"durationMs": 3151.0119999999233,
"styleRecalcs": 66,
"styleRecalcDurationMs": 19.03,
"layouts": 60,
"layoutDurationMs": 7.844,
"taskDurationMs": 1270.2369999999999,
"heapDeltaBytes": -8895176,
"heapUsedBytes": 53074960,
"domNodes": -263,
"jsHeapTotalBytes": 4542464,
"scriptDurationMs": 465.78200000000004,
"eventListeners": -125,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "minimap-idle",
"durationMs": 2016.0640000000285,
"styleRecalcs": 9,
"styleRecalcDurationMs": 7.361,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 510.07199999999995,
"heapDeltaBytes": 5792284,
"heapUsedBytes": 66829300,
"domNodes": -264,
"jsHeapTotalBytes": 4018176,
"scriptDurationMs": 75.053,
"eventListeners": -131,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "minimap-idle",
"durationMs": 2031.5380000000118,
"styleRecalcs": 8,
"styleRecalcDurationMs": 6.610999999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 505.046,
"heapDeltaBytes": 2314768,
"heapUsedBytes": 62654276,
"domNodes": -267,
"jsHeapTotalBytes": 5271552,
"scriptDurationMs": 75.145,
"eventListeners": -129,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333335,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "minimap-idle",
"durationMs": 2037.113999999974,
"styleRecalcs": 10,
"styleRecalcDurationMs": 8.146,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 520.821,
"heapDeltaBytes": 16303252,
"heapUsedBytes": 76638608,
"domNodes": -254,
"jsHeapTotalBytes": -233472,
"scriptDurationMs": 77.903,
"eventListeners": -129,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-dom-widget-clipping",
"durationMs": 554.9589999999966,
"styleRecalcs": 48,
"styleRecalcDurationMs": 11.755,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 352.03499999999997,
"heapDeltaBytes": 9144332,
"heapUsedBytes": 57782388,
"domNodes": 21,
"jsHeapTotalBytes": 16252928,
"scriptDurationMs": 121.209,
"eventListeners": 8,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000273
},
{
"name": "subgraph-dom-widget-clipping",
"durationMs": 513.4869999999978,
"styleRecalcs": 46,
"styleRecalcDurationMs": 9.565,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 333.04699999999997,
"heapDeltaBytes": 8843040,
"heapUsedBytes": 57519624,
"domNodes": 18,
"jsHeapTotalBytes": 16515072,
"scriptDurationMs": 117.71699999999998,
"eventListeners": 8,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "subgraph-dom-widget-clipping",
"durationMs": 527.733000000012,
"styleRecalcs": 47,
"styleRecalcDurationMs": 10.406,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 346.52099999999996,
"heapDeltaBytes": 8891360,
"heapUsedBytes": 57584980,
"domNodes": 19,
"jsHeapTotalBytes": 15990784,
"scriptDurationMs": 121.518,
"eventListeners": 6,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000273
},
{
"name": "subgraph-idle",
"durationMs": 2007.4989999999957,
"styleRecalcs": 9,
"styleRecalcDurationMs": 7.366,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 374.373,
"heapDeltaBytes": -4856276,
"heapUsedBytes": 43719908,
"domNodes": -260,
"jsHeapTotalBytes": 14282752,
"scriptDurationMs": 13.33,
"eventListeners": -131,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "subgraph-idle",
"durationMs": 1992.6980000000185,
"styleRecalcs": 9,
"styleRecalcDurationMs": 7.346999999999998,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 325.86499999999995,
"heapDeltaBytes": 22383156,
"heapUsedBytes": 70912072,
"domNodes": 18,
"jsHeapTotalBytes": 14942208,
"scriptDurationMs": 12.645,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-idle",
"durationMs": 1989.6659999999429,
"styleRecalcs": 9,
"styleRecalcDurationMs": 7.150000000000002,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 331.507,
"heapDeltaBytes": 23279880,
"heapUsedBytes": 71958964,
"domNodes": 18,
"jsHeapTotalBytes": 14942208,
"scriptDurationMs": 12.615,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "subgraph-mouse-sweep",
"durationMs": 1673.827000000017,
"styleRecalcs": 75,
"styleRecalcDurationMs": 35.032000000000004,
"layouts": 16,
"layoutDurationMs": 4.56,
"taskDurationMs": 598.402,
"heapDeltaBytes": 14248900,
"heapUsedBytes": 62960268,
"domNodes": 61,
"jsHeapTotalBytes": 14942208,
"scriptDurationMs": 80.365,
"eventListeners": 6,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-mouse-sweep",
"durationMs": 1688.211000000024,
"styleRecalcs": 76,
"styleRecalcDurationMs": 37.18000000000001,
"layouts": 16,
"layoutDurationMs": 4.155,
"taskDurationMs": 658.477,
"heapDeltaBytes": -4874532,
"heapUsedBytes": 43781496,
"domNodes": -260,
"jsHeapTotalBytes": 15331328,
"scriptDurationMs": 82.63199999999998,
"eventListeners": -133,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333335,
"p95FrameDurationMs": 16.699999999999818
},
{
"name": "subgraph-mouse-sweep",
"durationMs": 1689.8730000000342,
"styleRecalcs": 75,
"styleRecalcDurationMs": 33.770999999999994,
"layouts": 16,
"layoutDurationMs": 4.08,
"taskDurationMs": 618.276,
"heapDeltaBytes": 14723528,
"heapUsedBytes": 63403192,
"domNodes": 60,
"jsHeapTotalBytes": 14680064,
"scriptDurationMs": 87.168,
"eventListeners": 6,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "viewport-pan-sweep",
"durationMs": 8247.281999999985,
"styleRecalcs": 250,
"styleRecalcDurationMs": 55.481,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 3508.245,
"heapDeltaBytes": 10047620,
"heapUsedBytes": 68512252,
"domNodes": -263,
"jsHeapTotalBytes": 6787072,
"scriptDurationMs": 1166.1889999999999,
"eventListeners": -113,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.670000000000012,
"p95FrameDurationMs": 16.80000000000109
},
{
"name": "viewport-pan-sweep",
"durationMs": 8129.867999999988,
"styleRecalcs": 250,
"styleRecalcDurationMs": 54.026999999999994,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 3448.613,
"heapDeltaBytes": 21189804,
"heapUsedBytes": 78928996,
"domNodes": -258,
"jsHeapTotalBytes": 1544192,
"scriptDurationMs": 1148.2930000000001,
"eventListeners": -113,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.669999999999952,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "viewport-pan-sweep",
"durationMs": 8161.3700000000335,
"styleRecalcs": 250,
"styleRecalcDurationMs": 55.246,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 3684.1539999999995,
"heapDeltaBytes": 13447908,
"heapUsedBytes": 73270144,
"domNodes": -259,
"jsHeapTotalBytes": 10223616,
"scriptDurationMs": 1302.355,
"eventListeners": -113,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "vue-large-graph-idle",
"durationMs": 12468.16799999999,
"styleRecalcs": 0,
"styleRecalcDurationMs": 0,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 12438.267000000002,
"heapDeltaBytes": -25630656,
"heapUsedBytes": 170874644,
"domNodes": -8331,
"jsHeapTotalBytes": 23130112,
"scriptDurationMs": 598.047,
"eventListeners": -16466,
"totalBlockingTimeMs": 0,
"frameDurationMs": 17.220000000000073,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "vue-large-graph-idle",
"durationMs": 12221.897000000012,
"styleRecalcs": 0,
"styleRecalcDurationMs": 0,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 12210.808,
"heapDeltaBytes": -18512192,
"heapUsedBytes": 186351848,
"domNodes": -8331,
"jsHeapTotalBytes": 24702976,
"scriptDurationMs": 539.5020000000001,
"eventListeners": -16462,
"totalBlockingTimeMs": 0,
"frameDurationMs": 17.779999999999927,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "vue-large-graph-idle",
"durationMs": 12593.76800000007,
"styleRecalcs": 0,
"styleRecalcDurationMs": 0,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 12582.813000000002,
"heapDeltaBytes": -35697308,
"heapUsedBytes": 171572396,
"domNodes": -8331,
"jsHeapTotalBytes": 23654400,
"scriptDurationMs": 598.617,
"eventListeners": -16462,
"totalBlockingTimeMs": 0,
"frameDurationMs": 17.223333333333358,
"p95FrameDurationMs": 16.80000000000291
},
{
"name": "vue-large-graph-pan",
"durationMs": 15192.836999999998,
"styleRecalcs": 72,
"styleRecalcDurationMs": 19.038,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 15143.384000000002,
"heapDeltaBytes": -47470940,
"heapUsedBytes": 165176840,
"domNodes": -8331,
"jsHeapTotalBytes": 847872,
"scriptDurationMs": 974.1610000000001,
"eventListeners": -16458,
"totalBlockingTimeMs": 1,
"frameDurationMs": 17.219999999999953,
"p95FrameDurationMs": 16.80000000000291
},
{
"name": "vue-large-graph-pan",
"durationMs": 14596.01100000009,
"styleRecalcs": 66,
"styleRecalcDurationMs": 19.453,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 14570.079,
"heapDeltaBytes": -56335104,
"heapUsedBytes": 153250984,
"domNodes": -8331,
"jsHeapTotalBytes": -724992,
"scriptDurationMs": 848.3159999999999,
"eventListeners": -16488,
"totalBlockingTimeMs": 62,
"frameDurationMs": 17.220000000000073,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "vue-large-graph-pan",
"durationMs": 14753.652999999986,
"styleRecalcs": 68,
"styleRecalcDurationMs": 18.20900000000003,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 14731.618,
"heapDeltaBytes": -45072612,
"heapUsedBytes": 152905628,
"domNodes": -8329,
"jsHeapTotalBytes": -724992,
"scriptDurationMs": 839.6859999999999,
"eventListeners": -16488,
"totalBlockingTimeMs": 59,
"frameDurationMs": 17.219999999999953,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "workflow-execution",
"durationMs": 459.31999999999107,
"styleRecalcs": 19,
"styleRecalcDurationMs": 27.566999999999997,
"layouts": 5,
"layoutDurationMs": 1.495,
"taskDurationMs": 130.91599999999997,
"heapDeltaBytes": 5406332,
"heapUsedBytes": 55148316,
"domNodes": 170,
"jsHeapTotalBytes": 524288,
"scriptDurationMs": 31.049999999999994,
"eventListeners": 69,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000273
},
{
"name": "workflow-execution",
"durationMs": 467.78299999994033,
"styleRecalcs": 17,
"styleRecalcDurationMs": 22.899999999999995,
"layouts": 5,
"layoutDurationMs": 1.399,
"taskDurationMs": 122.74399999999999,
"heapDeltaBytes": 5173952,
"heapUsedBytes": 54933968,
"domNodes": 157,
"jsHeapTotalBytes": 0,
"scriptDurationMs": 29.275,
"eventListeners": 69,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000273
},
{
"name": "workflow-execution",
"durationMs": 455.09100000003855,
"styleRecalcs": 16,
"styleRecalcDurationMs": 19.993999999999996,
"layouts": 4,
"layoutDurationMs": 1.1440000000000003,
"taskDurationMs": 108.96600000000001,
"heapDeltaBytes": 5049396,
"heapUsedBytes": 54925572,
"domNodes": 155,
"jsHeapTotalBytes": 262144,
"scriptDurationMs": 22.626,
"eventListeners": 69,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000273
}
]
} |
Codecov Report✅ All modified and coverable lines are covered by tests. @@ Coverage Diff @@
## main #12009 +/- ##
===========================================
- Coverage 72.82% 57.64% -15.18%
===========================================
Files 1499 1390 -109
Lines 83910 70816 -13094
Branches 23151 19764 -3387
===========================================
- Hits 61108 40823 -20285
- Misses 21970 29491 +7521
+ Partials 832 502 -330
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 990 files with indirect coverage changes 🚀 New features to boost your workflow:
|
Read actual canvas pixel data in serializeValue instead of trusting the closure-local hasStrokes/isDirty flags. Per Slack thread evidence (Matt Miller, 2026-05-05), the canvas can hold visible strokes (~9% non-zero alpha pixels) while hasStrokes stays false. Two known triggers: 1. WidgetPainter remount swaps usePainter() for a fresh closure with hasStrokes=false; the registered serializeValue on the mask widget then disagrees with what the user sees on the canvas. 2. handlePointerDown early-returns on e.button !== 0, which can fire for some touchpad / pen pointer-event variants. startStroke never runs and hasStrokes never flips, even though pixels reach the canvas via other paths. The new isCanvasPixelEmpty(el) reads ImageData and scans the alpha channel. When canvas is empty, serializeValue defers to modelValue so workflow-restored references survive a pre-image-load remount; handleClear now also resets modelValue so a user-initiated clear still resolves to ''. Removes the now-unused hasStrokes flag and isCanvasEmpty helper. Adds unit tests covering: pixel-driven upload despite isDirty=false, empty canvas + empty modelValue → '', and post-handleClear → ''.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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/composables/painter/usePainter.ts`:
- Around line 643-655: When detecting an empty canvas in usePainter (the
isCanvasPixelEmpty(el) check), do not return the old modelValue if the canvas
was modified (isDirty.value === true); instead clear the cached reference and
return '' so erased/restored masks do not reappear. Concretely: in the
empty-canvas branch, if isDirty.value is true set modelValue.value = '' (or
otherwise clear the cached upload ref) and return ''; only fall back to
modelValue when the canvas is empty AND isDirty is false. Update the logic
around isCanvasPixelEmpty, isDirty, and modelValue and add a regression test
"erase restored mask to fully transparent pixels" that simulates restoring a
mask then erasing it to fully transparent to assert serialize/upload yields ''
(handleClear remains unchanged).
🪄 Autofix (Beta)
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: CHILL
Plan: Pro
Run ID: 57732b4b-5441-4ce1-af08-da5825ca6129
📒 Files selected for processing (2)
src/composables/painter/usePainter.test.tssrc/composables/painter/usePainter.ts
| // Authoritative emptiness check: read actual pixel data instead of | ||
| // relying on the `isDirty` flag, which can desync from canvas content | ||
| // on WidgetPainter remount or on non-primary pointerdown variants where | ||
| // the closure-local stroke bookkeeping was bypassed. | ||
| // When the canvas is empty, defer to `modelValue` so a workflow-restored | ||
| // mask reference (or a pending image-restore) survives. `handleClear` | ||
| // explicitly resets `modelValue` so a user-initiated clear still yields ''. | ||
| if (isCanvasPixelEmpty(el)) return modelValue.value | ||
|
|
||
| // Canvas has visible content. If we already uploaded this exact content | ||
| // (no new strokes since last successful upload) and the cached value is | ||
| // valid, reuse it to avoid redundant uploads. | ||
| if (!isDirty.value && modelValue.value) return modelValue.value |
There was a problem hiding this comment.
Do not reuse modelValue after a dirty erase-to-empty path.
If a previously restored mask is fully erased with the eraser, isDirty.value is true but isCanvasPixelEmpty(el) is also true, so this returns the old upload reference instead of serializing an empty mask. That brings deleted content back on the next save. Only fall back to modelValue for an empty canvas when nothing changed since the last successful serialize; otherwise clear the cached reference and return ''. Please also add a regression test for “erase restored mask to fully transparent pixels” since handleClear() does not cover that path.
Suggested fix
- if (isCanvasPixelEmpty(el)) return modelValue.value
+ if (isCanvasPixelEmpty(el)) {
+ if (isDirty.value) {
+ modelValue.value = ''
+ return ''
+ }
+ return modelValue.value
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Authoritative emptiness check: read actual pixel data instead of | |
| // relying on the `isDirty` flag, which can desync from canvas content | |
| // on WidgetPainter remount or on non-primary pointerdown variants where | |
| // the closure-local stroke bookkeeping was bypassed. | |
| // When the canvas is empty, defer to `modelValue` so a workflow-restored | |
| // mask reference (or a pending image-restore) survives. `handleClear` | |
| // explicitly resets `modelValue` so a user-initiated clear still yields ''. | |
| if (isCanvasPixelEmpty(el)) return modelValue.value | |
| // Canvas has visible content. If we already uploaded this exact content | |
| // (no new strokes since last successful upload) and the cached value is | |
| // valid, reuse it to avoid redundant uploads. | |
| if (!isDirty.value && modelValue.value) return modelValue.value | |
| // Authoritative emptiness check: read actual pixel data instead of | |
| // relying on the `isDirty` flag, which can desync from canvas content | |
| // on WidgetPainter remount or on non-primary pointerdown variants where | |
| // the closure-local stroke bookkeeping was bypassed. | |
| // When the canvas is empty, defer to `modelValue` so a workflow-restored | |
| // mask reference (or a pending image-restore) survives. `handleClear` | |
| // explicitly resets `modelValue` so a user-initiated clear still yields ''. | |
| if (isCanvasPixelEmpty(el)) { | |
| if (isDirty.value) { | |
| modelValue.value = '' | |
| return '' | |
| } | |
| return modelValue.value | |
| } | |
| // Canvas has visible content. If we already uploaded this exact content | |
| // (no new strokes since last successful upload) and the cached value is | |
| // valid, reuse it to avoid redundant uploads. | |
| if (!isDirty.value && modelValue.value) return modelValue.value |
🤖 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/composables/painter/usePainter.ts` around lines 643 - 655, When detecting
an empty canvas in usePainter (the isCanvasPixelEmpty(el) check), do not return
the old modelValue if the canvas was modified (isDirty.value === true); instead
clear the cached reference and return '' so erased/restored masks do not
reappear. Concretely: in the empty-canvas branch, if isDirty.value is true set
modelValue.value = '' (or otherwise clear the cached upload ref) and return '';
only fall back to modelValue when the canvas is empty AND isDirty is false.
Update the logic around isCanvasPixelEmpty, isDirty, and modelValue and add a
regression test "erase restored mask to fully transparent pixels" that simulates
restoring a mask then erasing it to fully transparent to assert serialize/upload
yields '' (handleClear remains unchanged).
Summary
Painter widget no longer fires
POST /upload/imageon queue:serializeValuereturns''even when the canvas has visible strokes. Backend then receivesmask=""(locally) or surfacesImageDownloadError: Failed to validate imageson cloud (cloud rejects empty mask paths since cloud PR #2946 / commitd06857ba0).Root cause
isCanvasEmpty()returned!hasStrokes, wherehasStrokesis a closure-localletinsideusePainter(). That flag desyncs from the actual canvas content under at least two confirmed conditions:usePainter()re-runs insetup, producing a brand-new closure withhasStrokes = false.registerWidgetSerialization()inonMountedoverwriteslitegraphNode.widgets[mask].serializeValuewith the new closure's version. The nextserializeValuecall seeshasStrokes=falseeven though the user already painted, so it returns''.handlePointerDownearly-returns one.button !== 0. For some touchpad/pen pointer-event variants observed in the wild,pointerdownfires withe.button !== 0, sostartStrokenever runs andhasStrokesnever flips. Pixels can still reach the canvas through other paths, leaving the flag desynced.Confirmed via Matt Miller's console diagnostic on prod 2026-05-05: he painted strokes (~9% non-zero alpha pixels), clicked Run, and
serializeValuereturned""whilemask.valuestayed""throughout.Fix
Replace the flag-based
isCanvasEmpty()check with a pixel-data scan:Behaviour matrix:
isDirtymodelValue''''hasStrokes=falsedue to remount or non-primary pointerdown''painter/x.png [temp]painter/x.png [temp]''(cleared byhandleClear)''handleClearnow also resetsmodelValue.value = ''so a user-initiated clear still resolves to an empty mask even though the pixel-data check would otherwise defer to the cached upload reference.The
hasStrokesflag and theisCanvasEmptyhelper that read it are now dead code and have been removed.Why this is the right fix (not just defense in depth)
The earlier iteration of this PR reordered the short-circuits to return
modelValue.valuewhen!isDirty.value. That fix handles scenario 4 (workflow-restored mask reference) but fails the exact case the user actually reported: fresh node, painted strokes,hasStrokes=falsebecause of the desync above,modelValue.value=''. The pixel-data scan addresses the real failure mode.Investigation: which PR introduced the regression?
I ran a parallel-subagent investigation across the rendering and reactivity paths between
v1.42.15andmain. None of the suspected PRs (#10966, #10741, #10302/#10309, #11423, #11613, #11691, #11541) showed a verifiable change that increasesWidgetPainterremount frequency. Specifically:useGraphNodeManager.ts: 4-line diff (addedtype: stringtoWidgetSlotMetadata). Reactivity machinery unchanged.useProcessedWidgets.tsextracted by refactor: extract composables from VTU holdout components, complete VTL migration #10966: reactive dependency set materially identical to v1.42.15's inline version. refactor: extract composables from VTU holdout components, complete VTL migration #10966 exonerated.useNodeErrorFlagSync.tsfrom refactor: error system cleanup — store separation, DDD fix, test improvements #10302/feat: detect and resolve missing media inputs in error tab #10309: the underlyingwatch([lastNodeErrors, missingModelNodeIds], ...)already existed inline inexecutionErrorStore.tsat v1.42.15. feat: detect and resolve missing media inputs in error tab #10309 addedmissingMediaNodeIdsandshowErrorsTabdeps that don't fire on clean queues.NodeWidgets.vuetemplate for the Painter render path is byte-identical (only:keyadded on sibling<InputSlot>).WidgetPainter.vuediff =data-testidattributes only.widgetRegistry.tsonly added arangewidget; Painter unchanged.LGraphNode.vuehad a major restructure in refactor: inline node footer layout to fix selection bounding box #10741 but the<NodeWidgets v-if=...>line is unchanged;isRerouteNodev-if/v-else-if branch is stable per node type.GraphCanvas.vuev-for /:key="nodeData.id"unchanged.A synthetic Vitest reproducer that mutates
nodeData.widgetsreactively does not remount WidgetPainter on currentmain, so the trigger is empirical and would surface only in a real browser session. Given that the second condition (non-primary pointerdown) was added in #8521 and has been latent forever, the user-visible regression is most plausibly the touchpad/pen pointer-event class of bug, not a remount-frequency change. Either way, the pixel-data fix in this PR addresses both failure modes simultaneously.Verification
Three new tests added under
describe('serializeValue'):uploads canvas content even when the isDirty flag is false (regression: stroke-tracking flag can desync from real canvas pixel data on remount or non-primary pointerdown)returns empty string when canvas has no pixels and modelValue is emptyreturns empty string after handleClear even when modelValue previously held an upload reference