diff --git a/crates/perry-runtime/src/object/class_constructors.rs b/crates/perry-runtime/src/object/class_constructors.rs index 0ec97202fb..5cbf0fabad 100644 --- a/crates/perry-runtime/src/object/class_constructors.rs +++ b/crates/perry-runtime/src/object/class_constructors.rs @@ -161,12 +161,36 @@ pub extern "C" fn js_class_capture_value(class_id: u32, index: u32) -> f64 { pub extern "C" fn js_class_capture_value_or(class_id: u32, index: u32, fallback: f64) -> f64 { CLASS_CAPTURE_VALUES.with(|m| { match m.borrow().get(&class_id) { - // A snapshot exists for this class: it is authoritative (W6). - Some(v) => v - .get(index as usize) - .copied() - .map(f64::from_bits) - .unwrap_or(f64::from_bits(crate::value::TAG_UNDEFINED)), + // A snapshot exists for this class. The recorded slot is + // authoritative WHEN IT HOLDS A REAL VALUE (W6: the bundle's + // multi-level capture chain can materialize a mis-boxed value into + // the `new`-site appended `fallback`, so the decl-site snapshot of + // a stable require-result must win over it). + // + // #5437 (hoisted-class stale snapshot): the decl-site snapshot is + // taken at the class's DECLARATION position — and because class + // declarations hoist to the top of the enclosing function body, + // that runs BEFORE a captured local assigned LATER in the same body + // (`class f { m(){ return cache } } const cache = a || await foo()` + // — the `RegisterClassCaptures` statement is emitted before the + // captured local's `Let` binding). At that point the captured slot + // is still `undefined` (TDZ), so the snapshot recorded `undefined` + // while the bare-`new f(LocalGet…)` site appended the CORRECT + // post-assignment local. Returning the `undefined` snapshot dropped + // that live value — every method reading the captured local then + // saw `undefined` (e.g. `cache.get(…)` → `Cannot read properties of + // undefined`). + // + // Resolve by SLOT value: an `undefined` snapshot slot carries no + // information, so fall back to the `new`-site appended value; a slot + // holding a real value stays authoritative (keeps W6). An entirely + // absent slot (out-of-range index) also falls back. Same shape as + // the require-derived getSpan fix (no snapshot → fallback), extended + // to the snapshot-present-but-`undefined`-slot case. + Some(v) => match v.get(index as usize).copied() { + Some(bits) if bits != crate::value::TAG_UNDEFINED => f64::from_bits(bits), + _ => fallback, + }, // No snapshot registered: use the `new`-site appended cap value. None => fallback, } diff --git a/crates/perry/tests/issue_5437_hoisted_local_captured_class.rs b/crates/perry/tests/issue_5437_hoisted_local_captured_class.rs new file mode 100644 index 0000000000..d829e23da7 --- /dev/null +++ b/crates/perry/tests/issue_5437_hoisted_local_captured_class.rs @@ -0,0 +1,134 @@ +//! Regression test for #5437 (Next.js ResponseCache `handleGet` wall): a +//! function-nested class that captures an enclosing local which is assigned +//! LATER in the same function body (after the class's hoisted declaration) +//! read `undefined` for that capture inside its methods. +//! +//! The render threw `TypeError: Cannot read properties of undefined (reading +//! 'get')` from `r.incrementalCache.get(...)` in the minified `nh.handleGet`, +//! where `r` is a `class f` instance whose `incrementalCache` field is a +//! CAPTURE of the hoisted `const incrementalCache = … || await +//! getIncrementalCache(…)` local. +//! +//! Root: the W6 / getSpan capture fix makes a bare-identifier +//! `new C(localCaptures...)` fill its synthesized `__perry_cap_*` params from +//! the class's DECL-SITE capture snapshot (`js_class_capture_value_or`), the +//! snapshot being authoritative because the bundle's multi-level capture chain +//! can materialize a mis-boxed value into the appended cap arg. But the +//! snapshot is registered at the class's DECLARATION position, and class +//! declarations hoist to the top of the function body — so the +//! `RegisterClassCaptures` statement runs BEFORE the captured local is +//! assigned (TDZ), recording `undefined`. The `new C` site then appended the +//! CORRECT post-assignment local, but the (undefined) snapshot won and +//! dropped it. +//! +//! Fix: `js_class_capture_value_or` falls back to the `new`-site appended cap +//! value when the snapshot SLOT holds `undefined` (not only when the whole +//! snapshot is absent). A snapshot slot holding a real value stays +//! authoritative (keeps W6); a require-derived class with no snapshot still +//! falls back (keeps getSpan). +//! +//! Pinned by the minimal repro (`w6-repro/classf/cf2.js`): class declared +//! BEFORE the await-assigned local fails on the pre-fix compiler with the +//! exact `.get`-on-undefined error; class declared AFTER (`cf1`/`cf3`) passes. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.js"); + let output = dir.path().join("main_bin"); + std::fs::write(&entry, src).expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output).output().expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).to_string() +} + +/// The failing shape: `class f` is HOISTED above the assignment of the +/// captured local (`incrementalCache`), so its decl-site snapshot records +/// `undefined` while the `new f` site appends the correct post-assignment +/// value. Pre-fix this read `undefined` inside `handleGet` → `.get` on +/// undefined. +#[test] +fn hoisted_class_captures_locally_assigned_local_resolves() { + let out = compile_and_run( + r#" +"use strict"; +async function getIC(n) { return { get: (k) => ({ hit: k, n }) }; } +class RC { + async handleResponse(n) { + // class `f` hoists to the top of the body, ABOVE the assignment below. + class f { + handleGet(e) { return incrementalCache.get(e); } + } + const incrementalCache = (this.ic) || (await getIC(n)); + const r = new f(); + return r.handleGet(n); + } +} +(async () => { + const rc = new RC(); + console.log(JSON.stringify(await rc.handleResponse(1))); + console.log(JSON.stringify(await rc.handleResponse(2))); +})(); +"#, + ); + assert_eq!( + out, "{\"hit\":1,\"n\":1}\n{\"hit\":2,\"n\":2}\n", + "a function-nested class capturing a later-assigned local must read \ + the live value, not the undefined decl-site snapshot — #5437 handleGet" + ); +} + +/// Control: class declared AFTER the captured local is assigned. The snapshot +/// is correct here; must keep working (and did on the pre-fix compiler). +#[test] +fn class_declared_after_local_assignment_still_resolves() { + let out = compile_and_run( + r#" +"use strict"; +async function getIC(n) { return { get: (k) => ({ hit: k, n }) }; } +class RC { + async handleResponse(n) { + const incrementalCache = (this.ic) || (await getIC(n)); + class f { + handleGet(e) { return incrementalCache.get(e); } + } + const r = new f(); + return r.handleGet(n); + } +} +(async () => { + const rc = new RC(); + console.log(JSON.stringify(await rc.handleResponse(7))); +})(); +"#, + ); + assert_eq!(out, "{\"hit\":7,\"n\":7}\n"); +}