Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,27 @@ describe('NumberField', function () {
expect(screen.getByRole('textbox')).toHaveAttribute('value', '$10.00');
});

it.each`
Name
${'NumberField'}
`('$Name does not reset input during typing when re-rendered with same formatOptions content', async () => {
let {textField, rerender} = renderNumberField({
defaultValue: 1,
formatOptions: {maximumFractionDigits: 2}
});

act(() => {textField.focus();});
await user.clear(textField);
await user.keyboard('2');
expect(textField).toHaveAttribute('value', '2');

// Re-render with same-content but new-reference formatOptions (simulates inline object literal)
rerender({defaultValue: 1, formatOptions: {maximumFractionDigits: 2}});

// Should NOT be reset to '1'
expect(textField).toHaveAttribute('value', '2');
});

it.each`
Name
${'NumberField'}
Expand Down
39 changes: 39 additions & 0 deletions packages/react-aria-components/test/NumberField.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,45 @@ describe('NumberField', () => {
expect(onChange).toHaveBeenCalledWith(1024);
});

it('does not reset input value when parent re-renders with same formatOptions content', async () => {
function Wrapper() {
let [, setCount] = React.useState(0);
return (
<>
<button onClick={() => setCount(c => c + 1)}>Force rerender</button>
<TestNumberField defaultValue={1} formatOptions={{maximumFractionDigits: 2}} />
</>
);
}

let {getByRole} = render(<Wrapper />);
let input = getByRole('textbox');

await user.tab();
await user.clear(input);
await user.keyboard('2');
expect(input).toHaveValue('2');

// Trigger parent re-render — inline object literal creates new formatOptions reference
await user.click(getByRole('button', {name: 'Force rerender'}));

// Input should still show '2', NOT be reset to '1'
expect(input).toHaveValue('2');
});

it('updates formatted value when formatOptions content actually changes', async () => {
let {getByRole, rerender} = render(
<TestNumberField defaultValue={1024} formatOptions={{style: 'currency', currency: 'EUR'}} />
);
let input = getByRole('textbox');
expect(input).toHaveValue('€1,024.00');

rerender(
<TestNumberField defaultValue={1024} formatOptions={{style: 'currency', currency: 'USD'}} />
);
expect(input).toHaveValue('$1,024.00');
});

it('should support pasting into a format', async () => {
let onChange = jest.fn();
let {getByRole} = render(<TestNumberField defaultValue={200} onChange={onChange} formatOptions={{style: 'currency', currency: 'USD'}} />);
Expand Down
30 changes: 29 additions & 1 deletion packages/react-stately/src/numberfield/useNumberFieldState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {clamp, snapValueToStep} from '../utils/number';
import {FocusableProps, HelpTextProps, InputBase, LabelableProps, RangeInputBase, TextInputBase, Validation, ValueBase} from '@react-types/shared';
import {FormValidationState, useFormValidationState} from '../form/useFormValidationState';
import {NumberFormatter, NumberParser} from '@internationalized/number';
import {useCallback, useMemo, useState} from 'react';
import {useCallback, useMemo, useRef, useState} from 'react';
import {useControlledState} from '../utils/useControlledState';

export interface NumberFieldProps extends InputBase, Validation<number>, FocusableProps, TextInputBase, ValueBase<number>, RangeInputBase<number>, LabelableProps, HelpTextProps {
Expand Down Expand Up @@ -111,6 +111,14 @@ export function useNumberFieldState(
commitBehavior = 'snap'
} = props;

// Stabilize formatOptions reference to avoid unnecessary re-renders
// when consumers pass inline object literals with the same content.
let formatOptionsRef = useRef(formatOptions);
if (!isEqualFormatOptions(formatOptions, formatOptionsRef.current)) {
formatOptionsRef.current = formatOptions;
}
formatOptions = formatOptionsRef.current;

if (value === null) {
value = NaN;
}
Expand Down Expand Up @@ -304,6 +312,26 @@ export function useNumberFieldState(
};
}

function isEqualFormatOptions(a: Intl.NumberFormatOptions | undefined, b: Intl.NumberFormatOptions | undefined) {
if (a === b) {
return true;
}
if (!a || !b) {
return false;
}
let aKeys = Object.keys(a);
let bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) {
return false;
}
for (let key of aKeys) {
if (b[key] !== a[key]) {
return false;
}
}
return true;
}

function handleDecimalOperation(operator: '-' | '+', value1: number, value2: number): number {
let result = operator === '+' ? value1 + value2 : value1 - value2;

Expand Down
Loading