From cf8209ffc07a5a5ad0c3a82dd35ddc368c30a9f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 21 Sep 2026 11:16:01 +0200 Subject: [PATCH] fix(compile): retain classes using multiline var bindings --- changelog.d/10660-cjs-multiline-var-hoist.md | 1 + .../compile/cjs_wrap/hoist_classes.rs | 13 +++++++-- .../compile/cjs_wrap/tests/hoist_scanner.rs | 28 +++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 changelog.d/10660-cjs-multiline-var-hoist.md diff --git a/changelog.d/10660-cjs-multiline-var-hoist.md b/changelog.d/10660-cjs-multiline-var-hoist.md new file mode 100644 index 0000000000..e62ecb1be0 --- /dev/null +++ b/changelog.d/10660-cjs-multiline-var-hoist.md @@ -0,0 +1 @@ +- Fixed CommonJS class hoisting when a comma-separated `var` declaration continues across lines. Classes such as Redis's `RedisClient` now remain inside their module factory when they capture private-field helper variables declared on later lines, preserving the initialized helper bindings at runtime. (#10660) diff --git a/crates/perry/src/commands/compile/cjs_wrap/hoist_classes.rs b/crates/perry/src/commands/compile/cjs_wrap/hoist_classes.rs index a746bf5254..05a765e71c 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/hoist_classes.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/hoist_classes.rs @@ -808,9 +808,10 @@ fn collect_top_level_let_const_var_names(source: &str) -> Vec { continue; }; let mut q = p + kw_len; - // Walk through one or more comma-separated declarators on the same - // logical line. Stop at `=` (initializer), `;`, or the end of line - // (semicolon optional in JS). + // Walk through one or more comma-separated declarators. A comma may + // continue the declaration onto another physical line (as emitted by + // TypeScript for long lists of private-field helpers). Otherwise a + // top-level newline ends an ASI-terminated declaration. loop { while q < bytes.len() && (bytes[q] == b' ' || bytes[q] == b'\t') { q += 1; @@ -874,6 +875,12 @@ fn collect_top_level_let_const_var_names(source: &str) -> Vec { } b',' if inner == 0 => { q += 1; + // A top-level comma proves that another declarator + // follows, so newlines here are continuation + // whitespace rather than ASI boundaries. + while q < bytes.len() && bytes[q].is_ascii_whitespace() { + q += 1; + } break; } b';' | b'\n' if inner == 0 => { diff --git a/crates/perry/src/commands/compile/cjs_wrap/tests/hoist_scanner.rs b/crates/perry/src/commands/compile/cjs_wrap/tests/hoist_scanner.rs index 169b82c9bd..7bf6e63999 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/tests/hoist_scanner.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/tests/hoist_scanner.rs @@ -29,3 +29,31 @@ module.exports = { ChecksumStream }; "the class declaration must remain at its source position inside the factory" ); } + +#[test] +fn multiline_var_declarator_keeps_dependent_class_in_cjs_iife() { + // @redis/client declares its transpiled private-field helpers as one long + // comma-separated `var` statement. The later helpers start on new lines; + // missing them here hoists RedisClient out of the CommonJS factory and + // severs the class methods' captures of those helper functions. + let src = r#"var _RedisClient_instances, _a, + _RedisClient_options, + _RedisClient_isolationPool, + _RedisClient_init; +class RedisClient { + connect() { return _RedisClient_isolationPool(this); } +} +_RedisClient_isolationPool = function _RedisClient_isolationPool() {}; +module.exports = { RedisClient }; +"#; + + let (blocks, hoisted_names, rest) = extract_top_level_class_decls(src); + assert!( + !hoisted_names.iter().any(|name| name == "RedisClient"), + "a class depending on a continued CJS-local declaration must not hoist; hoisted block:\n{blocks}" + ); + assert!( + rest.contains("class RedisClient"), + "the dependent class must remain inside the CommonJS factory" + ); +}