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
3 changes: 3 additions & 0 deletions changelog.d/9106-for-head-counter-keeps-init-slot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Fixed

- Classic `for` heads shaped `for (let i = 0, len = arr.length; i < len; i++)` regained their versioned packed-loop fast clones: when hoisting the tail declarators around a literal-initialized counter is provably unobservable, the counter stays in the loop's own init slot the counted-loop matchers key on, while order-observable heads keep the #9062 source-order lowering.
55 changes: 55 additions & 0 deletions crates/perry-hir/src/lower/for_multi_decl_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,58 @@ fn function_classic_for_lexical_declarators_lower_in_source_order() {
.expect("run should lower");
assert_source_ordered_prelude(&function.body);
}

#[test]
fn safe_counter_head_keeps_the_for_init_slot() {
// #9106: `let i = 0, len = arr.length` — a literal-initialized counter
// that no tail declarator mentions. Hoisting the tail around it is
// unobservable, so the counter stays in `For::init` (the versioned
// counted-loop matchers key their admission on it) and only the tail
// moves to the loop-scoped prelude.
let hir = lower(
"function run(arr: number[]) { let sum = 0; for (let i = 0, len = arr.length; i < len; i++) { sum += arr[i]; } return sum; }",
);
let function = hir
.functions
.iter()
.find(|function| function.name == "run")
.expect("run should lower");
let for_index = function
.body
.iter()
.position(|stmt| matches!(stmt, Stmt::For { .. }))
.expect("classic for should be present");
let len_id = function.body[..for_index]
.iter()
.find_map(|stmt| match stmt {
Stmt::Let { id, name, .. } if name == "len" => Some(*id),
_ => None,
})
.expect("tail binding should be hoisted into the prelude");
assert!(
hir.classic_for_lexical_bindings.contains(&len_id),
"hoisted tail binding keeps per-iteration capture semantics"
);
let crate::ir::Stmt::For {
init: Some(init), ..
} = &function.body[for_index]
else {
panic!(
"counter must stay in For::init: {:#?}",
function.body[for_index]
);
};
let counter_id = match init.as_ref() {
Stmt::Let {
id,
name,
init: Some(crate::ir::Expr::Integer(0)),
..
} if name == "i" => *id,
other => panic!("counter Let expected in For::init, got {other:#?}"),
};
assert!(
!hir.classic_for_lexical_bindings.contains(&counter_id),
"the init-slot counter is handled by the For machinery, not the prelude set"
);
}
25 changes: 17 additions & 8 deletions crates/perry-hir/src/lower/stmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1575,13 +1575,22 @@ pub(crate) fn lower_stmt(
// head into the loop-scoped prelude in source order,
// registering each binding before the next
// initializer is lowered.
let has_multiple_decls = var_decl.decls.len() > 1;
for decl in
var_decl
.decls
.iter()
.skip(if has_multiple_decls { 0 } else { 1 })
{
//
// #9106 carve-out: when hoisting the tail around a
// literal-initialized first declarator is provably
// unobservable, keep that declarator in `For::init`
// — the versioned counted-loop matchers key their
// counter on it (`for (let i = 0, len = arr.length;
// i < len; i++)`).
let first_decl_keeps_init_slot = var_decl.decls.len() == 1
|| crate::lower_decl::for_head_first_decl_keeps_init_slot(
&var_decl.decls,
);
for decl in var_decl.decls.iter().skip(if first_decl_keeps_init_slot {
1
} else {
0
}) {
if let Some(init_ast) = decl.init.as_ref() {
module.init.extend(predeclare_implicit_assignment_targets(
ctx, init_ast,
Expand Down Expand Up @@ -1629,7 +1638,7 @@ pub(crate) fn lower_stmt(
init: init_expr,
});
}
if has_multiple_decls {
if !first_decl_keeps_init_slot {
None
} else if let Some(decl) = var_decl.decls.first() {
if let Some(init_ast) = decl.init.as_ref() {
Expand Down
25 changes: 17 additions & 8 deletions crates/perry-hir/src/lower_decl/body_stmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -732,13 +732,22 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result<Ve
// head into the loop-scoped prelude in source order,
// registering each binding before the next
// initializer is lowered.
let has_multiple_decls = var_decl.decls.len() > 1;
for decl in
var_decl
.decls
.iter()
.skip(if has_multiple_decls { 0 } else { 1 })
{
//
// #9106 carve-out: when hoisting the tail around a
// literal-initialized first declarator is provably
// unobservable, keep that declarator in `For::init`
// — the versioned counted-loop matchers key their
// counter on it (`for (let i = 0, len = arr.length;
// i < len; i++)`).
let first_decl_keeps_init_slot = var_decl.decls.len() == 1
|| crate::lower_decl::for_head_first_decl_keeps_init_slot(
&var_decl.decls,
);
for decl in var_decl.decls.iter().skip(if first_decl_keeps_init_slot {
1
} else {
0
}) {
if let Some(init_ast) = decl.init.as_ref() {
result.extend(predeclare_implicit_assignment_targets(
ctx, init_ast,
Expand Down Expand Up @@ -788,7 +797,7 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result<Ve
init: init_expr,
});
}
if has_multiple_decls {
if !first_decl_keeps_init_slot {
None
} else if let Some(decl) = var_decl.decls.first() {
if let Some(init_ast) = decl.init.as_ref() {
Expand Down
52 changes: 52 additions & 0 deletions crates/perry-hir/src/lower_decl/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -762,3 +762,55 @@ pub(crate) fn lower_well_known_computed_method(
// Other well-known on a class: not yet implemented — drop.
Ok(Some(WellKnownComputedMethod::Unsupported))
}

/// #9106: whether a multi-declarator lexical `for` head may keep its FIRST
/// declarator in the loop's own `For::init` slot while the remaining
/// declarators are hoisted into the loop-scoped prelude.
///
/// The prelude runs before `For::init`, so that split evaluates the tail
/// declarators before the first one — the reordering #9062 removed to
/// preserve source-order semantics. The reorder is provably unobservable
/// when:
/// - the first declarator is a plain identifier binding,
/// - its initializer is a pure literal (no reads, no writes, no effects, a
/// value independent of the tail declarators), and
/// - no tail declarator mentions the first binding's name anywhere in its
/// pattern or initializer, so neither the value nor the TDZ state of the
/// first binding can be observed early and no closure can capture it.
///
/// Keeping the counter's `let i = 0` in `For::init` is what the versioned
/// counted-loop matchers key on (`for (let i = 0, len = arr.length; i < len;
/// i++)` — the wolf-ecs scan idiom), so this carve-out restores their fast
/// clones for the classic shape while every order-observable head stays on
/// the #9062 prelude path.
pub fn for_head_first_decl_keeps_init_slot(decls: &[ast::VarDeclarator]) -> bool {
use swc_ecma_visit::{Visit, VisitWith};
let Some((first, tail)) = decls.split_first() else {
return false;
};
let ast::Pat::Ident(first_ident) = &first.name else {
return false;
};
if !matches!(first.init.as_deref(), Some(ast::Expr::Lit(_))) {
return false;
}
struct Mentions<'a> {
sym: &'a str,
found: bool,
}
impl Visit for Mentions<'_> {
fn visit_ident(&mut self, ident: &ast::Ident) {
if ident.sym.as_ref() == self.sym {
self.found = true;
}
}
}
let mut mentions = Mentions {
sym: first_ident.id.sym.as_ref(),
found: false,
};
for decl in tail {
decl.visit_with(&mut mentions);
}
!mentions.found
}
8 changes: 4 additions & 4 deletions crates/perry-hir/src/lower_decl/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ pub(crate) use enum_decl::{compute_enum_members, lower_enum_decl};
pub(crate) use fn_decl::lower_fn_decl;
pub(crate) use helpers::{
append_synthetic_arguments_param, body_has_use_strict, body_uses_arguments,
build_default_param_stmts, collect_let_decls_in_stmt, is_inspect_custom_key,
is_symbol_iterator_key, lower_well_known_computed_method, mapped_argument_parameter_ids,
params_are_simple_arguments_list, params_use_arguments, symbol_well_known_key,
with_static_member_context, WellKnownComputedMethod,
build_default_param_stmts, collect_let_decls_in_stmt, for_head_first_decl_keeps_init_slot,
is_inspect_custom_key, is_symbol_iterator_key, lower_well_known_computed_method,
mapped_argument_parameter_ids, params_are_simple_arguments_list, params_use_arguments,
symbol_well_known_key, with_static_member_context, WellKnownComputedMethod,
};
pub(crate) use interface_decl::lower_interface_decl;
pub(crate) use private_members::{
Expand Down
Loading