Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions changelog.d/10548-export-default-fn-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
### Fixed

- **`function F(){}; export default F;` exports `F` itself (#10434).** Importers
used to get a second function object for the default export: prototype
methods and statics assigned on `F` were missing (`new F().m` undefined),
`F === imported` was false, and calling the import through a value
(`const g = f; g()`, `.call`, `.apply`, `new`) passed garbage for missing
arguments and skipped default/rest parameter handling. It blocked axios 1.19.0
(`AxiosURLSearchParams.prototype.append`), uuid 14 (`v5.DNS`), lodash-es
(`MapCache.prototype.clear`) and long 5.3 (`Long.fromInt`).

Root cause: the `ExportDefaultExpr` arm in `perry-hir`'s `module_decl.rs`
recorded a `FuncRef` default export as `Export::Named { local: "default" }`.
The CLI driver only maps a renamed declared-function export back to its local
name when the row names that local, so importers resolved the `default`
closure wrapper instead of `F`'s. That wrapper forwards calls to `F`'s body
but is a separate closure with no expandos and no registered arity.
`export { F as default }` written after the declaration already wrote
`{ local: "F" }` and worked.

Fix: when the exported expression is an identifier naming the function
(through parentheses and erased TypeScript wrappers), the row is
`{ local: "F", exported: "default" }`, the same as the alias form.

A second, hoisting-order bug had the same symptoms. The export arms mark a
function `is_exported` only if its body is already lowered, so an export
clause that comes before its hoisted declaration (`export default F;
function F(){}` or `export { F as default }; function F(){}`) left the flag
unset, and the driver skipped the origin-name mapping. That made even the
alias form lose identity, but only in that ordering. After the whole module
is lowered, a function is now marked exported when an export row names it as
its local binding and `exported_functions` lists its id. A value alias
(`export const g = F`) names `g`, so `F` is left as it was.

Validation: new `test_gap_10434_export_default_fn_identity` (identity across
two importers, a barrel, a namespace import and a dynamic import; prototype
methods; statics; `instanceof`; argument padding through a value; default and
rest parameters; export ahead of a hoisted declaration; cyclic imports; the
alias, `export default function`, class, arrow and function-expression forms
as controls) fails on the baseline and matches Node on the fix, plus four
`perry-hir` unit tests. Package probes that failed on the baseline now match
Node: axios 1.19.0's default ESM entry (a GET with params and a JSON POST
against a local server), uuid 14.0.1 (`v5.DNS`, `v3.URL`, `v1`/`v4`/`v7`
called through values) and lodash-es 4.18.1 (`get`, `memoize`, `set`).
1 change: 1 addition & 0 deletions crates/perry-hir/src/lower/lower_module_fn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1710,6 +1710,7 @@ pub fn lower_module_full_with_platform_globals(
);
}

module_decl::mark_exported_function_bodies(&mut module);
module_decl::register_exported_local_variables(&ctx, &mut module);

// Populate exported_native_instances by matching native_instances with exports
Expand Down
11 changes: 10 additions & 1 deletion crates/perry-hir/src/lower/module_decl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use swc_ecma_ast as ast;
use super::*;
use crate::ir::*;

mod default_export_binding;
mod namespace;
pub(super) mod native_default_import;
pub(super) mod native_profile_import;
Expand All @@ -18,6 +19,8 @@ mod typescript;

