From b0bfcb87b70abdc6c22e95b54e397a3faed30fd8 Mon Sep 17 00:00:00 2001 From: Wonseok Chang <122918159+Wonchang0314@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:47:53 +0900 Subject: [PATCH 1/3] Fix/numberfield format options input reset (#9905) * fix(numberfield): use shallow equality for formatOptions to prevent input reset on re-render * test(numberfield): add test codes for inline formatOptions stability * fix(numberfield): use deep equality for formatOptions * test(numberfield): replace component tests with renderHook-based state tests * test(numberfield): add style: 'unit' to formatOptions test fixture --------- Co-authored-by: wchang101 --- .../src/numberfield/useNumberFieldState.ts | 23 ++++++- .../numberfield/useNumberFieldState.test.ts | 67 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 packages/react-stately/test/numberfield/useNumberFieldState.test.ts diff --git a/packages/react-stately/src/numberfield/useNumberFieldState.ts b/packages/react-stately/src/numberfield/useNumberFieldState.ts index b1f1f45a9eb..d5a46978ad7 100644 --- a/packages/react-stately/src/numberfield/useNumberFieldState.ts +++ b/packages/react-stately/src/numberfield/useNumberFieldState.ts @@ -155,7 +155,7 @@ export function useNumberFieldState( let [prevValue, setPrevValue] = useState(numberValue); let [prevLocale, setPrevLocale] = useState(locale); let [prevFormatOptions, setPrevFormatOptions] = useState(formatOptions); - if (!Object.is(numberValue, prevValue) || locale !== prevLocale || formatOptions !== prevFormatOptions) { + if (!Object.is(numberValue, prevValue) || locale !== prevLocale || !isEqualFormatOptions(formatOptions, prevFormatOptions)) { setInputValue(format(numberValue)); setPrevValue(numberValue); setPrevLocale(locale); @@ -304,6 +304,27 @@ export function useNumberFieldState( }; } +// Shallow equality is sufficient here because all values in Intl.NumberFormatOptions are primitives. +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; diff --git a/packages/react-stately/test/numberfield/useNumberFieldState.test.ts b/packages/react-stately/test/numberfield/useNumberFieldState.test.ts new file mode 100644 index 00000000000..e9f54df8b20 --- /dev/null +++ b/packages/react-stately/test/numberfield/useNumberFieldState.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {actHook as act, renderHook} from '@react-spectrum/test-utils-internal'; +import {useNumberFieldState} from '../../src/numberfield/useNumberFieldState'; + +describe('useNumberFieldState', () => { + describe('formatOptions stability', () => { + it('does not reset inputValue when re-rendered with same formatOptions content', () => { + let initialFormatOptions = {style: 'unit', unit: 'millisecond', useGrouping: false} as Intl.NumberFormatOptions; + let {result, rerender} = renderHook( + ({formatOptions}) => useNumberFieldState({defaultValue: 1, formatOptions, locale: 'en-US'}), + {initialProps: {formatOptions: initialFormatOptions}} + ); + + // Simulate user typing '2' (without committing) + act(() => { + result.current.setInputValue('2'); + }); + expect(result.current.inputValue).toBe('2'); + + // Re-render with new-reference but same-content formatOptions (simulates inline object literal) + rerender({formatOptions: {style: 'unit', unit: 'millisecond', useGrouping: false} as Intl.NumberFormatOptions}); + + // inputValue should NOT be reset to '1' (the formatted defaultValue) + expect(result.current.inputValue).toBe('2'); + }); + + it('resets inputValue when formatOptions content actually changes', () => { + let {result, rerender} = renderHook( + ({formatOptions}) => useNumberFieldState({defaultValue: 1024, formatOptions, locale: 'en-US'}), + {initialProps: {formatOptions: {style: 'currency', currency: 'EUR'} as Intl.NumberFormatOptions}} + ); + + expect(result.current.inputValue).toBe('€1,024.00'); + + rerender({formatOptions: {style: 'currency', currency: 'USD'}}); + + expect(result.current.inputValue).toBe('$1,024.00'); + }); + + it('does not reset inputValue when re-rendered with undefined formatOptions', () => { + let {result, rerender} = renderHook( + ({formatOptions}) => useNumberFieldState({defaultValue: 1, formatOptions, locale: 'en-US'}), + {initialProps: {formatOptions: undefined as Intl.NumberFormatOptions | undefined}} + ); + + act(() => { + result.current.setInputValue('2'); + }); + expect(result.current.inputValue).toBe('2'); + + rerender({formatOptions: undefined}); + + expect(result.current.inputValue).toBe('2'); + }); + }); +}); From ac1ca0d1884c741fa984230ed2d3ed31c2a362fa Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Wed, 22 Apr 2026 14:21:04 +1000 Subject: [PATCH 2/3] chore: update NumberParser's cldr data (#9907) --- packages/@internationalized/number/src/NumberParser.ts | 4 ++-- scripts/generateAllPlurals.mjs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/@internationalized/number/src/NumberParser.ts b/packages/@internationalized/number/src/NumberParser.ts index dfeb7c6c24f..6311f920787 100644 --- a/packages/@internationalized/number/src/NumberParser.ts +++ b/packages/@internationalized/number/src/NumberParser.ts @@ -274,9 +274,9 @@ class NumberParserImpl { const nonLiteralParts = new Set(['decimal', 'fraction', 'integer', 'minusSign', 'plusSign', 'group']); -// This list is derived from https://www.unicode.org/cldr/charts/43/supplemental/language_plural_rules.html#comparison and includes +// This list is derived from https://www.unicode.org/cldr/charts/49/supplemental/language_plural_rules.html#comparison and includes // all unique numbers which we need to check in order to determine all the plural forms for a given locale. -// See: https://github.com/adobe/react-spectrum/pull/5134/files#r1337037855 for used script +// Run scripts/generateAllPlurals.mjs to generate this list. const pluralNumbers = [ 0, 4, 2, 1, 11, 20, 3, 7, 100, 21, 0.1, 1.1 ]; diff --git a/scripts/generateAllPlurals.mjs b/scripts/generateAllPlurals.mjs index feb395767e4..12ceb29937b 100644 --- a/scripts/generateAllPlurals.mjs +++ b/scripts/generateAllPlurals.mjs @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -/* Scrapes data on CLDR https://www.unicode.org/cldr/charts/44/supplemental/language_plural_rules.html#comparison +/* Scrapes data on CLDR https://www.unicode.org/cldr/charts/49/supplemental/language_plural_rules.html#comparison * and generates a list of all possible values needed between all locales for plural rules. * It is used by our NumberParser to generate all literal strings, ex units 1 foot, 2 feet, but other locales have more than 2 forms. */ @@ -150,7 +150,7 @@ function extractTable(dom, integerTable, fractionTable) { return Array.from(values); } -fetch('https://www.unicode.org/cldr/charts/44/supplemental/language_plural_rules.html#comparison') +fetch('https://www.unicode.org/cldr/charts/49/supplemental/language_plural_rules.html#comparison') .then(async (response) => { let data = await response.text(); const dom = new JSDOM(data); From e3913baaaa9d850014462d0af2f5079e0502da23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= <25958801+nwidynski@users.noreply.github.com> Date: Wed, 22 Apr 2026 06:37:24 +0200 Subject: [PATCH 3/3] fix: layout shift in s2 modal docs (#9956) --- .gitignore | 1 + packages/react-aria-components/docs/Modal.mdx | 8 ++- .../stories/Modal.stories.tsx | 58 ++++++++++++++++++- starters/docs/src/Modal.css | 3 +- starters/docs/src/Sheet.css | 3 +- 5 files changed, 67 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 178fe850797..63448f766af 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .idea .package-lock.json .parcel-cache +.vscode build-storybook.log coverage dist diff --git a/packages/react-aria-components/docs/Modal.mdx b/packages/react-aria-components/docs/Modal.mdx index f9e6dbd8210..d17bd684aad 100644 --- a/packages/react-aria-components/docs/Modal.mdx +++ b/packages/react-aria-components/docs/Modal.mdx @@ -82,7 +82,7 @@ import {DialogTrigger, Modal, Dialog, Button, Heading, TextField, Label, Input} position: absolute; top: 0; left: 0; - width: 100vw; + width: var(--page-width); height: var(--page-height); background: rgba(0 0 0 / .5); z-index: 100; @@ -246,10 +246,11 @@ import {ModalOverlay} from 'react-aria-components'; position: absolute; top: 0; left: 0; - width: 100%; + width: var(--page-width); height: var(--page-height); background: rgba(45 0 0 / .3); backdrop-filter: blur(10px); + overflow: clip; &[data-entering] { animation: mymodal-blur 300ms; @@ -421,11 +422,12 @@ A `Modal` can be targeted with the `.react-aria-Modal` CSS selector, or by overr By default, `Modal` includes a builtin `ModalOverlay`, which renders a backdrop over the page when a modal is open. This can be targeted using the `.react-aria-ModalOverlay` CSS selector. To customize the `ModalOverlay` with a different class name or other attributes, render a `ModalOverlay` and place a `Modal` inside. -The `--page-height` and `--visual-viewport-height` CSS custom property will be set on the `ModalOverlay`, the latter of which you can use to set the height of the Modal to account for the virtual keyboard on mobile. +The `--page-height`, `--page-width`, `--visual-viewport-height` and `--visual-viewport-width` CSS custom properties will be set on the `ModalOverlay`, the latter of which you can use to set the size of the Modal to account for the virtual keyboard on mobile. ```css render=false .react-aria-ModalOverlay { position: absolute; + width: var(--page-width); height: var(--page-height); } diff --git a/packages/react-aria-components/stories/Modal.stories.tsx b/packages/react-aria-components/stories/Modal.stories.tsx index 0bb9dd808df..e84ff65d815 100644 --- a/packages/react-aria-components/stories/Modal.stories.tsx +++ b/packages/react-aria-components/stories/Modal.stories.tsx @@ -28,7 +28,6 @@ import styles from '../example/index.css'; import {TextField} from '../src/TextField'; import './styles.css'; - export default { title: 'React Aria Components/Modal', component: Modal @@ -80,6 +79,63 @@ export const ModalExample: ModalStory = () => ( ); +export const SheetExample: ModalStory = () => ( +
+
+ + + + + + {({close}) => ( +
+ Sign up + + + +
+ )} +
+
+
+
+
+
+
+); + function InertTest() { return ( diff --git a/starters/docs/src/Modal.css b/starters/docs/src/Modal.css index 848e09bb8c7..96db3275429 100644 --- a/starters/docs/src/Modal.css +++ b/starters/docs/src/Modal.css @@ -4,9 +4,10 @@ position: absolute; top: 0; left: 0; - width: 100vw; + width: var(--page-width); height: var(--page-height); background: rgba(0 0 0 / .5); + overflow: clip; z-index: 100; font-family: system-ui; font-size: var(--font-size); diff --git a/starters/docs/src/Sheet.css b/starters/docs/src/Sheet.css index aa316fb74d1..9d417df9e96 100644 --- a/starters/docs/src/Sheet.css +++ b/starters/docs/src/Sheet.css @@ -2,11 +2,12 @@ position: absolute; top: 0; left: 0; - width: 100%; + width: var(--page-width); height: var(--page-height); background: rgba(0 0 0 / .3); backdrop-filter: blur(10px); z-index: 100; + overflow: clip; &[data-entering] { animation: sheet-blur 300ms;