js_parser: discard the fake if-block scope for a forward-declared function (#31533)
## Summary
A TypeScript forward-declared function used as the single-statement body
of an `if` (or `else`), followed by another statement that opens a block
scope, crashes the transpiler:
```
panic: Internal error: attempted to call popScope() on the topmost scope
```
### Repro
```js
new Bun.Transpiler({ loader: "ts", target: "browser", deadCodeElimination: true })
.transformSync("if(l)function f(ag): g;\nfor (g in {}) {}");
```
```ts
if (l) function f(ag): g; // forward declaration (no body) — emits nothing
for (g in {}) {}
```
## Cause
A function declaration in a single-statement `if`/`else` body is given a
**fake block scope** during parsing so it can be wrapped in a block if
it turns out to have a body (`if (l) { let f = function () {} }`).
When the declaration is a TypeScript **forward declaration** (no body),
`parse_fn_stmt` emits nothing (`S::TypeScript`) and takes an early
return. On that path it:
1. pops the function-args scope with `pop_and_discard_scope` — which
also removes it from `scopes_in_order`, and
2. "balances" the fake block scope with a plain `pop_scope()`.
But `pop_scope()` only walks `current_scope` up to its parent — it does
**not** remove the block's entry from `scopes_in_order`. That leaves an
orphaned block scope in the scope order.
The visit pass replays the scope-order list. The orphan gets handed to
the next statement that opens a block (here the `for (g in {})`), so
every subsequent scope is read one step out of sync. In debug builds
this trips the scope-location sanity check (`Scope mismatch while
visiting`); in release builds that check is compiled out, so the desync
propagates until the parser pops past the topmost scope and panics.
A function declaration *with* a body in the same position was already
fine, because it is emitted as a real `S::Function` that the visit pass
re-pushes the block scope for — only the no-op forward-declaration path
was unbalanced.
## Fix
On the forward-declaration path, discard the fake block scope with
`pop_and_discard_scope(if_stmt_scope_index)` instead of a plain
`pop_scope()`. This removes the block from `scopes_in_order` as well as
walking `current_scope` up, mirroring the existing "dropped TypeScript
construct → discard the scopes it recorded" handling used elsewhere in
the parser (e.g. `declare global { ... }`).
## Verification
- New test in `test/bundler/transpiler/transpiler.test.js` ("scope
tracking stays balanced for a forward-declared function inside an if").
It panics (exit 132) on the current canary and passes with this change.
- Covers the exact fuzz repro plus `if`/`else` variants and confirms the
with-body form is still wrapped in a block.
- Full `test/bundler/transpiler/transpiler.test.js` (166 pass) and
`test/bundler/esbuild/ts.test.ts` (57 pass) green with the fix.
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> R
robobun committed
a740d915723cdb208f40ef5734902151ad6baeee
Parent: d632fc5
Committed by GitHub <noreply@github.com>
on 5/28/2026, 9:32:42 PM