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-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/__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/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/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/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..990ea0a64d --- /dev/null +++ b/src/i18n/providers/__tests__/remote-provider.test.tsx @@ -0,0 +1,105 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useContext } from 'react'; +import { render } 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 jest.runAllTimersAsync(); + 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 jest.runAllTimersAsync(); + 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 jest.runAllTimersAsync(); + 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 jest.runAllTimersAsync(); + 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..1b815b98d1 --- /dev/null +++ b/src/i18n/providers/remote-provider.tsx @@ -0,0 +1,62 @@ +// 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) { + setFormatFunction(() => formatter.format.bind(formatter)); + } + // If formatter isn't available, do nothing. + }) + .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';