diff --git a/changelog.d/8896-class-capture-refresh.md b/changelog.d/8896-class-capture-refresh.md new file mode 100644 index 0000000000..e0a6684cf1 --- /dev/null +++ b/changelog.d/8896-class-capture-refresh.md @@ -0,0 +1,3 @@ +Preserve shared mutable capture cells when refreshing per-evaluation class +expressions, restoring dynamic construction when a fresh class captures an +assigned dependency through its heritage or members. diff --git a/crates/perry-hir/src/lower/shared_mutable_capture.rs b/crates/perry-hir/src/lower/shared_mutable_capture.rs index 0a90e6cac9..429c1c136a 100644 --- a/crates/perry-hir/src/lower/shared_mutable_capture.rs +++ b/crates/perry-hir/src/lower/shared_mutable_capture.rs @@ -967,6 +967,27 @@ fn rewrite_expr(expr: &mut Expr, shared: &HashSet, index_uses: &HashSet // Capture sites snapshot the WHOLE handle (the array). Leave the bare // `LocalGet(id)` capture args alone; still rewrite non-capture children. Expr::RegisterClassCaptures { .. } => return, + // Release follow-up: the end-of-body refresh for a fresh class + // object carries the same capture-param-ordered handles as the initial + // `ClassExprFresh` snapshot. A shared-mutable capture is a one-element + // array cell, so refreshing with `id[0]` replaces the cell with its + // current scalar value. The constructor still treats the refreshed + // slot as a cell and reads `[0]`, producing `undefined`. Preserve bare + // shared-cell handles in `captures`, while still rewriting the owner + // expression and any non-capture children normally. + Expr::RefreshClassExprCaptures { + class_value, + captures, + } => { + rewrite_expr(class_value, shared, index_uses); + for capture in captures.iter_mut() { + if matches!(capture, Expr::LocalGet(id) if index_uses.contains(id)) { + continue; + } + rewrite_expr(capture, shared, index_uses); + } + return; + } // #6497: the per-evaluation fresh-binding path (#6470) carries the // same capture-param-ordered `LocalGet` args as RegisterClassCaptures // — they too must snapshot the WHOLE box handle. Rewriting them to diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 89f113ae34..f81aa8caa8 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -1187,6 +1187,50 @@ fn fresh_class_declaration_collision_keeps_lexical_binding() { })); } +/// A fresh class's end-of-body capture refresh must preserve the whole +/// one-element shared-mutable cell, matching the initial `ClassExprFresh` +/// snapshot. Refreshing with `cell[0]` stores the scalar value, while lifted +/// members still read the constructor capture as `capture[0]`. +#[test] +fn fresh_class_refresh_keeps_shared_capture_cell_handle() { + let source = r#" + const exported = (() => { + let dep; + dep = { default: "ok" }; + const holder = {}; + holder.default = class { + read() { return dep.default; } + }; + return holder.default; + })(); + "#; + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); + let compact: String = format!("{:#?}", hir.init) + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect(); + + let mut remainder = compact.as_str(); + let mut refreshes = 0usize; + while let Some(offset) = remainder.find("RefreshClassExprCaptures{") { + remainder = &remainder[offset + "RefreshClassExprCaptures{".len()..]; + let captures = remainder + .find("captures:[") + .map(|index| &remainder[index + "captures:[".len()..]) + .expect("refresh includes a captures vector"); + assert!( + captures.starts_with("LocalGet("), + "fresh-class refresh must carry the shared cell handle, not an indexed value: {captures}" + ); + refreshes += 1; + } + assert!( + refreshes > 0, + "fixture must emit at least one fresh-class refresh" + ); +} + /// Companion (the case the depth rule must NOT break): a module-scope `class e` /// and a factory-local `let e` holding a different constructor. JS says the /// nearer local wins, so `new e()` inside the factory must still construct the