From 8905d6bf53d8ebe5b1070ecaf5c42f82f224e9b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 05:05:07 +0200 Subject: [PATCH 1/3] fix(cjs): preserve live getter re-export bindings --- .../10153-cjs-getter-reexport-binding.md | 11 ++ crates/perry-codegen/src/codegen/artifacts.rs | 2 + .../perry-codegen/src/codegen/cjs_exports.rs | 123 ++++++++++++++++++ crates/perry-codegen/src/codegen/mod.rs | 5 + .../src/codegen/module_globals_emit.rs | 24 +++- .../src/codegen/namespace_value_getters.rs | 41 ++++++ .../perry-codegen/src/expr/dyn_extern_i18n.rs | 53 ++++++-- .../src/commands/compile/cjs_wrap/detect.rs | 6 +- .../compile/cjs_wrap/extract_exports.rs | 20 ++- .../src/commands/compile/cjs_wrap/mod.rs | 3 +- .../compile/cjs_wrap/tests/source_graph.rs | 46 +++++++ .../tests/source_graph_export_regressions.rs | 3 + .../issue_10153.rs | 108 +++++++++++++++ test-files/cjs_getter_reexport/index.cjs | 20 +++ test-files/cjs_getter_reexport/transform.cjs | 4 + test-files/test_cjs_getter_reexport.ts | 12 ++ 16 files changed, 459 insertions(+), 22 deletions(-) create mode 100644 changelog.d/10153-cjs-getter-reexport-binding.md create mode 100644 crates/perry-codegen/src/codegen/cjs_exports.rs create mode 100644 crates/perry-codegen/src/codegen/namespace_value_getters.rs create mode 100644 crates/perry/tests/source_graph_export_regressions/issue_10153.rs create mode 100644 test-files/cjs_getter_reexport/index.cjs create mode 100644 test-files/cjs_getter_reexport/transform.cjs create mode 100644 test-files/test_cjs_getter_reexport.ts diff --git a/changelog.d/10153-cjs-getter-reexport-binding.md b/changelog.d/10153-cjs-getter-reexport-binding.md new file mode 100644 index 0000000000..a21c8efda9 --- /dev/null +++ b/changelog.d/10153-cjs-getter-reexport-binding.md @@ -0,0 +1,11 @@ +### Fixed + +- Recognize CommonJS exports installed with `Object.defineProperty`, including + Babel's getter re-exports, as value exports. ESM named and namespace imports + now read the current property through the existing getter-aware runtime + lookup and call the returned value, instead of referencing a function symbol + that the CommonJS barrel never defined. +- Avoid evaluating synthetic CommonJS property exports during initialization, + preserving accessor side effects and exports assigned or replaced later. + Regression coverage includes named imports, namespace calls, ESM barrels, + materialized namespaces, writable descriptors, and late assignments (#10153). diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index fcfd8ec434..60128290d3 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -1955,5 +1955,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { ); progress.checkpoint("string pool and registration initializer"); + super::namespace_value_getters::emit(llmod, module_prefix, cross_module); + Ok(()) } diff --git a/crates/perry-codegen/src/codegen/cjs_exports.rs b/crates/perry-codegen/src/codegen/cjs_exports.rs new file mode 100644 index 0000000000..1451edf820 --- /dev/null +++ b/crates/perry-codegen/src/codegen/cjs_exports.rs @@ -0,0 +1,123 @@ +//! Turn the CJS wrapper's synthetic property exports into live value getters. + +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; + +use perry_hir::{Expr, Module, Stmt}; + +use crate::module::LlModule; +use crate::types::{DOUBLE, I32, I64, PTR}; + +pub(super) type PropertyExports = HashMap; + +/// The wrapper emits `export const x = _cjs.x` to register value-export +/// metadata. Do not execute that read during module initialization: it can +/// invoke a getter too early and freezes exports assigned after initialization. +/// Keep the caller's HIR immutable (it is also the object-cache input). +pub(super) fn prepare(hir: &Module) -> (Cow<'_, Module>, PropertyExports) { + // Pair the wrapper's private binding with its compiler-owned preamble; + // an ordinary ESM variable named `_cjs` still has snapshot semantics. + let has_preamble = hir.imports.iter().any(|import| { + import + .source + .strip_prefix("node:") + .unwrap_or(&import.source) + == "module" + && import.specifiers.iter().any(|specifier| { + matches!(specifier, perry_hir::ImportSpecifier::Named { imported, local } + if imported == "createRequire" && local == "__perry_cjs_create_require") + }) + }); + if !has_preamble { + return (Cow::Borrowed(hir), HashMap::new()); + } + let entry = super::entry_outline::logical_entry_stmts(hir); + let Some(object_id) = entry.iter().find_map(|stmt| match stmt { + Stmt::Let { id, name, .. } if name == "_cjs" => Some(*id), + _ => None, + }) else { + return (Cow::Borrowed(hir), HashMap::new()); + }; + let exported: HashSet<&str> = hir + .exports + .iter() + .filter_map(|export| match export { + perry_hir::Export::Named { local, .. } => Some(local.as_str()), + _ => None, + }) + .collect(); + let mut properties = HashMap::new(); + let mut bindings = HashSet::new(); + for stmt in entry { + if let Stmt::Let { + id, + name, + init: Some(Expr::PropertyGet { + object, property, .. + }), + .. + } = stmt + { + if exported.contains(name.as_str()) + && matches!(object.as_ref(), Expr::LocalGet(id) if *id == object_id) + { + properties.insert(name.clone(), (object_id, property.clone())); + bindings.insert(*id); + } + } + } + if properties.is_empty() { + return (Cow::Borrowed(hir), properties); + } + let mut owned = hir.clone(); + let clear_snapshots = |stmts: &mut [Stmt]| { + for stmt in stmts { + if let Stmt::Let { id, init, .. } = stmt { + if bindings.contains(id) { + *init = None; + } + } + } + }; + clear_snapshots(&mut owned.init); + for function in &mut owned.functions { + if super::entry_outline::is_entry_chunk(function) { + clear_snapshots(&mut function.body); + } + } + (Cow::Owned(owned), properties) +} + +pub(super) fn emit_getter( + llmod: &mut LlModule, + getter_name: &str, + module_prefix: &str, + object_id: u32, + property: &str, +) { + let (key_global, key_len) = llmod.add_string_constant(property); + let getter = llmod.define_function(getter_name, DOUBLE, vec![]); + getter.create_block("entry"); + let blk = getter.block_mut(0).unwrap(); + // Allocate the key before loading the GC-rooted namespace. No raw object + // pointer survives that allocation; the boxed property helper handles + // accessors, prototypes and function-valued module.exports. + let key = blk.call( + I64, + "js_string_from_bytes", + &[ + (PTR, &format!("@{key_global}")), + (I32, &key_len.to_string()), + ], + ); + let object = blk.load( + DOUBLE, + &format!("@perry_global_{module_prefix}__{object_id}"), + ); + let value = blk.call( + DOUBLE, + "js_object_get_field_by_name_boxed", + &[(DOUBLE, &object), (I64, &key)], + ); + blk.ret(DOUBLE, &value); +} diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 2c93833b55..df28e079e1 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -182,6 +182,7 @@ mod artifact_display_names; mod artifact_source_text; mod artifacts; mod boxed_locals; +mod cjs_exports; #[cfg(test)] mod clone_suffix_tests; mod closure; @@ -213,6 +214,7 @@ mod method; mod method_registry; mod method_trampolines; mod module_globals_emit; +pub(crate) mod namespace_value_getters; mod native_namespace_exports; #[cfg(test)] mod number_exactness_tests; @@ -410,6 +412,8 @@ pub fn user_function_symbol(module_name: &str, function_name: &str) -> String { /// guarantee — do not change to `&mut` without also moving the cache /// hash to AFTER codegen. pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> { + let (live_cjs_hir, cjs_property_exports) = cjs_exports::prepare(hir); + let hir = live_cjs_hir.as_ref(); let progress = CompileProgress::new(&hir.name, module_callable_count(hir)); let triple = opts.target.clone().unwrap_or_else(default_target_triple); let fp_flags = crate::block::FpFlags::new(opts.fast_math, opts.fp_contract_mode); @@ -2731,6 +2735,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> &opts.imported_classes, &cross_module.compile_time_constants, &module_prefix, + &cjs_property_exports, ); cross_module.module_global_proven_types = module_global_proven_types; diff --git a/crates/perry-codegen/src/codegen/module_globals_emit.rs b/crates/perry-codegen/src/codegen/module_globals_emit.rs index 3e4c057b18..ee0669c9a4 100644 --- a/crates/perry-codegen/src/codegen/module_globals_emit.rs +++ b/crates/perry-codegen/src/codegen/module_globals_emit.rs @@ -160,6 +160,7 @@ pub(crate) fn emit_module_globals( imported_classes: &[ImportedClass], compile_time_constants: &HashMap, module_prefix: &str, + cjs_property_exports: &super::cjs_exports::PropertyExports, ) -> ModuleGlobals { // Module-level globals registry. Pre-walk: // 1. Collect every LocalId referenced from any function or method @@ -170,6 +171,9 @@ pub(crate) fn emit_module_globals( // as cheap stack alloca (preserves perf for the bench // benchmarks that don't share state with helper functions). let mut referenced_from_fn: std::collections::HashSet = std::collections::HashSet::new(); + // Live CJS value getters read the namespace even after its synthetic + // snapshot initializers have been removed. + referenced_from_fn.extend(cjs_property_exports.values().map(|(id, _)| *id)); // Helper that handles "params + lets define a scope, refs minus // defines flow out". Used for every function/method/closure body. let scan_body = |params: &[perry_hir::Param], @@ -505,11 +509,21 @@ pub(crate) fn emit_module_globals( let getter_name = format!("perry_fn_{}__{}", module_prefix, sanitize(public_name)); if !llmod.has_function(&getter_name) { - let getter = llmod.define_function(&getter_name, DOUBLE, vec![]); - let _ = getter.create_block("entry"); - let blk = getter.block_mut(0).unwrap(); - let val = blk.load(DOUBLE, &format!("@{}", global_name)); - blk.ret(DOUBLE, &val); + if let Some((object_id, property)) = cjs_property_exports.get(name) { + super::cjs_exports::emit_getter( + llmod, + &getter_name, + module_prefix, + *object_id, + property, + ); + } else { + let getter = llmod.define_function(&getter_name, DOUBLE, vec![]); + let _ = getter.create_block("entry"); + let blk = getter.block_mut(0).unwrap(); + let val = blk.load(DOUBLE, &format!("@{}", global_name)); + blk.ret(DOUBLE, &val); + } } // Import-origin metadata preserves the raw exported diff --git a/crates/perry-codegen/src/codegen/namespace_value_getters.rs b/crates/perry-codegen/src/codegen/namespace_value_getters.rs new file mode 100644 index 0000000000..4b5d14dd0d --- /dev/null +++ b/crates/perry-codegen/src/codegen/namespace_value_getters.rs @@ -0,0 +1,41 @@ +//! Closure-ABI adapters for live exports in materialized static namespaces. + +use super::helpers::sanitize_member; +use super::opts::CrossModuleCtx; +use crate::module::LlModule; +use crate::types::{DOUBLE, I64}; + +pub(crate) fn symbol(module_prefix: &str, namespace: &str, member: &str) -> String { + format!( + "__perry_namespace_value_{}__{}", + module_prefix, + sanitize_member(&format!("{namespace}:{member}")) + ) +} + +pub(super) fn emit(llmod: &mut LlModule, module_prefix: &str, imports: &CrossModuleCtx) { + let mut members: Vec<_> = imports.namespace_member_prefixes.iter().collect(); + members.sort_by_key(|(key, _)| *key); + for ((namespace, member), source_prefix) in members { + let wrapper_name = symbol(module_prefix, namespace, member); + // Lowering declares an adapter only when a namespace value actually + // escapes. Direct `ns.x` reads need no additional wrapper. + if !llmod.is_declared(&wrapper_name) || llmod.has_function(&wrapper_name) { + continue; + } + let origin = crate::expr::import_origin_suffix_ns( + &imports.import_function_origin_names, + &imports.namespace_member_origin_names, + namespace, + member, + ); + let getter_name = format!("perry_fn_{source_prefix}__{origin}"); + llmod.declare_function(&getter_name, DOUBLE, &[]); + let wrapper = + llmod.define_function(wrapper_name, DOUBLE, vec![(I64, "%closure".to_string())]); + wrapper.create_block("entry"); + let block = wrapper.block_mut(0).unwrap(); + let value = block.call(DOUBLE, &getter_name, &[]); + block.ret(DOUBLE, &value); + } +} diff --git a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs index f6e9c4edf8..9eda91dd0d 100644 --- a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs +++ b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs @@ -8,7 +8,7 @@ use anyhow::{anyhow, bail, Result}; use perry_hir::types::Type as HirType; use perry_hir::Expr; -use crate::nanbox::{double_literal, POINTER_MASK_I64}; +use crate::nanbox::double_literal; use crate::rooting::{with_rooted_accumulator, with_rooted_group, Arg, Repr}; use crate::types::{DOUBLE, I32, I64, PTR}; @@ -436,9 +436,10 @@ fn materialize_compiled_namespace(ctx: &mut FnCtx<'_>, name: &str) -> Result, name: &str) -> Result bool { // — no `exports.X =`, no `require(`. Without this arm they fall // through to the ESM pipeline, where the bare `exports` identifier // throws a ReferenceError at module init. - || stripped.contains("defineProperty(exports,") + || perry_perex::tooling::Regex::new( + r"(?:^|[^A-Za-z0-9_$.])Object\s*\.\s*defineProperty\s*\(\s*(?:module\s*\.\s*)?exports\s*,", + ) + .unwrap() + .is_match(&stripped) { return true; } diff --git a/crates/perry/src/commands/compile/cjs_wrap/extract_exports.rs b/crates/perry/src/commands/compile/cjs_wrap/extract_exports.rs index bf8faf1689..ccb1dd31d1 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/extract_exports.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/extract_exports.rs @@ -385,7 +385,8 @@ pub fn extract_object_literal_exports_from_require(source: &str) -> Vec<(String, out } -/// Extract named-export patterns from CJS source. Three shapes are matched: +/// Extract named-export patterns from CJS source. These are value bindings, +/// including properties installed by accessors or by later function calls. /// /// 1. `exports.X = ...` and `module.exports.X = ...` — the canonical CJS /// named-export form. Skips `__esModule` (the interop marker injected @@ -463,6 +464,23 @@ pub fn extract_exports_from_source(source: &str) -> Vec { } } + // #10153: Babel emits accessor re-exports with defineProperty rather + // than an assignment. A descriptor's `get` or `value` may hold a function, + // but that is not a function declaration in this module. Surface the name + // through the same value-export path as ordinary exports assignments. + let descriptor_re = perry_perex::tooling::Regex::new( + r#"(?:^|[^A-Za-z0-9_$.])(Object\s*\.\s*defineProperty\s*\(\s*(?:module\s*\.\s*)?exports\s*,\s*['"]([A-Za-z_$][A-Za-z0-9_$]*)['"]\s*,)"#, + ) + .unwrap(); + let stripped = detect::strip_comments_and_strings(source); + for cap in descriptor_re.captures_iter(source) { + let call = cap.get(1).unwrap(); + // Ignore examples embedded in comments and strings. + if stripped[call.start()..].starts_with("Object") { + push_unique(&mut names, cap.get(2).unwrap().as_str()); + } + } + // Shape 2: `module.exports = { ... }` — extract every key from the // object literal body. Brace-balanced scan because the body may contain // nested braces (`module.exports = { fn: function() {} }`). Two key diff --git a/crates/perry/src/commands/compile/cjs_wrap/mod.rs b/crates/perry/src/commands/compile/cjs_wrap/mod.rs index e9ef420884..815617bb51 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/mod.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/mod.rs @@ -21,7 +21,8 @@ //! `_req_N` bindings, runs the original code, and returns //! `module.exports`. The IIFE result is bound to `_cjs`. //! 3. Emit `export default _cjs;` plus `export const X = _cjs.X;` for each -//! detected named export. +//! detected named export. Codegen turns these synthetic property exports +//! into live getters on `_cjs`, without evaluating a snapshot at init. //! //! Two named-export sources are unioned: //! diff --git a/crates/perry/src/commands/compile/cjs_wrap/tests/source_graph.rs b/crates/perry/src/commands/compile/cjs_wrap/tests/source_graph.rs index c11538364d..22c5e81eb4 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/tests/source_graph.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/tests/source_graph.rs @@ -1,5 +1,51 @@ use super::{extract_exports_from_source, wrap_commonjs, PathBuf}; +#[test] +fn detects_whitespace_separated_descriptor_exports() { + let source = "Object . defineProperty (\n exports , 'answer', { value: 42 });"; + assert!(super::is_commonjs(source)); + assert_eq!(extract_exports_from_source(source), ["answer"]); +} + +#[test] +fn descriptor_and_late_exports_are_values_not_declared_functions() { + let source = r#" +Object.defineProperty(exports, "__esModule", { value: true }); +Object.defineProperty(exports, "transform", { + enumerable: true, get: function () { return impl.transform; } +}); +Object.defineProperty(module.exports, 'value', { value: function () { return 42; } }); +exports.update = function () { exports.late = function () { return 43; }; }; +// Object.defineProperty(exports, "comment", { value: 0 }); +var text = 'Object.defineProperty(exports, "text", { value: 0 });'; +other.Object.defineProperty(exports, "other", { value: 0 }); +Object.defineProperty(other.exports, "inner", { value: 0 }); +"#; + let names = extract_exports_from_source(source); + assert_eq!(names, ["update", "late", "transform", "value"]); + let wrapped = wrap_commonjs(source, &PathBuf::from("/tmp/descriptor/index.cjs")); + for name in &names { + assert!(wrapped.contains(&format!("export const {name} = _cjs.{name};"))); + assert!(!wrapped.contains(&format!("export function {name}"))); + } + let ast = perry_parser::parse_typescript(&wrapped, "index.cjs").unwrap(); + let hir = perry_hir::lower_module(&ast, "index", "/tmp/descriptor/index.cjs").unwrap(); + for name in names { + assert!(hir + .exported_objects + .iter() + .any(|exported| exported == &name)); + assert!(!hir + .functions + .iter() + .any(|function| function.is_exported && function.name == name)); + assert!(!hir + .exported_functions + .iter() + .any(|(exported, _)| exported == &name)); + } +} + #[test] fn extracts_esbuild_export_helper_keys() { let src = r#" diff --git a/crates/perry/tests/source_graph_export_regressions.rs b/crates/perry/tests/source_graph_export_regressions.rs index ceade525bc..dc9b42d01d 100644 --- a/crates/perry/tests/source_graph_export_regressions.rs +++ b/crates/perry/tests/source_graph_export_regressions.rs @@ -6,6 +6,9 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::Once; +#[path = "source_graph_export_regressions/issue_10153.rs"] +mod issue_10153; + const GC_ENV_OVERRIDES: &[&str] = &[ "PERRY_GEN_GC", "PERRY_GC_SCAVENGE", diff --git a/crates/perry/tests/source_graph_export_regressions/issue_10153.rs b/crates/perry/tests/source_graph_export_regressions/issue_10153.rs new file mode 100644 index 0000000000..7ede34d837 --- /dev/null +++ b/crates/perry/tests/source_graph_export_regressions/issue_10153.rs @@ -0,0 +1,108 @@ +//! Babel's CommonJS accessor re-exports must remain live value bindings. + +use super::{compile_and_run, write}; + +fn fixtures(dir: &std::path::Path) { + write( + dir, + "index.cjs", + include_str!("../../../../test-files/cjs_getter_reexport/index.cjs"), + ); + write( + dir, + "transform.cjs", + include_str!("../../../../test-files/cjs_getter_reexport/transform.cjs"), + ); +} + +#[test] +fn named_cjs_getter_reexport_is_a_live_value() { + let dir = tempfile::tempdir().unwrap(); + fixtures(dir.path()); + write( + dir.path(), + "main.mjs", + include_str!("../../../../test-files/test_cjs_getter_reexport.ts") + .replace("./cjs_getter_reexport/index.cjs", "./index.cjs") + .as_str(), + ); + assert_eq!( + compile_and_run(dir.path(), "main.mjs"), + "0\nfunction\n2\n3\nundefined\n12\n13\n14\n3\n" + ); +} + +#[test] +fn namespace_cjs_getter_reexport_is_a_live_value() { + let dir = tempfile::tempdir().unwrap(); + fixtures(dir.path()); + write( + dir.path(), + "main.mjs", + "import * as ns from './index.cjs';\n\ + console.log(ns.reads());\n\ + console.log(typeof ns.transform);\n\ + console.log(ns.transform(1));\n\ + console.log(ns.value(2));\n\ + console.log(typeof ns.late);\n\ + ns.update();\n\ + console.log(ns.transform(1));\n\ + console.log(ns.value(2));\n\ + console.log(ns.late(3));\n\ + console.log(ns.reads());\n", + ); + assert_eq!( + compile_and_run(dir.path(), "main.mjs"), + "0\nfunction\n2\n3\nundefined\n12\n13\n14\n3\n" + ); +} + +#[test] +fn cjs_getter_reexport_survives_barrels_and_materialized_namespaces() { + let dir = tempfile::tempdir().unwrap(); + fixtures(dir.path()); + write( + dir.path(), + "barrel.mjs", + "export { transform as renamed, update, reads } from './index.cjs';\n", + ); + write( + dir.path(), + "main.mjs", + "import { renamed as transform, update, reads } from './barrel.mjs';\n\ + import * as ns from './barrel.mjs';\n\ + const materialized = ns;\n\ + console.log(reads());\n\ + console.log(transform(1));\n\ + console.log(materialized.renamed(2));\n\ + update();\n\ + console.log(transform(1));\n\ + console.log(materialized.renamed(2));\n\ + console.log(reads());\n", + ); + assert_eq!( + compile_and_run(dir.path(), "main.mjs"), + "0\n2\n3\n12\n13\n4\n" + ); +} + +#[test] +fn ordinary_esm_property_export_keeps_its_snapshot() { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + "value.mjs", + "const _cjs = { value: 1 };\n\ + export const value = _cjs.value;\n\ + export function update() { _cjs.value = 2; }\n", + ); + write( + dir.path(), + "main.mjs", + "import { value, update } from './value.mjs';\n\ + console.log(value);\n\ + update();\n\ + console.log(value);\n", + ); + assert_eq!(compile_and_run(dir.path(), "main.mjs"), "1\n1\n"); +} diff --git a/test-files/cjs_getter_reexport/index.cjs b/test-files/cjs_getter_reexport/index.cjs new file mode 100644 index 0000000000..6e7159f89f --- /dev/null +++ b/test-files/cjs_getter_reexport/index.cjs @@ -0,0 +1,20 @@ +var _transform = require("./transform.cjs"); +var readCount = 0; +Object.defineProperty(exports, "transform", { + enumerable: true, + get: function () { + readCount++; + return _transform.transform; + } +}); +Object.defineProperty(module.exports, "value", { + enumerable: true, + writable: true, + value: function (value) { return value + 1; } +}); +exports.reads = function () { return readCount; }; +exports.update = function () { + _transform.update(); + exports.value = function (value) { return value + 11; }; + exports.late = function (value) { return value + 11; }; +}; diff --git a/test-files/cjs_getter_reexport/transform.cjs b/test-files/cjs_getter_reexport/transform.cjs new file mode 100644 index 0000000000..b585b51053 --- /dev/null +++ b/test-files/cjs_getter_reexport/transform.cjs @@ -0,0 +1,4 @@ +exports.transform = function (value) { return value + 1; }; +exports.update = function () { + exports.transform = function (value) { return value + 11; }; +}; diff --git a/test-files/test_cjs_getter_reexport.ts b/test-files/test_cjs_getter_reexport.ts new file mode 100644 index 0000000000..8492bdde84 --- /dev/null +++ b/test-files/test_cjs_getter_reexport.ts @@ -0,0 +1,12 @@ +import { transform, value, late, update, reads } from "./cjs_getter_reexport/index.cjs"; + +console.log(reads()); +console.log(typeof transform); +console.log(transform(1)); +console.log(value(2)); +console.log(typeof late); +update(); +console.log(transform(1)); +console.log(value(2)); +console.log(late(3)); +console.log(reads()); From cf3a413796bfb340770bef941dab02ee36c6ca2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 05:11:45 +0200 Subject: [PATCH 2/3] fix(cjs): register live import fixture output --- test-files/test_cjs_getter_reexport.ts | 2 ++ test-parity/expected/test_cjs_getter_reexport.txt | 9 +++++++++ 2 files changed, 11 insertions(+) create mode 100644 test-parity/expected/test_cjs_getter_reexport.txt diff --git a/test-files/test_cjs_getter_reexport.ts b/test-files/test_cjs_getter_reexport.ts index 8492bdde84..351a6f4d66 100644 --- a/test-files/test_cjs_getter_reexport.ts +++ b/test-files/test_cjs_getter_reexport.ts @@ -1,3 +1,5 @@ +// #10153 requires live CJS value reads. The parity runner uses the companion +// test-parity/expected file because Node snapshots CJS named imports. import { transform, value, late, update, reads } from "./cjs_getter_reexport/index.cjs"; console.log(reads()); diff --git a/test-parity/expected/test_cjs_getter_reexport.txt b/test-parity/expected/test_cjs_getter_reexport.txt new file mode 100644 index 0000000000..24e5ae9123 --- /dev/null +++ b/test-parity/expected/test_cjs_getter_reexport.txt @@ -0,0 +1,9 @@ +0 +function +2 +3 +undefined +12 +13 +14 +3 From ec95325a0f86b248164a4f608a3da038b4deb81b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 07:07:19 +0200 Subject: [PATCH 3/3] fix(cjs): cover Babel markers and dynamic namespaces --- .../10153-cjs-getter-reexport-binding.md | 3 ++- .../issue_10153.rs | 18 ++++++++++++++++++ test-files/cjs_getter_reexport/index.cjs | 5 ++--- test-files/cjs_getter_reexport/transform.cjs | 14 ++++++++++++-- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/changelog.d/10153-cjs-getter-reexport-binding.md b/changelog.d/10153-cjs-getter-reexport-binding.md index a21c8efda9..31c304656b 100644 --- a/changelog.d/10153-cjs-getter-reexport-binding.md +++ b/changelog.d/10153-cjs-getter-reexport-binding.md @@ -8,4 +8,5 @@ - Avoid evaluating synthetic CommonJS property exports during initialization, preserving accessor side effects and exports assigned or replaced later. Regression coverage includes named imports, namespace calls, ESM barrels, - materialized namespaces, writable descriptors, and late assignments (#10153). + materialized and dynamic namespaces, writable descriptors, and late + assignments (#10153). diff --git a/crates/perry/tests/source_graph_export_regressions/issue_10153.rs b/crates/perry/tests/source_graph_export_regressions/issue_10153.rs index 7ede34d837..6f4e4b5ae6 100644 --- a/crates/perry/tests/source_graph_export_regressions/issue_10153.rs +++ b/crates/perry/tests/source_graph_export_regressions/issue_10153.rs @@ -86,6 +86,24 @@ fn cjs_getter_reexport_survives_barrels_and_materialized_namespaces() { ); } +#[test] +fn dynamic_cjs_namespace_keeps_getter_reexports_live() { + let dir = tempfile::tempdir().unwrap(); + fixtures(dir.path()); + write( + dir.path(), + "main.mjs", + "const ns = await import('./index.cjs');\n\ + const key = process.argv[2] || 'transform';\n\ + console.log(ns.reads());\n\ + console.log(Reflect.get(ns, key)(1));\n\ + ns.update();\n\ + console.log(Reflect.get(ns, key)(1));\n\ + console.log(ns.reads());\n", + ); + assert_eq!(compile_and_run(dir.path(), "main.mjs"), "0\n2\n12\n2\n"); +} + #[test] fn ordinary_esm_property_export_keeps_its_snapshot() { let dir = tempfile::tempdir().unwrap(); diff --git a/test-files/cjs_getter_reexport/index.cjs b/test-files/cjs_getter_reexport/index.cjs index 6e7159f89f..a20a1d5ee8 100644 --- a/test-files/cjs_getter_reexport/index.cjs +++ b/test-files/cjs_getter_reexport/index.cjs @@ -1,9 +1,8 @@ +Object.defineProperty(exports, "__esModule", { value: true }); var _transform = require("./transform.cjs"); -var readCount = 0; Object.defineProperty(exports, "transform", { enumerable: true, get: function () { - readCount++; return _transform.transform; } }); @@ -12,7 +11,7 @@ Object.defineProperty(module.exports, "value", { writable: true, value: function (value) { return value + 1; } }); -exports.reads = function () { return readCount; }; +exports.reads = function () { return _transform.reads(); }; exports.update = function () { _transform.update(); exports.value = function (value) { return value + 11; }; diff --git a/test-files/cjs_getter_reexport/transform.cjs b/test-files/cjs_getter_reexport/transform.cjs index b585b51053..8a19078fc7 100644 --- a/test-files/cjs_getter_reexport/transform.cjs +++ b/test-files/cjs_getter_reexport/transform.cjs @@ -1,4 +1,14 @@ -exports.transform = function (value) { return value + 1; }; +Object.defineProperty(exports, "__esModule", { value: true }); +var transform = function (value) { return value + 1; }; +var readCount = 0; +Object.defineProperty(exports, "transform", { + enumerable: true, + get: function () { + readCount++; + return transform; + } +}); +exports.reads = function () { return readCount; }; exports.update = function () { - exports.transform = function (value) { return value + 11; }; + transform = function (value) { return value + 11; }; };