Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions crates/perry-runtime/src/object/class_constructors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
134 changes: 134 additions & 0 deletions crates/perry/tests/issue_5437_hoisted_local_captured_class.rs
Original file line number Diff line number Diff line change
@@ -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");
}
Loading