diff --git a/.changeset/lower-const-let-to-var.md b/.changeset/lower-const-let-to-var.md new file mode 100644 index 0000000000..d8ead915b6 --- /dev/null +++ b/.changeset/lower-const-let-to-var.md @@ -0,0 +1,6 @@ +--- +"@lynx-js/rspeedy": minor +"@lynx-js/react-rsbuild-plugin": minor +--- + +Lower `let`/`const` to `var` in the build output for faster QuickJS parsing. The SWC `transform-block-scoping` pass is added to both the background and main-thread layers (on top of the existing target baseline), and rspack `output.environment.const` is set to `false` so bundler-generated runtime code also uses `var`. diff --git a/packages/rspeedy/core/src/plugins/output.plugin.ts b/packages/rspeedy/core/src/plugins/output.plugin.ts index 879d4512b8..dcd051d047 100644 --- a/packages/rspeedy/core/src/plugins/output.plugin.ts +++ b/packages/rspeedy/core/src/plugins/output.plugin.ts @@ -28,8 +28,17 @@ export function pluginOutput(options?: Output): RsbuildPlugin { name: 'lynx:rsbuild:output', setup(api) { api.modifyRsbuildConfig((config, { mergeRsbuildConfig }) => { + // Default bundler-generated runtime / wrapper code to `var` (QuickJS + // parses it faster than `const`/`let`); the SWC `transform-block-scoping` + // pass handles user source separately. Placed first so user-provided + // `tools.rspack.output.environment.const` can opt out. + const lowerToVar: RsbuildConfig = { + tools: { rspack: { output: { environment: { const: false } } } }, + } + if (!options) { return mergeRsbuildConfig( + lowerToVar, { output: { filename: { @@ -41,7 +50,7 @@ export function pluginOutput(options?: Output): RsbuildPlugin { ) } - return mergeRsbuildConfig(config, { + return mergeRsbuildConfig(lowerToVar, config, { output: { distPath: Object.assign( {}, diff --git a/packages/rspeedy/core/src/plugins/swc.plugin.ts b/packages/rspeedy/core/src/plugins/swc.plugin.ts index 0201ca4ec7..d199ae577e 100644 --- a/packages/rspeedy/core/src/plugins/swc.plugin.ts +++ b/packages/rspeedy/core/src/plugins/swc.plugin.ts @@ -33,12 +33,13 @@ export function pluginSwc(): RsbuildPlugin { ) } - // 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 = { + ...config.env, targets: ES_ENV_TARGETS, include: [ + // Lower `let`/`const` to `var`; QuickJS parses `var` faster. + // Listing it in `env.exclude` opts out (exclude > include). + 'transform-block-scoping', ...getESVersionEnvInclude(getESVersionTarget(isProd)), ...(config.env?.include ?? []), ], diff --git a/packages/rspeedy/core/test/plugins/__snapshots__/output.plugin.test.ts.snap b/packages/rspeedy/core/test/plugins/__snapshots__/output.plugin.test.ts.snap index b6308b368c..f9bbd4c82e 100644 --- a/packages/rspeedy/core/test/plugins/__snapshots__/output.plugin.test.ts.snap +++ b/packages/rspeedy/core/test/plugins/__snapshots__/output.plugin.test.ts.snap @@ -8,6 +8,9 @@ exports[`Plugins - Output > defaults - Production 1`] = ` "chunkLoading": "lynx", "devtoolFallbackModuleFilenameTemplate": "[relative-resource-path]?[hash]", "devtoolModuleFilenameTemplate": "[relative-resource-path]", + "environment": { + "const": false, + }, "filename": "static/js/[name].[contenthash:10].js", "path": "/dist", "publicPath": "/", @@ -23,6 +26,9 @@ exports[`Plugins - Output > defaults 1`] = ` "chunkLoading": "lynx", "devtoolFallbackModuleFilenameTemplate": "[relative-resource-path]?[hash]", "devtoolModuleFilenameTemplate": "[relative-resource-path]", + "environment": { + "const": false, + }, "filename": "static/js/[name].js", "path": "/dist", "publicPath": "/", @@ -38,6 +44,9 @@ exports[`Plugins - Output > output.filename 1`] = ` "chunkLoading": "lynx", "devtoolFallbackModuleFilenameTemplate": "[relative-resource-path]?[hash]", "devtoolModuleFilenameTemplate": "[relative-resource-path]", + "environment": { + "const": false, + }, "filename": "static/js/[name].js", "path": "/dist", "publicPath": "/", @@ -53,6 +62,9 @@ exports[`Plugins - Output > output.filename.js 1`] = ` "chunkLoading": "lynx", "devtoolFallbackModuleFilenameTemplate": "[relative-resource-path]?[hash]", "devtoolModuleFilenameTemplate": "[relative-resource-path]", + "environment": { + "const": false, + }, "filename": "static/js/[name].js", "path": "/dist", "publicPath": "/", diff --git a/packages/rspeedy/core/test/plugins/output.plugin.test.ts b/packages/rspeedy/core/test/plugins/output.plugin.test.ts index 8f4dd8e402..31acb9092e 100644 --- a/packages/rspeedy/core/test/plugins/output.plugin.test.ts +++ b/packages/rspeedy/core/test/plugins/output.plugin.test.ts @@ -33,6 +33,34 @@ describe('Plugins - Output', () => { expect(config.output).toMatchSnapshot() }) + test('lowers const/let to var via output.environment', async () => { + const rsbuild = await createStubRspeedy({}) + + const config = await rsbuild.unwrapConfig({ + action: 'build', + }) + + // Bundler-generated runtime/wrapper code uses `var` (QuickJS parses it + // faster); SWC `transform-block-scoping` handles user source separately. + expect(config.output?.environment?.const).toBe(false) + }) + + test('user can opt out of const/let lowering', async () => { + const rsbuild = await createStubRspeedy({ + tools: { + rspack: { + output: { environment: { const: true } }, + }, + }, + }) + + const config = await rsbuild.unwrapConfig({ + action: 'build', + }) + + expect(config.output?.environment?.const).toBe(true) + }) + test('output.filename', async () => { const rsbuild = await createStubRspeedy({ output: { diff --git a/packages/rspeedy/core/test/plugins/swc.plugin.test.ts b/packages/rspeedy/core/test/plugins/swc.plugin.test.ts index 9fa4302ea7..ee3dc5acb8 100644 --- a/packages/rspeedy/core/test/plugins/swc.plugin.test.ts +++ b/packages/rspeedy/core/test/plugins/swc.plugin.test.ts @@ -25,6 +25,7 @@ describe('Plugins - SWC', () => { "detectSyntax": "auto", "env": { "include": [ + "transform-block-scoping", "transform-exponentiation-operator", "transform-async-to-generator", "transform-async-generator-functions", @@ -87,6 +88,7 @@ describe('Plugins - SWC', () => { "detectSyntax": "auto", "env": { "include": [ + "transform-block-scoping", "transform-nullish-coalescing-operator", "transform-optional-chaining", "transform-export-namespace-from", @@ -142,6 +144,32 @@ describe('Plugins - SWC', () => { ) }) + test('user can opt out of transform-block-scoping via env.exclude', async () => { + const rsbuild = await createStubRspeedy({ + mode: 'production', + tools: { + swc: { + env: { + exclude: ['transform-block-scoping'], + }, + }, + }, + }) + + const config = await rsbuild.unwrapConfig() + const loaderOptions = getLoaderOptions( + config, + 'builtin:swc-loader', + ) + + // SWC's `env.exclude` wins over `include`, so forwarding the user's + // exclude opts out of the let/const → var lowering. + expect(loaderOptions?.env?.exclude).toEqual(['transform-block-scoping']) + expect(loaderOptions?.env?.include).toContain( + 'transform-async-to-generator', + ) + }) + test('user-configured env.include is merged onto the baseline', async () => { const rsbuild = await createStubRspeedy({ mode: 'production', @@ -204,6 +232,7 @@ describe('Plugins - SWC', () => { "detectSyntax": "auto", "env": { "include": [ + "transform-block-scoping", "transform-nullish-coalescing-operator", "transform-optional-chaining", "transform-export-namespace-from", diff --git a/packages/rspeedy/plugin-react/src/loaders.ts b/packages/rspeedy/plugin-react/src/loaders.ts index fbf6e25b2c..ca8275b4fd 100644 --- a/packages/rspeedy/plugin-react/src/loaders.ts +++ b/packages/rspeedy/plugin-react/src/loaders.ts @@ -153,8 +153,12 @@ export function applyLoaders( ...swcLoaderOptions, jsc, env: { + ...swcLoaderOptions.env, targets: MAIN_THREAD_ENV_TARGETS, - include: MAIN_THREAD_ENV_INCLUDE, + // Lower `let`/`const` to `var`; QuickJS parses `var` faster. + // Spreading `swcLoaderOptions.env` carries `exclude` through, + // so the background opt-out also applies here. + include: ['transform-block-scoping', ...MAIN_THREAD_ENV_INCLUDE], }, } satisfies Rspack.SwcLoaderOptions, ) diff --git a/packages/rspeedy/plugin-react/test/swc-config.test.ts b/packages/rspeedy/plugin-react/test/swc-config.test.ts index f2a3ced2d8..4fdfb06fae 100644 --- a/packages/rspeedy/plugin-react/test/swc-config.test.ts +++ b/packages/rspeedy/plugin-react/test/swc-config.test.ts @@ -69,6 +69,7 @@ describe('SWC configuration', () => { "detectSyntax": "auto", "env": { "include": [ + "transform-block-scoping", "transform-nullish-coalescing-operator", "transform-optional-chaining", "transform-export-namespace-from", @@ -275,6 +276,10 @@ describe('SWC configuration', () => { expect(mainThreadLoaderOptions?.env?.include).not.toContain( 'transform-async-to-generator', ) + // `let`/`const` are lowered to `var` on the main thread too. + expect(mainThreadLoaderOptions?.env?.include).toContain( + 'transform-block-scoping', + ) }) test('user-configured jsc.target is rejected', async () => { @@ -307,7 +312,10 @@ describe('SWC configuration', () => { tools: { swc: { env: { - include: ['transform-block-scoping'], + // An extra transform that is in neither layer's baseline, so its + // routing is observable (`transform-block-scoping` would not work + // here — it is a default on both layers). + include: ['transform-arrow-functions'], }, }, }, @@ -348,7 +356,7 @@ describe('SWC configuration', () => { }, }, 'builtin:swc-loader') expect(backgroundLoaderOptions?.env?.include).toContain( - 'transform-block-scoping', + 'transform-arrow-functions', ) const mainThreadRule = getLayerRule(swcRule, LAYERS.MAIN_THREAD) @@ -371,13 +379,63 @@ describe('SWC configuration', () => { }, }, 'builtin:swc-loader') expect(mainThreadLoaderOptions?.env?.include).not.toContain( - 'transform-block-scoping', + 'transform-arrow-functions', ) expect(mainThreadLoaderOptions?.env?.include).toContain( 'transform-optional-chaining', ) }) + test('layers - user env.exclude opts both layers out of transform-block-scoping', async () => { + const { pluginReactLynx } = await import('../src/pluginReactLynx.js') + const rsbuild = await createRspeedy({ + rspeedyConfig: { + tools: { + swc: { + env: { + exclude: ['transform-block-scoping'], + }, + }, + }, + plugins: [ + pluginStubRspeedyAPI(), + pluginReactLynx(), + ], + }, + }) + + const [config] = await rsbuild.initConfigs() + + const swcRule = config.module.rules.find( + (rule): rule is Rspack.RuleSetRule => { + return rule && rule !== '...' + && (rule.test as RegExp | undefined)?.toString() + === SCRIPT_REGEXP.toString() + }, + ) + assert(swcRule) + + // SWC's `env.exclude` wins over `include`, so forwarding the user's + // exclude opts out of the let/const → var lowering on both layers. + const backgroundRule = getLayerRule(swcRule, LAYERS.BACKGROUND) + assert(backgroundRule) + const backgroundLoaderOptions = getLoaderOptions({ + module: { rules: [backgroundRule] }, + }, 'builtin:swc-loader') + expect(backgroundLoaderOptions?.env?.exclude).toEqual([ + 'transform-block-scoping', + ]) + + const mainThreadRule = getLayerRule(swcRule, LAYERS.MAIN_THREAD) + assert(mainThreadRule) + const mainThreadLoaderOptions = getLoaderOptions({ + module: { rules: [mainThreadRule] }, + }, 'builtin:swc-loader') + expect(mainThreadLoaderOptions?.env?.exclude).toEqual([ + 'transform-block-scoping', + ]) + }) + test('`include` defaults to all js file if not configured by user', async () => { const { pluginReactLynx } = await import('../src/pluginReactLynx.js') const rsbuild = await createRspeedy({