Skip to content

Commit 4f61b84

Browse files
authored
[turbopack] Optimize ESM exports (#82214)
## Optimize ESM Exports ### What For an esm module like ```js export const foo = 2; ``` turbopack will generate a factory function like ```js __turbopack_context__ => { __turbopack_context__.s({foo: ()=>foo}); const foo = 2; } ``` * we expose the exports early to enable circular imports between modules * we expose as 'getter functions' to enable live bindings These behaviors are necessary to uphold the ESM specification, however, we can optimize it by avoiding objects. ```js __turbopack_context__ => { __turbopack_context__.s(['foo', ()=>foo]); const foo = 2; } ``` Since the runtime simply walks the object literal in order to call `defineProperty` a number of times switching to an array representation will speed up both construction and iteration. In a later step i will pursue an approach to avoid generating the getter functions. ### Performance analysis courtesy of v0 I ran the `module-cost` benchmark 30 times across this branch and canary. Each measurement was in a fresh incognito window. | Scenario | Metric | HEAD (ms) | HEAD σ | HEAD CV | CHANGE (ms) | CHANGE σ | CHANGE CV | Delta (ms) | % Change | |----------|--------|-----------|---------|---------|-------------|----------|-----------|------------|----------| | **Pages API ESM** | Load Duration | 39.84 | 2.8 | 7.0% | 36.61 | 2.7 | 7.4% | -3.23 | -8.1% ✅ | | | Execution Duration | 60.89 | 3.6 | 5.9% | 58.14 | 2.8 | 4.8% | -2.75 | -4.5% ✅ | | **Client ESM** | Load Duration | 46.09 | 2.4 | 5.2% | 46.31 | 3.2 | 6.9% | +0.22 | +0.5% ❌ | | | Execution Duration | 47.77 | 0.8 | 1.7% | 46.07 | 1.0 | 2.2% | -1.70 | -3.6% ✅ | ### Key Findings **Strong improvements in Pages API ESM performance:** - Load time reduced by 8.1% with consistent variance - Execution time improved by 4.5% with reduced variance **Mixed results for Client ESM:** - Load times remain essentially unchanged (+0.5%) - Execution times show solid improvement (-3.6%) - Measurements remain highly consistent across both versions
1 parent db561cb commit 4f61b84

File tree

135 files changed

+985
-836
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

135 files changed

+985
-836
lines changed

test/development/acceptance-app/error-recovery.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ describe('Error recovery app', () => {
302302
"Index.useCallback[increment] index.js (7:11)",
303303
"button <anonymous>",
304304
"Index index.js (12:7)",
305-
"Page index.js (8:16)",
305+
"Page index.js (10:5)",
306306
],
307307
}
308308
`)

turbopack/crates/turbopack-ecmascript-runtime/js/src/browser/runtime/base/build-base.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ const getOrInstantiateModuleFromParent: GetOrInstantiateModuleFromParent<
3636
const module = moduleCache[id]
3737

3838
if (module) {
39+
if (module.error) {
40+
throw module.error
41+
}
3942
return module
4043
}
4144

@@ -68,7 +71,6 @@ function instantiateModule(
6871
throw error
6972
}
7073

71-
module.loaded = true
7274
if (module.namespaceObject && module.exports !== module.namespaceObject) {
7375
// in case of a circular dependency: cjs1 -> esm2 -> cjs1
7476
interopEsm(module.exports, module.namespaceObject)

turbopack/crates/turbopack-ecmascript-runtime/js/src/browser/runtime/base/dev-base.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ const getOrInstantiateModuleFromParent: GetOrInstantiateModuleFromParent<
148148
}
149149

150150
if (module) {
151+
if (module.error) {
152+
throw module.error
153+
}
154+
151155
if (module.parents.indexOf(sourceModule.id) === -1) {
152156
module.parents.push(sourceModule.id)
153157
}
@@ -234,7 +238,6 @@ function instantiateModule(
234238
throw error
235239
}
236240

237-
module.loaded = true
238241
if (module.namespaceObject && module.exports !== module.namespaceObject) {
239242
// in case of a circular dependency: cjs1 -> esm2 -> cjs1
240243
interopEsm(module.exports, module.namespaceObject)

turbopack/crates/turbopack-ecmascript-runtime/js/src/browser/runtime/base/runtime-base.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ function factoryNotAvailable(
115115
moduleId: ModuleId,
116116
sourceType: SourceType,
117117
sourceData: SourceData
118-
) {
118+
): never {
119119
let instantiationReason
120120
switch (sourceType) {
121121
case SourceType.Runtime:

turbopack/crates/turbopack-ecmascript-runtime/js/src/nodejs/runtime.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,10 @@ function getOrInstantiateModuleFromParent(
296296
const module = moduleCache[id]
297297

298298
if (module) {
299+
if (module.error) {
300+
throw module.error
301+
}
302+
299303
return module
300304
}
301305

turbopack/crates/turbopack-ecmascript-runtime/js/src/shared/runtime-types.d.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@ type ExternalImport = (
101101
interface Module {
102102
exports: Function | Exports | Promise<Exports> | AsyncModulePromise
103103
error: Error | undefined
104-
loaded: boolean
105104
id: ModuleId
106105
namespaceObject?:
107106
| EsmNamespaceObject

turbopack/crates/turbopack-ecmascript-runtime/js/src/shared/runtime-utils.ts

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@ function createModuleObject(id: ModuleId): Module {
8989
return {
9090
exports: {},
9191
error: undefined,
92-
loaded: false,
9392
id,
9493
namespaceObject: undefined,
9594
[REEXPORTED_OBJECTS]: undefined,
@@ -101,20 +100,24 @@ function createModuleObject(id: ModuleId): Module {
101100
*/
102101
function esm(
103102
exports: Exports,
104-
getters: Record<string, (() => any) | [() => any, (v: any) => void]>
103+
getters: Array<string | (() => unknown) | ((v: unknown) => void)>
105104
) {
106105
defineProp(exports, '__esModule', { value: true })
107106
if (toStringTag) defineProp(exports, toStringTag, { value: 'Module' })
108-
for (const key in getters) {
109-
const item = getters[key]
110-
if (Array.isArray(item)) {
111-
defineProp(exports, key, {
112-
get: item[0],
113-
set: item[1],
107+
let i = 0
108+
while (i < getters.length) {
109+
const propName = getters[i++] as string
110+
// TODO(luke.sandberg): we could support raw values here, but would need a discriminator beyond 'not a function'
111+
const getter = getters[i++] as () => unknown
112+
if (typeof getters[i] === 'function') {
113+
// a setter
114+
defineProp(exports, propName, {
115+
get: getter,
116+
set: getters[i++] as (v: unknown) => void,
114117
enumerable: true,
115118
})
116119
} else {
117-
defineProp(exports, key, { get: item, enumerable: true })
120+
defineProp(exports, propName, { get: getter, enumerable: true })
118121
}
119122
}
120123
Object.seal(exports)
@@ -125,16 +128,19 @@ function esm(
125128
*/
126129
function esmExport(
127130
this: TurbopackBaseContext<Module>,
128-
getters: Record<string, () => any>,
131+
getters: Array<string | (() => unknown) | ((v: unknown) => void)>,
129132
id: ModuleId | undefined
130133
) {
131-
let module = this.m
132-
let exports = this.e
134+
let module: Module
135+
let exports: Module['exports']
133136
if (id != null) {
134137
module = getOverwrittenModule(this.c, id)
135138
exports = module.exports
139+
} else {
140+
module = this.m
141+
exports = this.e
136142
}
137-
module.namespaceObject = module.exports
143+
module.namespaceObject = exports
138144
esm(exports, getters)
139145
}
140146
contextPrototype.s = esmExport
@@ -246,22 +252,32 @@ function interopEsm(
246252
ns: EsmNamespaceObject,
247253
allowExportDefault?: boolean
248254
) {
249-
const getters: { [s: string]: () => any } = Object.create(null)
255+
const getters: Array<string | (() => unknown) | ((v: unknown) => void)> = []
256+
// The index of the `default` export if any
257+
let defaultLocation = -1
250258
for (
251259
let current = raw;
252260
(typeof current === 'object' || typeof current === 'function') &&
253261
!LEAF_PROTOTYPES.includes(current);
254262
current = getProto(current)
255263
) {
256264
for (const key of Object.getOwnPropertyNames(current)) {
257-
getters[key] = createGetter(raw, key)
265+
getters.push(key, createGetter(raw, key))
266+
if (defaultLocation === -1 && key === 'default') {
267+
defaultLocation = getters.length - 1
268+
}
258269
}
259270
}
260271

261272
// this is not really correct
262273
// we should set the `default` getter if the imported module is a `.cjs file`
263-
if (!(allowExportDefault && 'default' in getters)) {
264-
getters['default'] = () => raw
274+
if (!(allowExportDefault && defaultLocation >= 0)) {
275+
// Replace the binding with one for the namespace itself in order to preserve iteration order.
276+
if (defaultLocation >= 0) {
277+
getters[defaultLocation] = () => raw
278+
} else {
279+
getters.push('default', () => raw)
280+
}
265281
}
266282

267283
esm(ns, getters)
@@ -283,7 +299,6 @@ function esmImport(
283299
id: ModuleId
284300
): Exclude<Module['namespaceObject'], undefined> {
285301
const module = getOrInstantiateModuleFromParent(id, this.m)
286-
if (module.error) throw module.error
287302

288303
// any ES module has to have `module.namespaceObject` defined.
289304
if (module.namespaceObject) return module.namespaceObject
@@ -325,9 +340,7 @@ function commonJsRequire(
325340
this: TurbopackBaseContext<Module>,
326341
id: ModuleId
327342
): Exports {
328-
const module = getOrInstantiateModuleFromParent(id, this.m)
329-
if (module.error) throw module.error
330-
return module.exports
343+
return getOrInstantiateModuleFromParent(id, this.m).exports
331344
}
332345
contextPrototype.r = commonJsRequire
333346

turbopack/crates/turbopack-ecmascript/src/references/esm/export.rs

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ use std::{borrow::Cow, collections::BTreeMap, ops::ControlFlow};
33
use anyhow::{Result, bail};
44
use rustc_hash::FxHashSet;
55
use serde::{Deserialize, Serialize};
6+
use smallvec::{SmallVec, smallvec};
67
use swc_core::{
78
common::{DUMMY_SP, SyntaxContext},
89
ecma::ast::{
9-
AssignTarget, Expr, ExprStmt, Ident, KeyValueProp, ObjectLit, Prop, PropName, PropOrSpread,
10-
SimpleAssignTarget, Stmt, Str,
10+
ArrayLit, AssignTarget, Expr, ExprOrSpread, ExprStmt, Ident, SimpleAssignTarget, Stmt, Str,
1111
},
1212
quote, quote_expr,
1313
};
@@ -614,10 +614,10 @@ impl EsmExports {
614614

615615
let mut getters = Vec::new();
616616
for (exported, local) in &expanded.exports {
617-
let expr = match local {
618-
EsmExport::Error => Some(quote!(
617+
let exprs: SmallVec<[Expr; 1]> = match local {
618+
EsmExport::Error => smallvec![quote!(
619619
"(() => { throw new Error(\"Failed binding. See build errors!\"); })" as Expr,
620-
)),
620+
)],
621621
EsmExport::LocalBinding(name, mutable) => {
622622
// TODO ideally, this information would just be stored in
623623
// EsmExport::LocalBinding and we wouldn't have to re-correlated this
@@ -645,16 +645,20 @@ impl EsmExports {
645645
});
646646

647647
if *mutable {
648-
Some(quote!(
649-
"([() => $local, ($new) => $local = $new])" as Expr,
650-
local = Ident::new(local.into(), DUMMY_SP, ctxt),
651-
new = Ident::new(format!("new_{name}").into(), DUMMY_SP, ctxt),
652-
))
648+
let local = Ident::new(local.into(), DUMMY_SP, ctxt);
649+
smallvec![
650+
quote!("() => $local" as Expr, local = local.clone()),
651+
quote!(
652+
"($new) => $local = $new" as Expr,
653+
local = local,
654+
new = Ident::new(format!("new_{name}").into(), DUMMY_SP, ctxt),
655+
)
656+
]
653657
} else {
654-
Some(quote!(
655-
"(() => $local)" as Expr,
658+
smallvec![quote!(
659+
"() => $local" as Expr,
656660
local = Ident::new((name as &str).into(), DUMMY_SP, ctxt)
657-
))
661+
)]
658662
}
659663
}
660664
EsmExport::ImportedBinding(esm_ref, name, mutable) => {
@@ -666,9 +670,13 @@ impl EsmExports {
666670
.map(|ident| {
667671
let expr = ident.as_expr_individual(DUMMY_SP);
668672
if *mutable {
673+
smallvec![
669674
quote!(
670-
"([() => $expr, ($new) => $lhs = $new])" as Expr,
675+
"() => $expr" as Expr,
671676
expr: Expr = expr.clone().map_either(Expr::from, Expr::from).into_inner(),
677+
),
678+
quote!(
679+
"($new) => $lhs = $new" as Expr,
672680
lhs: AssignTarget = AssignTarget::Simple(
673681
expr.map_either(|i| SimpleAssignTarget::Ident(i.into()), SimpleAssignTarget::Member).into_inner()),
674682
new = Ident::new(
@@ -677,13 +685,14 @@ impl EsmExports {
677685
Default::default()
678686
),
679687
)
688+
]
680689
} else {
681-
quote!(
690+
smallvec![quote!(
682691
"(() => $expr)" as Expr,
683692
expr: Expr = expr.map_either(Expr::from, Expr::from).into_inner()
684-
)
693+
)]
685694
}
686-
})
695+
}).unwrap_or_default()
687696
}
688697
EsmExport::ImportedNamespace(esm_ref) => {
689698
let referenced_asset =
@@ -692,27 +701,29 @@ impl EsmExports {
692701
.get_ident(chunking_context, None, scope_hoisting_context)
693702
.await?
694703
.map(|ident| {
695-
quote!(
704+
smallvec![quote!(
696705
"(() => $imported)" as Expr,
697706
imported: Expr = ident.as_expr(DUMMY_SP, false)
698-
)
707+
)]
699708
})
709+
.unwrap_or_default()
700710
}
701711
};
702-
if let Some(expr) = expr {
703-
getters.push(PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
704-
key: PropName::Str(Str {
712+
if !exprs.is_empty() {
713+
getters.push(Some(
714+
Expr::Lit(swc_core::ecma::ast::Lit::Str(Str {
705715
span: DUMMY_SP,
706716
value: exported.as_str().into(),
707717
raw: None,
708-
}),
709-
value: Box::new(expr),
710-
}))));
718+
}))
719+
.into(),
720+
));
721+
getters.extend(exprs.into_iter().map(|e| Some(ExprOrSpread::from(e))));
711722
}
712723
}
713-
let getters = Expr::Object(ObjectLit {
724+
let getters = Expr::Array(ArrayLit {
714725
span: DUMMY_SP,
715-
props: getters,
726+
elems: getters,
716727
});
717728
let dynamic_stmt = if !dynamic_exports.is_empty() {
718729
Some(Stmt::Expr(ExprStmt {

turbopack/crates/turbopack-ecmascript/src/runtime_functions.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,10 @@ pub const TURBOPACK_WASM_MODULE: &TurbopackRuntimeFunctionShortcut =
110110

111111
/// Adding an entry to this list will automatically ensure that `__turbopack_XXX__` can be called
112112
/// from user code (by inserting a replacement into free_var_references)
113-
pub const TURBOPACK_RUNTIME_FUNCTION_SHORTCUTS: [(&str, &TurbopackRuntimeFunctionShortcut); 23] = [
113+
pub const TURBOPACK_RUNTIME_FUNCTION_SHORTCUTS: [(&str, &TurbopackRuntimeFunctionShortcut); 22] = [
114114
("__turbopack_require__", TURBOPACK_REQUIRE),
115115
("__turbopack_module_context__", TURBOPACK_MODULE_CONTEXT),
116116
("__turbopack_import__", TURBOPACK_IMPORT),
117-
("__turbopack_esm__", TURBOPACK_ESM),
118117
("__turbopack_export_value__", TURBOPACK_EXPORT_VALUE),
119118
("__turbopack_export_namespace__", TURBOPACK_EXPORT_NAMESPACE),
120119
("__turbopack_cache__", TURBOPACK_CACHE),

turbopack/crates/turbopack-tests/tests/execution/turbopack/basic/esm-interop/input/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@ it('should allow to access non-enumerable inherited properties', () => {
66
expect(test.default).toMatchObject({
77
named: 'named',
88
default: 'default',
9+
base: 'base',
910
})
1011
expect(test).toMatchObject({
1112
named: 'named',
13+
base: 'base',
1214
default: expect.objectContaining({
1315
named: 'named',
1416
default: 'default',
17+
base: 'base',
1518
}),
1619
})
1720
})

0 commit comments

Comments
 (0)