Summary
In compiled mode, when a generator or async-generator parameter has a default value and is captured-and-mutated by a nested arrow/closure, an omitted argument does not get its default: the undefined sentinel leaks into the closure's storage instead. The interpreter is correct. Supplying the argument explicitly works in both modes — only the default-firing (omitted) path is broken, and only when the parameter is captured.
Affects generators and async-generators, instance methods and free functions, and both value-type and reference-type defaults.
Repro
// value-type default, captured by arrow — free-function generator
function* g(acc: number = 5): Generator<number> { [1, 2, 3].forEach(n => acc += n); yield acc; }
console.log([...g()][0]); // interp: 11 compiled: NaN
// reference-type default, captured — instance generator method
class C { *gen(s: string = "x"): Generator<string> { [1, 2, 3].forEach(n => s += n); yield s; } }
console.log([...new C().gen()][0]); // interp: x123 compiled: 6
// value-type default, captured — async generator
async function* ag(acc: number = 5) { [1, 2, 3].forEach(n => acc += n); yield acc; }
(async () => { for await (const v of ag()) console.log(v); })(); // interp: 11 compiled: NaN
| case |
interp |
compiled |
g() value default, free-func generator |
11 |
NaN |
new C().gen() ref default, instance generator |
x123 |
6 |
ag() value default, async generator |
11 |
NaN |
any of the above with the argument supplied (g(100), gen("Y")) |
correct |
correct ✓ |
Compiled g() yields NaN because the omitted acc reaches the closure as the $Undefined sentinel and $Undefined + 1 is NaN; the ref case yields 6 because the omitted s is treated as 0 (0 + 1 + 2 + 3) — in both, the default (5 / "x") never reached the closure's copy of the parameter.
Root cause
A captured-and-mutated parameter's live storage in a compiled generator is the function display-class field (#724/#725), which the stub seeds from the incoming argument before MoveNext runs. The default-parameter prologue (#737) runs later, on initial MoveNext entry, and writes the default into the state-machine field — not the DC field. So for a captured parameter the default lands in storage the body no longer reads, and the arrow reads/writes the DC field, which still holds the omitted-arg $Undefined (or null). With the argument supplied, the DC field is seeded with the real value and there is nothing to default, so it works.
Fix direction: when the default prologue fires for a parameter that is captured into a function DC, write the default into the DC field (the live storage), not only the state-machine field — or seed the DC field after the prologue. Mirror across GeneratorMoveNextEmitter / AsyncGeneratorMoveNextEmitter and the corresponding stub seeding (EmitGeneratorFunctionDCInit).
Scope / discovery
Pre-existing and latent — it is the intersection of #737 (generator/async-generator default-parameter prologue) and #724/#725 (a generator parameter captured-and-mutated by a nested arrow), neither of whose individual repros captures a defaulted parameter. Surfaced while verifying the #723/#737/#738/#739 cluster: the basic generator default (*gen(b = 5) { yield b; } → 5) and the basic captured non-default parameter (*gen(acc: number) { forEach(n => acc += n) }) both work; only their combination fails. Not a regression of the build-break repair that exposed it (free-function generators, which do not go through that code path, exhibit it identically).
Summary
In compiled mode, when a generator or async-generator parameter has a default value and is captured-and-mutated by a nested arrow/closure, an omitted argument does not get its default: the
undefinedsentinel leaks into the closure's storage instead. The interpreter is correct. Supplying the argument explicitly works in both modes — only the default-firing (omitted) path is broken, and only when the parameter is captured.Affects generators and async-generators, instance methods and free functions, and both value-type and reference-type defaults.
Repro
g()value default, free-func generatornew C().gen()ref default, instance generatorag()value default, async generatorg(100),gen("Y"))Compiled
g()yieldsNaNbecause the omittedaccreaches the closure as the$Undefinedsentinel and$Undefined + 1isNaN; the ref case yields6because the omittedsis treated as0(0 + 1 + 2 + 3) — in both, the default (5/"x") never reached the closure's copy of the parameter.Root cause
A captured-and-mutated parameter's live storage in a compiled generator is the function display-class field (#724/#725), which the stub seeds from the incoming argument before
MoveNextruns. The default-parameter prologue (#737) runs later, on initialMoveNextentry, and writes the default into the state-machine field — not the DC field. So for a captured parameter the default lands in storage the body no longer reads, and the arrow reads/writes the DC field, which still holds the omitted-arg$Undefined(or null). With the argument supplied, the DC field is seeded with the real value and there is nothing to default, so it works.Fix direction: when the default prologue fires for a parameter that is captured into a function DC, write the default into the DC field (the live storage), not only the state-machine field — or seed the DC field after the prologue. Mirror across
GeneratorMoveNextEmitter/AsyncGeneratorMoveNextEmitterand the corresponding stub seeding (EmitGeneratorFunctionDCInit).Scope / discovery
Pre-existing and latent — it is the intersection of #737 (generator/async-generator default-parameter prologue) and #724/#725 (a generator parameter captured-and-mutated by a nested arrow), neither of whose individual repros captures a defaulted parameter. Surfaced while verifying the #723/#737/#738/#739 cluster: the basic generator default (
*gen(b = 5) { yield b; }→5) and the basic captured non-default parameter (*gen(acc: number) { forEach(n => acc += n) }) both work; only their combination fails. Not a regression of the build-break repair that exposed it (free-function generators, which do not go through that code path, exhibit it identically).