Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions packages/dev/optimize-locales-plugin/LocalesLoader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright 2026 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
const path = require('path');

module.exports = function localesLoader(source) {
let {locales} = this.getOptions();
let includedLocales = locales.map(locale => new Intl.Locale(locale));
let match = path.basename(this.resourcePath).match(/[a-z]{2}-[A-Z]{2}/);
if (match) {
let locale = new Intl.Locale(match[0]);
if (!includedLocales.some(includedLocale => localeMatches(locale, includedLocale))) {
return 'export default undefined;';
}
}

if (path.extname(this.resourcePath) === '.json') {
return `export default ${source.toString()};`;
}

return source;
};

function localeMatches(localeToMatch, includedLocale) {
return (
localeToMatch.language === includedLocale.language &&
(!includedLocale.region || localeToMatch.region === includedLocale.region)
);
}
33 changes: 32 additions & 1 deletion packages/dev/optimize-locales-plugin/LocalesPlugin.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,36 @@ type Options = {
locales: readonly string[];
};

declare const plugin: UnpluginInstance<Options, false>;
type TurbopackCondition =
| 'browser'
| 'foreign'
| 'development'
| 'production'
| 'node'
| 'edge-light'
| {not: TurbopackCondition}
| {all: TurbopackCondition[]}
| {any: TurbopackCondition[]}
| {path: string | RegExp; content?: RegExp}
| {path?: string | RegExp; content: RegExp};

type TurbopackOptions = Options & {
condition?: TurbopackCondition;
};

type TurbopackOptionsList = readonly [TurbopackOptions, ...TurbopackOptions[]];

type TurbopackRule = {
condition?: TurbopackCondition;
loaders: [{loader: string; options: {locales: readonly string[]}}];
as: '*.js';
};

type TurbopackConfig = {
rules: Record<string, TurbopackRule | TurbopackRule[]>;
};

declare const plugin: UnpluginInstance<Options, false> & {
turbopack(options: Options | TurbopackOptionsList): TurbopackConfig;
};
export = plugin;
50 changes: 49 additions & 1 deletion packages/dev/optimize-locales-plugin/LocalesPlugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,20 @@
const {createUnplugin} = require('unplugin');
const path = require('path');

