refactor: host-scoped promoted widget rows (no source traversal)#13434
Conversation
|
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 unit tests (beta)
Comment |
🎨 Storybook: ✅ Built — View Storybook🎭 Playwright: ✅ 1691 passed, 0 failed · 2 flaky📊 Browser Reports
📦 Bundle Size
⚡ Performance Report
Absolute values
Raw data{
"timestamp": "2026-07-17T22:07:51.487Z",
"gitSha": "b2d08da4ec0b4d242f215b15aab7a034ab1eafd1",
"branch": "drjkl/host-scoped-promoted-widgets",
"measurements": [
{
"name": "canvas-idle",
"durationMs": 2174.519000000004,
"styleRecalcs": 8,
"styleRecalcDurationMs": 9.602,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 541.516,
"heapDeltaBytes": -6469508,
"heapUsedBytes": 63284260,
"domNodes": -287,
"jsHeapTotalBytes": 18501632,
"scriptDurationMs": 29.788999999999998,
"eventListeners": -197,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "canvas-idle",
"durationMs": 2066.6510000000358,
"styleRecalcs": 9,
"styleRecalcDurationMs": 12.264999999999999,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 463.6,
"heapDeltaBytes": -6664024,
"heapUsedBytes": 63217964,
"domNodes": -278,
"jsHeapTotalBytes": 19025920,
"scriptDurationMs": 25.833,
"eventListeners": -199,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "canvas-mouse-sweep",
"durationMs": 1851.3169999999946,
"styleRecalcs": 77,
"styleRecalcDurationMs": 43.616,
"layouts": 12,
"layoutDurationMs": 3.908,
"taskDurationMs": 830.38,
"heapDeltaBytes": -5856156,
"heapUsedBytes": 53462008,
"domNodes": 59,
"jsHeapTotalBytes": 26214400,
"scriptDurationMs": 129.94,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333335,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "canvas-mouse-sweep",
"durationMs": 1889.2380000000344,
"styleRecalcs": 78,
"styleRecalcDurationMs": 44.118,
"layouts": 12,
"layoutDurationMs": 3.8319999999999994,
"taskDurationMs": 857.723,
"heapDeltaBytes": -12195132,
"heapUsedBytes": 57545304,
"domNodes": -238,
"jsHeapTotalBytes": 20336640,
"scriptDurationMs": 122.56800000000001,
"eventListeners": -199,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "canvas-zoom-sweep",
"durationMs": 1766.194999999982,
"styleRecalcs": 29,
"styleRecalcDurationMs": 17.741,
"layouts": 6,
"layoutDurationMs": 0.8170000000000003,
"taskDurationMs": 373.31399999999996,
"heapDeltaBytes": 6937748,
"heapUsedBytes": 59552632,
"domNodes": -237,
"jsHeapTotalBytes": 4321280,
"scriptDurationMs": 28.387,
"eventListeners": -184,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "canvas-zoom-sweep",
"durationMs": 1724.1409999999178,
"styleRecalcs": 32,
"styleRecalcDurationMs": 19.115,
"layouts": 6,
"layoutDurationMs": 0.7240000000000001,
"taskDurationMs": 379.41600000000005,
"heapDeltaBytes": 2874920,
"heapUsedBytes": 61994296,
"domNodes": 78,
"jsHeapTotalBytes": 25690112,
"scriptDurationMs": 32.909,
"eventListeners": 19,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "dom-widget-clipping",
"durationMs": 581.8840000000023,
"styleRecalcs": 9,
"styleRecalcDurationMs": 7.886000000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 371.5590000000001,
"heapDeltaBytes": -1294984,
"heapUsedBytes": 50866768,
"domNodes": -269,
"jsHeapTotalBytes": -135168,
"scriptDurationMs": 57.588,
"eventListeners": -203,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "dom-widget-clipping",
"durationMs": 604.5910000000276,
"styleRecalcs": 12,
"styleRecalcDurationMs": 9.429000000000002,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 402.468,
"heapDeltaBytes": -22544756,
"heapUsedBytes": 47365464,
"domNodes": -272,
"jsHeapTotalBytes": 5656576,
"scriptDurationMs": 60.551,
"eventListeners": -203,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "large-graph-idle",
"durationMs": 2023.8709999999855,
"styleRecalcs": 10,
"styleRecalcDurationMs": 9.006000000000002,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 706.343,
"heapDeltaBytes": -12990664,
"heapUsedBytes": 62570448,
"domNodes": 20,
"jsHeapTotalBytes": 10629120,
"scriptDurationMs": 236.31499999999997,
"eventListeners": 6,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "large-graph-idle",
"durationMs": 2001.037999999994,
"styleRecalcs": 9,
"styleRecalcDurationMs": 8.863,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 706.313,
"heapDeltaBytes": 8722488,
"heapUsedBytes": 63935692,
"domNodes": 18,
"jsHeapTotalBytes": 4980736,
"scriptDurationMs": 241.43300000000002,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "large-graph-pan",
"durationMs": 2154.1599999999903,
"styleRecalcs": 69,
"styleRecalcDurationMs": 18.894000000000002,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 1263.395,
"heapDeltaBytes": -9318364,
"heapUsedBytes": 55835988,
"domNodes": -271,
"jsHeapTotalBytes": -303104,
"scriptDurationMs": 558.072,
"eventListeners": -197,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "large-graph-pan",
"durationMs": 2156.7079999999805,
"styleRecalcs": 68,
"styleRecalcDurationMs": 18.469,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 1409.61,
"heapDeltaBytes": -13203792,
"heapUsedBytes": 59004276,
"domNodes": -273,
"jsHeapTotalBytes": 516096,
"scriptDurationMs": 661.2800000000001,
"eventListeners": -197,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "large-graph-zoom",
"durationMs": 3362.948000000017,
"styleRecalcs": 63,
"styleRecalcDurationMs": 17.979000000000003,
"layouts": 60,
"layoutDurationMs": 8.525,
"taskDurationMs": 1628.0910000000001,
"heapDeltaBytes": 28989424,
"heapUsedBytes": 83628520,
"domNodes": -281,
"jsHeapTotalBytes": 262144,
"scriptDurationMs": 791.1890000000001,
"eventListeners": 8,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "large-graph-zoom",
"durationMs": 3300.922000000014,
"styleRecalcs": 65,
"styleRecalcDurationMs": 20.77,
"layouts": 60,
"layoutDurationMs": 9.005,
"taskDurationMs": 1621.052,
"heapDeltaBytes": 1788588,
"heapUsedBytes": 60694204,
"domNodes": 12,
"jsHeapTotalBytes": 8650752,
"scriptDurationMs": 777.809,
"eventListeners": 8,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "minimap-idle",
"durationMs": 2010.0139999999556,
"styleRecalcs": 8,
"styleRecalcDurationMs": 8.129000000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 697.9459999999999,
"heapDeltaBytes": 8511852,
"heapUsedBytes": 65802460,
"domNodes": 16,
"jsHeapTotalBytes": 4456448,
"scriptDurationMs": 244.482,
"eventListeners": 6,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "minimap-idle",
"durationMs": 1995.025999999939,
"styleRecalcs": 8,
"styleRecalcDurationMs": 7.404000000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 709.838,
"heapDeltaBytes": 8213600,
"heapUsedBytes": 67710360,
"domNodes": 16,
"jsHeapTotalBytes": 5505024,
"scriptDurationMs": 233.833,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-dom-widget-clipping",
"durationMs": 612.542000000019,
"styleRecalcs": 47,
"styleRecalcDurationMs": 12.777000000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 420.45399999999995,
"heapDeltaBytes": -22363384,
"heapUsedBytes": 47257060,
"domNodes": -270,
"jsHeapTotalBytes": 6180864,
"scriptDurationMs": 126.476,
"eventListeners": -197,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.799999999999727
},
{
"name": "subgraph-dom-widget-clipping",
"durationMs": 608.6229999999659,
"styleRecalcs": 47,
"styleRecalcDurationMs": 14.765,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 437.832,
"heapDeltaBytes": -22135492,
"heapUsedBytes": 47719688,
"domNodes": -272,
"jsHeapTotalBytes": 5394432,
"scriptDurationMs": 131.073,
"eventListeners": -197,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "subgraph-idle",
"durationMs": 2004.872000000006,
"styleRecalcs": 10,
"styleRecalcDurationMs": 9.415,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 394.026,
"heapDeltaBytes": -61628,
"heapUsedBytes": 61473292,
"domNodes": 20,
"jsHeapTotalBytes": 27000832,
"scriptDurationMs": 19.517,
"eventListeners": 4,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-idle",
"durationMs": 2040.584000000024,
"styleRecalcs": 10,
"styleRecalcDurationMs": 10.634999999999998,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 456.805,
"heapDeltaBytes": -7578356,
"heapUsedBytes": 62202760,
"domNodes": -273,
"jsHeapTotalBytes": 20598784,
"scriptDurationMs": 20.840999999999998,
"eventListeners": -199,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "subgraph-mouse-sweep",
"durationMs": 1756.9569999999999,
"styleRecalcs": 78,
"styleRecalcDurationMs": 54.163999999999994,
"layouts": 16,
"layoutDurationMs": 4.5840000000000005,
"taskDurationMs": 773.25,
"heapDeltaBytes": -16867724,
"heapUsedBytes": 53081236,
"domNodes": -232,
"jsHeapTotalBytes": 19550208,
"scriptDurationMs": 102.419,
"eventListeners": -199,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333335,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "subgraph-mouse-sweep",
"durationMs": 1780.4179999999974,
"styleRecalcs": 76,
"styleRecalcDurationMs": 45.017,
"layouts": 16,
"layoutDurationMs": 4.8389999999999995,
"taskDurationMs": 771.291,
"heapDeltaBytes": -16748904,
"heapUsedBytes": 53161992,
"domNodes": -231,
"jsHeapTotalBytes": 20336640,
"scriptDurationMs": 99.798,
"eventListeners": -199,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.800000000000182
},
{
"name": "subgraph-transition-enter",
"durationMs": 949.1709999999784,
"styleRecalcs": 19,
"styleRecalcDurationMs": 33.347,
"layouts": 15,
"layoutDurationMs": 14.944000000000003,
"taskDurationMs": 776.7580000000003,
"heapDeltaBytes": 27123596,
"heapUsedBytes": 103598332,
"domNodes": 13673,
"jsHeapTotalBytes": 15204352,
"scriptDurationMs": 26.289999999999996,
"eventListeners": 2533,
"totalBlockingTimeMs": 153,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "viewport-pan-sweep",
"durationMs": 8279.696999999998,
"styleRecalcs": 250,
"styleRecalcDurationMs": 64.95200000000001,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 4789.584000000001,
"heapDeltaBytes": 9069276,
"heapUsedBytes": 72514636,
"domNodes": -270,
"jsHeapTotalBytes": 6717440,
"scriptDurationMs": 2040.628,
"eventListeners": -181,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.66333333333332,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "viewport-pan-sweep",
"durationMs": 8221.389999999928,
"styleRecalcs": 250,
"styleRecalcDurationMs": 62.48,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 4591.109,
"heapDeltaBytes": 15196740,
"heapUsedBytes": 70982428,
"domNodes": 18,
"jsHeapTotalBytes": 11710464,
"scriptDurationMs": 1890.243,
"eventListeners": 20,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "vue-large-graph-idle",
"durationMs": 14101.883000000043,
"styleRecalcs": 0,
"styleRecalcDurationMs": 0,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 14079.673,
"heapDeltaBytes": -10750616,
"heapUsedBytes": 183878652,
"domNodes": -3300,
"jsHeapTotalBytes": 13602816,
"scriptDurationMs": 937.2199999999999,
"eventListeners": -16378,
"totalBlockingTimeMs": 0,
"frameDurationMs": 17.776666666666642,
"p95FrameDurationMs": 16.700000000000728
},
{
"name": "vue-large-graph-idle",
"durationMs": 14174.10300000006,
"styleRecalcs": 0,
"styleRecalcDurationMs": 0,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 14157.243999999999,
"heapDeltaBytes": -39889548,
"heapUsedBytes": 163733320,
"domNodes": -3300,
"jsHeapTotalBytes": -2125824,
"scriptDurationMs": 977.1739999999999,
"eventListeners": -16376,
"totalBlockingTimeMs": 0,
"frameDurationMs": 17.776666666666763,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "vue-large-graph-pan",
"durationMs": 18007.60399999996,
"styleRecalcs": 124,
"styleRecalcDurationMs": 25.231999999999978,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 17985.703999999998,
"heapDeltaBytes": -34981592,
"heapUsedBytes": 164278708,
"domNodes": -3311,
"jsHeapTotalBytes": 15962112,
"scriptDurationMs": 1247.819,
"eventListeners": -16373,
"totalBlockingTimeMs": 45,
"frameDurationMs": 17.223333333333358,
"p95FrameDurationMs": 16.80000000000291
},
{
"name": "vue-large-graph-pan",
"durationMs": 17902.113999999983,
"styleRecalcs": 120,
"styleRecalcDurationMs": 25.283,
"layouts": 0,
"layoutDurationMs": 0,
"taskDurationMs": 17883.846999999998,
"heapDeltaBytes": -22920120,
"heapUsedBytes": 176422496,
"domNodes": -3311,
"jsHeapTotalBytes": 19808256,
"scriptDurationMs": 1263.9410000000003,
"eventListeners": -16369,
"totalBlockingTimeMs": 67,
"frameDurationMs": 18.333333333333332,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "workflow-execution",
"durationMs": 483.5260000000403,
"styleRecalcs": 16,
"styleRecalcDurationMs": 26.448999999999998,
"layouts": 4,
"layoutDurationMs": 1.354,
"taskDurationMs": 225.852,
"heapDeltaBytes": -22034468,
"heapUsedBytes": 49025716,
"domNodes": -167,
"jsHeapTotalBytes": 4345856,
"scriptDurationMs": 22.325999999999997,
"eventListeners": -134,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.663333333333338,
"p95FrameDurationMs": 16.799999999999272
},
{
"name": "workflow-execution",
"durationMs": 458.705000000009,
"styleRecalcs": 17,
"styleRecalcDurationMs": 23.453,
"layouts": 5,
"layoutDurationMs": 1.467,
"taskDurationMs": 118.892,
"heapDeltaBytes": 4993172,
"heapUsedBytes": 67530912,
"domNodes": 157,
"jsHeapTotalBytes": 4980736,
"scriptDurationMs": 18.953,
"eventListeners": 71,
"totalBlockingTimeMs": 0,
"frameDurationMs": 16.666666666666668,
"p95FrameDurationMs": 16.699999999999818
}
]
} |
Codecov Report❌ Patch coverage is @@ Coverage Diff @@
## feature/ecs-migration #13434 +/- ##
========================================================
Coverage ? 78.71%
========================================================
Files ? 1653
Lines ? 110837
Branches ? 40451
========================================================
Hits ? 87247
Misses ? 22742
Partials ? 848
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
5c69430 to
31dd441
Compare
7f920c1 to
eef74bd
Compare
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/lib/litegraph/src/LGraphNode.ts (1)
1064-1087: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winAvoid returning before widget sync.
setProperty(name, null)can normalize toundefinedand hit the early return when the stored property is alsoundefined, which skips the matching widget’sw.value = valueupdate. Move the widget sync outside the bailout, or make the bailout depend on the persisted write only.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/lib/litegraph/src/LGraphNode.ts` around lines 1064 - 1087, The early return in setProperty is skipping widget synchronization when a property normalizes to the same persisted value, so the matching widget never gets its value updated. Adjust LGraphNode.setProperty so the bailout only applies to the persisted property write, and ensure the widget sync loop still runs for the matching widget even when nextValue equals the stored property. Use the setProperty method and its w.options.property match to locate the fix.
🤖 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/components/rightSidePanel/parameters/WidgetActions.test.ts`:
- Around line 211-259: The current WidgetActions test coverage misses the
nested-subgraph edge case around the isLinked logic in WidgetActions.vue. Update
the test suite to add a case that exercises a real distinct node and host
scenario where node !== host and node.isSubgraphNode() returns true, so the
“Show input” visibility behavior is locked in for the advanced-input/nested
SubgraphNode path. Use the existing WidgetActions test helpers and the
isLinked-related behavior in WidgetActions.vue to place the new assertion near
the current “already linked” coverage.
In `@src/components/rightSidePanel/parameters/WidgetActions.vue`:
- Around line 35-41: The current isLinked computed in WidgetActions.vue hides
“Show input” based only on node.isSubgraphNode() and inputForWidget(node,
widget), which can incorrectly treat nested subgraph widgets as already
promoted. Update the logic to check the host context explicitly, using node ===
host or a host-based lookup before deciding canShowInput, so only widgets
actually promoted on the current host suppress the action.
In `@src/components/rightSidePanel/subgraph/SubgraphEditor.vue`:
- Around line 250-252: The `isRowLinked` helper in `SubgraphEditor.vue` is doing
a redundant `widgetId` null check for promoted rows. Since `buildPromotedRows`
only creates promoted rows when `promotedInputWidget(input)` succeeds and that
already guarantees `input.widgetId` is set, simplify `isRowLinked(row:
ActiveRow)` to rely on `row.kind === 'promoted'` alone and remove the extra
`row.input.widgetId != null` condition.
---
Outside diff comments:
In `@src/lib/litegraph/src/LGraphNode.ts`:
- Around line 1064-1087: The early return in setProperty is skipping widget
synchronization when a property normalizes to the same persisted value, so the
matching widget never gets its value updated. Adjust LGraphNode.setProperty so
the bailout only applies to the persisted property write, and ensure the widget
sync loop still runs for the matching widget even when nextValue equals the
stored property. Use the setProperty method and its w.options.property match to
locate the fix.
🪄 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: ASSERTIVE
Plan: Pro Plus
Run ID: 3d9e62f0-791a-4a6e-aaf7-42e623399514
📒 Files selected for processing (32)
src/components/rightSidePanel/parameters/SectionWidgets.vuesrc/components/rightSidePanel/parameters/TabSubgraphInputs.test.tssrc/components/rightSidePanel/parameters/TabSubgraphInputs.vuesrc/components/rightSidePanel/parameters/WidgetActions.test.tssrc/components/rightSidePanel/parameters/WidgetActions.vuesrc/components/rightSidePanel/parameters/WidgetItem.vuesrc/components/rightSidePanel/subgraph/SubgraphEditor.vuesrc/core/graph/subgraph/promotedInputWidget.tssrc/core/graph/subgraph/promotionUtils.test.tssrc/core/graph/subgraph/promotionUtils.tssrc/extensions/core/load3d.tssrc/extensions/core/widgetInputs.tssrc/extensions/core/widgetValuePropagation.tssrc/lib/litegraph/src/LGraphNode.tssrc/lib/litegraph/src/types/widgets.tssrc/locales/ar/main.jsonsrc/locales/en/main.jsonsrc/locales/es/main.jsonsrc/locales/fa/main.jsonsrc/locales/fr/main.jsonsrc/locales/he/main.jsonsrc/locales/ja/main.jsonsrc/locales/ko/main.jsonsrc/locales/pt-BR/main.jsonsrc/locales/ru/main.jsonsrc/locales/tr/main.jsonsrc/locales/zh-TW/main.jsonsrc/locales/zh/main.jsonsrc/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.tssrc/renderer/extensions/vueNodes/composables/useProcessedWidgets.tssrc/stores/workspace/favoritedWidgetsStore.tssrc/utils/widgetUtil.ts
💤 Files with no reviewable changes (14)
- src/locales/zh/main.json
- src/locales/ja/main.json
- src/locales/zh-TW/main.json
- src/locales/fr/main.json
- src/locales/es/main.json
- src/locales/ko/main.json
- src/locales/he/main.json
- src/locales/fa/main.json
- src/locales/pt-BR/main.json
- src/locales/ru/main.json
- src/locales/tr/main.json
- src/locales/ar/main.json
- src/core/graph/subgraph/promotedInputWidget.ts
- src/locales/en/main.json
72a1d83 to
38ec586
Compare
c221781 to
a71df2f
Compare
38ec586 to
b713409
Compare
jaeone94
left a comment
There was a problem hiding this comment.
The host-scoping core looks good to me — the three predicate replacements read as equivalent (or strictly safer on the dangling-input edge), and I agree the removed handleHideInput branch was unreachable. Two inline comments below, plus one PR-level note:
Suggestion: The null-widget-value work in the last commit (b71340970) — widening IBaseWidget's default TValue to WidgetValue, the setProperty normalization, and the follow-ups in useProcessedWidgets/widgetInputs/widgetValuePropagation/load3d — doesn't share any files with the host-scoping commits, and those all precede it in the stack, so they appear to build and behave without it. Since IBaseWidget's default value type and setProperty are extension-facing surfaces, they'd probably benefit from their own description and review. Given this is already a stack, would you consider splitting that commit into one more layer? That would also let the setProperty discussion below proceed without holding up the host-scoping work here.
| if (value === this.properties[name]) return | ||
| // Node properties are persisted and cannot hold null/void, so normalize | ||
| // before comparing and storing. | ||
| const nextValue = value ?? undefined |
There was a problem hiding this comment.
Issue: If I'm reading the value ?? undefined normalization right, it changes the observable behavior of setProperty(name, null) in two cases: (1) when the property is unset (or currently undefined), nextValue === properties[name] early-returns, so what used to store null and fire onPropertyChanged is now a silent no-op; (2) when the property holds a value, undefined gets stored, the key disappears from the serialized workflow (previously persisted as "name": null), and onPropertyChanged receives undefined where it used to get null. Since setProperty/onPropertyChanged are surfaces custom nodes use, if this change is intentional it'd be good to have a test and a short migration note; if not, reverting this hunk seems safer. One more thing worth confirming: within the same call, the widget-sync loop still assigns the pre-normalization value, so the property and the widget receive different values for a null argument — is that intended?
There was a problem hiding this comment.
Agreed 👍✅ Reverted the setProperty normalization hunk ↩️ and added regression coverage for property storage 💾, callbacks 📣, widget synchronization 🔄, and serialization 📦 in 326d4b8945 🧪🚀✨
| ).not.toBeInTheDocument() | ||
| }) | ||
|
|
||
| it('toggles the favorite for the host node itself', async () => { |
There was a problem hiding this comment.
Suggestion: Unless I'm misreading the fixture, "toggles the favorite for the host node itself" renders without a host. In a host-less configuration the pre-change logic (favoriteNode falling back to node when no parents were given) produces the same outcome, so I don't think this test can catch a regression in the favorite-target decision this PR changes. Passing { host } — so the test exercises the configuration its title describes — and asserting toggleFavorite still receives the row's node would pin against someone reintroducing host-directed favorites later (and the title could be adjusted to match).
There was a problem hiding this comment.
Agreed 👍✅ The test now supplies a distinct host 🏠 and verifies that the row’s node remains the favorite target ⭐🎯 in d1366ebe66 🧪🚀
| const canShowInput = computed(() => host != null && !isLinked.value) | ||
| const isFavorited = computed(() => | ||
| favoritedWidgetsStore.isFavorited(favoriteNode.value, widget.name) | ||
| favoritedWidgetsStore.isFavorited(node, widget.name) |
There was a problem hiding this comment.
issue: handleHideInput and the hide/show toggle are removed entirely, but the "Hide input" action was reachable for non-linked physically-promoted widgets. Git history shows bb96e3c (#10648) explicitly restored this path (canToggleVisibility = hasParents && !isLinked). After this PR, a user inside a subgraph can no longer demote a promoted widget from the Parameters tab three-dot menu.
Is the intent to remove this action completely, or should the demote path (demoteWidget(node, widget, [host])) be kept and gated on a separate condition?
There was a problem hiding this comment.
Remove until we have better handling. The previous path had special cases for "promoted" vs "linked to Subgraph input". Now that we have Subgraph Link Only Promotion, hiding becomes a difficult UX/product issue.
| sourceWidgetName: source.widgetName | ||
| }) | ||
| const linkedInput = row.input._subgraphSlot | ||
| if (linkedInput) { |
There was a problem hiding this comment.
issue: when row.input._subgraphSlot is absent (possible if the input was created before _subgraphSlot hydration), the demotion silently no-ops while refreshPromotedWidgetRendering() still fires, giving a false success signal. The old path via demotePromotedInput had the same silent skip, but the Partial<ISubgraphInput> widening makes this gap wider. Consider adding an else { console.warn(...) } branch, or asserting _subgraphSlot is non-null in dev mode.
There was a problem hiding this comment.
This is not a new runtime gap introduced here 🔍✅ The previous demotePromotedInput path also returned without mutation when _subgraphSlot was absent ↩️, and the widened static type does not expand the runtime inputs 🧠📐 I’m leaving the existing behavior unchanged 👍🧘✨
| }) | ||
| const linkedInput = row.input._subgraphSlot | ||
| if (linkedInput) { | ||
| if (row.input.link != null) linkedInput.disconnect() |
There was a problem hiding this comment.
question: when row.input.link != null, the code calls linkedInput.disconnect() but does not call subgraphNode.subgraph.removeInput(linkedInput). Did the old demotePromotedInput also leave the slot alive in this case, or did it always remove the slot? If the slot should be removed, this branch may leave a dangling empty subgraph input.
There was a problem hiding this comment.
IIRC, it left an empty slot. This was the looooong thread about demotion semantics with all of the axioms and lemmas.
| !!source && | ||
| isLinkedPromotion(activeNode.value, String(row.node.id), source.widgetName) | ||
| ) | ||
| return row.kind === 'promoted' && row.input.widgetId != null |
There was a problem hiding this comment.
question: the old isRowLinked had if (row.node.id === UNASSIGNED_NODE_ID) return true for synthetic promoted rows not yet assigned a real ID. The new check row.input.widgetId != null returns false for those rows if widgetId is not yet populated -- which would enable the demote button prematurely. Is widgetId guaranteed to be set for all rows that previously matched the UNASSIGNED_NODE_ID sentinel?
There was a problem hiding this comment.
buildPromotedRows only creates a promoted row after promotedInputWidget(input) succeeds 🏗️✅, which guarantees widgetId 🆔🔒 The unassigned source-node sentinel does not create a promoted row without that input identity 🚫🧙 The redundant check was removed in d1366ebe66 ✂️🧹🚀
| type: 'TestNode', | ||
| rootGraph: { id: 'graph-test' }, | ||
| isSubgraphNode: () => true, | ||
| getSlotFromWidget: () => ({ widgetId: 'graph-test:1:test_widget' }) |
There was a problem hiding this comment.
suggestion: (non-blocking) the mock returns { widgetId: '...' } unconditionally regardless of which widget is queried. A name-matching bug in getSlotFromWidget would still pass this test. Consider discriminating the mock on widget.name (e.g. (w) => w.name === 'test_widget' ? { widgetId: '...' } : undefined) so it only matches the correct widget.
There was a problem hiding this comment.
Agreed 👍✅ The mock now returns a slot only for the expected widget 🎯🧩 in d1366ebe66 🧪🚀✨
| expect(onResetToDefault).toHaveBeenCalledWith('option1') | ||
| }) | ||
|
|
||
| it('promotes the widget into the host when "Show input" is clicked', async () => { |
There was a problem hiding this comment.
suggestion: (non-blocking) there is no integration-level test that uses a real SubgraphNode + promoteValueWidgetViaSubgraphInput, then renders WidgetActions and asserts "Show input" is absent for a linked promotion and present for an unlinked one. The deleted isLinkedPromotion tests partially covered this invariant. One test at this level would guard the new inputForWidget(node, widget)?.widgetId != null check against future regressions.
There was a problem hiding this comment.
I’m deferring this ⏸️📝 The suggested test exercises the previous promotion-chain model 🔗🪦, while this PR intentionally makes widget behavior node-local 📍 and independent 🧩✨ The focused tests cover the intended boundary 🎯🧪✅
| /** In-memory array of favorited widget IDs, ordered for display */ | ||
| const favoritedIds = ref<string[]>([]) | ||
|
|
||
| // TODO(ADR0009): key favorites by the host-scoped WidgetId instead of |
There was a problem hiding this comment.
suggestion: (non-blocking) the TODO mentions a deferred migration but does not note the data-loss risk: existing favorited promoted widgets saved under the host's nodeLocatorId will silently disappear without a compat shim that reads the old key format on first load. Worth adding: // Risk: without a compat reader, existing persisted favorites for promoted widgets will be silently dropped on upgrade.
There was a problem hiding this comment.
The existing TODO already states that adopting the new persisted key requires a one-time migration 📝🔄💾 Since the migration is deferred ⏳ and no persisted format changes in this PR 🔒📦, I’m avoiding an additional comment that repeats the same requirement 🦜🚫🧹✨
| }) | ||
| const linkedInput = row.input._subgraphSlot | ||
| if (linkedInput) { | ||
| if (row.input.link != null) linkedInput.disconnect() |
There was a problem hiding this comment.
nitpick (non-blocking): the link != null ? disconnect() : removeInput() split and the variable name linkedInput (for what is actually a SubgraphInput slot, not a link) are non-obvious without context. A brief inline comment would help future readers understand why a live-linked slot is only disconnected while an unlinked slot is fully removed. Renaming linkedInput to subgraphSlot would also match the field name _subgraphSlot.
There was a problem hiding this comment.
Renamed linkedInput to subgraphSlot in d1366ebe66 🏷️✅🚀 The clearer name makes the relationship explicit 🔍✨, so I did not add a comment paraphrasing the disconnect/remove branches 📝🚫🦜🧹
| if (live) { | ||
| const normalized = normalizeWidgetValue(newValue) | ||
| live.widget.value = normalized ?? undefined | ||
| live.widget.value = normalized |
There was a problem hiding this comment.
question: live.widget.value = normalized now allows null to reach IBaseWidget.value, where before it was coerced to undefined via ?? undefined. Custom nodes that serialize widgets_values via LGraphNode.serialize() will now emit JSON null for this slot. Is that intentional? If so, it may be worth noting in the PR description or release notes since it is a subtle behavioral change for the extension ecosystem.
There was a problem hiding this comment.
LGraphNode.serialize() already normalizes both undefined and null widget values to JSON null 🔄➡️🈳, so this does not change the serialized widgets_values representation 📦🔒 No release note is needed for the serialization behavior described here 📝🚫✅✨
SectionWidgets and WidgetActions identified promoted subgraph-input widgets by walking live links back to the interior source. Per ADR0009 a promoted widget is host-scoped (widget.widgetId = graphId:hostNodeId:inputName, also carried on the host input slot), so reuse that instead: - isLinked: inputForWidget(node, widget)?.widgetId != null - isWidgetShownOnParents: match parent inputs by widget.widgetId - handleHideInput: drop unreachable source-resolution branch, keep demoteWidget - remove now-unused widgetPromotedSource export Favorites keep their current (nodeLocatorId, widgetName) key; added a TODO(ADR0009) to move to a pure WidgetId key (deferred: needs a persisted format migration). Amp-Thread-ID: https://ampcode.com/threads/T-019f2964-7a96-7579-8883-caeb3b2b07da Co-authored-by: Amp <amp@ampcode.com>
isShownOnParents never changed observable behavior: for promoted rows parents[0] === node (so favoriteNode was unchanged) and the toggle was suppressed by isLinked; for interior rows it was always false. Its Hide-toggle branch and parent-redirect were unreachable. - drop isShownOnParents prop from WidgetItem and WidgetActions - favorites key directly off node (was favoriteNode) - WidgetActions toggle becomes a one-way Show Input action; remove the dead handleHideInput + demoteWidget usage and the hideInput locale key - remove SectionWidgets.isWidgetShownOnParents Amp-Thread-ID: https://ampcode.com/threads/T-019f2964-7a96-7579-8883-caeb3b2b07da Co-authored-by: Amp <amp@ampcode.com>
- demoteRow now removes the linked input via the host input slot we already have, instead of re-resolving the source and calling demotePromotedInput - un-export demotePromotedInput (only internal callers remain) - drop dead parents param from renameWidget/promptRenameWidget Amp-Thread-ID: https://ampcode.com/threads/T-019f2964-7a96-7579-8883-caeb3b2b07da Co-authored-by: Amp <amp@ampcode.com>
isLinkedPromotion has no production callers outside promotionUtils. Drop its dedicated direct-unit describe block and rewrite the demoteWidget tests that used it as an assertion oracle to check observable interior link state instead. Amp-Thread-ID: https://ampcode.com/threads/T-019f2964-7a96-7579-8883-caeb3b2b07da Co-authored-by: Amp <amp@ampcode.com>
- WidgetActions: show-input promotion, isLinked gating, favorite toggle by host node - useProcessedWidgets: live update handler forwards null (not undefined) to the widget callback Amp-Thread-ID: https://ampcode.com/threads/T-019f2964-7a96-7579-8883-caeb3b2b07da Co-authored-by: Amp <amp@ampcode.com>
The promoted-widget side panel only ever has one host SubgraphNode, so collapse the parents: SubgraphNode[] prop to host?: SubgraphNode across SectionWidgets/WidgetItem/WidgetActions and drop the [node] wrapper in TabSubgraphInputs. Amp-Thread-ID: https://ampcode.com/threads/T-019f2964-7a96-7579-8883-caeb3b2b07da Co-authored-by: Amp <amp@ampcode.com>
Default IBaseWidget TValue to the canonical WidgetValue so a widget value may be null. The Vue update handler now assigns the same normalized value to the store, live widget .value, and callback instead of coercing .value to undefined. Coerce null/void to undefined only at the persisted NodeProperty boundary. Amp-Thread-ID: https://ampcode.com/threads/T-019f2a75-8d64-7605-980d-7aca51a5f19c Co-authored-by: Amp <amp@ampcode.com>
8105355 to
1343173
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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/lib/litegraph/src/types/widgets.ts`:
- Line 415: Update isWidgetValue to accept null consistently with the
WidgetValue default type, and ensure applySubgraphInputOrder preserves null
values through reordering rather than converting them to undefined or skipping
restoration. Add a regression test covering null widget values being reordered
and restored to the correct inputs.
In `@src/lib/litegraph/src/widgets/BaseWidget.ts`:
- Around line 430-435: Update the property synchronization condition in the
widget value-change logic to remove the v != null guard, while retaining the
options.property check and node.properties presence check. Ensure
node.setProperty is called for null values so this.value and node.properties
remain synchronized.
🪄 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: ASSERTIVE
Plan: Pro Plus
Run ID: 3e142bfa-e47c-49b1-a0d1-d8556a8896b8
📒 Files selected for processing (29)
src/components/rightSidePanel/parameters/SectionWidgets.vuesrc/components/rightSidePanel/parameters/TabSubgraphInputs.test.tssrc/components/rightSidePanel/parameters/TabSubgraphInputs.vuesrc/components/rightSidePanel/parameters/WidgetActions.test.tssrc/components/rightSidePanel/parameters/WidgetActions.vuesrc/components/rightSidePanel/parameters/WidgetItem.vuesrc/components/rightSidePanel/subgraph/SubgraphEditor.vuesrc/core/graph/subgraph/promotedInputWidget.tssrc/core/graph/subgraph/promotionUtils.test.tssrc/core/graph/subgraph/promotionUtils.tssrc/extensions/core/load3d.tssrc/extensions/core/widgetInputs.tssrc/extensions/core/widgetValuePropagation.tssrc/lib/litegraph/src/LGraphNode.test.tssrc/lib/litegraph/src/types/widgets.tssrc/lib/litegraph/src/widgets/BaseWidget.tssrc/locales/ar/main.jsonsrc/locales/en/main.jsonsrc/locales/es/main.jsonsrc/locales/fa/main.jsonsrc/locales/fr/main.jsonsrc/locales/he/main.jsonsrc/locales/ja/main.jsonsrc/locales/ko/main.jsonsrc/locales/pt-BR/main.jsonsrc/locales/ru/main.jsonsrc/locales/tr/main.jsonsrc/locales/zh-TW/main.jsonsrc/locales/zh/main.json
💤 Files with no reviewable changes (13)
- src/core/graph/subgraph/promotedInputWidget.ts
- src/locales/zh-TW/main.json
- src/locales/ko/main.json
- src/locales/fr/main.json
- src/locales/pt-BR/main.json
- src/locales/ja/main.json
- src/locales/zh/main.json
- src/locales/ar/main.json
- src/locales/es/main.json
- src/locales/fa/main.json
- src/locales/he/main.json
- src/locales/tr/main.json
- src/locales/ru/main.json
| */ | ||
| export interface IBaseWidget< | ||
| TValue = boolean | number | string | object | undefined, | ||
| TValue = WidgetValue, |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Keep isWidgetValue aligned with the new default type.
WidgetValue includes null, but isWidgetValue still rejects it. applySubgraphInputOrder consequently converts null values to undefined and skips restoring them after reordering, which can lose promoted widget values or associate values with the wrong input. Update the predicate and add a null-reordering regression test.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/litegraph/src/types/widgets.ts` at line 415, Update isWidgetValue to
accept null consistently with the WidgetValue default type, and ensure
applySubgraphInputOrder preserves null values through reordering rather than
converting them to undefined or skipping restoration. Add a regression test
covering null widget values being reordered and restored to the correct inputs.
| if ( | ||
| this.options?.property && | ||
| v != null && | ||
| node.properties[this.options.property] !== undefined | ||
| ) { | ||
| node.setProperty(this.options.property, v) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Do not skip property synchronization for null.
When a property-backed widget changes from a non-null value to null, this.value is updated but node.setProperty(...) is skipped. The widget and node.properties then diverge, and serialization retains the stale property value. Remove the v != null guard while retaining the existing property-presence check.
Proposed fix
if (
this.options?.property &&
- v != null &&
node.properties[this.options.property] !== undefined
) {📝 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.
| if ( | |
| this.options?.property && | |
| v != null && | |
| node.properties[this.options.property] !== undefined | |
| ) { | |
| node.setProperty(this.options.property, v) | |
| if ( | |
| this.options?.property && | |
| node.properties[this.options.property] !== undefined | |
| ) { | |
| node.setProperty(this.options.property, v) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/litegraph/src/widgets/BaseWidget.ts` around lines 430 - 435, Update
the property synchronization condition in the widget value-change logic to
remove the v != null guard, while retaining the options.property check and
node.properties presence check. Ensure node.setProperty is called for null
values so this.value and node.properties remain synchronized.
isWidgetValue rejected null while the canonical WidgetValue allows it, so promoted-input reorder silently dropped null values, and BaseWidget.setValue skipped setProperty for null, letting widget value and bound node property skew. TWidgetValue, WidgetValue, and WidgetState.value had collapsed into the same union spelled three ways (plus unknown in the store). Alias TWidgetValue to WidgetValue, default WidgetState to WidgetValue, drop the dead File[]/void members, and delete the four guards that only laundered unknown - isWidgetValue remains at the untrusted-JSON and serialize boundaries.
Summary
Make right-side-panel promoted subgraph-input widget rows host-scoped, removing runtime graph traversal, per ADR0009.
Changes
SectionWidgets.vueandWidgetActions.vueno longer walk live links back to the interior source to identify promoted widgets. A promoted widget is host-scoped (widget.widgetId = graphId:hostNodeId:inputName, also carried on the host input slot), so reuse that:isLinked→inputForWidget(node, widget)?.widgetId != nullisWidgetShownOnParents→ match parent inputs bywidget.widgetIdhandleHideInput→ drop the unreachable source-resolution branch (with theString(node.id)===String(parent.id)swap), keepdemoteWidgetwidgetPromotedSourceexport(nodeLocatorId, widgetName)key; added aTODO(ADR0009)to move to a pureWidgetIdkey (deferred — needs a persisted-format migration)resolvePromotedWidgetSourceinclearWidgetErrorsis left intact (ADR0009 permits source lookup for diagnostics).Review Focus
Behavior equivalence of the three replaced predicates. The removed
handleHideInputbranch was unreachable: reaching it requires!isLinked, but whenever the old source resolved,isLinkedwas already true. The only divergences are broken/unresolvable interior links, which are strictly safer under the new logic. Net -55 lines, no new APIs.Validated:
pnpm typecheck,pnpm lint,pnpm knipclean; 127 subgraph/parameter unit tests pass.Addendum: widget-value type consolidation
f48153dfixes null handling and starts collapsing the redundant value types. Current state and the path to one canonical type:The problem. Five spellings of the same union, each able to skew independently:
WidgetValue(simplifiedWidget.ts)string|number|boolean|object|undefined|null|void|File[]string|number|boolean|object|undefined|nullTWidgetValue(litegraph widgets.ts)IWidget['value'](rejected null)= WidgetValuealiasWidgetState['value'](widgetState.ts)unknownWidgetValueNodeProperty(LGraphNode.ts)|nulladdedIBaseWidget.callbackvalue paramunknownWidgetValueThe old
unknownin the store forcedisWidgetValue(x) ? x : undefinedlaundering at 6 call sites; 4 are now deleted. The guard survives only where it does real work: untrusted workflow JSON (proxyWidgetMigration.pickHostValue) and the serialize path (SubgraphNode.serialize).Remaining steps (follow-up PRs):
TWidgetValue->WidgetValue(~20 imports incl.serialisation.ts,litegraph-augmentation.d.ts, panel callbacks), delete the alias. Zero behavior change.NodeProperty = Exclude<WidgetValue, undefined>- derives the one legitimate variant (properties useundefinedas "absent") instead of a parallel hand-written union that can drift.WidgetValueto a leaf module (litegraph currently imports it from app-layer@/types/simplifiedWidget- inverted layering).migrateWidgetsValues<TWidgetValue>inlitegraphUtil.tsto<T>.objectmembers -IChartWidget.value: object,ICustomWidget.value: string\|object- toRecord<string, unknown>. This is the only change that makes the union meaningfully narrower than "anything non-callable"; needs an ecosystem deprecation window sincecustomis the extension escape hatch.Invariant to keep:
nullandundefinedare distinct on purpose.undefined= hole/absent inwidgets_valuesarrays and property-presence checks;null= a real stored value. Regression tests inpromotionUtils.test.tsandLGraphNode.test.tspin this.