Skip to content

Commit 2bf38b0

Browse files
banchichenbgw
andauthored
feat: add experimental.swcEnvOptions for SWC preset-env configuration (#92272)
### What? Add `experimental.swcEnvOptions` to expose SWC's preset-env `env` configuration options — including `mode`, `coreJs`, `include`, `exclude`, `skip`, `shippedProposals`, `forceAllTransforms`, `debug`, and `loose`. ### Why? Currently Next.js only passes `env.targets` (derived from browserslist) to SWC for **syntax downleveling**, but does not expose the polyfill injection capabilities that SWC already supports. This means: - Users who need automatic core-js polyfills (e.g. `Array.prototype.at()`, `Promise.withResolvers()`, `Set` methods) have no built-in way to get them. - The only workarounds are importing `core-js` globally (which bloats bundles significantly) or ejecting to Babel with `useBuiltIns: 'usage'` (which sacrifices SWC's performance benefits). - In the Babel era, Next.js supported this via `@babel/preset-env`'s `useBuiltIns` (PR #10574). That capability was lost when Next.js migrated to SWC. ### How? A new `experimental.swcEnvOptions` config is added. Its properties are spread into the `env` block that Next.js passes to SWC for client-side compilation, alongside the existing browserslist-derived `targets`. Server-side compilation is unaffected (always targets `node`). The option surface mirrors [SWC's preset-env docs](https://swc.rs/docs/configuration/supported-browsers) 1:1, keeping it familiar and forward-compatible. ```js // next.config.js module.exports = { experimental: { swcEnvOptions: { mode: 'usage', coreJs: '3.38', }, }, } ``` #### Changes: config-shared.ts — type definition with JSDoc config-schema.ts — zod validation next-swc-loader.ts → swc/options.ts — plumb config into the SWC env block Unit tests (6 cases) + e2e test (dev & production) Related issues #66562, #63104, #74978 Related discussion #46724 --------- Co-authored-by: Benjamin Woodruff <[email protected]>
1 parent 3d3405a commit 2bf38b0

20 files changed

Lines changed: 469 additions & 12 deletions

File tree

crates/next-core/src/next_client/context.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ use turbopack_core::{
2929
use turbopack_css::chunk::CssChunkType;
3030
use turbopack_ecmascript::{
3131
AnalyzeMode, TypeofWindow, chunk::EcmascriptChunkType, references::esm::UrlRewriteBehavior,
32+
transform::PresetEnvConfig,
3233
};
3334
use turbopack_node::{
3435
execution_context::ExecutionContext,
@@ -330,6 +331,24 @@ pub async fn get_client_module_options_context(
330331
let enable_foreign_postcss_transform = Some(postcss_foreign_transform_options.resolved_cell());
331332

332333
let source_maps = *next_config.client_source_maps(mode).await?;
334+
335+
let preset_env_config = (*next_config.experimental_swc_env_options().await?)
336+
.as_ref()
337+
.map(|opts| {
338+
PresetEnvConfig {
339+
mode: opts.mode.clone(),
340+
core_js: opts.core_js.clone(),
341+
skip: opts.skip.clone(),
342+
include: opts.include.clone(),
343+
exclude: opts.exclude.clone(),
344+
shipped_proposals: opts.shipped_proposals,
345+
force_all_transforms: opts.force_all_transforms,
346+
debug: opts.debug,
347+
loose: opts.loose,
348+
}
349+
.resolved_cell()
350+
});
351+
333352
let module_options_context = ModuleOptionsContext {
334353
ecmascript: EcmascriptOptionsContext {
335354
esm_url_rewrite_behavior: Some(UrlRewriteBehavior::Relative),
@@ -338,6 +357,7 @@ pub async fn get_client_module_options_context(
338357
enable_import_as_text: *next_config.turbopack_import_type_text().await?,
339358
source_maps,
340359
infer_module_side_effects: *next_config.turbopack_infer_module_side_effects().await?,
360+
preset_env_config,
341361
..Default::default()
342362
},
343363
css: CssOptionsContext {
@@ -374,6 +394,9 @@ pub async fn get_client_module_options_context(
374394
enable_typeof_window_inlining: None,
375395
// Ignore e.g. import(`${url}`) requests in node_modules.
376396
ignore_dynamic_requests: true,
397+
// Don't inject core-js polyfills into node_modules — only user code
398+
// should be processed by preset_env's usage/entry mode.
399+
preset_env_config: None,
377400
..module_options_context.ecmascript
378401
},
379402
enable_webpack_loaders: foreign_enable_webpack_loaders,
@@ -390,6 +413,8 @@ pub async fn get_client_module_options_context(
390413
TypescriptTransformOptions::default().resolved_cell(),
391414
),
392415
enable_jsx: Some(JsxTransformOptions::default().resolved_cell()),
416+
// Don't inject core-js polyfills into framework internals.
417+
preset_env_config: None,
393418
..module_options_context.ecmascript.clone()
394419
},
395420
enable_postcss_transform: None,

crates/next-core/src/next_config.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,6 +1081,7 @@ pub struct ExperimentalConfig {
10811081
strict_next_head: Option<bool>,
10821082
#[bincode(with = "turbo_bincode::serde_self_describing")]
10831083
swc_plugins: Option<Vec<(RcStr, serde_json::Value)>>,
1084+
swc_env_options: Option<SwcEnvOptions>,
10841085
external_middleware_rewrites_resolve: Option<bool>,
10851086
scroll_restoration: Option<bool>,
10861087
manual_client_base_path: Option<bool>,
@@ -1419,6 +1420,37 @@ pub struct SwcPlugins(
14191420
#[bincode(with = "turbo_bincode::serde_self_describing")] Vec<(RcStr, serde_json::Value)>,
14201421
);
14211422

1423+
/// Options for SWC's preset-env, exposed via `experimental.swcEnvOptions`.
1424+
#[derive(
1425+
Clone,
1426+
Debug,
1427+
Default,
1428+
PartialEq,
1429+
Eq,
1430+
Serialize,
1431+
Deserialize,
1432+
TraceRawVcs,
1433+
NonLocalValue,
1434+
OperationValue,
1435+
Encode,
1436+
Decode,
1437+
)]
1438+
#[serde(rename_all = "camelCase")]
1439+
pub struct SwcEnvOptions {
1440+
pub mode: Option<RcStr>,
1441+
pub core_js: Option<RcStr>,
1442+
pub skip: Option<Vec<RcStr>>,
1443+
pub include: Option<Vec<RcStr>>,
1444+
pub exclude: Option<Vec<RcStr>>,
1445+
pub shipped_proposals: Option<bool>,
1446+
pub force_all_transforms: Option<bool>,
1447+
pub debug: Option<bool>,
1448+
pub loose: Option<bool>,
1449+
}
1450+
1451+
#[turbo_tasks::value(transparent)]
1452+
pub struct OptionSwcEnvOptions(Option<SwcEnvOptions>);
1453+
14221454
#[turbo_tasks::value(transparent)]
14231455
pub struct OptionalMdxTransformOptions(Option<ResolvedVc<MdxTransformOptions>>);
14241456

@@ -1854,6 +1886,11 @@ impl NextConfig {
18541886
Vc::cell(self.experimental.swc_plugins.clone().unwrap_or_default())
18551887
}
18561888

1889+
#[turbo_tasks::function]
1890+
pub fn experimental_swc_env_options(&self) -> Vc<OptionSwcEnvOptions> {
1891+
Vc::cell(self.experimental.swc_env_options.clone())
1892+
}
1893+
18571894
#[turbo_tasks::function]
18581895
pub fn experimental_sri(&self) -> Vc<OptionSubResourceIntegrity> {
18591896
Vc::cell(self.experimental.sri.clone())

packages/next/src/build/swc/options.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,7 @@ export function getLoaderSWCOptions({
397397
optimizeServerReact,
398398
optimizePackageImports,
399399
swcPlugins,
400+
swcEnvOptions,
400401
compilerOptions,
401402
jsConfig,
402403
supportedBrowsers,
@@ -426,6 +427,7 @@ export function getLoaderSWCOptions({
426427
NextConfig['experimental']
427428
>['optimizePackageImports']
428429
swcPlugins: ExperimentalConfig['swcPlugins']
430+
swcEnvOptions?: ExperimentalConfig['swcEnvOptions']
429431
compilerOptions: NextConfig['compiler']
430432
jsConfig: any
431433
supportedBrowsers: string[] | undefined
@@ -539,6 +541,7 @@ export function getLoaderSWCOptions({
539541
? {
540542
env: {
541543
targets: supportedBrowsers,
544+
...swcEnvOptions,
542545
},
543546
}
544547
: {}),

packages/next/src/build/webpack/loaders/next-swc-loader.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ async function loaderTransform(
155155
modularizeImports: nextConfig?.modularizeImports,
156156
optimizePackageImports: nextConfig?.experimental?.optimizePackageImports,
157157
swcPlugins: nextConfig?.experimental?.swcPlugins,
158+
swcEnvOptions: nextConfig?.experimental?.swcEnvOptions,
158159
compilerOptions: nextConfig?.compiler,
159160
optimizeServerReact: nextConfig?.experimental?.optimizeServerReact,
160161
jsConfig,

packages/next/src/server/config-schema.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,19 @@ export const experimentalSchema = {
300300
// The specific swc plugin's option is unknown, use z.any() here
301301
.array(z.tuple([z.string(), z.record(z.string(), z.any())]))
302302
.optional(),
303+
swcEnvOptions: z
304+
.object({
305+
mode: z.enum(['usage', 'entry']).optional(),
306+
coreJs: z.string().optional(),
307+
skip: z.array(z.string()).optional(),
308+
include: z.array(z.string()).optional(),
309+
exclude: z.array(z.string()).optional(),
310+
shippedProposals: z.boolean().optional(),
311+
forceAllTransforms: z.boolean().optional(),
312+
debug: z.boolean().optional(),
313+
loose: z.boolean().optional(),
314+
})
315+
.optional(),
303316
swcTraceProfiling: z.boolean().optional(),
304317
// NonNullable<webpack.Configuration['experiments']>['buildHttp']
305318
urlImports: z.any().optional(),

packages/next/src/server/config-shared.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,53 @@ export interface ExperimentalConfig {
534534
forceSwcTransforms?: boolean
535535

536536
swcPlugins?: Array<[string, Record<string, unknown>]>
537+
538+
/**
539+
* Additional options for SWC's preset-env (`env` configuration).
540+
* These are merged into the `env` block that Next.js passes to SWC,
541+
* alongside the browserslist-derived `targets`.
542+
*
543+
* See https://swc.rs/docs/configuration/supported-browsers for full details.
544+
*
545+
* @example
546+
* ```js
547+
* // next.config.js
548+
* module.exports = {
549+
* experimental: {
550+
* swcEnvOptions: {
551+
* mode: 'usage',
552+
* coreJs: '3.38',
553+
* },
554+
* },
555+
* }
556+
* ```
557+
*/
558+
swcEnvOptions?: {
559+
/**
560+
* Polyfill injection mode, matching Babel's `useBuiltIns`.
561+
* - `'usage'`: Adds specific polyfill imports per file based on actual usage.
562+
* - `'entry'`: Replaces a single `import 'core-js'` with only the polyfills
563+
* needed for the target browsers.
564+
*/
565+
mode?: 'usage' | 'entry'
566+
/** The core-js version to use (e.g. `'3.38'`). Required when `mode` is set. */
567+
coreJs?: string
568+
/** Core-js modules or SWC transform passes to skip. */
569+
skip?: string[]
570+
/** Core-js modules or SWC transform passes to always include. */
571+
include?: string[]
572+
/** Core-js modules or SWC transform passes to always exclude. */
573+
exclude?: string[]
574+
/** Enable shipped TC39 proposals. */
575+
shippedProposals?: boolean
576+
/** Force all transforms regardless of targets. */
577+
forceAllTransforms?: boolean
578+
/** Enable debug output for preset-env. */
579+
debug?: boolean
580+
/** Enable loose mode for transforms. */
581+
loose?: boolean
582+
}
583+
537584
largePageDataBytes?: number
538585
/**
539586
* If set to `false`, webpack won't fall back to polyfill Node.js modules in the browser
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { ReactNode } from 'react'
2+
export default function Root({ children }: { children: ReactNode }) {
3+
return (
4+
<html>
5+
<body>{children}</body>
6+
</html>
7+
)
8+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
'use client'
2+
3+
export default function Page() {
4+
// Same code as the enabled fixture, but without swcEnvOptions
5+
const text = 'a-b-c'
6+
const result = text.replaceAll('-', '_')
7+
8+
return <p id="result">{result}</p>
9+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* @type {import('next').NextConfig}
3+
*/
4+
const nextConfig = {}
5+
6+
module.exports = nextConfig
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { nextTestSetup } from 'e2e-utils'
2+
import { retry } from 'next-test-utils'
3+
import fs from 'fs'
4+
import path from 'path'
5+
6+
describe('swc-auto-polyfill-disabled', () => {
7+
const { next, isNextDev, isNextDeploy } = nextTestSetup({
8+
files: __dirname,
9+
})
10+
11+
it('should render the page correctly without swcEnvOptions', async () => {
12+
const browser = await next.browser('/')
13+
await retry(async () => {
14+
const text = await browser.elementByCss('#result').text()
15+
expect(text).toBe('a_b_c')
16+
})
17+
})
18+
19+
if (!isNextDev && !isNextDeploy) {
20+
it('should not include replaceAll polyfill in non-framework chunks', async () => {
21+
const chunksDir = path.join(next.testDir, '.next', 'static', 'chunks')
22+
const files = fs.readdirSync(chunksDir, { recursive: true }) as string[]
23+
const jsFiles = files.filter((f) => f.endsWith('.js'))
24+
25+
for (const file of jsFiles) {
26+
const content = fs.readFileSync(path.join(chunksDir, file), 'utf-8')
27+
// Skip the built-in polyfill-nomodule chunk (contains core-js license URL)
28+
if (content.includes('core-js/blob/')) continue
29+
30+
// Without swcEnvOptions, no replaceAll polyfill should be injected
31+
expect(content).not.toMatch(/replaceAll[:]\s*function/)
32+
}
33+
})
34+
}
35+
})

0 commit comments

Comments
 (0)