module.exports = createUnplugin(({locales}) => {
const REACT_ARIA_PACKAGES = [
'@react-stately',
'@react-aria',
'@react-spectrum',
'@adobe/react-spectrum',
'react-stately',
'react-aria',
'react-aria-components'
];
const LOCALE_EXTENSIONS = ['json', 'mjs', 'js', 'cjs'];

const LOCALES_GLOB = `**/{${REACT_ARIA_PACKAGES.join(',')}}/**/??-??.{${LOCALE_EXTENSIONS.join(',')}}`;

let plugin = createUnplugin(({locales}) => {
locales = locales.map(l => new Intl.Locale(l));
return {
name: 'locales-plugin',
Expand Down Expand Up @@ -42,6 +55,41 @@ module.exports = createUnplugin(({locales}) => {
};
});

plugin.turbopack = options => {
let loader = path.join(__dirname, 'LocalesLoader.js');
let hasConditions = Array.isArray(options);
if (hasConditions && options.length === 0) {
throw new TypeError('Expected at least one Turbopack locale configuration.');
}

let configurations = hasConditions ? options : [options];
let localeRules = configurations.map(({locales, condition}) => {
let rule = {
loaders: [
{
loader,
options: {locales}
}
],
as: '*.js'
};

if (condition !== undefined) {
rule.condition = condition;
}

return rule;
});

return {
rules: {
[LOCALES_GLOB]: hasConditions ? localeRules : localeRules[0]
}
};
};

module.exports = plugin;

function localeMatches(localeToMatch, includedLocale) {
return (
localeToMatch.language === includedLocale.language &&
Expand Down
51 changes: 50 additions & 1 deletion packages/dev/optimize-locales-plugin/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @react-aria/optimize-locales-plugin

A build plugin to optimize React Aria to only include translated strings for locales that your app supports. It currently supports Vite, Rollup, Webpack, and esbuild via [unplugin](https://github.com/unjs/unplugin). For Parcel, please use `@react-aria/parcel-resolver-optimize-locales`.
A build plugin to optimize React Aria to only include translated strings for locales that your app supports. It currently supports Vite, Rollup, Webpack, and esbuild via [unplugin](https://github.com/unjs/unplugin), as well as Turbopack via a webpack loader. For Parcel, please use `@react-aria/parcel-resolver-optimize-locales`.

## Configuration

Expand Down Expand Up @@ -40,6 +40,55 @@ module.exports = {
};
```

When using Turbopack, spread the plugin's rules into the existing `turbopack.rules` configuration.
This requires Next.js 15.4 or newer, because the rule matches files using glob syntax that earlier
versions of Turbopack do not implement.

```ts
// next.config.ts
import type {NextConfig} from 'next';
import optimizeLocales from '@react-aria/optimize-locales-plugin';

const localeOptimization = optimizeLocales.turbopack({
locales: ['en-US', 'fr-FR']
});

const config: NextConfig = {
turbopack: {
rules: {
'*.css': {
loaders: ['@tailwindcss/turbopack'],
as: '*.css'
},
...localeOptimization.rules
}
}
};

export default config;
```

The object form above includes the configured locales in every module graph. This is useful when
the application relies on the locale strings bundled with React Aria components.

On Next.js 16 and newer, where Turbopack supports loader conditions, an array can be provided to
configure different locales for individual module graphs. For example, when using `LocalizedStringProvider`, keep the supported
locales on the server and exclude them from the browser bundle because the provider injects the
current locale's strings into the initial HTML:

```ts
const localeOptimization = optimizeLocales.turbopack([
{
locales: [],
condition: 'browser'
},
{
locales: ['en-US', 'fr-FR'],
condition: {not: 'browser'}
}
]);
```

### Vite

```js
Expand Down
88 changes: 88 additions & 0 deletions packages/dev/optimize-locales-plugin/test/LocalesPlugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@
*/
const path = require('path');
const LocalesPlugin = require('../LocalesPlugin');
const localesLoader = require('../LocalesLoader');

const EMPTY_JS = path.join(path.dirname(require.resolve('../LocalesPlugin')), 'empty.js');
const LOADER = path.join(path.dirname(require.resolve('../LocalesPlugin')), 'LocalesLoader.js');
const LOCALES_GLOB =
'**/{@react-stately,@react-aria,@react-spectrum,@adobe/react-spectrum,react-stately,react-aria,react-aria-components}/**/??-??.{json,mjs,js,cjs}';

function createPlugin(locales = ['en-US']) {
return LocalesPlugin.raw({locales}, {framework: 'rollup'});
Expand Down Expand Up @@ -82,4 +86,88 @@ describe('@react-aria/optimize-locales-plugin', () => {
const resolved = plugin.resolveId('./fr-FR.json', windowsImporter, {});
expect(resolved).toBe(EMPTY_JS);
});

describe('Turbopack', () => {
test('returns a single scoped loader rule', () => {
let config = LocalesPlugin.turbopack({locales: ['en-US', 'fr']});

expect(Object.keys(config.rules)).toEqual([LOCALES_GLOB]);
expect(config.rules[LOCALES_GLOB]).toEqual({
loaders: [
{
loader: LOADER,
options: {locales: ['en-US', 'fr']}
}
],
as: '*.js'
});
});

test('supports different locales in browser and server module graphs', () => {
let config = LocalesPlugin.turbopack([
{locales: [], condition: 'browser'},
{locales: ['en-US', 'fr'], condition: {not: 'browser'}}
]);

expect(config.rules[LOCALES_GLOB]).toEqual([
{
condition: 'browser',
loaders: [
{
loader: LOADER,
options: {locales: []}
}
],
as: '*.js'
},
{
condition: {not: 'browser'},
loaders: [
{
loader: LOADER,
options: {locales: ['en-US', 'fr']}
}
],
as: '*.js'
}
]);
});

test('requires at least one conditional configuration', () => {
expect(() => LocalesPlugin.turbopack([])).toThrow(
'Expected at least one Turbopack locale configuration.'
);
});

test('loader replaces an excluded locale with undefined', () => {
let result = callLoader('fr-FR', ['en-US']);
expect(result).toBe('export default undefined;');
});

test('loader preserves an included locale', () => {
let result = callLoader('fr-FR', ['en-US', 'fr-FR']);
expect(result).toBe('export default {"message":"Bonjour"};');
});

test('loader preserves regional locales included by language', () => {
let result = callLoader('fr-CA', ['en-US', 'fr']);
expect(result).toBe('export default {"message":"Bonjour"};');
});

test('loader preserves included compiled locale modules', () => {
let source = 'export default {"message":"Bonjour"};';
let result = callLoader('fr-FR', ['fr'], 'mjs', source);
expect(result.toString()).toBe(source);
});
});
});

function callLoader(locale, locales, extension = 'json', source = '{"message":"Bonjour"}') {
return localesLoader.call(
{
resourcePath: `/repo/node_modules/@react-aria/button/intl/${locale}.${extension}`,
getOptions: () => ({locales})
},
Buffer.from(source)
);
}
30 changes: 30 additions & 0 deletions packages/dev/s2-docs/pages/react-aria/frameworks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,36 @@ export const description = 'How to integrate with your framework.';
}
```
</Step>
<Step>
<Counter />By default, React Aria includes localized strings for 30+ languages. To optimize the JavaScript bundle to include only your supported languages, install our bundler plugin.
<InstallCommand pkg="@react-aria/optimize-locales-plugin" />

Edit `next.config.ts` to add the plugin's rules to your existing Turbopack configuration. This requires Next.js 15.4 or newer.

```ts
// next.config.ts
import type {NextConfig} from 'next';
import optimizeLocales from '@react-aria/optimize-locales-plugin';

const localeOptimization = optimizeLocales.turbopack({
locales: ['en-US', 'fr-FR']
});

const config: NextConfig = {
turbopack: {
rules: {
'*.css': {
loaders: ['@tailwindcss/turbopack'],
as: '*.css'
},
...localeOptimization.rules
}
}
};

export default config;
```
</Step>
<Step>
<Counter />If you are using a [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) (CSP) with a nonce, add a `<meta property="csp-nonce">` tag to your document head, setting the `content` attribute to the generated nonce value. React Aria automatically reads the nonce from this tag.
</Step>
Expand Down