diff --git a/packages/dev/optimize-locales-plugin/LocalesLoader.js b/packages/dev/optimize-locales-plugin/LocalesLoader.js new file mode 100644 index 00000000000..4c8ce04e67b --- /dev/null +++ b/packages/dev/optimize-locales-plugin/LocalesLoader.js @@ -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) + ); +} diff --git a/packages/dev/optimize-locales-plugin/LocalesPlugin.d.ts b/packages/dev/optimize-locales-plugin/LocalesPlugin.d.ts index f844c570b20..733429f4d86 100644 --- a/packages/dev/optimize-locales-plugin/LocalesPlugin.d.ts +++ b/packages/dev/optimize-locales-plugin/LocalesPlugin.d.ts @@ -4,5 +4,36 @@ type Options = { locales: readonly string[]; }; -declare const plugin: UnpluginInstance; +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; +}; + +declare const plugin: UnpluginInstance & { + turbopack(options: Options | TurbopackOptionsList): TurbopackConfig; +}; export = plugin; diff --git a/packages/dev/optimize-locales-plugin/LocalesPlugin.js b/packages/dev/optimize-locales-plugin/LocalesPlugin.js index 49b0557e435..ade81ed2828 100644 --- a/packages/dev/optimize-locales-plugin/LocalesPlugin.js +++ b/packages/dev/optimize-locales-plugin/LocalesPlugin.js @@ -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', @@ -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 && diff --git a/packages/dev/optimize-locales-plugin/README.md b/packages/dev/optimize-locales-plugin/README.md index d089af52ec2..9d006032152 100644 --- a/packages/dev/optimize-locales-plugin/README.md +++ b/packages/dev/optimize-locales-plugin/README.md @@ -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 @@ -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 diff --git a/packages/dev/optimize-locales-plugin/test/LocalesPlugin.test.js b/packages/dev/optimize-locales-plugin/test/LocalesPlugin.test.js index 5493361ef74..9d56c214094 100644 --- a/packages/dev/optimize-locales-plugin/test/LocalesPlugin.test.js +++ b/packages/dev/optimize-locales-plugin/test/LocalesPlugin.test.js @@ -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'}); @@ -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) + ); +} diff --git a/packages/dev/s2-docs/pages/react-aria/frameworks.mdx b/packages/dev/s2-docs/pages/react-aria/frameworks.mdx index 04c1288126c..d67e1f0b7ce 100644 --- a/packages/dev/s2-docs/pages/react-aria/frameworks.mdx +++ b/packages/dev/s2-docs/pages/react-aria/frameworks.mdx @@ -73,6 +73,36 @@ export const description = 'How to integrate with your framework.'; } ``` + + 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. + + + 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; + ``` + If you are using a [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) (CSP) with a nonce, add a `` tag to your document head, setting the `content` attribute to the generated nonce value. React Aria automatically reads the nonce from this tag.