Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2466,7 +2466,27 @@ declare module "bun" {
allowBunRuntime?: boolean;
exports?: {
eliminate?: string[];
replace?: Record<string, string>;
/**
* Replace exported declarations with a literal value, or rename+replace
* them by passing a `[newName, value]` tuple.
*
* @example
* ```js
* // export var foo = ... -> export var foo = "bar";
* foo: "bar",
* // export function getStaticProps() { ... } -> export var __N_SSG = true;
* getStaticProps: ["__N_SSG", true],
* ```
*/
replace?: Record<
string,
| string
| number
| boolean
| null
| undefined
| readonly [name: string, value: string | number | boolean | null | undefined]
>;
};
treeShaking?: boolean;
trimUnusedImports?: boolean;
Expand Down
16 changes: 9 additions & 7 deletions src/js_parser/scan/scan_imports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,17 +265,19 @@ impl<'a> ImportScanner<'a> {
// e.g. `import 'fancy-stylesheet-thing/style.css';`
// This is a breaking change though. We can make it an option with some guardrail
// so maybe if it errors, it shows a suggestion "retry without trimming unused imports"
let all_bindings_culled = found_imports
&& st.star_name_loc.is_empty()
&& st.items.slice().is_empty()
&& st.default_name.is_none();
// replace_exports (Bun.Transpiler-only) opts TS into dropping orphaned value imports (#12892).
let has_replace_exports = p.options.features.replace_exports.count() > 0;
if (is_typescript_enabled
&& found_imports
&& is_unused_in_typescript
&& !p.options.preserve_unused_imports_ts)
|| (!is_typescript_enabled
&& p.options.features.trim_unused_imports
&& found_imports
&& st.star_name_loc.is_empty()
// SAFETY: arena-owned slice; see above.
&& st.items.slice().is_empty()
&& st.default_name.is_none())
|| (p.options.features.trim_unused_imports
&& all_bindings_culled
&& (!is_typescript_enabled || has_replace_exports))
{
// internal imports are presumed to be always used
// require statements cannot be stripped
Expand Down
23 changes: 23 additions & 0 deletions test/bundler/bundler_edgecase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2858,6 +2858,29 @@ describe("bundler", () => {
expect(out).not.toContain("require_foo\u2014bar");
},
});
// https://github.com/oven-sh/bun/issues/12892
// Bun.Transpiler drops an import whose only binding is used in dead code, but
// the bundler must still resolve it so the module's side effects are kept.
itBundled("edgecase/TSImportUsedOnlyInDeadCodeKeepsSideEffect", {
files: {
"/entry.ts": /* ts */ `
import { devOnly } from './setup';
if (process.env.NODE_ENV !== 'production') {
devOnly();
}
console.log('entry');
`,
"/setup.ts": /* ts */ `
console.log('setup side effect');
export function devOnly() {}
`,
},
define: { "process.env.NODE_ENV": '"production"' },
run: { stdout: "setup side effect\nentry" },
onAfterBundle(api) {
expect(api.readFile("/out.js")).toContain("setup side effect");
},
});
});

for (const backend of ["api", "cli"] as const) {
Expand Down
26 changes: 26 additions & 0 deletions test/bundler/transpiler/runtime-transpiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,29 @@ describe("unterminated string literals in large files", () => {
expect(exitCode).toBe(1);
});
});

