From 837d4fee1c0c845e87e8d2adadc3329f1739c427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 17 Sep 2026 11:00:25 +0000 Subject: [PATCH 1/4] fix(hir): export default F shares the declared function's binding `function F(){}; export default F;` lowered to a synthetic `default` export row, so importers materialized a second function object: no prototype methods or statics assigned on F, `F === imported` false, and missing arguments unpadded when called through a value. Export the binding itself, as `export { F as default }` does, and mark a function named by an export row as exported after the whole module is lowered so an export clause ahead of its hoisted declaration resolves the same way. --- crates/perry-hir/src/lower/lower_module_fn.rs | 1 + crates/perry-hir/src/lower/module_decl.rs | 11 +- .../module_decl/default_export_binding.rs | 186 ++++++++++++++++++ 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 crates/perry-hir/src/lower/module_decl/default_export_binding.rs diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index 755b16dbc3..25e5710b8d 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -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 diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 256d9e6870..142efb4eb5 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -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; @@ -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, @@ -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 { diff --git a/crates/perry-hir/src/lower/module_decl/default_export_binding.rs b/crates/perry-hir/src/lower/module_decl/default_export_binding.rs new file mode 100644 index 0000000000..19f2df724e --- /dev/null +++ b/crates/perry-hir/src/lower/module_decl/default_export_binding.rs @@ -0,0 +1,186 @@ +//! Binding identity for `export default ` 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 ` refers to, when +/// `` 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 { + 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 = 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 { + 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`" + ); + } +} From cc37a846dabfe7cf00a7ddc65c63b2ac576b46b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 17 Sep 2026 11:00:25 +0000 Subject: [PATCH 2/4] test(gap): export default F identity, statics, prototype and argument padding (#10434) --- .../_helpers/export_default_fn_10434/alias.ts | 10 ++ .../_helpers/export_default_fn_10434/arrow.ts | 5 + .../_helpers/export_default_fn_10434/ctor.ts | 20 ++++ .../export_default_fn_10434/cycle_a.ts | 10 ++ .../export_default_fn_10434/cycle_b.ts | 10 ++ .../_helpers/export_default_fn_10434/decl.ts | 9 ++ .../_helpers/export_default_fn_10434/fexpr.ts | 10 ++ .../export_default_fn_10434/hoisted.ts | 10 ++ .../export_default_fn_10434/hoisted_alias.ts | 10 ++ .../_helpers/export_default_fn_10434/klass.ts | 10 ++ .../export_default_fn_10434/params.ts | 5 + .../export_default_fn_10434/parens.ts | 10 ++ .../_helpers/export_default_fn_10434/plain.ts | 7 ++ .../second_importer.ts | 11 +++ ...st_gap_10434_export_default_fn_identity.ts | 95 +++++++++++++++++++ 15 files changed, 232 insertions(+) create mode 100644 test-files/_helpers/export_default_fn_10434/alias.ts create mode 100644 test-files/_helpers/export_default_fn_10434/arrow.ts create mode 100644 test-files/_helpers/export_default_fn_10434/ctor.ts create mode 100644 test-files/_helpers/export_default_fn_10434/cycle_a.ts create mode 100644 test-files/_helpers/export_default_fn_10434/cycle_b.ts create mode 100644 test-files/_helpers/export_default_fn_10434/decl.ts create mode 100644 test-files/_helpers/export_default_fn_10434/fexpr.ts create mode 100644 test-files/_helpers/export_default_fn_10434/hoisted.ts create mode 100644 test-files/_helpers/export_default_fn_10434/hoisted_alias.ts create mode 100644 test-files/_helpers/export_default_fn_10434/klass.ts create mode 100644 test-files/_helpers/export_default_fn_10434/params.ts create mode 100644 test-files/_helpers/export_default_fn_10434/parens.ts create mode 100644 test-files/_helpers/export_default_fn_10434/plain.ts create mode 100644 test-files/_helpers/export_default_fn_10434/second_importer.ts create mode 100644 test-files/test_gap_10434_export_default_fn_identity.ts diff --git a/test-files/_helpers/export_default_fn_10434/alias.ts b/test-files/_helpers/export_default_fn_10434/alias.ts new file mode 100644 index 0000000000..eae18fd182 --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/alias.ts @@ -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 }; diff --git a/test-files/_helpers/export_default_fn_10434/arrow.ts b/test-files/_helpers/export_default_fn_10434/arrow.ts new file mode 100644 index 0000000000..189d18fcab --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/arrow.ts @@ -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; diff --git a/test-files/_helpers/export_default_fn_10434/ctor.ts b/test-files/_helpers/export_default_fn_10434/ctor.ts new file mode 100644 index 0000000000..da0476e22b --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/ctor.ts @@ -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; diff --git a/test-files/_helpers/export_default_fn_10434/cycle_a.ts b/test-files/_helpers/export_default_fn_10434/cycle_a.ts new file mode 100644 index 0000000000..536f1c8e22 --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/cycle_a.ts @@ -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; diff --git a/test-files/_helpers/export_default_fn_10434/cycle_b.ts b/test-files/_helpers/export_default_fn_10434/cycle_b.ts new file mode 100644 index 0000000000..6f61c44349 --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/cycle_b.ts @@ -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; diff --git a/test-files/_helpers/export_default_fn_10434/decl.ts b/test-files/_helpers/export_default_fn_10434/decl.ts new file mode 100644 index 0000000000..61898da86d --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/decl.ts @@ -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 }; diff --git a/test-files/_helpers/export_default_fn_10434/fexpr.ts b/test-files/_helpers/export_default_fn_10434/fexpr.ts new file mode 100644 index 0000000000..67b4df3569 --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/fexpr.ts @@ -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; diff --git a/test-files/_helpers/export_default_fn_10434/hoisted.ts b/test-files/_helpers/export_default_fn_10434/hoisted.ts new file mode 100644 index 0000000000..b4672beb86 --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/hoisted.ts @@ -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"; diff --git a/test-files/_helpers/export_default_fn_10434/hoisted_alias.ts b/test-files/_helpers/export_default_fn_10434/hoisted_alias.ts new file mode 100644 index 0000000000..e791bb9d34 --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/hoisted_alias.ts @@ -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"; diff --git a/test-files/_helpers/export_default_fn_10434/klass.ts b/test-files/_helpers/export_default_fn_10434/klass.ts new file mode 100644 index 0000000000..b360539c7a --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/klass.ts @@ -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; diff --git a/test-files/_helpers/export_default_fn_10434/params.ts b/test-files/_helpers/export_default_fn_10434/params.ts new file mode 100644 index 0000000000..c4798bf896 --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/params.ts @@ -0,0 +1,5 @@ +// Default and rest parameters behind `export default `. +function withDefaults(a?: unknown, b: number = 5, ...rest: unknown[]) { + return String(a) + "," + b + "," + rest.length; +} +export default withDefaults; diff --git a/test-files/_helpers/export_default_fn_10434/parens.ts b/test-files/_helpers/export_default_fn_10434/parens.ts new file mode 100644 index 0000000000..2038af548e --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/parens.ts @@ -0,0 +1,10 @@ +// Parenthesized / type-asserted identifier. +function Wrapped(this: any, value?: unknown) { + this.value = value; +} +Wrapped.prototype.read = function (this: any) { + return "wrapped:" + typeof this.value; +}; +(Wrapped as any).kind = "wrapped-static"; +export const holder = { Wrapped }; +export default (Wrapped as unknown as typeof Wrapped); diff --git a/test-files/_helpers/export_default_fn_10434/plain.ts b/test-files/_helpers/export_default_fn_10434/plain.ts new file mode 100644 index 0000000000..a14ae72d8d --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/plain.ts @@ -0,0 +1,7 @@ +// A plain (non-constructor) declared function, default-exported by name. +function kinds(a?: unknown, b?: unknown, c?: unknown) { + return typeof a + "," + typeof b + "," + typeof c; +} +(kinds as any).label = "kinds-label"; +export const holder = { kinds }; +export default kinds; diff --git a/test-files/_helpers/export_default_fn_10434/second_importer.ts b/test-files/_helpers/export_default_fn_10434/second_importer.ts new file mode 100644 index 0000000000..72eb92bc61 --- /dev/null +++ b/test-files/_helpers/export_default_fn_10434/second_importer.ts @@ -0,0 +1,11 @@ +// A second importer of the same default exports, plus a barrel re-export. +import Point from "./ctor.ts"; +import kinds from "./plain.ts"; +export { default as PointViaBarrel } from "./ctor.ts"; +export { default as kindsViaBarrel } from "./plain.ts"; +export function pointSeenHere() { + return Point; +} +export function kindsSeenHere() { + return kinds; +} diff --git a/test-files/test_gap_10434_export_default_fn_identity.ts b/test-files/test_gap_10434_export_default_fn_identity.ts new file mode 100644 index 0000000000..f861825f3c --- /dev/null +++ b/test-files/test_gap_10434_export_default_fn_identity.ts @@ -0,0 +1,95 @@ +// #10434: `function F(){}; export default F;` must export the very function +// object the module calls `F` — prototype methods, statics, identity across +// importers, `instanceof`, and argument padding through a function value. +import Point, { holder as pointHolder, isPoint, makeLocal } from "./_helpers/export_default_fn_10434/ctor.ts"; +import kinds, { holder as kindsHolder } from "./_helpers/export_default_fn_10434/plain.ts"; +import Later, { holder as laterHolder } from "./_helpers/export_default_fn_10434/hoisted.ts"; +import Aliased, { holder as aliasedHolder } from "./_helpers/export_default_fn_10434/hoisted_alias.ts"; +import Wrapped, { holder as wrappedHolder } from "./_helpers/export_default_fn_10434/parens.ts"; +import Alias, { holder as aliasHolder } from "./_helpers/export_default_fn_10434/alias.ts"; +import Decl, { holder as declHolder } from "./_helpers/export_default_fn_10434/decl.ts"; +import Klass, { holder as klassHolder } from "./_helpers/export_default_fn_10434/klass.ts"; +import arrow, { holder as arrowHolder } from "./_helpers/export_default_fn_10434/arrow.ts"; +import Expr, { holder as exprHolder } from "./_helpers/export_default_fn_10434/fexpr.ts"; +import withDefaults from "./_helpers/export_default_fn_10434/params.ts"; +import { + PointViaBarrel, + kindsViaBarrel, + pointSeenHere, + kindsSeenHere, +} from "./_helpers/export_default_fn_10434/second_importer.ts"; +import * as pointNs from "./_helpers/export_default_fn_10434/ctor.ts"; +import { describeBeta } from "./_helpers/export_default_fn_10434/cycle_a.ts"; +import { describeAlpha } from "./_helpers/export_default_fn_10434/cycle_b.ts"; + +// 1. Constructor function with prototype methods and statics. +const P: any = Point; +const p = new P(1); +console.log("ctor identity:", P === pointHolder.Point); +console.log("ctor statics:", P.origin, typeof P.create); +console.log("ctor prototype:", typeof P.prototype.describe, P.prototype.constructor === P); +console.log("ctor instance:", p.describe(), typeof p.y, p instanceof P, isPoint(p)); +const local = makeLocal(); +console.log("ctor local instance:", local instanceof P, local.describe()); +console.log("ctor static factory:", P.create("s").describe(), isPoint(P.create(0))); +console.log("ctor two importers:", P === pointSeenHere(), P === PointViaBarrel, P === pointNs.default); + +// 2. Plain function: identity, expando, argument padding through a value. +const k: any = kinds; +console.log("plain identity:", k === kindsHolder.kinds, k === kindsSeenHere(), k === kindsViaBarrel); +console.log("plain label:", k.label); +console.log("plain direct:", kinds(), kinds(1)); +console.log("plain via value:", k(), k(1), k(1, "b")); +console.log("plain call/apply:", k.call(null), k.apply(null, [1]), k.call(null, 1, 2, 3)); +let padded = ""; +for (let i = 0; i < 3; i++) { + const fn: any = i % 2 === 0 ? kinds : kindsSeenHere(); + padded += fn(i) + ";"; +} +console.log("plain loop:", padded); + +// Default and rest parameters, called directly and through a value. +const wd: any = withDefaults; +console.log("params direct:", withDefaults(), withDefaults(1, undefined, 7, 8)); +console.log("params via value:", wd(), wd(1), wd(1, 2, 3, 4), wd.call(null, "c")); + +// 3. Export clause ahead of the hoisted declaration, plain and aliased. +for (const [label, C, held] of [ + ["hoisted default", Later, laterHolder.Later], + ["hoisted alias", Aliased, aliasedHolder.Aliased], + ["parenthesized", Wrapped, wrappedHolder.Wrapped], +] as [string, any, any][]) { + const o = new C(); + console.log(label + ":", C === held, C.kind, typeof C.prototype.read, o.read(), o instanceof C); +} + +// 4. Forms that already worked (controls). +for (const [label, C, held] of [ + ["alias control", Alias, aliasHolder.Alias], + ["decl control", Decl, declHolder.Decl], + ["fexpr control", Expr, exprHolder.Expr], +] as [string, any, any][]) { + const o = new C(); + console.log(label + ":", C === held, C.kind, typeof C.prototype.read, o.read(), o instanceof C); +} +const K: any = Klass; +console.log("class control:", K === klassHolder.Klass, K.kind, K.extra, new K().read(), new K() instanceof K); +const a: any = arrow; +console.log("arrow control:", a === arrowHolder.arrow, a.kind, a(), a.call(null, 1)); + +// 5. Cyclic imports: each side reads the other's default after evaluation. +console.log("cycle:", describeAlpha(), describeBeta()); + +// 6. A dynamic import's `default` is the same binding as the static import. +async function viaDynamicImport() { + const ctorNs: any = await import("./_helpers/export_default_fn_10434/ctor.ts"); + const plainNs: any = await import("./_helpers/export_default_fn_10434/plain.ts"); + console.log( + "dynamic import:", + ctorNs.default === Point, + ctorNs.default === ctorNs.holder.Point, + plainNs.default === kinds, + plainNs.default.label, + ); +} +viaDynamicImport().then(() => console.log("done")); From f64a627bf307c642290978cc48df1aabe91ef5b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 17 Sep 2026 13:38:45 +0000 Subject: [PATCH 3/4] docs(changelog): add fragment for #10548 --- .../10548-export-default-fn-identity.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 changelog.d/10548-export-default-fn-identity.md diff --git a/changelog.d/10548-export-default-fn-identity.md b/changelog.d/10548-export-default-fn-identity.md new file mode 100644 index 0000000000..dde05f1438 --- /dev/null +++ b/changelog.d/10548-export-default-fn-identity.md @@ -0,0 +1,38 @@ +### 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 }` 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. Separately, + every body listed in `exported_functions` is now marked `is_exported` after + the whole module is lowered. An export clause that comes before its hoisted + declaration (`export default F; function F(){}` or + `export { F as default }; function F(){}`) used to miss the flag, and the + alias form lost identity the same way. + + 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`). From 3b535d21a56df56d7cd0ed076bb2d6a2b54337c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 17 Sep 2026 15:00:28 +0000 Subject: [PATCH 4/4] docs(changelog): separate the hoisting-order case from the wrapper bug --- .../10548-export-default-fn-identity.md | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/changelog.d/10548-export-default-fn-identity.md b/changelog.d/10548-export-default-fn-identity.md index dde05f1438..384ae7c076 100644 --- a/changelog.d/10548-export-default-fn-identity.md +++ b/changelog.d/10548-export-default-fn-identity.md @@ -15,16 +15,22 @@ 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 }` already wrote `{ local: "F" }` and worked. + `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. Separately, - every body listed in `exported_functions` is now marked `is_exported` after - the whole module is lowered. An export clause that comes before its hoisted - declaration (`export default F; function F(){}` or - `export { F as default }; function F(){}`) used to miss the flag, and the - alias form lost identity the same way. + `{ 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