Skip to content

chore: Allow dynamic deferred loading of internationalization#4237

Open
avinashbot wants to merge 3 commits intomainfrom
feat/remote-provider-poc
Open

chore: Allow dynamic deferred loading of internationalization#4237
avinashbot wants to merge 3 commits intomainfrom
feat/remote-provider-poc

Conversation

@avinashbot
Copy link
Member

@avinashbot avinashbot commented Feb 10, 2026

Description

Partial implementation of dynamically loaded internationalization. This lets us more readily embed the I18nProvider into performance-sensitive parts of the code (like the app layout toolbar) without worrying so much about added bundle size of the intl library, intl-messageformat (34.3 kB, 9.83 kB gzip) and the locale files (465kB, 68kB gzip). This also avoids dealing with any additional build-time considerations (intl-messageformat raises our bare minimum TypeScript version requirement, which we don't document publicly, though we do call out the raised minimum in our documentation).

The added cost is one asynchronously triggered full-application rerender.

components/LocalI18nProvider  <- The application may provide this already (this is fine)
↳ components/AppLayout
  ↳ components/AppLayout/skeleton
    ↳ components/RemoteI18nProvider <-------+
      ↳ application code                    | 
      ↳ widgetized/AppLayout/toolbar        | --- Once messages and formatter are loaded,
      ↳ widgetized/AppLayout/drawers        |     the content is re-rendered.
    ↳ widgetized/loadRemoteI18nMessages() --+

"Dynamic loading" of the internationalization bundle isn't all that complicated, it's just a runtime import of I18nFormatter (which this PR refactors out) preloaded with the appropriate locale file (which are already exposed under /messages/all.<locale>.js).


Possible optimizations (either later in this PR or after release):

  • Wrapping the app rerender in a React transition: For React versions that support it, it avoids our rerender from blocking user actions and affecting web vitals.
  • Removing ICU parser from the remote bundle (reference): If we can safely guarantee that the remote provider only accepts and handles pre-parsed AST-only strings, we can remove the parser from the dynamically loaded bundle and reduce the dynamic bundle's file size by 26.34 kB (7.1 kB gzip). But this is a non-blocking network request, so it's not high priority. See CR-253557644.

Related links, issue #, if available: AWSUI-61508

How has this been tested?

Existing tests for locally (i.e. application) provided strings, additional tests for remote inclusion of logic will follow.

Review checklist

The following items are to be evaluated by the author(s) and the reviewer(s).

Correctness

  • Changes include appropriate documentation updates.
  • Changes are backward-compatible if not indicated, see CONTRIBUTING.md.
  • Changes do not include unsupported browser features, see CONTRIBUTING.md.
  • Changes were manually tested for accessibility, see accessibility guidelines.

Security

Testing

  • Changes are covered with new/existing unit tests?
  • Changes are covered with new/existing integration tests?

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@codecov
Copy link

codecov bot commented Feb 10, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.43%. Comparing base (69d131d) to head (e999602).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4237   +/-   ##
=======================================
  Coverage   97.43%   97.43%           
=======================================
  Files         897      901    +4     
  Lines       26325    26359   +34     
  Branches     9514     9508    -6     
=======================================
+ Hits        25650    25684   +34     
- Misses        632      669   +37     
+ Partials       43        6   -37     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@avinashbot avinashbot force-pushed the feat/remote-provider-poc branch 2 times, most recently from b380ceb to 107d893 Compare February 12, 2026 10:07
@avinashbot avinashbot force-pushed the feat/remote-provider-poc branch from 107d893 to a270b41 Compare February 13, 2026 09:05
@avinashbot avinashbot force-pushed the feat/remote-provider-poc branch from a270b41 to afd8f0a Compare February 17, 2026 16:50
@avinashbot avinashbot force-pushed the feat/remote-provider-poc branch 2 times, most recently from 91e62bb to 16f6b31 Compare February 27, 2026 13:17
@avinashbot avinashbot force-pushed the feat/remote-provider-poc branch from 16f6b31 to 3c12e2e Compare February 27, 2026 13:55
@avinashbot avinashbot marked this pull request as ready for review March 2, 2026 15:17
@avinashbot avinashbot requested a review from a team as a code owner March 2, 2026 15:17
@avinashbot avinashbot requested review from georgylobko and taheramr and removed request for a team March 2, 2026 15:17
<>
<AppLayoutStateProvider
forceDeduplicationType={forceDeduplicationType}
<RemoteI18nProvider loadFormatter={loadFormatter}>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need to wrap the whole thing with this provider? We can only wrap SkeletonLayout and call useAriaLabels inside

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seemed cleaner and safer this way. I wasn't sure if the weird "hidden copy of breadcrumbs" thing should also be wrapped by the provider or if it's safe for its strings to not match the skeleton's strings, and if the deduplication breadcrumbs are included, the wrapped component ends up requiring passing along a lot of internal props and state tracking, which seemed unmaintainable to me.

If the breadcrumbs are 100% safe to exclude, I can go for this approach.

it('loads the formatter and provides the context to the children', async () => {
document.documentElement.lang = 'es';

const loadFormatter = jest.fn().mockResolvedValue(createMockFormatter());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you provide this function as a prop? Could it be an internal value in the provider?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what you mean, could you expand? The loadFormatter function is a widget export — locally it just returns null; in production, it would return a promise with a formatter.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, why do you provide this function as a prop for the RemoteI18nProvider? It could be a part of its implementation

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can, but this is better for bundle size (because intl-messageformat is not in the bundle):

  • RemoteI18nProvider is loaded by the application
  • loadFormatter is part of the loader (in the application but not this package)
  • loadFormatter then fetches I18nFormatter from the widget

return <div data-testid="locale">{context?.locale || 'no-context'}</div>;
}

describe('RemoteI18nProvider', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add more realistic scenarios:

  • what happens with provider's children i18n strings until loadFormatter is loaded?
  • let's test it with our AppLayout component, especially with dynamically injected content
  • let's make sure it works correctly if consumers wrap the content area with our i18nprovider

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some more tests!

</RemoteI18nProvider>
);

await waitFor(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[non-blocking] Let's make it more explicit: await jest.runAllTimersAsync() waits until the async function is resolved

// If formatter isn't available, do nothing.
})
.catch(() => {
// Do nothing. Failure in fetching the formatter should not be fatal.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh absolutely, thanks for the callout; was wishing there was a way we could send a metric with included data. Let me see what the limits are, maybe I can include the error message.

* where possible; a new instance must be created if locale or messages may
* have changed.
*/
export class I18nFormatter {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we name it more explicitly like RemoveI18nFormatter and put to the same folder as other widget parts?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the common formatting logic — it's imported by both the local provider and the widget (which passes it to remote provider). So it's not specific to either scenario.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants