Compare commits

...

4 Commits

Author SHA1 Message Date
Kelly Yang
9d610c4fec refactor: extract isNumericWidget predicate, flatten null guard, improve tests
- Add isNumericWidget() to widgetMap.ts alongside isComboWidget/isAssetWidget,
  backed by a module-scoped Set — single source of truth for numeric types
- Simplify configure() guard: replace double-negation if-block with early
  continue and use == null to cover both null and undefined
- Drop unused createTestingPinia/setActivePinia from test (LGraphNode has no
  Pinia dependency)
- Replace as unknown[] as number[] double-casts with roundTrip() helper that
  also documents the NaN→null JSON round-trip as the real-world source
- Add test case for serialize:false widget preceding a sanitized numeric widget
  to verify i++ slot ordering is unaffected
- Add TODO comment on text-widget null test to document intentional scope limit
2026-05-04 14:04:09 -07:00
Kelly Yang
92162dfd2a fix: include gradientslider in numeric widget sanitization 2026-05-03 21:07:40 -07:00
Kelly Yang
5013d816aa fix: sanitize null/NaN widget values when loading workflows 2026-05-03 21:03:57 -07:00
Kelly Yang
e66c2ca050 test: add failing tests for null/NaN numeric widget value sanitization on configure 2026-05-03 21:01:26 -07:00
3 changed files with 107 additions and 2 deletions

View File

@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
// Mirrors JSON.stringify(NaN) === "null" — the real source of null in workflows.
const roundTrip = <T>(v: T): T => JSON.parse(JSON.stringify(v))
function serialisedNode(
overrides: Partial<Omit<ISerialisedNode, 'widgets_values'>> & {
widgets_values?: unknown[]
} = {}
): ISerialisedNode {
return {
id: 1,
type: 'TestNode',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
...overrides
} as ISerialisedNode
}
describe('LGraphNode.configure numeric widget sanitization', () => {
let node: LGraphNode
beforeEach(() => {
node = new LGraphNode('TestNode')
})
it('preserves default when widgets_values contains null for a number widget', () => {
node.addWidget('number', 'seed', 42, null, {})
node.configure(serialisedNode({ widgets_values: roundTrip([NaN]) }))
expect(node.widgets![0].value).toBe(42)
})
it('preserves default when widgets_values contains NaN for a number widget', () => {
node.addWidget('number', 'seed', 42, null, {})
node.configure(serialisedNode({ widgets_values: [NaN] }))
expect(Number.isNaN(node.widgets![0].value)).toBe(false)
expect(node.widgets![0].value).toBe(42)
})
it('still applies valid numeric values normally', () => {
node.addWidget('number', 'seed', 42, null, {})
node.configure(serialisedNode({ widgets_values: [99999] }))
expect(node.widgets![0].value).toBe(99999)
})
it('preserves default when widgets_values contains null for a gradientslider widget', () => {
node.addWidget('gradientslider', 'denoise', 0.75, null, {})
node.configure(serialisedNode({ widgets_values: roundTrip([NaN]) }))
expect(node.widgets![0].value).toBe(0.75)
})
it('does not sanitize null for non-numeric widget types', () => {
// TODO: null from a serialized workflow probably should not clobber text
// widgets either; this test documents the current intentional scope limit.
node.addWidget('text', 'prompt', 'default text', null, {})
node.configure(serialisedNode({ widgets_values: [null] }))
expect(node.widgets![0].value).toBeNull()
})
it('preserves correct slot ordering when a non-serialized widget precedes a sanitized numeric widget', () => {
// widget A has serialize:false and must not consume a slot in widgets_values
const widgetA = node.addWidget('text', 'label', 'hello', null, {})
widgetA.serialize = false
node.addWidget('number', 'seed', 42, null, {})
// widgets_values has one entry — for the number widget only
node.configure(serialisedNode({ widgets_values: roundTrip([NaN]) }))
expect(node.widgets![0].value).toBe('hello') // non-serialized, untouched
expect(node.widgets![1].value).toBe(42) // sanitized, default preserved
})
})

View File

@@ -93,7 +93,7 @@ import { warnDeprecated } from './utils/feedback'
import { distributeSpace } from './utils/spaceDistribution'
import { truncateText } from './utils/textUtils'
import { BaseWidget } from './widgets/BaseWidget'
import { toConcreteWidget } from './widgets/widgetMap'
import { isNumericWidget, toConcreteWidget } from './widgets/widgetMap'
import type { WidgetTypeMap } from './widgets/widgetMap'
// #region Types
@@ -919,7 +919,12 @@ export class LGraphNode
for (const widget of this.widgets ?? []) {
if (widget.serialize === false) continue
if (i >= info.widgets_values.length) break
widget.value = info.widgets_values[i++]
const incoming = info.widgets_values[i++]
const isInvalid =
incoming == null ||
(typeof incoming === 'number' && !Number.isFinite(incoming))
if (isNumericWidget(widget) && isInvalid) continue
widget.value = incoming
}
}
}

View File

@@ -165,4 +165,16 @@ export function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
return widget.type === 'asset'
}
const NUMERIC_WIDGET_TYPES = new Set<string>([
'number',
'slider',
'gradientslider',
'knob'
])
/** Returns true when the widget's value must be a finite number (rejects null/NaN). */
export function isNumericWidget(widget: IBaseWidget): boolean {
return NUMERIC_WIDGET_TYPES.has(widget.type)
}
// #endregion Type Guards