Skip to content

Compiled: a defaulted generator/async-generator parameter captured by a nested arrow ignores its default on omitted args #792

@nickna

Description

@nickna

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions