Skip to content

Commit 75d31f9

Browse files
fix large integer precision handling in Vue int widgets (#5787)
## Summary Fixed increment/decrement button lockup in number widgets when values exceed JavaScript's safe integer limit (2^53 - 1). ## Changes - **What**: Added precision-aware button disabling and user feedback to `WidgetInputNumberInput` component using [Number.isSafeInteger()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isSafeInteger) - We still need to support values greater than 2^53 because they may be in workflows. ## Review Focus JavaScript floating-point precision behavior at scale - buttons hide when arithmetic operations like `value + 1` would be unreliable due to IEEE 754 limitations. Test coverage includes edge cases (NaN, Infinity) and boundary conditions at MAX_SAFE_INTEGER. ```mermaid graph TD A[User Input] --> B{Value > 2^53?} B -->|No| C[Show Buttons] B -->|Yes| D[Hide Buttons] D --> E[Show Tooltip] E --> F[User Can Still Type] style A fill:#f9f9f9,stroke:#333,color:#000 style F fill:#f9f9f9,stroke:#333,color:#000 ``` ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5787-fix-large-integer-precision-handling-in-Vue-int-widgets-27a6d73d365081d9ae00e485740cfafb) by [Unito](https://www.unito.io)
1 parent edcbcdf commit 75d31f9

File tree

2 files changed

+198
-25
lines changed

2 files changed

+198
-25
lines changed

src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberInput.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,3 +206,148 @@ describe('WidgetInputNumberInput Grouping Behavior', () => {
206206
expect(input.value).not.toContain(',')
207207
})
208208
})
209+
210+
describe('WidgetInputNumberInput Large Integer Precision Handling', () => {
211+
const SAFE_INTEGER_MAX = Number.MAX_SAFE_INTEGER // 9,007,199,254,740,991
212+
const UNSAFE_LARGE_INTEGER = 18446744073709552000 // Example seed value that exceeds safe range
213+
214+
it('shows buttons for safe integer values', () => {
215+
const widget = createMockWidget(1000, 'int')
216+
const wrapper = mountComponent(widget, 1000)
217+
218+
const inputNumber = wrapper.findComponent(InputNumber)
219+
expect(inputNumber.props('showButtons')).toBe(true)
220+
})
221+
222+
it('shows buttons for values at safe integer limit', () => {
223+
const widget = createMockWidget(SAFE_INTEGER_MAX, 'int')
224+
const wrapper = mountComponent(widget, SAFE_INTEGER_MAX)
225+
226+
const inputNumber = wrapper.findComponent(InputNumber)
227+
expect(inputNumber.props('showButtons')).toBe(true)
228+
})
229+
230+
it('hides buttons for unsafe large integer values', () => {
231+
const widget = createMockWidget(UNSAFE_LARGE_INTEGER, 'int')
232+
const wrapper = mountComponent(widget, UNSAFE_LARGE_INTEGER)
233+
234+
const inputNumber = wrapper.findComponent(InputNumber)
235+
expect(inputNumber.props('showButtons')).toBe(false)
236+
})
237+
238+
it('hides buttons for unsafe negative integer values', () => {
239+
const unsafeNegative = -UNSAFE_LARGE_INTEGER
240+
const widget = createMockWidget(unsafeNegative, 'int')
241+
const wrapper = mountComponent(widget, unsafeNegative)
242+
243+
const inputNumber = wrapper.findComponent(InputNumber)
244+
expect(inputNumber.props('showButtons')).toBe(false)
245+
})
246+
247+
it('shows tooltip for disabled buttons due to precision limits', () => {
248+
const widget = createMockWidget(UNSAFE_LARGE_INTEGER, 'int')
249+
const wrapper = mountComponent(widget, UNSAFE_LARGE_INTEGER)
250+
251+
// Check that tooltip wrapper div exists
252+
const tooltipDiv = wrapper.find('div[v-tooltip]')
253+
expect(tooltipDiv.exists()).toBe(true)
254+
})
255+
256+
it('does not show tooltip for safe integer values', () => {
257+
const widget = createMockWidget(1000, 'int')
258+
const wrapper = mountComponent(widget, 1000)
259+
260+
// For safe values, tooltip should not be set (computed returns null)
261+
const tooltipDiv = wrapper.find('div')
262+
expect(tooltipDiv.attributes('v-tooltip')).toBeUndefined()
263+
})
264+
265+
it('handles edge case of zero value', () => {
266+
const widget = createMockWidget(0, 'int')
267+
const wrapper = mountComponent(widget, 0)
268+
269+
const inputNumber = wrapper.findComponent(InputNumber)
270+
expect(inputNumber.props('showButtons')).toBe(true)
271+
})
272+
273+
it('correctly identifies safe vs unsafe integers using Number.isSafeInteger', () => {
274+
// Test the JavaScript behavior our component relies on
275+
expect(Number.isSafeInteger(SAFE_INTEGER_MAX)).toBe(true)
276+
expect(Number.isSafeInteger(SAFE_INTEGER_MAX + 1)).toBe(false)
277+
expect(Number.isSafeInteger(UNSAFE_LARGE_INTEGER)).toBe(false)
278+
expect(Number.isSafeInteger(-SAFE_INTEGER_MAX)).toBe(true)
279+
expect(Number.isSafeInteger(-SAFE_INTEGER_MAX - 1)).toBe(false)
280+
})
281+
282+
it('maintains readonly behavior even for unsafe values', () => {
283+
const widget = createMockWidget(UNSAFE_LARGE_INTEGER, 'int')
284+
const wrapper = mountComponent(widget, UNSAFE_LARGE_INTEGER, true)
285+
286+
const inputNumber = wrapper.findComponent(InputNumber)
287+
expect(inputNumber.props('disabled')).toBe(true)
288+
expect(inputNumber.props('showButtons')).toBe(false) // Still hidden due to unsafe value
289+
})
290+
291+
it('handles floating point values correctly', () => {
292+
const safeFloat = 1000.5
293+
const widget = createMockWidget(safeFloat, 'float')
294+
const wrapper = mountComponent(widget, safeFloat)
295+
296+
const inputNumber = wrapper.findComponent(InputNumber)
297+
expect(inputNumber.props('showButtons')).toBe(true)
298+
})
299+
300+
it('hides buttons for unsafe floating point values', () => {
301+
const unsafeFloat = UNSAFE_LARGE_INTEGER + 0.5
302+
const widget = createMockWidget(unsafeFloat, 'float')
303+
const wrapper = mountComponent(widget, unsafeFloat)
304+
305+
const inputNumber = wrapper.findComponent(InputNumber)
306+
expect(inputNumber.props('showButtons')).toBe(false)
307+
})
308+
})
309+
310+
describe('WidgetInputNumberInput Edge Cases for Precision Handling', () => {
311+
it('handles null/undefined model values gracefully', () => {
312+
const widget = createMockWidget(0, 'int')
313+
// Mount with undefined as modelValue
314+
const wrapper = mount(WidgetInputNumberInput, {
315+
global: {
316+
plugins: [PrimeVue],
317+
components: { InputNumber }
318+
},
319+
props: {
320+
widget,
321+
modelValue: undefined as any
322+
}
323+
})
324+
325+
const inputNumber = wrapper.findComponent(InputNumber)
326+
expect(inputNumber.props('showButtons')).toBe(true) // Should default to safe behavior
327+
})
328+
329+
it('handles NaN values gracefully', () => {
330+
const widget = createMockWidget(NaN, 'int')
331+
const wrapper = mountComponent(widget, NaN)
332+
333+
const inputNumber = wrapper.findComponent(InputNumber)
334+
// NaN is not a safe integer, so buttons should be hidden
335+
expect(inputNumber.props('showButtons')).toBe(false)
336+
})
337+
338+
it('handles Infinity values', () => {
339+
const widget = createMockWidget(Infinity, 'int')
340+
const wrapper = mountComponent(widget, Infinity)
341+
342+
const inputNumber = wrapper.findComponent(InputNumber)
343+
expect(inputNumber.props('showButtons')).toBe(false)
344+
})
345+
346+
it('handles negative Infinity values', () => {
347+
const widget = createMockWidget(-Infinity, 'int')
348+
const wrapper = mountComponent(widget, -Infinity)
349+
350+
const inputNumber = wrapper.findComponent(InputNumber)
351+
expect(inputNumber.props('showButtons')).toBe(false)
352+
})
353+
})

