diff --git a/Jint.Tests/Runtime/LoopBodyFlatteningTests.cs b/Jint.Tests/Runtime/LoopBodyFlatteningTests.cs
new file mode 100644
index 000000000..325134434
--- /dev/null
+++ b/Jint.Tests/Runtime/LoopBodyFlatteningTests.cs
@@ -0,0 +1,163 @@
+namespace Jint.Tests.Runtime;
+
+///
+/// Pins the loop-body lexical flattening (JintForStatement): an eligible body block's let/const
+/// bindings fold into the pooled loop environment, and the semantics must be indistinguishable
+/// from the per-iteration block environment.
+///
+public class LoopBodyFlatteningTests
+{
+ [Fact]
+ public void StopwatchModernShapeComputesCorrectly()
+ {
+ var engine = new Engine();
+ // the stopwatch-modern inner-loop shape: let header, const body bindings, if/else chain
+ var result = engine.Evaluate("""
+ (function () {
+ let hits = 0;
+ for (let x = 0; x < 8; x++) {
+ const z = x ^ 1;
+ const doubled = z * 2;
+ if (z % 2 == 0) { hits += doubled; }
+ else if (z % 3 == 0) { hits += 100; }
+ }
+ return hits;
+ })()
+ """).AsNumber();
+
+ // z values: 1,0,3,2,5,4,7,6 → even z (0,2,4,6) add 2z = 0+4+8+12 = 24; z=3 adds 100
+ Assert.Equal(124, result);
+ }
+
+ [Fact]
+ public void ConstIsFreshPerIterationAndTdzReestablished()
+ {
+ var engine = new Engine();
+ var result = engine.Evaluate("""
+ (function () {
+ var seen = [];
+ for (let i = 0; i < 3; i++) {
+ try { touch; } catch (e) { seen.push('tdz' + i); }
+ const touch = i * 10;
+ seen.push(touch);
+ }
+ return seen.join(',');
+ })()
+ """).AsString();
+
+ Assert.Equal("tdz0,0,tdz1,10,tdz2,20", result);
+ }
+
+ [Fact]
+ public void ContinueReestablishesTdzForSkippedDeclarations()
+ {
+ var engine = new Engine();
+ // continue jumps before `late` is initialized; the next iteration must still see TDZ,
+ // not the previous iteration's value
+ var result = engine.Evaluate("""
+ (function () {
+ var seen = [];
+ for (let i = 0; i < 4; i++) {
+ const early = i;
+ if (i % 2 == 0) { continue; }
+ try { seen.push(late); } catch (e) { seen.push('tdz'); }
+ const late = 'v' + i;
+ seen.push(late, early);
+ }
+ return seen.join(',');
+ })()
+ """).AsString();
+
+ Assert.Equal("tdz,v1,1,tdz,v3,3", result);
+ }
+
+ [Fact]
+ public void ShadowingHeaderNameKeepsBlockScoping()
+ {
+ var engine = new Engine();
+ // body const shadows the header let: names overlap, flattening must decline and the
+ // block env must keep proper shadowing semantics
+ var result = engine.Evaluate("""
+ (function () {
+ var seen = [];
+ for (let v = 0; v < 3; v++) {
+ seen.push(v);
+ {
+ const v = 'inner';
+ seen.push(v);
+ }
+ }
+ var direct = [];
+ for (let w = 0; w < 2; w++) {
+ const w2 = w + 10;
+ direct.push(w2);
+ }
+ return seen.join(',') + '|' + direct.join(',');
+ })()
+ """).AsString();
+
+ Assert.Equal("0,inner,1,inner,2,inner|10,11", result);
+ }
+
+ [Fact]
+ public void CapturingBodyKeepsPerIterationSemantics()
+ {
+ var engine = new Engine();
+ // closures capture the body const: escape analysis must exclude flattening (and env
+ // reuse), so each captured binding is distinct
+ var result = engine.Evaluate("""
+ (function () {
+ const fns = [];
+ for (let i = 0; i < 3; i++) {
+ const snapshot = i * 2;
+ fns.push(() => snapshot);
+ }
+ return fns.map(f => f()).join(',');
+ })()
+ """).AsString();
+
+ Assert.Equal("0,2,4", result);
+ }
+
+ [Fact]
+ public void ThrowMidBodyLeavesConsistentState()
+ {
+ var engine = new Engine();
+ var result = engine.Evaluate("""
+ (function () {
+ var caught = 0, total = 0;
+ for (let i = 0; i < 5; i++) {
+ try {
+ const val = i;
+ if (i === 2) { throw new Error('x'); }
+ total += val;
+ } catch (e) {
+ caught++;
+ }
+ }
+ return total + ':' + caught;
+ })()
+ """).AsString();
+
+ Assert.Equal("8:1", result); // 0+1+3+4, one catch
+ }
+
+ [Fact]
+ public void UsingDeclarationsKeepBlockDisposeSemantics()
+ {
+ var engine = new Engine();
+ // using declarations need block-exit dispose per iteration: flattening must decline
+ var result = engine.Evaluate("""
+ (function () {
+ const order = [];
+ for (let i = 0; i < 2; i++) {
+ using res = { [Symbol.dispose]() { order.push('d' + i); } };
+ order.push('b' + i);
+ }
+ return order.join(',');
+ })()
+ """).AsString();
+
+ Assert.Equal("b0,d0,b1,d1", result);
+ }
+}
diff --git a/Jint/Runtime/Interpreter/Statements/JintBlockStatement.cs b/Jint/Runtime/Interpreter/Statements/JintBlockStatement.cs
index adb4b7ba6..ae938f6b8 100644
--- a/Jint/Runtime/Interpreter/Statements/JintBlockStatement.cs
+++ b/Jint/Runtime/Interpreter/Statements/JintBlockStatement.cs
@@ -19,6 +19,7 @@ internal sealed class JintBlockStatement : JintStatement
// exposed so enclosing loop fast paths can reuse the exact handler instances of this block
internal JintStatement? SingleStatement => _singleStatement;
internal JintStatementList? StatementList => _statementList;
+ internal BlockState State => _blockState;
// Reuse cache for this block's fixed-slot environment. Held on the handler instance — which is built
// per statement list, i.e. per engine — rather than on the shared BlockState: BlockState lives on the
@@ -323,6 +324,15 @@ private void SetupDisposeSuspension(Engine engine, AsyncFunctionInstance asyncFn
PromiseOperations.PerformPromiseThen(engine, promise, onFulfilled, onRejected, null!);
}
+ ///
+ /// Executes the block's contents against the CURRENT lexical environment, without creating,
+ /// attaching or parking the block environment. For enclosing-loop flattening only: the caller
+ /// owns an environment that already carries this block's slot layout (re-TDZ'd per iteration)
+ /// and has verified there are no dispose resources to run.
+ ///
+ internal Completion ExecuteFlattenedContents(EvaluationContext context)
+ => _statementList is not null ? _statementList.Execute(context) : ExecuteSingle(context);
+
private Completion ExecuteSingle(EvaluationContext context)
{
Completion blockValue;
diff --git a/Jint/Runtime/Interpreter/Statements/JintForStatement.cs b/Jint/Runtime/Interpreter/Statements/JintForStatement.cs
index b7d3fefcf..03591f12d 100644
--- a/Jint/Runtime/Interpreter/Statements/JintForStatement.cs
+++ b/Jint/Runtime/Interpreter/Statements/JintForStatement.cs
@@ -30,6 +30,16 @@ internal sealed class JintForStatement : JintStatement
private readonly JintExpressionStatement? _tightSingleStatement;
private readonly JintStatementList? _tightBodyList;
+ // Body-lexical flattening: when the body block's let/const bindings are slot-eligible
+ // (no function/class declarations, no escaping closures — BlockState.SlotNames implies both),
+ // contain no using declarations and don't shadow the loop header's names, they fold into the
+ // pooled loop environment. The body then runs against that environment directly, eliding the
+ // block-env attach/swap/park ceremony per iteration; the body's slot range is re-TDZ'd before
+ // each iteration instead.
+ private readonly bool _bodyFlattened;
+ private readonly int _flattenedHeaderSlotCount;
+ private readonly JintBlockStatement? _flattenedBodyBlock;
+
private readonly bool _shouldCreatePerIterationEnvironment;
private readonly bool _canReuseIterationEnvironment;
@@ -100,6 +110,30 @@ public JintForStatement(ForStatement statement) : base(statement)
_incrementCanDiscard = _increment.HasDiscardFastPath;
}
+ if (_canPoolLoopEnv && _body.BlockStatement is { } flattenCandidate)
+ {
+ var bodyState = flattenCandidate.State;
+ if (bodyState.SlotNames is { } bodyNames
+ && _boundNames!.Count + bodyNames.Length <= 16
+ && !HasUsingDeclarations(bodyState)
+ && !NamesOverlap(_loopSlotNames!, bodyNames))
+ {
+ var headerCount = _loopSlotNames!.Length;
+ var combinedNames = new Key[headerCount + bodyNames.Length];
+ var combinedTemplates = new Binding[combinedNames.Length];
+ System.Array.Copy(_loopSlotNames, combinedNames, headerCount);
+ System.Array.Copy(_loopSlotTemplates!, combinedTemplates, headerCount);
+ System.Array.Copy(bodyNames, 0, combinedNames, headerCount, bodyNames.Length);
+ System.Array.Copy(bodyState.SlotTemplates!, 0, combinedTemplates, headerCount, bodyNames.Length);
+
+ _loopSlotNames = combinedNames;
+ _loopSlotTemplates = combinedTemplates;
+ _flattenedHeaderSlotCount = headerCount;
+ _flattenedBodyBlock = flattenCandidate;
+ _bodyFlattened = true;
+ }
+ }
+
if (_test is not null && IsTightBodyShape(statement.Body))
{
_tightBodyEligible = true;
@@ -255,7 +289,9 @@ protected override Completion ExecuteInternal(EvaluationContext context)
}
}
- completion = ForBodyEvaluation(context, suspendData?.AccumulatedValue ?? JsValue.Undefined, skipTestOnce: resumingInBody, resumeUpdateOnce: resumingInUpdate);
+ // body flattening engages only when the pooled combined-slot environment is live
+ var flattenActive = _bodyFlattened && loopEnv is not null && _canPoolLoopEnv && suspendable is null;
+ completion = ForBodyEvaluation(context, suspendData?.AccumulatedValue ?? JsValue.Undefined, skipTestOnce: resumingInBody, resumeUpdateOnce: resumingInUpdate, flattenActive);
return completion;
}
finally
@@ -319,6 +355,48 @@ private static void ResetSlots(Binding[] slots, Binding[] templates)
}
}
+ ///
+ /// Re-establishes the TDZ of the flattened body's slot range before an iteration; the header
+ /// range is left untouched (a reused iteration environment keeps its header bindings).
+ ///
+ [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
+ private static void ResetBodySlotRange(Binding[] slots, Binding[] templates, int headerCount)
+ {
+ for (var i = headerCount; i < templates.Length; i++)
+ {
+ slots[i] = templates[i];
+ }
+ }
+
+ private static bool HasUsingDeclarations(JintBlockStatement.BlockState state)
+ {
+ foreach (var declaration in state.Declarations)
+ {
+ if (declaration.Declaration is VariableDeclaration { Kind: VariableDeclarationKind.Using or VariableDeclarationKind.AwaitUsing })
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool NamesOverlap(Key[] headerNames, Key[] bodyNames)
+ {
+ foreach (var bodyName in bodyNames)
+ {
+ foreach (var headerName in headerNames)
+ {
+ if (bodyName == headerName)
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
///
/// Checks if the given node is inside this for statement's body, test, or update (but NOT init).
/// Used to determine if we're resuming from a yield/await inside the loop.
@@ -362,7 +440,7 @@ private bool IsNodeInsideForStatementExcludingInit(Node node)
///
/// https://tc39.es/ecma262/#sec-forbodyevaluation
///
- private Completion ForBodyEvaluation(EvaluationContext context, JsValue initialValue, bool skipTestOnce, bool resumeUpdateOnce)
+ private Completion ForBodyEvaluation(EvaluationContext context, JsValue initialValue, bool skipTestOnce, bool resumeUpdateOnce, bool flattenActive = false)
{
var v = initialValue;
@@ -416,7 +494,20 @@ private Completion ForBodyEvaluation(EvaluationContext context, JsValue initialV
var suspendable = context.Engine.ExecutionContext.Suspendable;
if (!resumeUpdateOnce)
{
- var result = _body.Execute(context);
+ Completion result;
+ if (flattenActive && !context.DebugMode)
+ {
+ // the pooled loop environment carries the body's slots: re-establish their TDZ
+ // and run the block contents in place. Under a debugger the normal block path
+ // runs instead — its fresh env shadows the (uninitialized) flattened slots.
+ var env = (DeclarativeEnvironment) context.Engine.ExecutionContext.LexicalEnvironment;
+ ResetBodySlotRange(env._slots!, _loopSlotTemplates!, _flattenedHeaderSlotCount);
+ result = _flattenedBodyBlock!.ExecuteFlattenedContents(context);
+ }
+ else
+ {
+ result = _body.Execute(context);
+ }
if (!result.Value.IsEmpty)
{
v = result.Value;