// https://github.com/oven-sh/bun/issues/12892
test("TS import whose only binding is used in dead code still loads for side effects", async () => {
using dir = tempDir("ts-dead-import-side-effect", {
"entry.ts": `
import { setup } from './side';
if (false) { setup(); }
console.log('entry');
`,
"side.ts": `
console.log('side effect');
export function setup() {}
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "entry.ts"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout).toBe("side effect\nentry\n");
expect(exitCode).toBe(0);
});
145 changes: 134 additions & 11 deletions test/bundler/transpiler/transpiler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1851,19 +1851,142 @@ export default class {
trimUnusedImports: true,
});

it("a deletes dead exports and any imports only referenced in dead regions", () => {
const out = transpiler.transformSync(`
import {getUserById} from './my-database';
// https://github.com/oven-sh/bun/issues/12892
describe.each(["jsx", "js", "tsx", "ts"])(`loader: %s`, loader => {
const t = new Bun.Transpiler({
loader,
exports: {
replace: {
getStaticProps: ["__N_SSG", true],
},
eliminate: ["loader"],
},
treeShaking: true,
trimUnusedImports: true,
define: { "process.env.NODE_ENV": '"production"' },
});
const jsx = loader.endsWith("x") ? `<div id='user'>{user.name}</div>` : `user.name`;

export async function getStaticProps(ctx){
return { props: { user: await getUserById(ctx.params.id) } };
}
it("drops imports only used by a replaced export", () => {
const out = t.transformSync(`
import {getUserById} from './my-database';

export default function MyComponent({user}) {
getStaticProps();
return <div id='user'>{user.name}</div>;
}
`);
export async function getStaticProps(ctx){
return { props: { user: await getUserById(ctx.params.id) } };
}

export default function MyComponent({user}) {
getStaticProps();
return ${jsx};
}
`);
expect(out).not.toContain("my-database");
expect(out).not.toContain("getUserById");
expect(out).toContain("__N_SSG");
expect(out).toContain("MyComponent");
});

it("drops default and namespace imports only used by a replaced export", () => {
const out = t.transformSync(`
import db from './my-database';
import * as ns from './other';

export async function getStaticProps(ctx){
return { props: { user: await db.getUserById(ns.params.id) } };
}

export const live = 1;
`);
expect(out).not.toContain("my-database");
expect(out).not.toContain("./other");
expect(out).toContain("__N_SSG");
expect(out).toContain("live");
});

it("drops imports only used by an eliminated export", () => {
const out = t.transformSync(`
import {deadFS} from 'dead-fs';
import {liveFS} from 'live-fs';

export function loader() {
return deadFS.readFileSync("/etc/passwd");
}

export function action() {
return liveFS.readFileSync("/etc/passwd");
}
`);
expect(out).not.toContain("dead-fs");
expect(out).not.toContain("deadFS");
expect(out).not.toContain("loader");
expect(out).toContain("live-fs");
expect(out).toContain("liveFS");
expect(out).toContain("action");
});

it("drops imports only used in dead code when exports.replace is configured", () => {
const out = t.transformSync(`
import {devOnly} from './dev-stuff';

if (process.env.NODE_ENV !== "production") {
devOnly();
}

export const x = 1;
`);
expect(out).not.toContain("dev-stuff");
expect(out).not.toContain("devOnly");
expect(out).toContain("export const x = 1");
});

it("without exports.replace: TypeScript keeps the bare import for side effects", () => {
const noReplace = new Bun.Transpiler({
loader,
treeShaking: true,
trimUnusedImports: true,
define: { "process.env.NODE_ENV": '"production"' },
});
const out = noReplace.transformSync(`
import {devOnly} from './dev-stuff';

if (process.env.NODE_ENV !== "production") {
devOnly();
}

export const x = 1;
`);
if (loader === "ts" || loader === "tsx") {
expect(out).toContain("./dev-stuff");
expect(out).not.toContain("devOnly");
} else {
expect(out).not.toContain("dev-stuff");
}
});

it("keeps imports with live uses", () => {
const out = t.transformSync(`
import {shared} from './shared';

export async function getStaticProps(ctx){
return { props: { x: shared() } };
}

export const y = shared();
`);
expect(out).toMatch(/\{\s*shared\s*\}\s*from\s*"\.\/shared"/);
expect(out).toContain("shared()");
});

it("keeps originally-bare imports", () => {
const out = t.transformSync(`
import './side-effect';

export async function getStaticProps(ctx){
return { props: {} };
}
`);
expect(out).toContain("./side-effect");
});
});

it("deletes dead exports and any imports only referenced in dead regions", () => {
Expand Down
Loading