Skip to content

js_parser: drop a TypeScript import whose clause binds nothing - #41542

Closed
robobun wants to merge 1 commit into
mainfrom
robobun/6d1ce283/elide-empty-import-clause
Closed

robobun wants to merge 1 commit into
mainfrom
robobun/6d1ce283/elide-empty-import-clause

Conversation

@robobun

@robobun robobun commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • In a .ts file, import {} from "./polyfill.ts" is kept and the module runs. tsc and esbuild drop it. The same sources behave differently under bun run and under a tsc or esbuild build.
  • tsc drops an import declaration that has an import clause with no value bindings. Only the bare import "x" form is a side-effect import. bun already drops import { type T } from "x", but an empty clause fell through to the side-effect path (parse_stmt.rs, the T::TOpenBrace arm of t_import).

Fix

  • An empty import clause in a TypeScript file is now treated like a type-only clause: the statement parses to S::TypeScript and emits nothing. import foo, {} from "x" is unchanged, since the default binding decides it.
  • JavaScript files are unchanged. They have no type-only bindings, so import {} from "x" still runs the module, as esbuild does.
  • The had_type_only_imports field of ImportClause had no other reader, so it is removed.
  • Verified: test/bundler/transpiler/transpiler.test.js (new an import clause with no bindings is dropped like tsc does test, fails on 1.4.3). Also test/bundler/esbuild/ts.test.ts, test/bundler/esbuild/importstar_ts.test.ts, test/bundler/esbuild/tsconfig.test.ts, test/bundler/bundler_edgecase.test.ts, test/js/bun/typescript/.

Background

  • TypeScript cannot tell a value import from a type import without a checker, so its isolated emit drops any import whose bindings are all unused or type-only. An empty clause is the degenerate case of that rule.
  • esbuild tracks "had a clause" separately from "has items" and drops the statement in TypeScript files. bun's S::Import stores only the items, so the decision moves to the parser, next to the existing type-only elision.
Notes

Repro from the report:

// polyfill.ts
(globalThis as any).__polyfilled = true; export {};
// main.ts
import {} from "./polyfill.ts";
console.log("polyfilled:", (globalThis as any).__polyfilled === true);

tsc 6.0.3 + node and esbuild 0.28.1: polyfilled: false. bun 1.4.3: polyfilled: true. bun with this change: polyfilled: false.

Bun.Transpiler output for the covered forms (loader ts):

import {} from 'bar'; x            ->  x;
import { } from 'bar'; x           ->  x;
import {} from 'bar'; import 'bar' ->  import"bar";
import foo, {} from 'bar'; foo     ->  import foo from "bar"; foo;
import 'bar'; x                    ->  import"bar"; x;

@coderabbitai

coderabbitai Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 14 days. After that, they cost $0.25 per reviewed file.

Or wait 2 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 0609bf0a-e76f-481e-be7c-bf17d39b1ffa

📥 Commits

Reviewing files that changed from the base of the PR and between ae7b8f4 and c6f6667.

📒 Files selected for processing (4)
  • src/js_parser/parse/parse_import_export.rs
  • src/js_parser/parse/parse_stmt.rs
  • src/js_parser/parser.rs
  • test/bundler/transpiler/transpiler.test.js

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Sep 6, 2026
@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:03 AM PT - Sep 6th, 2026

❌ @robobun, your commit c6f6667 has 3 failures in Build #110970 (All Failures):

  • test/js/bun/util/csrf.test.ts - 1 failing in the parallel batch on 🍎 any aarch64
  • test/js/node/test/parallel/test-crypto-dh-leak.js - code 1 on 🐧 13 x64-asan
  • ❌ CPU instruction violation on Linux x64 — 1 check(s) failed
  • The baseline build contains instructions not available on Nehalem (SSE4.2, no AVX/AVX2/AVX512).

    • Static instruction scan

    Static scan violations

    llint_op_wide16  [INVLPGB]  (1 insns)
    

    If these are runtime-dispatched behind a CPUID gate: add each symbol to scripts/verify-baseline-static/allowlist-x64.txt with a comment pointing at the gate.

    If there's no gate: this is a real bug — a -march leaked into a subbuild.

  • ❌ CPU instruction violation on Linux x64 — 1 check(s) failed
  • The baseline build contains instructions not available on Nehalem (SSE4.2, no AVX/AVX2/AVX512).

    • Static instruction scan

    Static scan violations

    llint_op_wide16  [INVLPGB]  (1 insns)
    

    If these are runtime-dispatched behind a CPUID gate: add each symbol to scripts/verify-baseline-static/allowlist-x64.txt with a comment pointing at the gate.

    If there's no gate: this is a real bug — a -march leaked into a subbuild.