// Re-export moved items so existing `crate::...` / `super::*` call paths keep
// resolving. `lower_namespace_as_class` is also called from `lower/stmt.rs`.
use default_export_binding::default_export_function_binding;
pub(super) use default_export_binding::mark_exported_function_bodies;
pub(crate) use namespace::lower_namespace_as_class;
use native_default_import::{
canonicalize_native_import_source, is_cjs_style_native_default_import,
Expand Down Expand Up @@ -1887,8 +1890,14 @@ pub(crate) fn lower_module_decl(
break;
}
}
// #10434: `export default F` of a declared function exports
// the binding `F` itself (the `export { F as default }` row),
// so importers share F's function object.
let local =
default_export_function_binding(ctx, &export_default_expr.expr, func_id)
.unwrap_or_else(|| "default".to_string());
module.exports.push(Export::Named {
local: "default".to_string(),
local,
exported: "default".to_string(),
});
} else if let Expr::ClassRef(class_name) = &lowered {
Expand Down
186 changes: 186 additions & 0 deletions crates/perry-hir/src/lower/module_decl/default_export_binding.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
//! Binding identity for `export default <identifier>` of a declared function
//! (#10434).
//!
//! `function F() {}; F.prototype.m = …; export default F;` must hand importers
//! the very function object the module calls `F`. Importers resolve a renamed
//! declared-function export through its origin (local) name, so the export row
//! has to be `{ local: "F", exported: "default" }` — the shape
//! `export { F as default }` already produces — rather than a
//! `{ local: "default" }` row that materializes a second function value with
//! none of F's expandos or prototype methods and no argument padding.

use std::collections::HashSet;

use swc_ecma_ast as ast;

use crate::ir::{Export, Module};
use crate::lower::LoweringContext;
use crate::types::FuncId;

/// The module-scope function name an `export default <expr>` refers to, when
/// `<expr>` is (modulo parentheses and erased TypeScript wrappers) a bare
/// identifier that resolves to the function `func_id`. Any other expression
/// that lowered to a `FuncRef` (a function expression) has no local binding
/// to share, so it keeps the synthetic `default` export row.
pub(super) fn default_export_function_binding(
ctx: &LoweringContext,
expr: &ast::Expr,
func_id: FuncId,
) -> Option<String> {
let mut expr = expr;
loop {
expr = match expr {
ast::Expr::Paren(inner) => &inner.expr,
ast::Expr::TsAs(inner) => &inner.expr,
ast::Expr::TsNonNull(inner) => &inner.expr,
ast::Expr::TsSatisfies(inner) => &inner.expr,
ast::Expr::TsTypeAssertion(inner) => &inner.expr,
ast::Expr::TsConstAssertion(inner) => &inner.expr,
ast::Expr::TsInstantiation(inner) => &inner.expr,
ast::Expr::Ident(ident) => {
let name = ident.sym.to_string();
return (ctx.lookup_func(&name) == Some(func_id)).then_some(name);
}
_ => return None,
};
}
}

/// Mark a function body exported when an export row names it as its local
/// binding (`export { F }`, `export { F as default }`, `export default F`).
///
/// The export arms flip `is_exported` on the functions already lowered, but a
/// hoisted declaration that appears AFTER its export clause
/// (`export { F as default }; function F() {}` or `export default F; function
/// F() {}`) is not in `module.functions` yet at that point. The CLI driver
/// only records a renamed export's origin name — the thing that keeps the
/// importer's value identical to the local `F` — for exported function
/// bodies, so the flag is settled once the whole module is lowered. Value
/// aliases (`export const g = F`) name `g`, not `F`, and stay as they were.
pub(crate) fn mark_exported_function_bodies(module: &mut Module) {
if module.exported_functions.is_empty() {
return;
}
let exported_ids: HashSet<FuncId> = module
.exported_functions
.iter()
.map(|(_, id)| *id)
.collect();
let export_locals: HashSet<&str> = module
.exports
.iter()
.filter_map(|export| match export {
Export::Named { local, .. } => Some(local.as_str()),
_ => None,
})
.collect();
for func in &mut module.functions {
if exported_ids.contains(&func.id) && export_locals.contains(func.name.as_str()) {
func.is_exported = true;
}
}
}

#[cfg(test)]
mod tests {
use crate::ir::{Export, Module};
use crate::lower_module;
use perry_diagnostics::SourceCache;
use perry_parser::parse_typescript_with_cache;

fn lower_src(src: &str) -> Module {
let src = src.to_string();
std::thread::Builder::new()
.stack_size(32 * 1024 * 1024)
.spawn(move || {
let mut cache = SourceCache::new();
let parsed = parse_typescript_with_cache(&src, "test.ts", &mut cache)
.expect("parse should succeed");
lower_module(&parsed.module, "test", "test.ts").expect("lowering should succeed")
})
.expect("spawn")
.join()
.expect("lowering thread")
}

fn default_rows(module: &Module) -> Vec<String> {
module
.exports
.iter()
.filter_map(|export| match export {
Export::Named { local, exported } if exported == "default" => Some(local.clone()),
_ => None,
})
.collect()
}

/// The function named `name` is exported and is what `default` resolves to.
fn assert_default_is_function(module: &Module, name: &str) {
assert_eq!(
default_rows(module),
vec![name.to_string()],
"{:?}",
module.exports
);
let func = module
.functions
.iter()
.find(|f| f.name == name)
.unwrap_or_else(|| panic!("function {name} not lowered"));
assert!(func.is_exported, "{name} must be flagged exported");
assert!(
module
.exported_functions
.iter()
.any(|(exported, id)| exported == "default" && *id == func.id),
"{:?}",
module.exported_functions
);
}

#[test]
fn export_default_identifier_exports_the_function_binding() {
let module = lower_src(
"function F(this: any, a: any) { this.a = a; }\n\
F.prototype.m = function () { return 1; };\n\
export default F;\n",
);
assert_default_is_function(&module, "F");
}

#[test]
fn export_default_ahead_of_hoisted_declaration_marks_it_exported() {
let module = lower_src("export default F;\nfunction F() { return 1; }\n");
assert_default_is_function(&module, "F");
}

#[test]
fn export_alias_ahead_of_hoisted_declaration_marks_it_exported() {
let module = lower_src("export { F as default };\nfunction F() { return 1; }\n");
assert_default_is_function(&module, "F");
}

#[test]
fn export_default_sees_through_parens_and_type_assertions() {
let module = lower_src("function F() { return 1; }\nexport default ((F as any)!);\n");
assert_default_is_function(&module, "F");
}

#[test]
fn value_alias_does_not_mark_the_aliased_body_exported() {
let module = lower_src("export const g = F;\nfunction F() { return 1; }\n");
let func = module.functions.iter().find(|f| f.name == "F").unwrap();
assert!(
module
.exported_functions
.iter()
.any(|(name, id)| name == "g" && *id == func.id),
"{:?}",
module.exported_functions
);
assert!(
!func.is_exported,
"`export const g = F` exports `g`, not `F`"
);
}
}
10 changes: 10 additions & 0 deletions test-files/_helpers/export_default_fn_10434/alias.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Control: `export { F as default }` after the declaration.
function Alias(this: any, value?: unknown) {
this.value = value;
}
Alias.prototype.read = function (this: any) {
return "alias:" + typeof this.value;
};
(Alias as any).kind = "alias-static";
export const holder = { Alias };
export { Alias as default };
5 changes: 5 additions & 0 deletions test-files/_helpers/export_default_fn_10434/arrow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Control: an arrow function held in a const.
const arrow: any = (a?: unknown, b?: unknown) => typeof a + "," + typeof b;
arrow.kind = "arrow-static";
export const holder = { arrow };
export default arrow;
20 changes: 20 additions & 0 deletions test-files/_helpers/export_default_fn_10434/ctor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// `function F(){}` + expandos, then `export default F;` (#10434).
function Point(this: any, x?: unknown, y?: unknown) {
this.x = x;
this.y = y;
}
Point.prototype.describe = function (this: any) {
return "Point(" + String(this.x) + "," + String(this.y) + ")";
};
(Point as any).origin = "static-origin";
(Point as any).create = function (x: unknown) {
return new (Point as any)(x, "via-static");
};
export const holder = { Point };
export function isPoint(value: unknown) {
return value instanceof Point;
}
export function makeLocal() {
return new (Point as any)("local", 1);
}
export default Point;
10 changes: 10 additions & 0 deletions test-files/_helpers/export_default_fn_10434/cycle_a.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Beta, { betaHolder } from "./cycle_b.ts";
function Alpha() {
return "alpha";
}
(Alpha as any).tag = "alpha-tag";
export const alphaHolder = { Alpha };
export function describeBeta() {
return Beta() + ":" + (Beta as any).tag + ":" + String(Beta === betaHolder.Beta);
}
export default Alpha;
10 changes: 10 additions & 0 deletions test-files/_helpers/export_default_fn_10434/cycle_b.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Alpha, { alphaHolder } from "./cycle_a.ts";
function Beta() {
return "beta";
}
(Beta as any).tag = "beta-tag";
export const betaHolder = { Beta };
export function describeAlpha() {
return Alpha() + ":" + (Alpha as any).tag + ":" + String(Alpha === alphaHolder.Alpha);
}
export default Beta;
9 changes: 9 additions & 0 deletions test-files/_helpers/export_default_fn_10434/decl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Control: `export default function F() {}`.
export default function Decl(this: any, value?: unknown) {
this.value = value;
}
Decl.prototype.read = function (this: any) {
return "decl:" + typeof this.value;
};
(Decl as any).kind = "decl-static";
export const holder = { Decl };
10 changes: 10 additions & 0 deletions test-files/_helpers/export_default_fn_10434/fexpr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Control: a function expression held in a const.
const Expr: any = function (this: any, value?: unknown) {
this.value = value;
};
Expr.prototype.read = function (this: any) {
return "expr:" + typeof this.value;
};
Expr.kind = "expr-static";
export const holder = { Expr };
export default Expr;
10 changes: 10 additions & 0 deletions test-files/_helpers/export_default_fn_10434/hoisted.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// The export clause precedes the hoisted declaration it names.
export default Later;
export const holder = { Later };
function Later(this: any, value?: unknown) {
this.value = value;
}
Later.prototype.read = function (this: any) {
return "later:" + typeof this.value;
};
(Later as any).kind = "later-static";
10 changes: 10 additions & 0 deletions test-files/_helpers/export_default_fn_10434/hoisted_alias.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Same, through an alias clause ahead of the declaration.
export { Aliased as default };
export const holder = { Aliased };
function Aliased(this: any, value?: unknown) {
this.value = value;
}
Aliased.prototype.read = function (this: any) {
return "aliased:" + typeof this.value;
};
(Aliased as any).kind = "aliased-static";
10 changes: 10 additions & 0 deletions test-files/_helpers/export_default_fn_10434/klass.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Control: `export default K` for a class.
class Klass {
static kind = "class-static";
read() {
return "class-read";
}
}
(Klass as any).extra = "class-extra";
export const holder = { Klass };
export default Klass;
5 changes: 5 additions & 0 deletions test-files/_helpers/export_default_fn_10434/params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Default and rest parameters behind `export default <identifier>`.
function withDefaults(a?: unknown, b: number = 5, ...rest: unknown[]) {
return String(a) + "," + b + "," + rest.length;
}
export default withDefaults;
Loading
Loading