SIGN IN SIGN UP
oven-sh / bun UNCLAIMED

Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one

0 0 150 Rust

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