From 4a5bf19543da8c67ea736c9a4ab0cadf745a31e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 15 Sep 2026 06:54:23 +0200 Subject: [PATCH 1/3] fix(cjs): evaluate conditional requires at the call site A top-level CommonJS require inside a branch, ternary, short-circuit operand, logical assignment, try block, switch case or loop body was hoisted into an eager synthetic import. Its target then initialized before the requiring module's first statement, and even when the branch was never taken; Node runs it only when the require executes. Classify requires with the AST (the brace scanner missed concise arrows, unbraced branches and short-circuit expressions) and route every conditional or function-local specifier through the existing _lazyreq_N deferred path. A specifier with any unconditional occurrence keeps the eager path. Deferred relative targets initialize through the path-module registry, which also covers side-effect-only modules and keeps a throwing require inside its try/catch; deferred class bindings initialize before the class fast path so static fields exist on first use. On OpenCode 1.18.30 this defers 18 require edges in 12 of 940 CJS files (React's prod/dev selector, debug's browser/node selector, isexe, domino). A focused reproducer's skipped dependency no longer runs: 116.4M -> 65.3M instructions, 19.4 -> 14.6 MB peak RSS. --- .../perry-codegen/src/expr/dyn_extern_i18n.rs | 23 +- .../compile/cjs_wrap/deferred_requires.rs | 242 ++++++++++++ .../src/commands/compile/cjs_wrap/mod.rs | 4 +- .../src/commands/compile/cjs_wrap/tests.rs | 5 +- .../src/commands/compile/cjs_wrap/wrap.rs | 24 +- .../perry/tests/conditional_require_init.rs | 347 ++++++++++++++++++ 6 files changed, 621 insertions(+), 24 deletions(-) create mode 100644 crates/perry/src/commands/compile/cjs_wrap/deferred_requires.rs create mode 100644 crates/perry/tests/conditional_require_init.rs diff --git a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs index 109d708c81..3e39a69a05 100644 --- a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs +++ b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs @@ -829,6 +829,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // checks work; calling those values via stored references would // need a separate runtime path that this commit doesn't add. Expr::ExternFuncRef { name, .. } => { + // A synthetic deferred require evaluates its dependency at the + // original call site. Do this before the class/namespace fast + // paths too: those values can depend on module initialization. + if name.starts_with("_lazyreq_") { + if let Some(source_prefix) = ctx.import_function_prefixes.get(name) { + let init_fn = format!("{}__init", source_prefix); + ctx.pending_declares + .push((init_fn.clone(), crate::types::VOID, vec![])); + ctx.block().call_void(&init_fn, &[]); + } + } // Imported class references (refs #420 / drizzle): when `name` // resolves to a class registered in `ctx.class_ids` (populated // from `opts.imported_classes` for imported classes too), emit @@ -881,18 +892,6 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } } if let Some(source_prefix) = ctx.import_function_prefixes.get(name).cloned() { - // Next.js lazy-require: a `_lazyreq_N` binding is the CJS require - // shim's handle to a FUNCTION-LOCAL `require('S')`. S is - // `Deferred` (never eager-initialized), so before reading its - // default-export getter, fire `__init()` — idempotent, so - // re-reads cost a guard check. This is the moment Node would run - // S's module body: when `require('S')` is actually called. - if name.starts_with("_lazyreq_") { - let init_fn = format!("{}__init", source_prefix); - ctx.pending_declares - .push((init_fn.clone(), crate::types::VOID, vec![])); - ctx.block().call_void(&init_fn, &[]); - } // Issue #678 followup: a V8-fallback import used as a value // (rather than called directly) has no native singleton // wrapper to point at — the `__perry_wrap_extern_*` for V8 diff --git a/crates/perry/src/commands/compile/cjs_wrap/deferred_requires.rs b/crates/perry/src/commands/compile/cjs_wrap/deferred_requires.rs new file mode 100644 index 0000000000..ba09dcda59 --- /dev/null +++ b/crates/perry/src/commands/compile/cjs_wrap/deferred_requires.rs @@ -0,0 +1,242 @@ +//! Preserve the evaluation boundary of conditional and function-local requires. + +use std::collections::{HashMap, HashSet}; + +use swc_ecma_ast as ast; +use swc_ecma_visit::{Visit, VisitWith}; + +/// Synthetic imports collect the target, but must not evaluate it before a +/// conditional branch or a function actually calls `require`. A specifier with +/// any unconditional occurrence keeps the existing eager/alias-adoption path. +/// Use the AST: brace scanning misses concise arrows, unbraced branches, and +/// short-circuit expressions. On a parse failure retain the existing scanner's +/// function-local classification (some CJS sources need wrapping to parse). +pub(super) fn deferred_require_specs(source: &str) -> HashSet { + let Ok(module) = perry_parser::parse_typescript(source, "requires.cjs") else { + return super::extract_requires::function_local_specs(source); + }; + let mut visitor = Requires::default(); + module.visit_with(&mut visitor); + visitor + .sites + .into_iter() + .filter_map(|(specifier, deferred)| deferred.then_some(specifier)) + .collect() +} + +#[derive(Default)] +struct Requires { + deferred: bool, + sites: HashMap, +} + +impl Requires { + fn defer(&mut self, visit: impl FnOnce(&mut Self)) { + let previous = self.deferred; + self.deferred = true; + visit(self); + self.deferred = previous; + } +} + +impl Visit for Requires { + fn visit_call_expr(&mut self, call: &ast::CallExpr) { + if let ast::Callee::Expr(callee) = &call.callee { + if matches!(callee.as_ref(), ast::Expr::Ident(name) if name.sym == *"require") { + if let [arg] = call.args.as_slice() { + if arg.spread.is_none() { + if let ast::Expr::Lit(ast::Lit::Str(specifier)) = arg.expr.as_ref() { + self.sites + .entry(specifier.value.to_string_lossy().into_owned()) + .and_modify(|deferred| *deferred &= self.deferred) + .or_insert(self.deferred); + } + } + } + } + } + call.visit_children_with(self); + } + + fn visit_function(&mut self, function: &ast::Function) { + function.decorators.visit_with(self); + self.defer(|visitor| { + function.params.visit_with(visitor); + function.body.visit_with(visitor); + }); + } + + fn visit_arrow_expr(&mut self, arrow: &ast::ArrowExpr) { + self.defer(|visitor| arrow.visit_children_with(visitor)); + } + + fn visit_constructor(&mut self, constructor: &ast::Constructor) { + self.defer(|visitor| constructor.visit_children_with(visitor)); + } + + fn visit_getter_prop(&mut self, getter: &ast::GetterProp) { + getter.key.visit_with(self); + self.defer(|visitor| getter.body.visit_with(visitor)); + } + + fn visit_setter_prop(&mut self, setter: &ast::SetterProp) { + setter.key.visit_with(self); + self.defer(|visitor| setter.body.visit_with(visitor)); + } + + fn visit_if_stmt(&mut self, stmt: &ast::IfStmt) { + stmt.test.visit_with(self); + self.defer(|visitor| { + stmt.cons.visit_with(visitor); + stmt.alt.visit_with(visitor); + }); + } + + fn visit_cond_expr(&mut self, expr: &ast::CondExpr) { + expr.test.visit_with(self); + self.defer(|visitor| { + expr.cons.visit_with(visitor); + expr.alt.visit_with(visitor); + }); + } + + fn visit_bin_expr(&mut self, expr: &ast::BinExpr) { + expr.left.visit_with(self); + if matches!( + expr.op, + ast::BinaryOp::LogicalAnd | ast::BinaryOp::LogicalOr | ast::BinaryOp::NullishCoalescing + ) { + self.defer(|visitor| expr.right.visit_with(visitor)); + } else { + expr.right.visit_with(self); + } + } + + fn visit_try_stmt(&mut self, stmt: &ast::TryStmt) { + // In particular, a throwing require must stay inside its try/catch. + self.defer(|visitor| stmt.visit_children_with(visitor)); + } + + fn visit_assign_expr(&mut self, expr: &ast::AssignExpr) { + expr.left.visit_with(self); + if matches!( + expr.op, + ast::AssignOp::AndAssign | ast::AssignOp::OrAssign | ast::AssignOp::NullishAssign + ) { + self.defer(|visitor| expr.right.visit_with(visitor)); + } else { + expr.right.visit_with(self); + } + } + + fn visit_switch_stmt(&mut self, stmt: &ast::SwitchStmt) { + stmt.discriminant.visit_with(self); + self.defer(|visitor| stmt.cases.visit_with(visitor)); + } + + fn visit_while_stmt(&mut self, stmt: &ast::WhileStmt) { + stmt.test.visit_with(self); + self.defer(|visitor| stmt.body.visit_with(visitor)); + } + + fn visit_for_stmt(&mut self, stmt: &ast::ForStmt) { + stmt.init.visit_with(self); + stmt.test.visit_with(self); + self.defer(|visitor| { + stmt.update.visit_with(visitor); + stmt.body.visit_with(visitor); + }); + } + + fn visit_for_in_stmt(&mut self, stmt: &ast::ForInStmt) { + stmt.right.visit_with(self); + self.defer(|visitor| { + stmt.left.visit_with(visitor); + stmt.body.visit_with(visitor); + }); + } + + fn visit_for_of_stmt(&mut self, stmt: &ast::ForOfStmt) { + stmt.right.visit_with(self); + self.defer(|visitor| { + stmt.left.visit_with(visitor); + stmt.body.visit_with(visitor); + }); + } +} + +#[cfg(test)] +mod tests { + use super::deferred_require_specs; + + #[test] + fn preserves_conditional_and_function_evaluation_boundaries() { + for source in [ + "if (enabled) require('dep');", + "if (enabled) { const dep = require('dep'); }", + "enabled ? require('dep') : 0;", + "enabled && require('dep');", + "enabled || require('dep');", + "enabled ?? require('dep');", + "value &&= require('dep');", + "value ||= require('dep');", + "value ??= require('dep');", + "try { require('dep'); } catch (e) {}", + "switch (value) { case 1: require('dep'); }", + "while (enabled) require('dep');", + "for (; enabled;) require('dep');", + "for (const item of items) require('dep');", + "for (const key in object) require('dep');", + "module.exports = () => require('dep');", + "function load(dep = require('dep',)) { return dep; }", + "module.exports = {get value() { return require('dep'); }};", + ] { + assert!(deferred_require_specs(source).contains("dep"), "{source}"); + } + } + + #[test] + fn unconditional_occurrences_keep_existing_eager_classification() { + for source in [ + "const dep = require('dep');", + "if (require('dep')) {}", + "require('dep') && enabled;", + "const x = require('dep') + 1;", + "value = require('dep');", + "{ require('dep'); }", + "if (enabled) require('dep'); require('dep');", + "require('dep'); module.exports = () => require('dep');", + ] { + assert!(!deferred_require_specs(source).contains("dep"), "{source}"); + } + } + + #[test] + fn ignores_comments_strings_and_member_calls() { + assert!(deferred_require_specs( + "// require('dep')\nconst text = \"require('dep')\";\nif (enabled) other.require('dep');" + ).is_empty()); + } + + #[test] + fn wrapping_keeps_conditional_aliases_and_exports_inside_the_body() { + let source = "class Unrelated {}\nif (enabled) {\nconst dep = require('dep');\nexports.value = require('dep');\nconsole.log(dep);\n}\n"; + let wrapped = + super::super::wrap::wrap_commonjs(source, std::path::Path::new("/fixture/index.cjs")); + assert!( + wrapped.contains("import _lazyreq_0 from 'dep';"), + "{wrapped}" + ); + assert!(wrapped.contains("const dep = require('dep');"), "{wrapped}"); + assert!(!wrapped.contains("const dep = _lazyreq_0;"), "{wrapped}"); + assert!( + wrapped.contains("export const value = _cjs.value;"), + "{wrapped}" + ); + assert!( + !wrapped.contains("export { _lazyreq_0 as value };"), + "{wrapped}" + ); + perry_parser::parse_typescript(&wrapped, "wrapped.cjs").unwrap(); + } +} diff --git a/crates/perry/src/commands/compile/cjs_wrap/mod.rs b/crates/perry/src/commands/compile/cjs_wrap/mod.rs index 815617bb51..2cf0d56302 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/mod.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/mod.rs @@ -38,6 +38,7 @@ //! switching; deeper indirection is rare and gets the no-op fallback. pub(crate) mod detect; +mod deferred_requires; mod extract_exports; mod extract_requires; mod hoist_classes; @@ -52,6 +53,7 @@ mod preamble_canary_tests; // Cross-sibling helpers — siblings reach for these via `use super::*;`. use detect::is_js_reserved_word; +use deferred_requires::deferred_require_specs; use extract_exports::{ extract_exports_from_source, extract_named_exports_from_require, extract_object_literal_exports_from_require, extract_single_module_exports_assignment, @@ -60,7 +62,7 @@ use extract_exports::{ // #8547: the stdlib-link decision needs the literal `require()` specifiers. pub(crate) use extract_requires::extract_require_specifiers; use extract_requires::{ - extract_export_star_specs, extract_require_aliases_with_ranges, function_local_specs, + extract_export_star_specs, extract_require_aliases_with_ranges, identifier_is_declared_binding, identifier_is_reassigned, }; use hoist_classes::{ diff --git a/crates/perry/src/commands/compile/cjs_wrap/tests.rs b/crates/perry/src/commands/compile/cjs_wrap/tests.rs index 3317e32298..8a03dafef4 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/tests.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/tests.rs @@ -747,9 +747,8 @@ exports.spawn = function spawn() { return terminalCtor; }; Some("windows"), ); assert!( - wrapped.contains("import _req_0 from './windowsTerminal';") - || wrapped.contains("import terminalCtor from './windowsTerminal';"), - "expected live Windows require to stay hoisted, got:\n{}", + wrapped.contains("import _lazyreq_0 from './windowsTerminal';"), + "expected live Windows require to remain collected and initialize in its branch, got:\n{}", wrapped ); assert!( diff --git a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs index 40f74665a2..9cab3767c3 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs @@ -302,15 +302,15 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( } true }; - // Next.js lazy-require: specifiers whose every `require('S')` call site is - // inside a function body (lazy in Node). Computed up front because it also + // Specifiers whose every `require('S')` call site is conditional or inside + // a function body. Computed up front because it also // suppresses alias ADOPTION below — a function-local `const dep = // require('S')` is a function-scoped const, not a module binding, and // adopting it would hoist `import dep from 'S'` to module scope (eager). We // instead keep the synthetic binding and rename it `_lazyreq_N` so the // target stays `Deferred` and inits only when the shim's // `return _lazyreq_N` runs (i.e. when the function actually calls require). - let mut lazy_specs = function_local_specs(source); + let mut lazy_specs = deferred_require_specs(source); let cyclic_specs = cyclic_require_specs(source, source_path); let parent_sensitive_specs = parent_sensitive_require_specs(source, source_path); lazy_specs.extend(cyclic_specs.iter().cloned()); @@ -457,8 +457,11 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( ) }) .unwrap_or_default(); - let needs_runtime_record = - cyclic_specs.contains(spec) || parent_sensitive_specs.contains(spec); + // Deferred targets must initialize even when they have no default + // export getter (for example, a side-effect-only module). The path + // registry owns initialization and cached exports independently of + // the target's export shape, and preserves thrown exceptions here. + let needs_runtime_record = lazy_specs.contains(spec); let runtime_require = if needs_runtime_record { resolved_target .as_ref() @@ -507,7 +510,7 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( // `typeof {local} === 'boolean'` sentinel guard does not apply // (builtins are never the pruned-build TRUE sentinel). format!(" if (specifier === '{spec}') {{ {required_value} }}") - } else if require_site_in_try(source, spec) { + } else if require_site_in_try(source, spec) && runtime_require.is_none() { format!( " if (specifier === '{spec}') {{ if (typeof {local} === 'boolean') \ throw __perry_cjs_require_error('error', 'MODULE_NOT_FOUND', \ @@ -724,7 +727,7 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( .iter() .filter_map(|(name, spec)| { let n = require_specs.iter().position(|s| s == spec)?; - if builtin_requires.contains(spec) { + if builtin_requires.contains(spec) || lazy_specs.contains(spec) { // #8343 followup: built-in specs no longer hoist a static // `import _req_N` (the codegen doesn't initialize // native-module import bindings in CJS-wrapped modules), @@ -733,7 +736,10 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( // `exports.name = require("")` resolves through // the synthetic require's `createRequire` arm and populates // `_cjs.name`, so back the re-export with that — the same - // surface `named_export_decls` uses below. + // surface `named_export_decls` uses below. Conditional + // requires also need the actual CJS property: forwarding + // their import binding would bypass the branch and expose + // a dependency that the module never required. Some(format!("export const {name} = _cjs.{name};")) } else { Some(format!( @@ -830,6 +836,7 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( // from the blanking filter below) and resolves through the synthetic // require's `createRequire` arm at runtime. .filter(|(_, spec, _)| !builtin_requires.contains(spec)) + .filter(|(_, spec, _)| !lazy_specs.contains(spec)) .filter_map(|(alias, spec, _range)| { let idx = require_specs.iter().position(|s| s == spec)?; // When the alias is already the spec's import local name @@ -848,6 +855,7 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( let ranges = aliases .into_iter() .filter(|(_, spec, _)| require_specs.iter().any(|s| s == spec)) + .filter(|(_, spec, _)| !lazy_specs.contains(spec)) .filter(|(alias, _, _)| !identifier_is_reassigned(source, alias)) // #sdxgen: Don't blank alias declarations for Node.js built-in // modules — let them stay in the IIFE body and resolve through diff --git a/crates/perry/tests/conditional_require_init.rs b/crates/perry/tests/conditional_require_init.rs new file mode 100644 index 0000000000..4186aa24a4 --- /dev/null +++ b/crates/perry/tests/conditional_require_init.rs @@ -0,0 +1,347 @@ +//! Conditional CommonJS dependencies must run at the require call site. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn compile(root: &Path, entry: &str) -> PathBuf { + let compiler = PathBuf::from(env!("CARGO_BIN_EXE_perry")); + let runtime = std::env::var_os("PERRY_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| compiler.parent().unwrap().to_owned()); + let output = root.join("native"); + let result = Command::new(compiler) + .current_dir(root) + .args(["compile", entry, "--no-cache", "--no-auto-optimize"]) + .arg("-o") + .arg(&output) + .env("PERRY_RUNTIME_DIR", runtime) + .output() + .expect("compile fixture"); + assert!( + result.status.success(), + "{}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + output +} + +fn run(binary: &Path, args: &[&str]) -> String { + let result = Command::new(binary) + .args(args) + .output() + .expect("run fixture"); + assert!( + result.status.success(), + "{}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + String::from_utf8(result.stdout).unwrap() +} + +#[test] +fn conditional_require_defers_the_transitive_graph_and_initializes_once() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write( + root.join("leaf.cjs"), + "console.log('leaf'); module.exports = 42;", + ) + .unwrap(); + std::fs::write(root.join("dep.cjs"), + "const leaf = require('./leaf.cjs');\nconsole.log('dependency');\nmodule.exports = {value: leaf};").unwrap(); + std::fs::write( + root.join("entry.cjs"), + r#" +console.log('entry'); +if (process.argv.includes('--load')) { + const first = require('./dep.cjs'); + const second = require('./dep.cjs'); + console.log(first.value, first === second); +} +console.log('done'); +"#, + ) + .unwrap(); + let binary = compile(root, "entry.cjs"); + assert_eq!(run(&binary, &[]), "entry\ndone\n"); + assert_eq!( + run(&binary, &["--load"]), + "entry\nleaf\ndependency\n42 true\ndone\n" + ); +} + +#[test] +fn concise_arrow_and_short_circuit_require_stay_lazy() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write( + root.join("dep.cjs"), + "console.log('dependency'); module.exports = {value: 42};", + ) + .unwrap(); + std::fs::write( + root.join("entry.cjs"), + r#" +const load = () => require('./dep.cjs'); +console.log('entry'); +process.argv.includes('--load') && console.log(load().value); +console.log('done'); +"#, + ) + .unwrap(); + let binary = compile(root, "entry.cjs"); + assert_eq!(run(&binary, &[]), "entry\ndone\n"); + assert_eq!(run(&binary, &["--load"]), "entry\ndependency\n42\ndone\n"); +} + +#[test] +fn require_exception_is_caught_at_its_original_try_boundary() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write( + root.join("dep.cjs"), + "console.log('dependency'); throw new Error('fixture');", + ) + .unwrap(); + std::fs::write( + root.join("entry.cjs"), + r#" +console.log('entry'); +try { require('./dep.cjs'); } +catch (error) { console.log('caught', error.message); } +console.log('done'); +"#, + ) + .unwrap(); + let binary = compile(root, "entry.cjs"); + assert_eq!( + run(&binary, &[]), + "entry\ndependency\ncaught fixture\ndone\n" + ); +} + +#[test] +fn static_import_still_evaluates_before_the_entry_body() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write( + root.join("dep.mjs"), + "console.log('static'); export const value = 42;", + ) + .unwrap(); + std::fs::write( + root.join("lazy.mjs"), + "console.log('unexpected dynamic init'); export const value = 0;", + ) + .unwrap(); + std::fs::write( + root.join("entry.mjs"), + r#" +import { value } from './dep.mjs'; +export function unused() { return import('./lazy.mjs'); } +console.log('entry', value); +"#, + ) + .unwrap(); + let binary = compile(root, "entry.mjs"); + assert_eq!(run(&binary, &[]), "static\nentry 42\n"); +} + +#[test] +fn conditional_class_require_initializes_static_state() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write( + root.join("dep.cjs"), + "console.log('dependency');\nclass Value { static answer = 42; }\nmodule.exports = Value;", + ) + .unwrap(); + std::fs::write( + root.join("entry.cjs"), + r#" +class Unrelated {} +console.log('entry'); +if (process.argv.includes('--load')) { + const Value = require('./dep.cjs'); + console.log(Value.answer); +} +console.log('done'); +"#, + ) + .unwrap(); + let binary = compile(root, "entry.cjs"); + assert_eq!(run(&binary, &[]), "entry\ndone\n"); + assert_eq!(run(&binary, &["--load"]), "entry\ndependency\n42\ndone\n"); +} + +#[test] +fn conditional_named_export_does_not_forward_an_unloaded_dependency() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write( + root.join("dep.cjs"), + "console.log('dependency'); module.exports = {value: 42};", + ) + .unwrap(); + std::fs::write( + root.join("wrapper.cjs"), + r#" +console.log('wrapper'); +if (process.argv.includes('--load')) exports.optional = require('./dep.cjs'); +"#, + ) + .unwrap(); + std::fs::write( + root.join("entry.mjs"), + r#" +import { optional } from './wrapper.cjs'; +console.log('entry', optional === undefined ? 'absent' : optional.value); +"#, + ) + .unwrap(); + let binary = compile(root, "entry.mjs"); + assert_eq!(run(&binary, &[]), "wrapper\nentry absent\n"); + assert_eq!(run(&binary, &["--load"]), "wrapper\ndependency\nentry 42\n"); +} + +#[test] +fn conditional_side_effect_only_require_runs_once_when_reached() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("dep.cjs"), "console.log('dependency');").unwrap(); + std::fs::write( + root.join("entry.cjs"), + r#" +console.log('entry'); +if (process.argv.includes('--load')) { + require('./dep.cjs'); + require('./dep.cjs'); +} +console.log('done'); +"#, + ) + .unwrap(); + let binary = compile(root, "entry.cjs"); + assert_eq!(run(&binary, &[]), "entry\ndone\n"); + assert_eq!(run(&binary, &["--load"]), "entry\ndependency\ndone\n"); +} + +#[test] +fn esm_function_local_class_require_initializes_static_state() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write( + root.join("dep.cjs"), + "console.log('dependency');\nclass Value { static answer = 42; }\nmodule.exports = Value;", + ) + .unwrap(); + std::fs::write( + root.join("entry.mjs"), + r#" +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const load = () => require('./dep.cjs'); +console.log('entry'); +if (process.argv.includes('--load')) console.log(load().answer); +console.log('done'); +"#, + ) + .unwrap(); + let binary = compile(root, "entry.mjs"); + assert_eq!(run(&binary, &[]), "entry\ndone\n"); + assert_eq!(run(&binary, &["--load"]), "entry\ndependency\n42\ndone\n"); +} + +/// A deferred target in a require cycle must still see its partner's exports +/// assigned at run time: the partner reads the target's partial exports object +/// during the cycle and the value only exists after the target's body ends. +#[test] +fn conditional_require_cycle_partner_sees_runtime_assigned_exports() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write( + root.join("a.cjs"), + r#" +console.log('a start'); +exports.value = undefined; +const b = require('./b.cjs'); +exports.value = () => 'a-runtime'; +exports.read = () => b.callA(); +console.log('a end'); +"#, + ) + .unwrap(); + std::fs::write( + root.join("b.cjs"), + r#" +console.log('b start', typeof require('./a.cjs').value); +const a = require('./a.cjs'); +exports.callA = () => a.value(); +"#, + ) + .unwrap(); + std::fs::write( + root.join("entry.cjs"), + r#" +console.log('entry'); +if (process.argv.includes('--load')) { + const a = require('./a.cjs'); + console.log(a.read(), typeof a.value); +} +console.log('done'); +"#, + ) + .unwrap(); + let binary = compile(root, "entry.cjs"); + assert_eq!(run(&binary, &[]), "entry\ndone\n"); + assert_eq!( + run(&binary, &["--load"]), + "entry\na start\nb start undefined\na end\na-runtime function\ndone\n" + ); +} + +/// The same cycle shape reached from ESM through `createRequire`, next to an +/// ESM partner whose export is assigned (not declared) at module run time. +#[test] +fn esm_conditional_require_cycle_keeps_runtime_assigned_bindings() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write( + root.join("partner.mjs"), + "export let handler;\nhandler = () => 'partner-runtime';\nexport function readLater() { return typeof handler; }\n", + ) + .unwrap(); + std::fs::write( + root.join("dep.cjs"), + "console.log('dep');\nconst cyc = require('./cycle.cjs');\nmodule.exports = { run: () => cyc.call() };\n", + ) + .unwrap(); + std::fs::write( + root.join("cycle.cjs"), + "const dep = require('./dep.cjs');\nlet fn;\nfn = () => 'cycle-runtime:' + typeof dep;\nexports.call = () => fn();\n", + ) + .unwrap(); + std::fs::write( + root.join("entry.mjs"), + r#" +import { createRequire } from 'node:module'; +import { readLater } from './partner.mjs'; +const require = createRequire(import.meta.url); +console.log('entry'); +if (process.argv.includes('--load')) { + const dep = require('./dep.cjs'); + console.log(dep.run(), readLater()); +} +console.log('done'); +"#, + ) + .unwrap(); + let binary = compile(root, "entry.mjs"); + assert_eq!(run(&binary, &[]), "entry\ndone\n"); + assert_eq!( + run(&binary, &["--load"]), + "entry\ndep\ncycle-runtime:object function\ndone\n" + ); +} From 76c6d2b870ccadf1bf5a8932e2cd77b266b09992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 15 Sep 2026 07:06:39 +0200 Subject: [PATCH 2/3] changelog: key conditional-require fragment to PR 10285 --- changelog.d/10285-conditional-require-call-site.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog.d/10285-conditional-require-call-site.md diff --git a/changelog.d/10285-conditional-require-call-site.md b/changelog.d/10285-conditional-require-call-site.md new file mode 100644 index 0000000000..de5996bf2c --- /dev/null +++ b/changelog.d/10285-conditional-require-call-site.md @@ -0,0 +1,8 @@ +Fixed CommonJS `require()` calls inside branches, ternaries, short-circuit +operands, logical assignments, `try` blocks, `switch` cases and loop bodies +being hoisted into eager imports. The required module now initializes when the +`require` executes, as in Node, instead of before the requiring module's first +statement — and not at all when the branch is never taken. Deferred targets +initialize through the path-module registry, so side-effect-only modules run, +a throwing `require` stays inside its `try`/`catch`, and deferred classes get +their static fields. From 5d69f408114583b2212b5fee16277f04076cc985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 15 Sep 2026 11:56:29 +0200 Subject: [PATCH 3/3] fix(cjs): defer a require in either half of a do-while MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `do { break } while (require('./dep'))` never evaluates the require in Node: the body can break or return before the test runs. The visitor had no `visit_do_while_stmt`, so both halves classified eager and the wrapper initialized the dependency before the module body — running its side effects, and paying its startup cost, where Node runs nothing. Defer both halves, and pin the shape with a unit case and a native test whose expectation was taken from Node 26.5.1. --- .../compile/cjs_wrap/deferred_requires.rs | 12 ++++++++ .../perry/tests/conditional_require_init.rs | 30 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/crates/perry/src/commands/compile/cjs_wrap/deferred_requires.rs b/crates/perry/src/commands/compile/cjs_wrap/deferred_requires.rs index ba09dcda59..31c8c5ddf6 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/deferred_requires.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/deferred_requires.rs @@ -139,6 +139,16 @@ impl Visit for Requires { self.defer(|visitor| stmt.body.visit_with(visitor)); } + fn visit_do_while_stmt(&mut self, stmt: &ast::DoWhileStmt) { + // Both halves are conditional: the body can `break` or `return` before + // the test runs, so `do { break } while (require("dep"))` never + // evaluates the require in Node. + self.defer(|visitor| { + stmt.body.visit_with(visitor); + stmt.test.visit_with(visitor); + }); + } + fn visit_for_stmt(&mut self, stmt: &ast::ForStmt) { stmt.init.visit_with(self); stmt.test.visit_with(self); @@ -184,6 +194,8 @@ mod tests { "try { require('dep'); } catch (e) {}", "switch (value) { case 1: require('dep'); }", "while (enabled) require('dep');", + "do { break; } while (require('dep'));", + "do { require('dep'); } while (enabled);", "for (; enabled;) require('dep');", "for (const item of items) require('dep');", "for (const key in object) require('dep');", diff --git a/crates/perry/tests/conditional_require_init.rs b/crates/perry/tests/conditional_require_init.rs index 4186aa24a4..13f1fc03ae 100644 --- a/crates/perry/tests/conditional_require_init.rs +++ b/crates/perry/tests/conditional_require_init.rs @@ -345,3 +345,33 @@ console.log('done'); "entry\ndep\ncycle-runtime:object function\ndone\n" ); } + +/// A `do…while` test runs only if the body falls through, so a `require` in +/// either half is conditional. Node never evaluates the dependency below. +#[test] +fn do_while_require_stays_at_its_call_site() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("dep.cjs"), "console.log('dependency');\nmodule.exports = 7;").unwrap(); + std::fs::write( + root.join("entry.cjs"), + r#" +console.log('entry'); +if (!process.argv.includes('--load')) { + do { break; } while (require('./dep.cjs')); +} else { + let seen = 0; + do { seen += require('./dep.cjs'); } while (false); + console.log('sum', seen); +} +console.log('done'); +"#, + ) + .unwrap(); + let binary = compile(root, "entry.cjs"); + assert_eq!(run(&binary, &[]), "entry\ndone\n"); + assert_eq!( + run(&binary, &["--load"]), + "entry\ndependency\nsum 7\ndone\n" + ); +}