Skip to content

Report duplicate declarations for import bindings kept in TypeScript output#31240

Merged
Jarred-Sumner merged 11 commits into
mainfrom
farm/300bb1c9/ts-import-duplicate-binding
May 23, 2026
Merged

Report duplicate declarations for import bindings kept in TypeScript output#31240
Jarred-Sumner merged 11 commits into
mainfrom
farm/300bb1c9/ts-import-duplicate-binding

Conversation

@robobun

@robobun robobun commented May 22, 2026

Copy link
Copy Markdown
Collaborator

What

Parser fuzzing found two inputs where the transpiler's own output fails to re-parse (both are duplicate-binding early errors per spec, and errors in tsc):

// 1. the same name imported twice
new Bun.Transpiler({ loader: "tsx" }).transformSync(`import{Observable}from""\nimport{Observable} from "x"`);
// succeeded, emitted both imports; re-parsing the output fails with
// error: "Observable" has already been declared

// 2. an import colliding with a class declaration
new Bun.Transpiler({ loader: "ts" }).transformSync(`import { Foo } from ".";export class Foo {}`);
// succeeded, emitted both the import and the class

Cause

Scope::can_merge_symbol_kinds deliberately lets any later declaration take over the name of an import binding when TypeScript is enabled (the import may be type-only, e.g. import type-less interface imports, namespace declaration merging). That lenience is only sound when the shadowed import is later elided as unused. The runtime and the bundler always trim unused imports for TS, but Bun.Transpiler defaults to keeping them, so the shadowed import binding was printed right next to the declaration that replaced it — output that declares the same name twice.

Fix

