From 8d8327b32ba29696c9bdfd92299a14119f08a3b9 Mon Sep 17 00:00:00 2001 From: Kai Gritun Date: Mon, 9 Feb 2026 12:50:26 -0500 Subject: [PATCH 1/2] fix(numberfield): preserve validation errors on blur when value unchanged When a NumberField has validation errors (e.g., from Form validationErrors), focusing and blurring the field without changing its value would incorrectly reset the validation state, removing the data-invalid attribute. This fixes the issue by checking if the input value has actually changed from the state's inputValue before calling commitAndAnnounce() on blur. Fixes #9444 --- .../numberfield/src/useNumberField.ts | 6 ++- .../test/NumberField.test.js | 38 ++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/numberfield/src/useNumberField.ts b/packages/@react-aria/numberfield/src/useNumberField.ts index 5c117bc7767..06e2365e6c9 100644 --- a/packages/@react-aria/numberfield/src/useNumberField.ts +++ b/packages/@react-aria/numberfield/src/useNumberField.ts @@ -110,7 +110,11 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt let inputId = useId(id); let {focusProps} = useFocus({ onBlur() { - commitAndAnnounce(); + // Only commit if the input value has actually changed from the state's input value. + // This prevents validation from being reset when the user focuses and blurs without editing. + if (inputRef.current?.value !== inputValue) { + commitAndAnnounce(); + } } }); diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js index ae7fb7c4130..cd3516072e1 100644 --- a/packages/react-aria-components/test/NumberField.test.js +++ b/packages/react-aria-components/test/NumberField.test.js @@ -13,7 +13,7 @@ jest.mock('@react-aria/live-announcer'); import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; import {announce} from '@react-aria/live-announcer'; -import {Button, FieldError, Group, Input, Label, NumberField, NumberFieldContext, Text} from '../'; +import {Button, FieldError, Form, Group, Input, Label, NumberField, NumberFieldContext, Text} from '../'; import React from 'react'; import userEvent from '@testing-library/user-event'; @@ -250,4 +250,40 @@ describe('NumberField', () => { await user.keyboard('{Enter}'); expect(input).toHaveValue('200'); }); + + it('should not reset validation errors on blur when value has not changed', async () => { + let {getByRole} = render( +
+ + + + + + + + + +
+ ); + + let input = getByRole('textbox'); + let numberfield = input.closest('.react-aria-NumberField'); + + // Validation error should be displayed + expect(numberfield).toHaveAttribute('data-invalid'); + expect(input).toHaveAttribute('aria-describedby'); + expect(document.getElementById(input.getAttribute('aria-describedby').split(' ')[0])).toHaveTextContent('This field has an error.'); + + // Focus the field without changing the value + act(() => { input.focus(); }); + expect(numberfield).toHaveAttribute('data-invalid'); + + // Blur the field without changing the value + act(() => { input.blur(); }); + + // Validation error should still be displayed because the value didn't change + expect(numberfield).toHaveAttribute('data-invalid'); + expect(input).toHaveAttribute('aria-describedby'); + expect(document.getElementById(input.getAttribute('aria-describedby').split(' ')[0])).toHaveTextContent('This field has an error.'); + }); }); From 3569e4816e867980897d67a9dca99c8c3467b682 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Tue, 17 Mar 2026 15:45:51 +1100 Subject: [PATCH 2/2] move to stately for easier checks --- packages/@react-aria/numberfield/src/useNumberField.ts | 6 +----- .../@react-stately/numberfield/src/useNumberFieldState.ts | 5 ++++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/@react-aria/numberfield/src/useNumberField.ts b/packages/@react-aria/numberfield/src/useNumberField.ts index 5fb050b0100..06e5d42e526 100644 --- a/packages/@react-aria/numberfield/src/useNumberField.ts +++ b/packages/@react-aria/numberfield/src/useNumberField.ts @@ -110,11 +110,7 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt let inputId = useId(id); let {focusProps} = useFocus({ onBlur() { - // Only commit if the input value has actually changed from the state's input value. - // This prevents validation from being reset when the user focuses and blurs without editing. - if (inputRef.current?.value !== inputValue) { - commitAndAnnounce(); - } + commitAndAnnounce(); } }); diff --git a/packages/@react-stately/numberfield/src/useNumberFieldState.ts b/packages/@react-stately/numberfield/src/useNumberFieldState.ts index 5bc374a4313..ad71558b3c9 100644 --- a/packages/@react-stately/numberfield/src/useNumberFieldState.ts +++ b/packages/@react-stately/numberfield/src/useNumberFieldState.ts @@ -175,11 +175,14 @@ export function useNumberFieldState( } clampedValue = numberParser.parse(format(clampedValue)); + let shouldValidate = clampedValue !== numberValue; setNumberValue(clampedValue); // in a controlled state, the numberValue won't change, so we won't go back to our old input without help setInputValue(format(value === undefined ? clampedValue : numberValue)); - validation.commitValidation(); + if (shouldValidate) { + validation.commitValidation(); + } }; let safeNextStep = (operation: '+' | '-', minMax: number = 0) => {