Skip to content

Commit 12b084d

Browse files
authored
feat(babel): add runtimeVersion option for deduplicating Babel helpers (#8)
1 parent abf05b0 commit 12b084d

8 files changed

Lines changed: 275 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ jobs:
7171
node-version: 24
7272
cache: 'pnpm'
7373

74-
- name: Override @babel/core to v7
75-
run: yq -i '.overrides."@babel/core" = "^7.29.0"' pnpm-workspace.yaml
74+
- name: Override Babel related deps to v7
75+
run: yq -i '.overrides."@babel/core" = "catalog:babel7" | .overrides."@babel/plugin-transform-runtime" = "catalog:babel7" | .overrides."@babel/runtime" = "catalog:babel7"' pnpm-workspace.yaml
7676

7777
- name: Install deps
7878
run: pnpm install --no-frozen-lockfile

packages/babel/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,43 @@ List of Babel plugins to apply.
7171

7272
Array of additional configurations that are merged into the current configuration. Use with Babel's `test`/`include`/`exclude` options to conditionally apply overrides.
7373

74+
### `runtimeVersion`
75+
76+
- **Type:** `string`
77+
78+
When set, automatically adds [`@babel/plugin-transform-runtime`](https://babeljs.io/docs/babel-plugin-transform-runtime) so that Babel helpers are imported from `@babel/runtime` instead of being inlined into every file. This deduplicates helpers across modules and reduces bundle size.
79+
80+
The value is the version of `@babel/runtime` that is assumed to be installed. If you are externalizing `@babel/runtime` (for example, you are packaging a library), you should set the version range of `@babel/runtime` in your package.json. If you are bundling `@babel/runtime` for your application, you should set the version of `@babel/runtime` that is installed.
81+
82+
```bash
83+
pnpm add -D @babel/plugin-transform-runtime @babel/runtime
84+
```
85+
86+
```js
87+
import babel from '@rolldown/plugin-babel'
88+
89+
// if you are externalizing @babel/runtime
90+
import fs from 'node:fs'
91+
import path from 'node:path'
92+
const packageJson = JSON.parse(
93+
fs.readFileSync(path.join(import.meta.dirname, 'package.json'), 'utf8'),
94+
)
95+
const babelRuntimeVersion = packageJson.dependencies['@babel/runtime']
96+
97+
// if you are bundling @babel/runtime
98+
import babelRuntimePackageJson from '@babel/runtime/package.json'
99+
const babelRuntimeVersion = babelRuntimePackageJson.version
100+
101+
export default {
102+
plugins: [
103+
babel({
104+
runtimeVersion: babelRuntimeVersion,
105+
plugins: ['@babel/plugin-proposal-decorators'],
106+
}),
107+
],
108+
}
109+
```
110+
74111
### Other Babel options
75112
76113
The following [Babel options](https://babeljs.io/docs/options) are forwarded directly:

packages/babel/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,28 @@
3434
},
3535
"devDependencies": {
3636
"@babel/core": "^8.0.0-rc.1",
37+
"@babel/plugin-proposal-decorators": "^8.0.0-rc.2",
38+
"@babel/plugin-transform-runtime": "^8.0.0-rc.2",
39+
"@babel/runtime": "^8.0.0-rc.2",
3740
"@types/node": "^22.19.11",
3841
"@types/picomatch": "^4.0.2",
3942
"rolldown": "1.0.0-rc.5",
4043
"vite": "^8.0.0-beta.15"
4144
},
4245
"peerDependencies": {
4346
"@babel/core": "^7.29.0 || ^8.0.0-rc.1",
47+
"@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1",
48+
"@babel/runtime": "^7.27.0 || ^8.0.0-rc.1",
4449
"rolldown": "^1.0.0-rc.5",
4550
"vite": "^8.0.0"
4651
},
4752
"peerDependenciesMeta": {
53+
"@babel/plugin-transform-runtime": {
54+
"optional": true
55+
},
56+
"@babel/runtime": {
57+
"optional": true
58+
},
4859
"vite": {
4960
"optional": true
5061
}

packages/babel/src/index.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,58 @@ test('babel syntax error produces enhanced error message', async () => {
571571
`)
572572
})
573573

574+
test('runtimeVersion deduplicates helpers via @babel/runtime', async () => {
575+
const entryCode = `
576+
import { decorated } from './dep.js'
577+
@decorator
578+
class Entry { method() { return decorated } }
579+
function decorator(target) { return target }
580+
export { Entry }
581+
`
582+
const depCode = `
583+
@decorator
584+
class Dep { method() { return 42 } }
585+
function decorator(target) { return target }
586+
export const decorated = new Dep()
587+
`
588+
589+
const files: Record<string, string> = {
590+
'entry.js': entryCode,
591+
'dep.js': depCode,
592+
}
593+
594+
const babelRuntimeVersion = (
595+
await import('@babel/runtime/package.json', { with: { type: 'json' } })
596+
).default.version
597+
598+
const bundle = await rolldown({
599+
input: 'entry.js',
600+
external: [/^@babel\/runtime/],
601+
plugins: [
602+
{
603+
name: 'virtual',
604+
resolveId(id) {
605+
if (id in files) return id
606+
if (id === './dep.js') return 'dep.js'
607+
},
608+
load(id) {
609+
if (id in files) return files[id]
610+
},
611+
},
612+
babelPlugin({
613+
runtimeVersion: babelRuntimeVersion,
614+
plugins: [['@babel/plugin-proposal-decorators', { version: '2023-11' }]],
615+
}),
616+
],
617+
})
618+
const { output } = await bundle.generate()
619+
const chunk = output.find((o) => o.type === 'chunk')
620+
assert(chunk, 'expected a chunk in output')
621+
622+
// Helpers should come from @babel/runtime, not be inlined
623+
expect(chunk.code).toContain('@babel/runtime')
624+
})
625+
574626
describe('optimizeDeps.include', () => {
575627
test('collectOptimizeDepsInclude merges from presets and overrides', () => {
576628
const topPreset: RolldownBabelPreset = {

packages/babel/src/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ import { calculatePluginFilters } from './filter.ts'
1313
import type { ResolvedConfig, Plugin as VitePlugin } from 'vite'
1414

1515
async function babelPlugin(rawOptions: PluginOptions): Promise<Plugin> {
16+
if (rawOptions.runtimeVersion) {
17+
try {
18+
import.meta.resolve('@babel/plugin-transform-runtime')
19+
} catch (err) {
20+
throw new Error(
21+
`Failed to load @babel/plugin-transform-runtime. Please install it to use the runtime option.`,
22+
{ cause: err },
23+
)
24+
}
25+
}
26+
1627
let configFilteredOptions: PluginOptions | undefined
1728
const envState = new Map<string | undefined, ReturnType<typeof createBabelOptionsConverter>>()
1829

@@ -88,9 +99,19 @@ async function babelPlugin(rawOptions: PluginOptions): Promise<Plugin> {
8899
filename: id,
89100
})
90101
if (!loadedOptions || loadedOptions.plugins.length === 0) {
102+
// No plugins to run — @babel/plugin-transform-runtime only affects
103+
// how other plugins' helpers are emitted, so skip it too.
91104
return
92105
}
93106

107+
if (rawOptions.runtimeVersion) {
108+
loadedOptions.plugins ??= []
109+
loadedOptions.plugins.push([
110+
'@babel/plugin-transform-runtime',
111+
{ version: rawOptions.runtimeVersion },
112+
])
113+
}
114+
94115
let result: babel.FileResult | null
95116
try {
96117
result = await babel.transformAsync(

packages/babel/src/options.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,15 @@ export interface InnerTransformOptions extends Pick<
3737
}
3838

3939
export interface PluginOptions extends Omit<InnerTransformOptions, 'include' | 'exclude'> {
40+
/**
41+
* When set, automatically adds `@babel/plugin-transform-runtime` so that
42+
* babel helpers are imported from `@babel/runtime` instead of being inlined
43+
* into every file.
44+
*
45+
* Requires `@babel/plugin-transform-runtime` and `@babel/runtime` to be installed.
46+
*/
47+
runtimeVersion?: string
48+
4049
/**
4150
* If specified, only files matching the pattern will be processed by babel.
4251
* @default `/\.(?:[jt]sx?|[cm][jt]s)(?:$|\?)/`
@@ -175,8 +184,10 @@ export function createBabelOptionsConverter(options: ResolvedPluginOptions) {
175184
)
176185

177186
return function (ctx: PresetConversionContext): babel.InputOptions {
187+
// Strip plugin-level options that babel doesn't understand
188+
const { runtimeVersion: _, ...babelOptions } = options
178189
return {
179-
...options,
190+
...babelOptions,
180191
presets: options.presets
181192
? filterMap(options.presets, (preset, i) =>
182193
convertToBabelPresetItem(ctx, preset, presetFilters![i]),

pnpm-lock.yaml

Lines changed: 134 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)