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. 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..31c8c5ddf6 --- /dev/null +++ b/crates/perry/src/commands/compile/cjs_wrap/deferred_requires.rs @@ -0,0 +1,254 @@ +//! 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_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); + 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');", + "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');", + "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..13f1fc03ae --- /dev/null +++ b/crates/perry/tests/conditional_require_init.rs @@ -0,0 +1,377 @@ +//! 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" + ); +} + +/// 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" + ); +}