In ImportScanner::scan (src/js_parser/scan/scan_imports.rs), after unused-import elision (which runs post-visit, once use counts are known), each import binding still in the statement whose symbol was replaced (its link is set by ReplaceWithNew) is checked against the module scope's member for that name; when the member points at the final symbol in that chain of replacements, it's reported as "X" has already been declared at the re-declaration (the member's location) with a note at the import. Compiler-generated symbols that reuse a name (the JSX runtime auto-imports in bundle mode) don't set that link, so they can't trip the check. No parser state is added, declare_symbol is untouched, and non-TypeScript files are unaffected.

So the error fires when a re-declared import binding is kept in the output, and the existing lenience is preserved whenever the import is actually elided:

input (ts/tsx) before after
import{Observable}from"" + import{Observable} from "x" (imports kept) emits invalid output error: already declared
import { Foo } from "."; export class Foo {} (imports kept) emits invalid output error: already declared
same inputs with trimUnusedImports: true (runtime/bundler default for TS) shadowed import elided unchanged
import type { Foo } from "x"; class Foo {} import erased, class Foo {} unchanged
import { type Foo, Bar } from "x"; class Foo {} type specifier erased unchanged
import { Foo } from "x"; declare class Foo {} / interface / overload-only signatures import kept, no conflict emitted unchanged
import { Foo } from "x"; namespace Foo {} at runtime (valid TS declaration merging) import elided, namespace emitted unchanged
import { Foo } from "x"; import Foo = Bar.Baz (unused, imports kept) import-equals dropped by its own elision, import emitted error: already declared (tsc rejects this input too)
plain JS loaders already errored unchanged

Bun.Transpiler.scan() with a TS loader now also reports the error for these inputs (it already did for JS loaders); scanImports() is unchanged.

Verification

  • bun bd test test/bundler/transpiler/transpiler.test.js — new tests re-declaring an import binding that is kept in the output is an error / re-declaring an elided import binding is allowed pass; the error-case test fails on a build without the fix.
  • Full test/bundler/transpiler/, test/js/bun/transpiler/, test/bundler/esbuild/ts.test.ts, test/bundler/esbuild/importstar_ts.test.ts, and test/js/bun/resolve/import-defer.test.ts pass.
  • Runtime and bun build behavior on the repro files is unchanged (shadowed imports still elided there).

…output

TypeScript allows a later declaration to take over the name of an import
binding because the import may be type-only. When the import is not elided
(Bun.Transpiler keeps unused imports by default), both bindings were printed,
producing output that fails to re-parse. Record the collision in
declare_symbol and report "has already been declared" from ImportScanner
when the import binding survives import elision.
@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, we couldn't start this review because you've used your available PR reviews for now.

Your plan currently allows 2 reviews/hour. Refill in 24 minutes and 10 seconds.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more review capacity refills, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than trial, open-source, and free plans. In all cases, review capacity refills continuously over time.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 65d90f02-da4a-4c6d-8999-a0f74d811ef5

📥 Commits

Reviewing files that changed from the base of the PR and between a3f0a10 and 23bc6fb.

📒 Files selected for processing (1)
  • test/bundler/bundler_jsx.test.ts

Walkthrough

The PR adds a TypeScript-only import-binding redeclaration check in the parser and three regression tests: two covering redeclaration vs. elided imports for the transpiler, and one ensuring a user-imported JSX Fragment isn't conflated with the auto-imported helper.

Changes

Import Re-declaration Detection

Layer / File(s) Summary
Import scanner redeclaration check
src/js_parser/scan/scan_imports.rs
ImportScanner::scan enumerates default/namespace/named import bindings, follows each binding's symbol.link to the live symbol, looks up the module-scope member by original name, and emits a "symbol already declared" error referencing the prior member location and the current import location.
Regression tests: transpiler redeclaration and elided imports
test/bundler/transpiler/transpiler.test.js
Adds a test expecting a parse error when a retained import binding is redeclared, and a test allowing redeclaration when the import is elided (type-only) with trimUnusedImports: true output assertions.
Bundler JSX Fragment import case
test/bundler/bundler_jsx.test.ts
Adds jsx/AutomaticFragmentNamedImport test verifying a user-imported Fragment is not treated as Bun's auto-imported JSX Fragment helper in both dev and prod builds.

Suggested reviewers

  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main fix: reporting duplicate declarations for import bindings that are kept in TypeScript output.
Description check ✅ Passed The description includes both required sections with comprehensive detail: 'What' explains the bug with examples and root cause, and 'How did you verify' details testing approach and results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@robobun

robobun commented May 22, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:33 AM PT - May 23rd, 2026

@robobun, your commit 23bc6fb has 2 failures in Build #57245 (All Failures):

  • 📦 Binary size — 6 over 0.50 MB
  • targetthis build canary: main #57260
    sizeΔ
    bun-darwin-aarch6454.76 MB54.84 MB-81.2 KB
    bun-darwin-x6458.69 MB58.88 MB-192.5 KB
    bun-linux-aarch6480.86 MB69.74 MB+11.12 MB
    bun-linux-x6481.70 MB70.69 MB+11.02 MB
    bun-linux-x64-baseline80.77 MB69.77 MB+11.00 MB
    bun-linux-aarch64-musl75.48 MB64.55 MB+10.94 MB
    bun-linux-x64-musl76.66 MB65.77 MB+10.89 MB
    bun-linux-x64-musl-baseline76.00 MB65.11 MB+10.89 MB
    bun-linux-aarch64-android77.76 MB77.82 MB-63.6 KB
    bun-linux-x64-android79.66 MB79.75 MB-96.0 KB
    bun-freebsd-x6482.10 MB82.29 MB-192.0 KB
    bun-freebsd-aarch6484.20 MB84.30 MB-112.0 KB
    bun-windows-x6490.16 MB90.32 MB-165.0 KB
    bun-windows-x64-baseline89.22 MB89.37 MB-157.5 KB
    bun-windows-aarch6487.17 MB87.23 MB-64.0 KB

    Add [skip size check] to the commit message if this increase is intentional.

  • test/js/node/test/parallel/test-net-write-slow.js - pid 6932 illegal instruction at address 0x7ff719d0b74c on 🪟 11 aarch64

🧪   To try this PR locally:

bunx bun-pr 31240

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

bun-31240 --bun

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Delete your slop cmoments.

@robobun

robobun commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author

Removed in b955b25.

Comment thread src/js_parser/scan/scan_imports.rs
Comment thread src/js_parser/p.rs Outdated

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is there a chaeper way to do this? Can you delete your slop commetns/

@robobun

robobun commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author

Done in 7cad1b1 — dropped the side table. The scanner now compares each kept import binding against the module-scope member for its name; if the member no longer points at the binding, the name was re-declared. No new field on the parser, no work in declare_symbol, nothing for non-TypeScript files, and the remaining comments went with it. Same errors, same tests.

Comment thread src/js_parser/scan/scan_imports.rs Outdated
Generated symbols (the JSX runtime auto-imports in bundle mode) reuse raw
names and take over the scope member without replacing the import, which
false-positived the duplicate check on valid TSX.
Comment thread src/js_parser/scan/scan_imports.rs
Comment thread src/js_parser/scan/scan_imports.rs
Comment thread test/bundler/bundler_jsx.test.ts Outdated
Comment thread test/bundler/bundler_jsx.test.ts Outdated
@Jarred-Sumner Jarred-Sumner merged commit 52898c8 into main May 23, 2026
67 of 68 checks passed
@Jarred-Sumner Jarred-Sumner deleted the farm/300bb1c9/ts-import-duplicate-binding branch May 23, 2026 08:11
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