From db6d0a1e10c2b4bafe4c590545c95cc64071fd79 Mon Sep 17 00:00:00 2001 From: Herc Date: Thu, 10 Jul 2025 19:40:12 +0300 Subject: [PATCH 1/6] fix: remove setting defaults in ssr from useDefaultLocale --- packages/@react-aria/i18n/src/useDefaultLocale.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/@react-aria/i18n/src/useDefaultLocale.ts b/packages/@react-aria/i18n/src/useDefaultLocale.ts index 456f68764c7..09ca5be05f4 100644 --- a/packages/@react-aria/i18n/src/useDefaultLocale.ts +++ b/packages/@react-aria/i18n/src/useDefaultLocale.ts @@ -13,7 +13,6 @@ import {Direction} from '@react-types/shared'; import {isRTL} from './utils'; import {useEffect, useState} from 'react'; -import {useIsSSR} from '@react-aria/ssr'; export interface Locale { /** The [BCP47](https://www.ietf.org/rfc/bcp/bcp47.txt) language code for the locale. */ @@ -59,7 +58,6 @@ function updateLocale() { * Returns the current browser/system language, and updates when it changes. */ export function useDefaultLocale(): Locale { - let isSSR = useIsSSR(); let [defaultLocale, setDefaultLocale] = useState(currentLocale); useEffect(() => { @@ -77,14 +75,5 @@ export function useDefaultLocale(): Locale { }; }, []); - // We cannot determine the browser's language on the server, so default to - // en-US. This will be updated after hydration on the client to the correct value. - if (isSSR) { - return { - locale: 'en-US', - direction: 'ltr' - }; - } - return defaultLocale; } From e2ec321cb889d2392bbee4e6b246ace03c3c3a5d Mon Sep 17 00:00:00 2001 From: Herc Date: Fri, 11 Jul 2025 12:55:11 +0300 Subject: [PATCH 2/6] chore: revert previous useDefaultLocale commit --- packages/@react-aria/i18n/src/useDefaultLocale.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/@react-aria/i18n/src/useDefaultLocale.ts b/packages/@react-aria/i18n/src/useDefaultLocale.ts index 09ca5be05f4..456f68764c7 100644 --- a/packages/@react-aria/i18n/src/useDefaultLocale.ts +++ b/packages/@react-aria/i18n/src/useDefaultLocale.ts @@ -13,6 +13,7 @@ import {Direction} from '@react-types/shared'; import {isRTL} from './utils'; import {useEffect, useState} from 'react'; +import {useIsSSR} from '@react-aria/ssr'; export interface Locale { /** The [BCP47](https://www.ietf.org/rfc/bcp/bcp47.txt) language code for the locale. */ @@ -58,6 +59,7 @@ function updateLocale() { * Returns the current browser/system language, and updates when it changes. */ export function useDefaultLocale(): Locale { + let isSSR = useIsSSR(); let [defaultLocale, setDefaultLocale] = useState(currentLocale); useEffect(() => { @@ -75,5 +77,14 @@ export function useDefaultLocale(): Locale { }; }, []); + // We cannot determine the browser's language on the server, so default to + // en-US. This will be updated after hydration on the client to the correct value. + if (isSSR) { + return { + locale: 'en-US', + direction: 'ltr' + }; + } + return defaultLocale; } From e5572e14977da9890c0f46fc5adcbe222e621be1 Mon Sep 17 00:00:00 2001 From: Herc Date: Fri, 11 Jul 2025 12:59:15 +0300 Subject: [PATCH 3/6] fix: split I18nProvider internally into two components A component uses`useDefaultLocale` and another the locale value set in the Provider. --- packages/@react-aria/i18n/src/context.tsx | 61 +++++++++++++++++------ 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/packages/@react-aria/i18n/src/context.tsx b/packages/@react-aria/i18n/src/context.tsx index 34ffc5797c4..937aa8e762d 100644 --- a/packages/@react-aria/i18n/src/context.tsx +++ b/packages/@react-aria/i18n/src/context.tsx @@ -10,8 +10,8 @@ * governing permissions and limitations under the License. */ +import {getDefaultLocale, Locale, useDefaultLocale} from './useDefaultLocale'; import {isRTL} from './utils'; -import {Locale, useDefaultLocale} from './useDefaultLocale'; import React, {JSX, ReactNode, useContext} from 'react'; export interface I18nProviderProps { @@ -23,36 +23,65 @@ export interface I18nProviderProps { const I18nContext = React.createContext(null); +interface I18nProviderWithLocaleProps extends I18nProviderProps { + locale: string +} + /** - * Provides the locale for the application to all child components. + * Internal component that handles the case when locale is provided. */ -export function I18nProvider(props: I18nProviderProps): JSX.Element { - let {locale, children} = props; - let defaultLocale = useDefaultLocale(); +function I18nProviderWithLocale(props: I18nProviderWithLocaleProps): JSX.Element { + let {locale, children} = props; + let value: Locale = React.useMemo(() => ({ + locale, + direction: isRTL(locale) ? 'rtl' : 'ltr' + }), [locale]); + + return ( + + {children} + + ); +} - let value: Locale = React.useMemo(() => { - if (!locale) { - return defaultLocale; - } +interface I18nProviderWithDefaultLocaleProps { + children: ReactNode +} - return { - locale, - direction: isRTL(locale) ? 'rtl' : 'ltr' - }; - }, [defaultLocale, locale]); +/** + * Internal component that handles the case when no locale is provided. + */ +function I18nProviderWithDefaultLocale(props: I18nProviderWithDefaultLocaleProps): JSX.Element { + let {children} = props; + let defaultLocale = useDefaultLocale(); return ( - + {children} ); } +/** + * Provides the locale for the application to all child components. + */ +export function I18nProvider(props: I18nProviderProps): JSX.Element { + let {locale, children} = props; + + // Conditionally render different components to avoid calling useDefaultLocale. + // This is necessary because useDefaultLocale triggers a re-render. + if (locale) { + return ; + } + + return ; +} + /** * Returns the current locale and layout direction. */ export function useLocale(): Locale { - let defaultLocale = useDefaultLocale(); + let defaultLocale = getDefaultLocale(); let context = useContext(I18nContext); return context || defaultLocale; } From 833de0dead1e4ac7895b156e111e4700f1d550b3 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Mon, 14 Jul 2025 17:17:05 +1000 Subject: [PATCH 4/6] fix tests --- .../numberfield/test/NumberField.test.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/@react-spectrum/numberfield/test/NumberField.test.js b/packages/@react-spectrum/numberfield/test/NumberField.test.js index 73f1c306fff..c5574309aec 100644 --- a/packages/@react-spectrum/numberfield/test/NumberField.test.js +++ b/packages/@react-spectrum/numberfield/test/NumberField.test.js @@ -11,7 +11,7 @@ */ jest.mock('@react-aria/live-announcer'); -import {act, fireEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {act, fireEvent, pointerMap, render, screen, within} from '@react-spectrum/test-utils-internal'; import {announce} from '@react-aria/live-announcer'; import {Button} from '@react-spectrum/button'; import {chain} from '@react-aria/utils'; @@ -70,7 +70,7 @@ describe('NumberField', function () { incrementButton, decrementButton, debug, - rerender: (props = {}, locale) => rerender() + rerender: (props = {}, locale) => rerender() }; } @@ -871,7 +871,7 @@ describe('NumberField', function () { expect(textField).toHaveAttribute('value', '€10.00'); rerender({defaultValue: 10, formatOptions: {style: 'currency', currency: 'USD'}}); - expect(textField).toHaveAttribute('value', '$10.00'); + expect(screen.getByRole('textbox')).toHaveAttribute('value', '$10.00'); }); it.each` @@ -2280,7 +2280,7 @@ describe('NumberField', function () { expect(hiddenInput).toHaveValue('30'); rerender({name: 'age', value: null}); - expect(hiddenInput).toHaveValue(''); + expect(document.querySelector('input[type=hidden]')).toHaveValue(''); }); it('supports form reset', async () => { @@ -2314,7 +2314,7 @@ describe('NumberField', function () { it('resets to defaultValue when submitting form action', async () => { function Test() { const [value, formAction] = React.useActionState(() => 33, 22); - + return (
From 43001de607939f14a98d8e89f9344831690a6d44 Mon Sep 17 00:00:00 2001 From: Herc Date: Mon, 14 Jul 2025 15:48:50 +0300 Subject: [PATCH 5/6] refactor(i18n): Replace getDefaultLocale with useDefaultLocale in useLocale function --- packages/@react-aria/i18n/src/context.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/i18n/src/context.tsx b/packages/@react-aria/i18n/src/context.tsx index 937aa8e762d..d7d8f1a6d0b 100644 --- a/packages/@react-aria/i18n/src/context.tsx +++ b/packages/@react-aria/i18n/src/context.tsx @@ -10,8 +10,8 @@ * governing permissions and limitations under the License. */ -import {getDefaultLocale, Locale, useDefaultLocale} from './useDefaultLocale'; import {isRTL} from './utils'; +import {Locale, useDefaultLocale} from './useDefaultLocale'; import React, {JSX, ReactNode, useContext} from 'react'; export interface I18nProviderProps { @@ -81,7 +81,7 @@ export function I18nProvider(props: I18nProviderProps): JSX.Element { * Returns the current locale and layout direction. */ export function useLocale(): Locale { - let defaultLocale = getDefaultLocale(); + let defaultLocale = useDefaultLocale(); let context = useContext(I18nContext); return context || defaultLocale; } From 772cfa63bcf387eebd959debb17210aefadc15dd Mon Sep 17 00:00:00 2001 From: Herc Date: Mon, 14 Jul 2025 15:52:03 +0300 Subject: [PATCH 6/6] test(i18n): Add tests for language change handling in useLocale --- .../i18n/test/languagechange.test.js | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 packages/@react-aria/i18n/test/languagechange.test.js diff --git a/packages/@react-aria/i18n/test/languagechange.test.js b/packages/@react-aria/i18n/test/languagechange.test.js new file mode 100644 index 00000000000..c18275ebd54 --- /dev/null +++ b/packages/@react-aria/i18n/test/languagechange.test.js @@ -0,0 +1,131 @@ +/* + * Copyright 2023 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 {act, render} from '@react-spectrum/test-utils-internal'; +import {I18nProvider, useLocale} from '../src/context'; +import React from 'react'; + +function TestComponent() { + let locale = useLocale(); + return ( +
+
{locale.locale}
+
{locale.direction}
+
+ ); +} + +function languageProps(language) { + return { + value: language, + writable: true, + configurable: true + }; +} + +describe('useLocale languagechange', () => { + let originalNavigator; + let originalLanguage; + + beforeEach(() => { + originalNavigator = window.navigator; + originalLanguage = window.navigator.language; + + Object.defineProperty(window.navigator, 'language', languageProps('en-US')); + + act(() => { + window.dispatchEvent(new Event('languagechange')); + }); + }); + + afterEach(() => { + Object.defineProperty(window.navigator, 'language', languageProps(originalLanguage)); + + act(() => { + window.dispatchEvent(new Event('languagechange')); + }); + + Object.defineProperty(window, 'navigator', languageProps(originalNavigator)); + }); + + it('should update locale when languagechange event is triggered', () => { + let {getByTestId} = render( + + + + ); + + // Initial render should show en-US + expect(getByTestId('locale')).toHaveTextContent('en-US'); + expect(getByTestId('direction')).toHaveTextContent('ltr'); + + // Change navigator.language and trigger languagechange event + act(() => { + Object.defineProperty(window.navigator, 'language', languageProps('pt-PT')); + window.dispatchEvent(new Event('languagechange')); + }); + + // Should re-render with new locale + expect(getByTestId('locale')).toHaveTextContent('pt-PT'); + expect(getByTestId('direction')).toHaveTextContent('ltr'); + }); + + it('should update locale direction when changing from LTR to RTL language', () => { + let {getByTestId} = render( + + + + ); + + // Change to Hebrew (RTL language) + act(() => { + Object.defineProperty(window.navigator, 'language', languageProps('he-IL')); + window.dispatchEvent(new Event('languagechange')); + }); + + // Should update to Hebrew with RTL direction + expect(getByTestId('locale')).toHaveTextContent('he-IL'); + expect(getByTestId('direction')).toHaveTextContent('rtl'); + + // Change back to Portuguese + act(() => { + Object.defineProperty(window.navigator, 'language', languageProps('pt-PT')); + window.dispatchEvent(new Event('languagechange')); + }); + + // Should update to Portuguese + expect(getByTestId('locale')).toHaveTextContent('pt-PT'); + expect(getByTestId('direction')).toHaveTextContent('ltr'); + }); + + it('should not change displayed locale when explicit locale is provided via I18nProvider', () => { + let {getByTestId} = render( + + + + ); + + // Initial render should show fr-FR + expect(getByTestId('locale')).toHaveTextContent('fr-FR'); + expect(getByTestId('direction')).toHaveTextContent('ltr'); + + // Change navigator.language and trigger languagechange event + act(() => { + Object.defineProperty(window.navigator, 'language', languageProps('ja-JP')); + window.dispatchEvent(new Event('languagechange')); + }); + + // Should still show fr-FR (explicit locale takes precedence) + expect(getByTestId('locale')).toHaveTextContent('fr-FR'); + expect(getByTestId('direction')).toHaveTextContent('ltr'); + }); +});