src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberInput.vue

Lines changed: 53 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import InputNumber from 'primevue/inputnumber'
33
import { computed } from 'vue'
44
5+
import { useNumberWidgetValue } from '@/composables/graph/useWidgetValue'
56
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
67
import { cn } from '@/utils/tailwindUtil'
78
import {
@@ -14,10 +15,19 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
1415
1516
const props = defineProps<{
1617
widget: SimplifiedWidget<number>
18+
modelValue: number
1719
readonly?: boolean
1820
}>()
1921
20-
const modelValue = defineModel<number>({ default: 0 })
22+
const emit = defineEmits<{
23+
'update:modelValue': [value: number]
24+
}>()
25+
26+
const { localValue, onChange } = useNumberWidgetValue(
27+
props.widget,
28+
props.modelValue,
29+
emit
30+
)
2131
2232
const filteredProps = computed(() =>
2333
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
@@ -53,34 +63,52 @@ const stepValue = computed(() => {
5363
const useGrouping = computed(() => {
5464
return props.widget.options?.useGrouping === true
5565
})
66+
67+
// Check if increment/decrement buttons should be disabled due to precision limits
68+
const buttonsDisabled = computed(() => {
69+
const currentValue = localValue.value || 0
70+
return !Number.isSafeInteger(currentValue)
71+
})
72+
73+
// Tooltip message for disabled buttons
74+
const buttonTooltip = computed(() => {
75+
if (props.readonly) return null
76+
if (buttonsDisabled.value) {
77+
return 'Increment/decrement disabled: value exceeds JavaScript precision limit (±2^53)'
78+
}
79+
return null
80+
})
5681
</script>
5782

5883
<template>
5984
<WidgetLayoutField :widget>
60-
<InputNumber
61-
v-model="modelValue"
62-
v-bind="filteredProps"
63-
show-buttons
64-
button-layout="horizontal"
65-
size="small"
66-
:disabled="readonly"
67-
:step="stepValue"
68-
:use-grouping="useGrouping"
69-
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
70-
:pt="{
71-
incrementButton:
72-
'!rounded-r-lg bg-transparent border-none hover:bg-zinc-500/30 active:bg-zinc-500/40',
73-
decrementButton:
74-
'!rounded-l-lg bg-transparent border-none hover:bg-zinc-500/30 active:bg-zinc-500/40'
75-
}"
76-
>
77-
<template #incrementicon>
78-
<span class="pi pi-plus text-sm" />
79-
</template>
80-
<template #decrementicon>
81-
<span class="pi pi-minus text-sm" />
82-
</template>
83-
</InputNumber>
85+
<div v-tooltip="buttonTooltip">
86+
<InputNumber
87+
v-model="localValue"
88+
v-bind="filteredProps"
89+
:show-buttons="!buttonsDisabled"
90+
button-layout="horizontal"
91+
size="small"
92+
:disabled="readonly"
93+
:step="stepValue"
94+
:use-grouping="useGrouping"
95+
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
96+
:pt="{
97+
incrementButton:
98+
'!rounded-r-lg bg-transparent border-none hover:bg-zinc-500/30 active:bg-zinc-500/40',
99+
decrementButton:
100+
'!rounded-l-lg bg-transparent border-none hover:bg-zinc-500/30 active:bg-zinc-500/40'
101+
}"
102+
@update:model-value="onChange"
103+
>
104+
<template #incrementicon>
105+
<span class="pi pi-plus text-sm" />
106+
</template>
107+
<template #decrementicon>
108+
<span class="pi pi-minus text-sm" />
109+
</template>
110+
</InputNumber>
111+
</div>
84112
</WidgetLayoutField>
85113
</template>
86114

0 commit comments

Comments
 (0)