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..f2869a2a8b --- /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';