Skip to content
Merged
8 changes: 8 additions & 0 deletions .changeset/swc-target-to-env.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@lynx-js/rspeedy": minor
"@lynx-js/react-rsbuild-plugin": minor
---

Express the SWC compilation baseline through `env` (a high `targets` plus an explicit `include` transform list) instead of `jsc.target`. The emitted build output is unchanged for existing projects.

Because `env` and `jsc.target` are mutually exclusive in SWC, `tools.swc.jsc.target` is no longer accepted and now throws a clear error. To downlevel specific syntax, add the corresponding transforms to `tools.swc.env.include` instead — they extend the base/background baseline (the main thread keeps its fixed es2019 baseline, matching the previous `jsc.target` behavior).
1 change: 1 addition & 0 deletions packages/rspeedy/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"@microsoft/api-extractor": "catalog:",
"@rollup/plugin-typescript": "^12.3.0",
"@rsdoctor/core": "~1.5.6",
"@swc/core": "^1.15.30",
"chokidar": "^4.0.3",
"commander": "^13.1.0",
"eventemitter3": "^5.0.4",
Expand Down
33 changes: 28 additions & 5 deletions packages/rspeedy/core/src/plugins/swc.plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@

import type { RsbuildPlugin } from '@rsbuild/core'

import { getESVersionTarget } from '../utils/getESVersionTarget.js'
import {
ES_ENV_TARGETS,
getESVersionEnvInclude,
getESVersionTarget,
} from '../utils/getESVersionTarget.js'