🧪   To try this PR locally:

bunx bun-pr 41542

That installs a local version of the PR into your bun-41542 executable, so you can run:

bun-41542 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this and found no bugs. Because it changes runtime semantics for a valid TS syntax form (import {} from 'x' no longer executes the module), a maintainer should confirm the compat trade-off is intended.

What was reviewed:

  • Confirmed the T::TOpenBrace arm only fires with no default binding; import foo, {} routes through T::TIdentifier → T::TComma and keeps the default (parse_stmt.rs:1641).
  • Grepped for had_type_only_imports — no remaining readers; the field removal is complete.
  • Checked the existing default/ImportFormsWithNoBundle test that asserts import {} from './b' is kept — it uses a .js entry, so JS mode remains unaffected.
  • preserve_unused_imports_ts is hardcoded false at every set site, so no verbatimModuleSyntax-style interaction is reachable today.
Extended reasoning...

Overview

This PR changes the TypeScript parser to drop import {} from 'x' at parse time, matching tsc's default emit and (per the PR description) esbuild. Previously bun kept this as a side-effect import because scan_imports.rs only elides when found_imports is true, and an already-empty clause never sets that flag. The fix removes the had_type_only_imports bookkeeping on ImportClause (parser.rs, parse_import_export.rs) and simplifies the drop condition in parse_stmt.rs to import_clause.items.is_empty() under IS_TYPESCRIPT_ENABLED. Tests in transpiler.test.js cover TS elision, JS preservation, default, {} retention, and a spawned runtime check that a side-effecting module is not evaluated.

Security risks

None. This is a parser control-flow change with no I/O, allocation, or trust-boundary implications. The dropped path (S::TypeScript {}) is the same one already used for import { type T } from 'x' and import type {…}.

Level of scrutiny

Medium-high. The diff is small and mechanically clean, but it is a user-visible semantic change to every .ts file bun runs: code that wrote import {} from './polyfill' and observed side effects will silently stop observing them. REVIEW.md flags both "for ported code, the reference implementation is the spec" and "never silently break existing users" as review-blocking concerns, so a maintainer should sign off on the compat call rather than an automated approval.

Other factors

I traced adjacent paths: the import foo, {} form goes through the T::TIdentifier → T::TComma → T::TOpenBrace branch at parse_stmt.rs:1641, which does not check items.is_empty(), so the default binding is preserved as tested. JS mode is gated out by IS_TYPESCRIPT_ENABLED, and the existing default/ImportFormsWithNoBundle test that asserts import {} from './b' is kept uses a .js entry, so it is unaffected. The preserve_unused_imports_ts option (which would map to importsNotUsedAsValues: "preserve" / verbatimModuleSyntax) is hardcoded to false at every set site today, so there is no currently-reachable configuration under which this parse-time drop conflicts with a "preserve" setting — but if that option is ever wired up, import {} from 'x' would still be dropped before scan_imports sees it, which is worth a maintainer being aware of. The new tests follow harness conventions (tempDir with using, bunEnv/bunExe, concurrent pipe drain, stderr/stdout asserted before exitCode) and would fail on the previous release.

@robobun

robobun commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Closing this PR. #40822 already covers the same case and does it correctly. This PR does not.

With "verbatimModuleSyntax": true, tsc keeps import {} from "./p" and the module runs. This PR drops the statement in every TypeScript file, because bun does not read that option on main. bun init writes the option (src/runtime/cli/init/tsconfig.default.json), so a default project is in the last row of this table.

m.ts is import {} from "./p" plus one console.log. p.ts prints a line.

compilerOptions tsc 6.0.2 emits esbuild 0.25.1 main (367d939) main + this diff
no tsconfig nothing does not run ./p runs ./p does not run
{} nothing does not run runs does not run
verbatimModuleSyntax: false nothing does not run runs does not run
isolatedModules: true nothing does not run runs does not run
verbatimModuleSyntax: true import {} from "./p"; runs runs does not run

The diff is right in four rows and wrong in the fifth, where main is right today.

#40822 drops an empty import {} or export {} from clause only when no tsconfig setting asks to keep the statement, and it has tests for both outcomes. It conflicts with main now and needs a rebase.

@robobun robobun closed this Oct 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants