From 9836fa91f9e1e5aef0998dfd8ef72d6c7f714452 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Tue, 10 Feb 2026 13:32:54 +0100 Subject: [PATCH 1/3] chore: Allow dynamic deferred loading of internationalization --- build-tools/tasks/generate-i18n-messages.js | 2 +- .../visual-refresh-toolbar/internal.tsx | 1 + .../visual-refresh-toolbar/skeleton/index.tsx | 66 ++++---- src/i18n/get-matchable-locales.ts | 15 -- src/i18n/provider.tsx | 146 ++---------------- .../__tests__/remote-provider.test.tsx | 109 +++++++++++++ src/i18n/providers/local-provider.tsx | 81 ++++++++++ src/i18n/providers/remote-provider.tsx | 71 +++++++++ src/i18n/utils/i18n-formatter.ts | 95 ++++++++++++ src/i18n/utils/locales.ts | 32 ++++ src/internal/widget-exports.ts | 3 + 11 files changed, 437 insertions(+), 184 deletions(-) delete mode 100644 src/i18n/get-matchable-locales.ts create mode 100644 src/i18n/providers/__tests__/remote-provider.test.tsx create mode 100644 src/i18n/providers/local-provider.tsx create mode 100644 src/i18n/providers/remote-provider.tsx create mode 100644 src/i18n/utils/i18n-formatter.ts create mode 100644 src/i18n/utils/locales.ts diff --git a/build-tools/tasks/generate-i18n-messages.js b/build-tools/tasks/generate-i18n-messages.js index 16f6ccf04f..fff8c9f78e 100644 --- a/build-tools/tasks/generate-i18n-messages.js +++ b/build-tools/tasks/generate-i18n-messages.js @@ -53,7 +53,7 @@ module.exports = function generateI18nMessages() { const dynamicFile = [ `import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; import { isDevelopment } from '../internal/is-development'; -import { getMatchableLocales } from './get-matchable-locales'; +import { getMatchableLocales } from './utils/locales'; export function importMessages(locale) { for (const matchableLocale of getMatchableLocales(locale)) { diff --git a/src/app-layout/visual-refresh-toolbar/internal.tsx b/src/app-layout/visual-refresh-toolbar/internal.tsx index a9e652a1b3..f0a6345a62 100644 --- a/src/app-layout/visual-refresh-toolbar/internal.tsx +++ b/src/app-layout/visual-refresh-toolbar/internal.tsx @@ -26,3 +26,4 @@ export const AppLayoutBottomContentSlot = createWidgetizedAppLayoutBottomContent export const AppLayoutWidgetizedState = createWidgetizedAppLayoutState( createLoadableComponent(AppLayoutStateImplementation) ); +export const loadFormatter = () => Promise.resolve(null); diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx b/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx index 0859ab9135..b6dbce9e62 100644 --- a/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx +++ b/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx @@ -7,6 +7,7 @@ import { getAnalyticsMetadataAttribute } from '@cloudscape-design/component-tool import { GeneratedAnalyticsMetadataAppLayoutToolbarComponent } from '../../../app-layout-toolbar/analytics-metadata/interfaces'; import { BuiltInErrorBoundary } from '../../../error-boundary/internal'; +import RemoteI18nProvider from '../../../i18n/providers/remote-provider'; import VisualContext from '../../../internal/components/visual-context'; import customCssProps from '../../../internal/generated/custom-css-properties'; import { AppLayoutInternalProps, AppLayoutPendingState } from '../interfaces'; @@ -15,6 +16,7 @@ import { AppLayoutBeforeMainSlot, AppLayoutBottomContentSlot, AppLayoutTopContentSlot, + loadFormatter, } from '../internal'; import { isWidgetReady } from '../state/invariants'; import { ToolbarProps } from '../toolbar'; @@ -64,39 +66,41 @@ export const SkeletonLayout = ({ return ( -
} - data-awsui-app-layout-widget-loaded={isWidgetLoaded} - {...wrapperElAttributes} - className={wrapperElAttributes?.className ?? clsx(styles.root, testutilStyles.root)} - style={ - wrapperElAttributes?.style ?? { - blockSize: `calc(100vh - ${appLayoutProps.placement.insetBlockStart + appLayoutProps.placement.insetBlockEnd}px)`, - [customCssProps.navigationWidth]: `${navigationWidth}px`, - } - } - > - -
- -
+
} + data-awsui-app-layout-widget-loaded={isWidgetLoaded} + {...wrapperElAttributes} + className={wrapperElAttributes?.className ?? clsx(styles.root, testutilStyles.root)} + style={ + wrapperElAttributes?.style ?? { + blockSize: `calc(100vh - ${appLayoutProps.placement.insetBlockStart + appLayoutProps.placement.insetBlockEnd}px)`, + [customCssProps.navigationWidth]: `${navigationWidth}px`, } - > - {contentHeader &&
{contentHeader}
} - {/*delay rendering the content until registration of this instance is complete*/} -
- {registered ? {content} : null} + } + > + +
+ +
+ {contentHeader &&
{contentHeader}
} + {/* delay rendering the content until registration of this instance is complete */} +
+ {registered ? {content} : null} +
-
- -
- -
+ + + + +
); }; diff --git a/src/i18n/get-matchable-locales.ts b/src/i18n/get-matchable-locales.ts deleted file mode 100644 index c22069a368..0000000000 --- a/src/i18n/get-matchable-locales.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -export function getMatchableLocales(ietfLanguageTag: string): string[] { - const parts = ietfLanguageTag.split('-'); - if (parts.length === 1) { - return [ietfLanguageTag]; - } - - const localeStrings: string[] = []; - for (let i = parts.length; i > 0; i--) { - localeStrings.push(parts.slice(0, i).join('-')); - } - return localeStrings; -} diff --git a/src/i18n/provider.tsx b/src/i18n/provider.tsx index 596da34ec5..2fe57df762 100644 --- a/src/i18n/provider.tsx +++ b/src/i18n/provider.tsx @@ -1,158 +1,30 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 - -import React, { useContext } from 'react'; -import { MessageFormatElement } from '@formatjs/icu-messageformat-parser'; -import IntlMessageFormat from 'intl-messageformat'; - -import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; +import React from 'react'; import useBaseComponent from '../internal/hooks/use-base-component'; import { applyDisplayName } from '../internal/utils/apply-display-name'; -import { CustomHandler, FormatFunction, InternalI18nContext } from './context'; -import { getMatchableLocales } from './get-matchable-locales'; +import { LocalI18nProvider } from './providers/local-provider'; +import { I18nMessages } from './utils/i18n-formatter'; export interface I18nProviderProps { - messages: ReadonlyArray; locale?: string; + messages: ReadonlyArray; children: React.ReactNode; } export namespace I18nProviderProps { - export interface Messages { - [namespace: string]: { - [locale: string]: { - [component: string]: { - [key: string]: string | MessageFormatElement[]; - }; - }; - }; - } + export type Messages = I18nMessages; } -/** - * Context to send parent messages down to child I18nProviders. This isn't - * included in the InternalI18nContext to avoid components from depending on - * MessageFormatElement types. - */ -const I18nMessagesContext = React.createContext({}); - -export function I18nProvider({ messages: messagesArray, locale: providedLocale, children }: I18nProviderProps) { +export function I18nProvider({ locale, messages, children }: I18nProviderProps) { useBaseComponent('I18nProvider'); - if (typeof document === 'undefined' && !providedLocale) { - warnOnce( - 'I18nProvider', - 'An explicit locale was not provided during server rendering. This can lead to a hydration mismatch on the client.' - ); - } - - // The provider accepts an array of configs. We merge parent messages and - // flatten the tree early on so that accesses by key are simpler and faster. - const parentMessages = useContext(I18nMessagesContext); - const messages = mergeMessages([parentMessages, ...messagesArray]); - - let locale: string; - if (providedLocale) { - // If a locale is explicitly provided, use the string directly. - // Locales have a recommended case, but are matched case-insensitively, - // so we lowercase it internally. - locale = providedLocale.toLowerCase(); - } else if (typeof document !== 'undefined' && document.documentElement.lang) { - // Otherwise, use the value provided in the HTML tag. - locale = document.documentElement.lang.toLowerCase(); - } else { - // Lastly, fall back to English. - locale = 'en'; - } - - // Create a per-render cache of messages and IntlMessageFormat instances. - // Not memoizing it allows us to reset the cache when the component rerenders - // with potentially different locale or messages. We expect this component to - // be placed above AppLayout and therefore rerender very infrequently. - const localeFormatterCache = new Map(); - - const format: FormatFunction = >( - namespace: string, - component: string, - key: string, - provided: ReturnValue, - customHandler?: CustomHandler - ): ReturnValue => { - // A general rule in this library is that undefined is basically - // treated as "not provided". So even if a user explicitly provides an - // undefined value, it will default to i18n provider values. - if (provided !== undefined) { - return provided; - } - - const cacheKey = `${namespace}.${component}.${key}`; - let intlMessageFormat: IntlMessageFormat; - - const cachedFormatter = localeFormatterCache.get(cacheKey); - if (cachedFormatter) { - // If an IntlMessageFormat instance was cached for this locale, just use that. - intlMessageFormat = cachedFormatter; - } else { - // Widen the locale string (e.g. en-GB -> en) until we find a locale - // that contains the message we need. - let message: string | MessageFormatElement[] | undefined; - const matchableLocales = getMatchableLocales(locale); - for (const matchableLocale of matchableLocales) { - message = messages?.[namespace]?.[matchableLocale]?.[component]?.[key]; - if (message !== undefined) { - break; - } - } - - // If a message wasn't found, exit early. - if (message === undefined) { - return provided; - } - - // Lazily create an IntlMessageFormat object for this key. - intlMessageFormat = new IntlMessageFormat(message, locale); - localeFormatterCache.set(cacheKey, intlMessageFormat); - } - - if (customHandler) { - return customHandler(args => intlMessageFormat.format(args) as string); - } - // Assuming `T extends string` since a customHandler wasn't provided. - return intlMessageFormat.format() as ReturnValue; - }; - return ( - - {children} - + + {children} + ); } applyDisplayName(I18nProvider, 'I18nProvider'); - -function mergeMessages(sources: ReadonlyArray): I18nProviderProps.Messages { - const result: I18nProviderProps.Messages = {}; - for (const messages of sources) { - for (const namespace in messages) { - if (!(namespace in result)) { - result[namespace] = {}; - } - for (const casedLocale in messages[namespace]) { - const locale = casedLocale.toLowerCase(); - if (!(locale in result[namespace])) { - result[namespace][locale] = {}; - } - for (const component in messages[namespace][casedLocale]) { - if (!(component in result[namespace][locale])) { - result[namespace][locale][component] = {}; - } - for (const key in messages[namespace][casedLocale][component]) { - result[namespace][locale][component][key] = messages[namespace][casedLocale][component][key]; - } - } - } - } - } - return result; -} diff --git a/src/i18n/providers/__tests__/remote-provider.test.tsx b/src/i18n/providers/__tests__/remote-provider.test.tsx new file mode 100644 index 0000000000..798b722f5d --- /dev/null +++ b/src/i18n/providers/__tests__/remote-provider.test.tsx @@ -0,0 +1,109 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useContext } from 'react'; +import { render, waitFor } from '@testing-library/react'; + +import { I18nProvider } from '../../../../lib/components/i18n'; +import { FormatFunction, InternalI18nContext } from '../../../../lib/components/i18n/context'; +import RemoteI18nProvider from '../../../../lib/components/i18n/providers/remote-provider'; + +afterEach(() => { + jest.restoreAllMocks(); + document.documentElement.lang = ''; +}); + +const createMockFormatter = () => { + const format: FormatFunction = (_ns: string, _component: string, _key: string, provided: any) => { + if (provided !== undefined) { + return provided; + } + return 'mocked string'; + }; + + return { format }; +}; + +function TestConsumer() { + const context = useContext(InternalI18nContext); + return
{context?.locale || 'no-context'}
; +} + +describe('RemoteI18nProvider', () => { + it('loads the formatter and provides the context to the children', async () => { + document.documentElement.lang = 'es'; + + const loadFormatter = jest.fn().mockResolvedValue(createMockFormatter()); + + const { getByTestId } = render( + + + + ); + + await waitFor(() => { + expect(getByTestId('locale')).toHaveTextContent('es'); + }); + expect(loadFormatter).toHaveBeenCalledWith({ locale: 'es' }); + }); + + it('falls back to "en" if a lang isn\'t set on the ', async () => { + const loadFormatter = jest.fn().mockResolvedValue(createMockFormatter()); + + const { getByTestId } = render( + + + + ); + + await waitFor(() => { + expect(getByTestId('locale')).toHaveTextContent('en'); + }); + expect(loadFormatter).toHaveBeenCalledWith({ locale: 'en' }); + }); + + it('does nothing when formatter returns null', async () => { + const loadFormatter = jest.fn().mockResolvedValue(null); + + const { getByTestId } = render( + + + + ); + + await waitFor(() => { + expect(loadFormatter).toHaveBeenCalled(); + }); + expect(getByTestId('locale')).toHaveTextContent('no-context'); + }); + + it('handles formatter loading errors gracefully', async () => { + const loadFormatter = jest.fn().mockRejectedValue(new Error('Network error')); + + const { getByTestId } = render( + + + + ); + + await waitFor(() => { + expect(loadFormatter).toHaveBeenCalled(); + }); + expect(getByTestId('locale')).toHaveTextContent('no-context'); + }); + + it('does not load formatter when wrapped by LocalI18nProvider', () => { + const loadFormatter = jest.fn().mockResolvedValue(createMockFormatter()); + + const { getByTestId } = render( + + + + + + ); + + expect(getByTestId('locale')).toHaveTextContent('en'); + expect(loadFormatter).not.toHaveBeenCalled(); + }); +}); diff --git a/src/i18n/providers/local-provider.tsx b/src/i18n/providers/local-provider.tsx new file mode 100644 index 0000000000..7b87e1fb1d --- /dev/null +++ b/src/i18n/providers/local-provider.tsx @@ -0,0 +1,81 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useContext } from 'react'; + +import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; + +import { InternalI18nContext } from '../context'; +import { I18nFormatter, I18nMessages } from '../utils/i18n-formatter'; +import { determineAppLocale } from '../utils/locales'; + +export interface LocalI18nProviderProps { + messages: ReadonlyArray; + locale?: string; + children: React.ReactNode; +} + +/** + * Context to send parent messages down to child I18nProviders. This isn't + * included in the InternalI18nContext to avoid components from depending on + * MessageFormatElement types. + */ +const I18nMessagesContext = React.createContext({}); + +export function LocalI18nProvider({ + messages: messagesArray, + locale: providedLocale, + children, +}: LocalI18nProviderProps) { + if (typeof document === 'undefined' && !providedLocale) { + warnOnce( + 'I18nProvider', + 'An explicit locale was not provided during server rendering. This can lead to a hydration mismatch on the client.' + ); + } + + const locale = determineAppLocale(providedLocale); + + // The provider accepts an array of configs. We merge parent messages and + // flatten the tree early on so that accesses by key are simpler and faster. + const parentMessages = useContext(I18nMessagesContext); + const messages = mergeMessages([parentMessages, ...messagesArray]); + + // The formatter is recreated on every render to ensure it has access to the + // latest messages. This is a trade-off between performance and correctness. + // In practice, this should only happen when the messages change, which is + // infrequent. + const formatter = new I18nFormatter(locale, messages); + + return ( + + {children} + + ); +} + +function mergeMessages(sources: ReadonlyArray): I18nMessages { + const result: I18nMessages = {}; + for (const messages of sources) { + for (const namespace in messages) { + if (!(namespace in result)) { + result[namespace] = {}; + } + for (const casedLocale in messages[namespace]) { + const locale = casedLocale.toLowerCase(); + if (!(locale in result[namespace])) { + result[namespace][locale] = {}; + } + for (const component in messages[namespace][casedLocale]) { + if (!(component in result[namespace][locale])) { + result[namespace][locale][component] = {}; + } + for (const key in messages[namespace][casedLocale][component]) { + result[namespace][locale][component][key] = messages[namespace][casedLocale][component][key]; + } + } + } + } + } + return result; +} diff --git a/src/i18n/providers/remote-provider.tsx b/src/i18n/providers/remote-provider.tsx new file mode 100644 index 0000000000..083209fea6 --- /dev/null +++ b/src/i18n/providers/remote-provider.tsx @@ -0,0 +1,71 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useEffect, useState } from 'react'; + +// NOTE: Ensure that direct or transitive dependencies never pull in +// intl-messageformat or any `@formatjs` dependencies! Otherwise, it +// would harm any bundle size improvements this component brings. +import { FormatFunction, InternalI18nContext } from '../context'; +import { determineAppLocale } from '../utils/locales'; + +interface I18nFormatterInterface { + format: FormatFunction; +} + +export interface RemoteI18nProviderProps { + /** + * A format function, loaded dynamically from the result of this callback. If + * the callback returns null, it means that the provider isn't available for + * whatever reason, and nothing happens. + */ + loadFormatter: (args: { locale: string }) => Promise; + + children: React.ReactNode; +} + +/** + * A lightweight implementation of the I18nProvider context wrapper that expects both the + * messages and the formatting logic to be provided from a remote source. Explicitly does + * nothing if it's wrapped by a LocalI18nProvider. + */ +export default function RemoteI18nProvider({ loadFormatter, children }: RemoteI18nProviderProps) { + const wrapperContext = useContext(InternalI18nContext); + const [formatFunction, setFormatFunction] = useState(); + + // Ensure that every dependency of the effect below can never change. + // The locale comes from the document, and the formatter only depends on that, + // so it should never need to update either. + const hasWrapperContext = !!wrapperContext; + const [locale] = useState(() => determineAppLocale()); + const [staticLoadFormatter] = useState(() => loadFormatter); + + useEffect(() => { + // Translations are already provided from a local provider, so skip. + if (hasWrapperContext) { + return; + } + + staticLoadFormatter({ locale }) + .then(formatter => { + if (!formatter) { + // Formatter wasn't available, bail. + return; + } + if ('startTransition' in React && typeof React.startTransition === 'function') { + // Use startTransition (if available) to prevent the followup render from blocking + // more important user interactions. + React.startTransition(() => { + setFormatFunction(() => formatter.format.bind(formatter)); + }); + } else { + setFormatFunction(() => formatter.format.bind(formatter)); + } + }) + .catch(() => { + // Do nothing. Failure in fetching the formatter should not be fatal. + }); + }, [hasWrapperContext, locale, staticLoadFormatter]); + + const value = wrapperContext || (formatFunction && { locale, format: formatFunction }); + return {children}; +} diff --git a/src/i18n/utils/i18n-formatter.ts b/src/i18n/utils/i18n-formatter.ts new file mode 100644 index 0000000000..764c01290b --- /dev/null +++ b/src/i18n/utils/i18n-formatter.ts @@ -0,0 +1,95 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { MessageFormatElement } from '@formatjs/icu-messageformat-parser'; +import IntlMessageFormat from 'intl-messageformat'; + +import { CustomHandler } from '../context'; +import { getMatchableLocales } from './locales'; + +/** + * The expected shape of the fully resolved messages object. + * Typescript ensures any static imports are properly typed, but since this + * depends on types from formatjs, it should not be included in any files that + * need to support older versions of TypeScript (3.7 and up). + */ +export interface I18nMessages { + [namespace: string]: { + [locale: string]: { + [component: string]: { + [key: string]: string | MessageFormatElement[]; + }; + }; + }; +} + +/** + * A stateful container for formatting internal strings. Caches formatters + * where possible; a new instance must be created if locale or messages may + * have changed. + */ +export class I18nFormatter { + private _locale: string; + private _messages: I18nMessages; + + // Create a per-render cache of messages and IntlMessageFormat instances. + // Not memoizing it allows us to reset the cache when the component rerenders + // with potentially different locale or messages. We expect this component to + // be placed above AppLayout and therefore rerender very infrequently. + private _localeFormatterCache = new Map(); + + constructor(locale: string, messages: I18nMessages) { + this._locale = locale; + this._messages = messages; + } + + format>( + namespace: string, + component: string, + key: string, + provided: ReturnValue, + customHandler?: CustomHandler + ): ReturnValue { + // A general rule in this library is that undefined is basically + // treated as "not provided". So even if a user explicitly provides an + // undefined value, it will default to i18n provider values. + if (provided !== undefined) { + return provided; + } + + const cacheKey = `${namespace}.${component}.${key}`; + let intlMessageFormat: IntlMessageFormat; + + const cachedFormatter = this._localeFormatterCache.get(cacheKey); + if (cachedFormatter) { + // If an IntlMessageFormat instance was cached for this locale, just use that. + intlMessageFormat = cachedFormatter; + } else { + // Widen the locale string (e.g. en-GB -> en) until we find a locale + // that contains the message we need. + let message: string | MessageFormatElement[] | undefined; + const matchableLocales = getMatchableLocales(this._locale); + for (const matchableLocale of matchableLocales) { + message = this._messages?.[namespace]?.[matchableLocale]?.[component]?.[key]; + if (message !== undefined) { + break; + } + } + + // If a message wasn't found, exit early. + if (message === undefined) { + return provided; + } + + // Lazily create an IntlMessageFormat object for this key. + intlMessageFormat = new IntlMessageFormat(message, this._locale); + this._localeFormatterCache.set(cacheKey, intlMessageFormat); + } + + if (customHandler) { + return customHandler(args => intlMessageFormat.format(args) as string); + } + // Assuming `ReturnValue extends string` since a customHandler wasn't provided. + return intlMessageFormat.format() as ReturnValue; + } +} diff --git a/src/i18n/utils/locales.ts b/src/i18n/utils/locales.ts new file mode 100644 index 0000000000..f100d1a5ef --- /dev/null +++ b/src/i18n/utils/locales.ts @@ -0,0 +1,32 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export function getMatchableLocales(ietfLanguageTag: string): string[] { + const parts = ietfLanguageTag.split('-'); + if (parts.length === 1) { + return [ietfLanguageTag]; + } + + const localeStrings: string[] = []; + for (let i = parts.length; i > 0; i--) { + localeStrings.push(parts.slice(0, i).join('-')); + } + return localeStrings; +} + +export function determineAppLocale(providedLocale?: string): string { + // If a locale is explicitly provided, use the string directly. + // Locales have a recommended case, but are matched case-insensitively, + // so we lowercase it internally. + if (providedLocale) { + return providedLocale.toLowerCase(); + } + + if (typeof document !== 'undefined' && document.documentElement.lang) { + // Otherwise, use the value provided in the HTML tag. + return document.documentElement.lang.toLowerCase(); + } + + // Lastly, fall back to English. + return 'en'; +} diff --git a/src/internal/widget-exports.ts b/src/internal/widget-exports.ts index 238232978c..e85a2d85e8 100644 --- a/src/internal/widget-exports.ts +++ b/src/internal/widget-exports.ts @@ -28,3 +28,6 @@ export { DrawerImplementation as Drawer } from '../drawer/implementation'; export { FlashbarImplementation as Flashbar } from '../flashbar/implementation'; export { SideNavigationImplementation as SideNavigation } from '../side-navigation/implementation'; export { HelpPanelImplementation as HelpPanel } from '../help-panel/implementation'; + +// Widgetized non-component exports +export { I18nFormatter } from '../i18n/utils/i18n-formatter'; From 082097710adb78c0d994fb266b538fba7e86ccdc Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Thu, 26 Feb 2026 11:21:52 +0100 Subject: [PATCH 2/3] Ensure AppLayout also receives remote aria labels. --- src/app-layout-toolbar/index.tsx | 15 ---- src/app-layout/index.tsx | 16 +--- src/app-layout/utils/use-aria-labels.ts | 21 +++++ .../visual-refresh-toolbar/index.tsx | 83 +++++++++++++------ .../visual-refresh-toolbar/skeleton/index.tsx | 66 +++++++-------- src/i18n/providers/remote-provider.tsx | 13 +-- 6 files changed, 113 insertions(+), 101 deletions(-) create mode 100644 src/app-layout/utils/use-aria-labels.ts diff --git a/src/app-layout-toolbar/index.tsx b/src/app-layout-toolbar/index.tsx index 564649c906..be5afdf22a 100644 --- a/src/app-layout-toolbar/index.tsx +++ b/src/app-layout-toolbar/index.tsx @@ -10,7 +10,6 @@ import { AppLayoutProps } from '../app-layout/interfaces'; import { useAppLayoutPlacement } from '../app-layout/utils/use-app-layout-placement'; import AppLayoutToolbarInternal from '../app-layout/visual-refresh-toolbar'; import { AppLayoutToolbarPublicContext } from '../app-layout/visual-refresh-toolbar/contexts'; -import { useInternalI18n } from '../i18n/context'; import { getBaseProps } from '../internal/base-component'; import { NonCancelableCustomEvent } from '../internal/events'; import useBaseComponent from '../internal/hooks/use-base-component'; @@ -79,19 +78,6 @@ const AppLayoutToolbar = React.forwardRef( ); const isMobile = useMobile(); - const i18n = useInternalI18n('app-layout'); - const ariaLabels = { - navigation: i18n('ariaLabels.navigation', rest.ariaLabels?.navigation), - navigationClose: i18n('ariaLabels.navigationClose', rest.ariaLabels?.navigationClose), - navigationToggle: i18n('ariaLabels.navigationToggle', rest.ariaLabels?.navigationToggle), - notifications: i18n('ariaLabels.notifications', rest.ariaLabels?.notifications), - tools: i18n('ariaLabels.tools', rest.ariaLabels?.tools), - toolsClose: i18n('ariaLabels.toolsClose', rest.ariaLabels?.toolsClose), - toolsToggle: i18n('ariaLabels.toolsToggle', rest.ariaLabels?.toolsToggle), - drawers: i18n('ariaLabels.drawers', rest.ariaLabels?.drawers), - drawersOverflow: i18n('ariaLabels.drawersOverflow', rest.ariaLabels?.drawersOverflow), - drawersOverflowWithBadge: i18n('ariaLabels.drawersOverflowWithBadge', rest.ariaLabels?.drawersOverflowWithBadge), - }; const { navigationOpen: defaultNavigationOpen, ...restDefaults } = applyDefaults( contentType, { maxContentWidth, minContentWidth }, @@ -120,7 +106,6 @@ const AppLayoutToolbar = React.forwardRef( onNavigationChange, ...restDefaults, ...rest, - ariaLabels, placement, }; diff --git a/src/app-layout/index.tsx b/src/app-layout/index.tsx index ab26a65223..f664ab4009 100644 --- a/src/app-layout/index.tsx +++ b/src/app-layout/index.tsx @@ -5,7 +5,6 @@ import React from 'react'; import { useMergeRefs, warnOnce } from '@cloudscape-design/component-toolkit/internal'; -import { useInternalI18n } from '../i18n/context'; import { getBaseProps } from '../internal/base-component'; import { NonCancelableCustomEvent } from '../internal/events'; import useBaseComponent from '../internal/hooks/use-base-component'; @@ -18,6 +17,7 @@ import { applyDefaults } from './defaults'; import { AppLayoutProps } from './interfaces'; import { AppLayoutInternal } from './internal'; import { useAppLayoutPlacement } from './utils/use-app-layout-placement'; +import { useAriaLabels } from './utils/use-aria-labels'; export { AppLayoutProps }; @@ -72,19 +72,7 @@ const AppLayout = React.forwardRef( const isRefresh = useVisualRefresh(); const isMobile = useMobile(); - const i18n = useInternalI18n('app-layout'); - const ariaLabels = { - navigation: i18n('ariaLabels.navigation', rest.ariaLabels?.navigation), - navigationClose: i18n('ariaLabels.navigationClose', rest.ariaLabels?.navigationClose), - navigationToggle: i18n('ariaLabels.navigationToggle', rest.ariaLabels?.navigationToggle), - notifications: i18n('ariaLabels.notifications', rest.ariaLabels?.notifications), - tools: i18n('ariaLabels.tools', rest.ariaLabels?.tools), - toolsClose: i18n('ariaLabels.toolsClose', rest.ariaLabels?.toolsClose), - toolsToggle: i18n('ariaLabels.toolsToggle', rest.ariaLabels?.toolsToggle), - drawers: i18n('ariaLabels.drawers', rest.ariaLabels?.drawers), - drawersOverflow: i18n('ariaLabels.drawersOverflow', rest.ariaLabels?.drawersOverflow), - drawersOverflowWithBadge: i18n('ariaLabels.drawersOverflowWithBadge', rest.ariaLabels?.drawersOverflowWithBadge), - }; + const ariaLabels = useAriaLabels(rest.ariaLabels); const { navigationOpen: defaultNavigationOpen, ...restDefaults } = applyDefaults( contentType, { maxContentWidth, minContentWidth }, diff --git a/src/app-layout/utils/use-aria-labels.ts b/src/app-layout/utils/use-aria-labels.ts new file mode 100644 index 0000000000..7cb235eec2 --- /dev/null +++ b/src/app-layout/utils/use-aria-labels.ts @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useInternalI18n } from '../../i18n/context'; +import { AppLayoutProps } from '../interfaces'; + +export function useAriaLabels(ariaLabelsOverride: AppLayoutProps.Labels | undefined): AppLayoutProps.Labels { + const i18n = useInternalI18n('app-layout'); + return { + navigation: i18n('ariaLabels.navigation', ariaLabelsOverride?.navigation), + navigationClose: i18n('ariaLabels.navigationClose', ariaLabelsOverride?.navigationClose), + navigationToggle: i18n('ariaLabels.navigationToggle', ariaLabelsOverride?.navigationToggle), + notifications: i18n('ariaLabels.notifications', ariaLabelsOverride?.notifications), + tools: i18n('ariaLabels.tools', ariaLabelsOverride?.tools), + toolsClose: i18n('ariaLabels.toolsClose', ariaLabelsOverride?.toolsClose), + toolsToggle: i18n('ariaLabels.toolsToggle', ariaLabelsOverride?.toolsToggle), + drawers: i18n('ariaLabels.drawers', ariaLabelsOverride?.drawers), + drawersOverflow: i18n('ariaLabels.drawersOverflow', ariaLabelsOverride?.drawersOverflow), + drawersOverflowWithBadge: i18n('ariaLabels.drawersOverflowWithBadge', ariaLabelsOverride?.drawersOverflowWithBadge), + }; +} diff --git a/src/app-layout/visual-refresh-toolbar/index.tsx b/src/app-layout/visual-refresh-toolbar/index.tsx index 4c61bbfb68..a53357ab98 100644 --- a/src/app-layout/visual-refresh-toolbar/index.tsx +++ b/src/app-layout/visual-refresh-toolbar/index.tsx @@ -3,11 +3,13 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { unstable_batchedUpdates } from 'react-dom'; +import RemoteI18nProvider from '../../i18n/providers/remote-provider'; import ScreenreaderOnly from '../../internal/components/screenreader-only'; import { AppLayoutProps } from '../interfaces'; +import { useAriaLabels } from '../utils/use-aria-labels'; import { AppLayoutVisibilityContext } from './contexts'; import { AppLayoutInternalProps, AppLayoutPendingState } from './interfaces'; -import { AppLayoutWidgetizedState } from './internal'; +import { AppLayoutWidgetizedState, loadFormatter } from './internal'; import { SkeletonLayout } from './skeleton'; import { SkeletonSlotsAttributes } from './skeleton/interfaces'; import { DeduplicationType, useMultiAppLayout } from './skeleton/multi-layout'; @@ -62,36 +64,65 @@ const AppLayoutStateProvider: React.FC<{ const AppLayoutVisualRefreshToolbar = React.forwardRef( (props, forwardRef) => { - const stateManager = useRef({ setState: undefined, hasToolbar: false, setToolbar: undefined }); - const { __forceDeduplicationType: forceDeduplicationType, __embeddedViewMode: embeddedViewMode } = props as any; + const stateManagerRef = useRef({ setState: undefined, hasToolbar: false, setToolbar: undefined }); return ( - <> - + - {(registered, appLayoutState, toolbarProps, skeletonAttributes) => ( - - {/* Rendering a hidden copy of breadcrumbs to trigger their deduplication */} - {(embeddedViewMode || !toolbarProps) && props.breadcrumbs ? ( - {props.breadcrumbs} - ) : null} - - - )} - - - + /> + ); } ); +function AppLayoutVisualRefreshToolbarWithI18n({ + appLayoutRef, + stateManagerRef, + appLayoutProps, +}: { + appLayoutRef: React.ForwardedRef; + stateManagerRef: React.MutableRefObject; + appLayoutProps: AppLayoutInternalProps; +}) { + const { __forceDeduplicationType: forceDeduplicationType, __embeddedViewMode: embeddedViewMode } = + appLayoutProps as any; + + const ariaLabels = useAriaLabels(appLayoutProps.ariaLabels); + const appLayoutPropsWithI18n = { ...appLayoutProps, ariaLabels }; + + return ( + <> + + {(registered, appLayoutState, toolbarProps, skeletonAttributes) => ( + + {/* Rendering a hidden copy of breadcrumbs to trigger their deduplication */} + {(embeddedViewMode || !toolbarProps) && appLayoutPropsWithI18n.breadcrumbs ? ( + {appLayoutPropsWithI18n.breadcrumbs} + ) : null} + + + )} + + + + ); +} + export default AppLayoutVisualRefreshToolbar; diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx b/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx index b6dbce9e62..0859ab9135 100644 --- a/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx +++ b/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx @@ -7,7 +7,6 @@ import { getAnalyticsMetadataAttribute } from '@cloudscape-design/component-tool import { GeneratedAnalyticsMetadataAppLayoutToolbarComponent } from '../../../app-layout-toolbar/analytics-metadata/interfaces'; import { BuiltInErrorBoundary } from '../../../error-boundary/internal'; -import RemoteI18nProvider from '../../../i18n/providers/remote-provider'; import VisualContext from '../../../internal/components/visual-context'; import customCssProps from '../../../internal/generated/custom-css-properties'; import { AppLayoutInternalProps, AppLayoutPendingState } from '../interfaces'; @@ -16,7 +15,6 @@ import { AppLayoutBeforeMainSlot, AppLayoutBottomContentSlot, AppLayoutTopContentSlot, - loadFormatter, } from '../internal'; import { isWidgetReady } from '../state/invariants'; import { ToolbarProps } from '../toolbar'; @@ -66,41 +64,39 @@ export const SkeletonLayout = ({ return ( - -
} - data-awsui-app-layout-widget-loaded={isWidgetLoaded} - {...wrapperElAttributes} - className={wrapperElAttributes?.className ?? clsx(styles.root, testutilStyles.root)} - style={ - wrapperElAttributes?.style ?? { - blockSize: `calc(100vh - ${appLayoutProps.placement.insetBlockStart + appLayoutProps.placement.insetBlockEnd}px)`, - [customCssProps.navigationWidth]: `${navigationWidth}px`, - } +
} + data-awsui-app-layout-widget-loaded={isWidgetLoaded} + {...wrapperElAttributes} + className={wrapperElAttributes?.className ?? clsx(styles.root, testutilStyles.root)} + style={ + wrapperElAttributes?.style ?? { + blockSize: `calc(100vh - ${appLayoutProps.placement.insetBlockStart + appLayoutProps.placement.insetBlockEnd}px)`, + [customCssProps.navigationWidth]: `${navigationWidth}px`, } - > - -
- -
- {contentHeader &&
{contentHeader}
} - {/* delay rendering the content until registration of this instance is complete */} -
- {registered ? {content} : null} -
+ } + > + +
+ +
+ {contentHeader &&
{contentHeader}
} + {/*delay rendering the content until registration of this instance is complete*/} +
+ {registered ? {content} : null}
- -
- -
- +
+ + + +
); }; diff --git a/src/i18n/providers/remote-provider.tsx b/src/i18n/providers/remote-provider.tsx index 083209fea6..1b815b98d1 100644 --- a/src/i18n/providers/remote-provider.tsx +++ b/src/i18n/providers/remote-provider.tsx @@ -47,19 +47,10 @@ export default function RemoteI18nProvider({ loadFormatter, children }: RemoteI1 staticLoadFormatter({ locale }) .then(formatter => { - if (!formatter) { - // Formatter wasn't available, bail. - return; - } - if ('startTransition' in React && typeof React.startTransition === 'function') { - // Use startTransition (if available) to prevent the followup render from blocking - // more important user interactions. - React.startTransition(() => { - setFormatFunction(() => formatter.format.bind(formatter)); - }); - } else { + if (formatter) { setFormatFunction(() => formatter.format.bind(formatter)); } + // If formatter isn't available, do nothing. }) .catch(() => { // Do nothing. Failure in fetching the formatter should not be fatal. From e99960251cdc2aee41bc83a4bd8b4b1e48773646 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Wed, 4 Mar 2026 18:32:40 +0100 Subject: [PATCH 3/3] Add tests that integrate with app layout toolbar. --- src/app-layout/__tests__/toolbar.test.tsx | 86 +++++++++++++++++++ .../__tests__/remote-provider.test.tsx | 22 ++--- 2 files changed, 95 insertions(+), 13 deletions(-) diff --git a/src/app-layout/__tests__/toolbar.test.tsx b/src/app-layout/__tests__/toolbar.test.tsx index bf6cc4110f..af61d0107d 100644 --- a/src/app-layout/__tests__/toolbar.test.tsx +++ b/src/app-layout/__tests__/toolbar.test.tsx @@ -1,14 +1,27 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import React from 'react'; +import { waitFor } from '@testing-library/react'; import AppLayout from '../../../lib/components/app-layout'; +import { loadFormatter } from '../../../lib/components/app-layout/visual-refresh-toolbar/internal'; +import { I18nProvider } from '../../../lib/components/i18n'; +import { I18nFormatter } from '../../../lib/components/i18n/utils/i18n-formatter'; import SplitPanel from '../../../lib/components/split-panel'; import { describeEachAppLayout, manyDrawers, renderComponent } from './utils'; // no-op function to suppress controllability warnings function noop() {} +jest.mock('../../../lib/components/app-layout/visual-refresh-toolbar/internal', () => ({ + ...jest.requireActual('../../../lib/components/app-layout/visual-refresh-toolbar/internal'), + loadFormatter: jest.fn(() => Promise.resolve(null)), +})); + +afterEach(() => { + jest.clearAllMocks(); +}); + describe('toolbar mode only features', () => { describeEachAppLayout({ themes: ['refresh-toolbar'] }, ({ size }) => { test('does not render the toolbar when all panels are hidden', () => { @@ -78,5 +91,78 @@ describe('toolbar mode only features', () => { expect(wrapper.findDrawerTriggerById(manyDrawers[0].id)!.getElement()).toHaveAttribute('aria-expanded', 'true'); }); }); + + describe('RemoteI18nProvider integration', () => { + test('does not block content while formatter is pending or null', async () => { + // Keep the promise pending until we manually resolve it. + let resolveFn: (value: null) => void = () => {}; + const loadFormatterImpl = () => { + return new Promise(resolve => { + resolveFn = resolve; + }); + }; + jest.mocked(loadFormatter).mockImplementation(loadFormatterImpl); + + const { wrapper } = renderComponent(); + expect(wrapper.findContentRegion().getElement()).toHaveTextContent('App layout content'); + + resolveFn(null); + await waitFor(() => { + expect(wrapper.findContentRegion().getElement()).toHaveTextContent('App layout content'); + }); + }); + + test('does not fail if loading formatter fails', async () => { + jest.mocked(loadFormatter).mockImplementation(() => Promise.reject(new Error('failed :('))); + const { wrapper } = renderComponent(); + + await waitFor(() => { + expect(wrapper.findContentRegion().getElement()).toHaveTextContent('App layout content'); + }); + }); + + test('does not call loadFormatter if app layout is wrapped by I18nProvider', () => { + renderComponent( + // It doesn't need to have messages, just the existence of a wrapping provider is enough. + + + + ); + expect(loadFormatter).not.toHaveBeenCalled(); + }); + + test('rerenders app layout with labels when formatter is correctly loaded', async () => { + // Keep the promise pending until we manually resolve it. + let resolveFn: (value: I18nFormatter) => void = () => {}; + const loadFormatterImpl = () => { + return new Promise(resolve => { + resolveFn = resolve; + }); + }; + jest.mocked(loadFormatter as unknown as () => Promise).mockImplementation(loadFormatterImpl); + + const { wrapper } = renderComponent(); + expect(wrapper.findNavigationClose().getElement()).not.toHaveAttribute('aria-label'); + expect(wrapper.findToolsClose().getElement()).not.toHaveAttribute('aria-label'); + + resolveFn( + new I18nFormatter('en-US', { + 'cloudscape-design-components': { + 'en-US': { + 'app-layout': { + 'ariaLabels.navigationClose': 'remote navigationClose', + 'ariaLabels.toolsClose': 'remote toolsClose', + }, + }, + }, + }) + ); + + await waitFor(() => { + expect(wrapper.findNavigationClose().getElement()).toHaveAttribute('aria-label', 'remote navigationClose'); + expect(wrapper.findToolsClose().getElement()).toHaveAttribute('aria-label', 'remote toolsClose'); + }); + }); + }); }); }); diff --git a/src/i18n/providers/__tests__/remote-provider.test.tsx b/src/i18n/providers/__tests__/remote-provider.test.tsx index 798b722f5d..990ea0a64d 100644 --- a/src/i18n/providers/__tests__/remote-provider.test.tsx +++ b/src/i18n/providers/__tests__/remote-provider.test.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React, { useContext } from 'react'; -import { render, waitFor } from '@testing-library/react'; +import { render } from '@testing-library/react'; import { I18nProvider } from '../../../../lib/components/i18n'; import { FormatFunction, InternalI18nContext } from '../../../../lib/components/i18n/context'; @@ -41,9 +41,8 @@ describe('RemoteI18nProvider', () => { ); - await waitFor(() => { - expect(getByTestId('locale')).toHaveTextContent('es'); - }); + await jest.runAllTimersAsync(); + expect(getByTestId('locale')).toHaveTextContent('es'); expect(loadFormatter).toHaveBeenCalledWith({ locale: 'es' }); }); @@ -56,9 +55,8 @@ describe('RemoteI18nProvider', () => { ); - await waitFor(() => { - expect(getByTestId('locale')).toHaveTextContent('en'); - }); + await jest.runAllTimersAsync(); + expect(getByTestId('locale')).toHaveTextContent('en'); expect(loadFormatter).toHaveBeenCalledWith({ locale: 'en' }); }); @@ -71,9 +69,8 @@ describe('RemoteI18nProvider', () => { ); - await waitFor(() => { - expect(loadFormatter).toHaveBeenCalled(); - }); + await jest.runAllTimersAsync(); + expect(loadFormatter).toHaveBeenCalled(); expect(getByTestId('locale')).toHaveTextContent('no-context'); }); @@ -86,9 +83,8 @@ describe('RemoteI18nProvider', () => { ); - await waitFor(() => { - expect(loadFormatter).toHaveBeenCalled(); - }); + await jest.runAllTimersAsync(); + expect(loadFormatter).toHaveBeenCalled(); expect(getByTestId('locale')).toHaveTextContent('no-context'); });