export function pluginSwc(): RsbuildPlugin {
return {
Expand All @@ -15,11 +19,30 @@ export function pluginSwc(): RsbuildPlugin {
return mergeRsbuildConfig(config, {
tools: {
swc(config) {
delete config.env
// `env` and `jsc.target` are mutually exclusive in SWC. Reject a
// user-set `jsc.target` instead of silently dropping it.
if (config.jsc?.target !== undefined) {
throw new Error(
'Rspeedy manages the SWC compilation target via `env`, which '
+ 'is mutually exclusive with `jsc.target`. Remove '
+ '`tools.swc.jsc.target` (received '
+ `\`${JSON.stringify(config.jsc.target)}\`). To downlevel `
+ 'specific syntax, add the corresponding transforms to '
+ '`tools.swc.env.include` instead (e.g. '
+ '`[\'transform-class-properties\']`).',
)
}

config.jsc ??= {}

config.jsc.target ??= getESVersionTarget(isProd)
// Merge any user `env.include` on top of Rspeedy's baseline.
// `targets` stays owned by Rspeedy; other `env` fields from the
// bundler default (e.g. `mode`) are dropped, as before.
config.env = {
targets: ES_ENV_TARGETS,
include: [
...getESVersionEnvInclude(getESVersionTarget(isProd)),
...(config.env?.include ?? []),
],
}
},
},
})
Expand Down
56 changes: 56 additions & 0 deletions packages/rspeedy/core/src/utils/getESVersionTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,59 @@
export function getESVersionTarget(isProd: boolean): 'es2015' | 'es2019' {
return isProd ? 'es2015' : 'es2019'
}

// Transforms newer than ES2019 (i.e. ES2020+) — exactly what an
// `jsc.target: 'es2019'` lowers.
const ES2019_INCLUDE = [
Comment thread
upupming marked this conversation as resolved.
// ES2020
'transform-nullish-coalescing-operator',
'transform-optional-chaining',
'transform-export-namespace-from',
// ES2021
'transform-logical-assignment-operators',
'transform-numeric-separator',
// ES2022
'transform-class-properties',
'transform-class-static-block',
'transform-private-methods',
'transform-private-property-in-object',
]

// Transforms newer than ES2015 (i.e. ES2016+) — exactly what an
// `jsc.target: 'es2015'` lowers: ES2016~ES2019, plus everything in
// {@link ES2019_INCLUDE}.
const ES2015_INCLUDE = [
// ES2016
'transform-exponentiation-operator',
// ES2017
'transform-async-to-generator',
// ES2018
'transform-async-generator-functions',
'transform-dotall-regex',
'transform-named-capturing-groups-regex',
'transform-object-rest-spread',
'transform-unicode-property-regex',
// ES2019
'transform-json-strings',
'transform-optional-catch-binding',
...ES2019_INCLUDE,
]

/**
* The SWC `env.include` list equivalent to a discrete `jsc.target`. Lets the
* baseline be expressed through `env` (which supports `include`/`exclude`)
* instead of `jsc.target`, while running the exact same set of transforms —
* so the emitted output is unchanged.
*
* Pair it with a high `env.targets` (so `env` auto-includes nothing and the
* returned list is the canonical transform set).
*/
export function getESVersionEnvInclude(
target: 'es2015' | 'es2019',
): string[] {
return target === 'es2015' ? [...ES2015_INCLUDE] : [...ES2019_INCLUDE]
}

// A high baseline so SWC `env` auto-includes nothing; the `include` list
// returned by {@link getESVersionEnvInclude} is the canonical transform set.
export const ES_ENV_TARGETS = { chrome: '120' }
92 changes: 87 additions & 5 deletions packages/rspeedy/core/test/plugins/swc.plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,31 @@ describe('Plugins - SWC', () => {
"typeExports": true,
},
"detectSyntax": "auto",
"env": {
"include": [
"transform-exponentiation-operator",
"transform-async-to-generator",
"transform-async-generator-functions",
"transform-dotall-regex",
"transform-named-capturing-groups-regex",
"transform-object-rest-spread",
"transform-unicode-property-regex",
"transform-json-strings",
"transform-optional-catch-binding",
"transform-nullish-coalescing-operator",
"transform-optional-chaining",
"transform-export-namespace-from",
"transform-logical-assignment-operators",
"transform-numeric-separator",
"transform-class-properties",
"transform-class-static-block",
"transform-private-methods",
"transform-private-property-in-object",
],
"targets": {
"chrome": "120",
},
},
"isModule": "unknown",
"jsc": {
"experimental": {
Expand All @@ -36,7 +61,6 @@ describe('Plugins - SWC', () => {
"parser": {
"decorators": true,
},
"target": "es2015",
"transform": {
"decoratorVersion": "2023-11",
"legacyDecorator": false,
Expand All @@ -61,6 +85,22 @@ describe('Plugins - SWC', () => {
"typeExports": true,
},
"detectSyntax": "auto",
"env": {
"include": [
"transform-nullish-coalescing-operator",
"transform-optional-chaining",
"transform-export-namespace-from",
"transform-logical-assignment-operators",
"transform-numeric-separator",
"transform-class-properties",
"transform-class-static-block",
"transform-private-methods",
"transform-private-property-in-object",
],
"targets": {
"chrome": "120",
},
},
"isModule": "unknown",
"jsc": {
"experimental": {
Expand All @@ -74,7 +114,6 @@ describe('Plugins - SWC', () => {
"parser": {
"decorators": true,
},
"target": "es2019",
"transform": {
"decoratorVersion": "2023-11",
"legacyDecorator": false,
Expand All @@ -84,7 +123,7 @@ describe('Plugins - SWC', () => {
`)
})

test('custom target', async () => {
test('user-configured jsc.target throws (target is managed via env)', async () => {
const rsbuild = await createStubRspeedy({
tools: {
swc: {
Expand All @@ -95,13 +134,41 @@ describe('Plugins - SWC', () => {
},
})

// `env` and `jsc.target` are mutually exclusive in SWC. Rspeedy controls
// the baseline via `env`, so a user-set `jsc.target` is rejected with a
// clear error rather than silently dropped.
await expect(rsbuild.unwrapConfig()).rejects.toThrowError(
/Rspeedy manages the SWC compilation target via `env`/,
)
})

test('user-configured env.include is merged onto the baseline', async () => {
const rsbuild = await createStubRspeedy({
mode: 'production',
tools: {
swc: {
env: {
// Extra transform the user opts into (e.g. lower let/const to var).
include: ['transform-block-scoping'],
},
},
},
})

const config = await rsbuild.unwrapConfig()
const loaderOptions = getLoaderOptions<Rspack.SwcLoaderOptions>(
config,
'builtin:swc-loader',
)

expect(loaderOptions?.jsc?.target).toBe('es5')
// The user's transform is honored ...
expect(loaderOptions?.env?.include).toContain('transform-block-scoping')
// ... on top of Rspeedy's baseline (es2015-equivalent in production) ...
expect(loaderOptions?.env?.include).toContain(
'transform-async-to-generator',
)
// ... and Rspeedy still owns `targets`.
expect(loaderOptions?.env?.targets).toEqual({ chrome: '120' })
})

test('modify swc config from plugin', async () => {
Expand Down Expand Up @@ -135,6 +202,22 @@ describe('Plugins - SWC', () => {
"typeExports": true,
},
"detectSyntax": "auto",
"env": {
"include": [
"transform-nullish-coalescing-operator",
"transform-optional-chaining",
"transform-export-namespace-from",
"transform-logical-assignment-operators",
"transform-numeric-separator",
"transform-class-properties",
"transform-class-static-block",
"transform-private-methods",
"transform-private-property-in-object",
],
"targets": {
"chrome": "120",
},
},
"isModule": "unknown",
"jsc": {
"experimental": {
Expand All @@ -148,7 +231,6 @@ describe('Plugins - SWC', () => {
"parser": {
"decorators": true,
},
"target": "es2019",
"transform": {
"decoratorVersion": "2023-11",
"legacyDecorator": false,
Expand Down
66 changes: 66 additions & 0 deletions packages/rspeedy/core/test/utils/getESVersionEnvInclude.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright 2024 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
import { transformSync } from '@swc/core'
import { describe, expect, test } from 'vitest'

import {
ES_ENV_TARGETS,
getESVersionEnvInclude,
} from '../../src/utils/getESVersionTarget.js'

// Syntax spanning ES2016 through ES2025+ (the regex `v` flag, regex modifiers
// and duplicate named capture groups are the newest entries). The guard below
// compiles this with `jsc.target` (SWC's canonical lowering for an ES version)
// and with our `env` config, then asserts byte-identical output.
//
// If a future SWC upgrade starts lowering some syntax at the target that our
// explicit `include` list omits — or stops needing one we list — the two
// outputs diverge and this test fails, flagging that `getESVersionEnvInclude`
// drifted from `jsc.target` and must be updated. Extend this fixture whenever
// SWC gains support for newer syntax so the guard keeps covering it.
const MODERN_SYNTAX = [
'export * as ns from "mod";',
'const oc = a?.b?.c;',
'const nc = a ?? b;',
'let x = 0; x ||= 1; x &&= 2; x ??= 3;',
'const num = 1_000_000;',
'async function af() { return await g(); }',
'async function* ag() { yield 1; }',
'const spread = { ...a, b: 1 };',
'const dotall = /a.b/s;',
'const named = /(?<year>\\d{4})/;',
'const exp = 2 ** 10;',
'try { foo() } catch { bar() }',
'class C { #priv = 1; static sb = 2; static { this.x = 1 } #m() {} }',
'const vFlag = /[\\p{ASCII}]/v;',
'const modifiers = /(?i:abc)/;',
'const dupNamed = /(?<a>x)|(?<a>y)/;',
].join('\n')

function viaTarget(target: 'es2015' | 'es2019'): string {
return transformSync(MODERN_SYNTAX, {
isModule: true,
jsc: { parser: { syntax: 'ecmascript' }, target },
}).code
}

function viaEnv(target: 'es2015' | 'es2019'): string {
return transformSync(MODERN_SYNTAX, {
isModule: true,
jsc: { parser: { syntax: 'ecmascript' } },
env: { targets: ES_ENV_TARGETS, include: getESVersionEnvInclude(target) },
}).code
}

describe('getESVersionEnvInclude', () => {
// Uses `@swc/core` as a stand-in for rspack's builtin SWC: the
// `env.include` <-> `jsc.target` equivalence is a preset-env property
// shared by both.
test.each(['es2015', 'es2019'] as const)(
'env.include reproduces `jsc.target: %s` exactly',
(target) => {
expect(viaEnv(target)).toBe(viaTarget(target))
},
)
})
35 changes: 32 additions & 3 deletions packages/rspeedy/plugin-react/src/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ import { LAYERS, ReactWebpackPlugin } from '@lynx-js/react-webpack-plugin'

import type { PluginReactLynxOptions } from './pluginReactLynx.js'

// The transforms an `es2019` SWC target lowers (ES2020+ syntax), expressed as
// an explicit `env.include` so the main thread no longer relies on
// `jsc.target` (mutually exclusive with `env`). Output is unchanged.
const MAIN_THREAD_ENV_INCLUDE = [
// ES2020
'transform-nullish-coalescing-operator',
'transform-optional-chaining',
'transform-export-namespace-from',
// ES2021
'transform-logical-assignment-operators',
'transform-numeric-separator',
// ES2022
'transform-class-properties',
'transform-class-static-block',
'transform-private-methods',
'transform-private-property-in-object',
]

// A high baseline so `env` auto-includes nothing beyond the explicit list.
const MAIN_THREAD_ENV_TARGETS = { chrome: '120' }

function getLoaderOptions(
api: RsbuildPluginAPI,
options: Required<PluginReactLynxOptions>,
Expand Down Expand Up @@ -118,14 +139,22 @@ export function applyLoaders(
.entries() as Rspack.RuleSetRule
const swcLoaderOptions = swcLoaderRule
.options as Rspack.SwcLoaderOptions
// `jsc.target` and `env` can't coexist in SWC: drop the target and
// express the fixed es2019 main-thread baseline through `env`. The
// main thread targets an es2019 engine, so its baseline is a platform
// constant — user `tools.swc.env.include` only extends the base/
// background config, matching the previous `jsc.target` behavior.
const jsc = { ...swcLoaderOptions.jsc } as Record<string, unknown>
delete jsc['target']
rule.use(CHAIN_ID.USE.SWC)
.merge(swcLoaderRule)
.options(
{
...swcLoaderOptions,
jsc: {
...swcLoaderOptions.jsc,
target: 'es2019',
jsc,
env: {
targets: MAIN_THREAD_ENV_TARGETS,
include: MAIN_THREAD_ENV_INCLUDE,
},
} satisfies Rspack.SwcLoaderOptions,
)
Expand Down
Loading
Loading