Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
7 changes: 4 additions & 3 deletions src/js_parser/visit/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,10 +279,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
.features
.replace_exports
.get_ptr(name)
.map(|r| (bun_ptr::BackRef::new(r), r.is_replace()));
if let Some((ptr, is_replace)) = found {
.map(bun_ptr::BackRef::new);
if let Some(ptr) = found {
replacement = Some(ptr);
if self.options.features.dead_code_elimination && !is_replace {
// Every entry kind discards this initializer, so nothing it references is a use.
if self.options.features.dead_code_elimination {
self.is_control_flow_dead = true;
}
}
Comment thread
robobun marked this conversation as resolved.
Resolved
Expand Down
22 changes: 20 additions & 2 deletions src/js_parser/visit/visit_stmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,13 +381,18 @@
}

let mut mark_for_replace: bool = false;
// `Replace` keeps this statement with a new value; `Delete` and `Inject` drop it.
let mut replace_keeps_stmt: bool = false;

let orig_dead = p.is_control_flow_dead;
if p.options.features.replace_exports.count() > 0 {
if let Some(entry) = p.options.features.replace_exports.get_ptr(b"default") {
p.is_control_flow_dead =
p.options.features.dead_code_elimination && !entry.is_replace();
// Every entry kind discards the exported value, so nothing it references is a use.
if p.options.features.dead_code_elimination {
p.is_control_flow_dead = true;
}
mark_for_replace = true;
replace_keeps_stmt = entry.is_replace();
}
}

Expand All @@ -397,6 +402,16 @@
};
}

// Only the discarded value is dead; the statement a `Replace` entry keeps is not.
// For the other kinds the flag stays set and the checks below drop the statement.
Comment thread
robobun marked this conversation as resolved.
Resolved
macro_rules! discarded_value_visited {
() => {
if replace_keeps_stmt {
restore_dead!();
}
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

match &mut data.value {
js_ast::StmtOrExpr::Expr(expr) => {
let was_anonymous_named_expr = expr.is_anonymous_named();
Expand All @@ -423,6 +438,7 @@
p.react_compiler_candidate_name = None;
p.react_compiler_in_react_hoc = false;
p.decorator_class_name = prev_decorator_class_name;
discarded_value_visited!();

if p.is_control_flow_dead {
restore_dead!();
Expand Down Expand Up @@ -605,6 +621,7 @@
let open_parens_loc = func.func.open_parens_loc;
func.func = p.visit_func(core::mem::take(&mut func.func), open_parens_loc);
p.react_compiler_candidate_name = None;
discarded_value_visited!();

if p.is_control_flow_dead {
p.react_refresh.hook_ctx_storage = prev;
Expand Down Expand Up @@ -771,9 +788,10 @@
record_on_exit!();
return Ok(());
}
StmtData::SClass(mut class_ref) => {
let class: &mut S::Class = &mut *class_ref;
let _ = p.visit_class(s2_loc, &mut class.class, data.default_name.ref_);
discarded_value_visited!();

Check failure on line 794 in src/js_parser/visit/visit_stmt.rs

View check run for this annotation

Claude / Claude Code Review

export default class + Replace: extends/decorators/field-initializers now reference trimmed imports

For `export default class ... extends Base {}` with a `Replace` entry, visiting the class as dead code stops `Base`'s use-count from being bumped, but line 863 (pre-existing #33378) still emits the class with `extends Base` intact — so `trimUnusedImports` now drops the import and the output throws `ReferenceError: Base is not defined` at runtime. The same applies to decorators, computed keys, and field initializers (only method *bodies* are stripped by the dead-stmt filter; these are visited via
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
claude[bot] marked this conversation as resolved.
Resolved

if p.is_control_flow_dead {
restore_dead!();
Expand Down
75 changes: 75 additions & 0 deletions test/bundler/transpiler/transpiler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2043,6 +2043,81 @@ export default class {
expect(output.includes("localVarToReplace")).toBe(true);
expect(output.includes("localVarToRemove")).toBe(false);
});

describe("the value an entry discards is dead code", () => {
// A plain replacement (`foo`), an injected export (`getStaticProps`) and an eliminated
// export (`loader`) all drop the original value, so the three must trim its imports alike.
const replacing = new Bun.Transpiler({
exports: {
replace: { foo: 42, getStaticProps: ["__N_SSG", true] },
eliminate: ["loader"],
},
treeShaking: true,
trimUnusedImports: true,
});
const replacingDefault = new Bun.Transpiler({
exports: { replace: { default: 42 } },
treeShaking: true,
trimUnusedImports: true,
});
const deadImport = `import deadFS from 'fs';\n`;
const deadCall = `deadFS.readFileSync("/etc/passwd")`;

it.each([
["export const, replaced", `export const foo = () => ${deadCall};`, "export const foo = 42;\n"],
["export var, replaced", `export var foo = () => ${deadCall};`, "export var foo = 42;\n"],
["export let, replaced", `export let foo = () => ${deadCall};`, "export let foo = 42;\n"],
["export function, replaced", `export function foo() { return ${deadCall}; }`, "export var foo = 42;\n"],
[
"export const, injected",
`export const getStaticProps = () => ${deadCall};`,
"export const __N_SSG = true;\n",
],
["export const, eliminated", `export const loader = () => ${deadCall};`, ""],
])("%s: trims an import only the discarded value used", (_, source, expected) => {
expect(replacing.transformSync(deadImport + source)).toBe(expected);
expect(replacing.scan(deadImport + source).imports).toEqual([]);
});

it.each([
["expression", `export default ${deadCall};`],
["arrow function", `export default () => ${deadCall};`],
["function declaration", `export default function Page() { return ${deadCall}; }`],
])("export default %s, replaced: trims an import only the discarded value used", (_, source) => {
expect(replacingDefault.transformSync(deadImport + source)).toBe("export default 42;\n");
expect(replacingDefault.scan(deadImport + source)).toEqual({ exports: ["default"], imports: [] });
});

it("export default class, replaced: trims an import only the discarded value used", () => {
const source = deadImport + `export default class Page { method() { return ${deadCall}; } }`;
expect(replacingDefault.scan(source)).toEqual({ exports: ["default"], imports: [] });
expect(replacingDefault.transformSync(source)).not.toContain("deadFS");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

it("does not record an import() made inside the discarded value", () => {
const source = `export const foo = () => import("./only-used-here");`;
expect(replacing.transformSync(source)).toBe("export const foo = 42;\n");
expect(replacing.scan(source).imports).toEqual([]);
});

it("keeps an import that is also used outside the discarded value", () => {
expect(
replacing.transformSync(
`import deadFS from 'fs';
import liveFS from 'fs';
export const foo = () => ${deadCall};
export function baz() { return liveFS.readFileSync("/etc/passwd"); }`,
),
).toBe(
'import liveFS from "fs";\nexport const foo = 42;\nexport function baz() {\n return liveFS.readFileSync("/etc/passwd");\n}\n',
);

// Only the replaced declaration is dead, not the other declarations of the same statement.
expect(
replacing.transformSync(`import { dead, live } from 'fs';\nexport const foo = () => dead(), other = live;`),
).toBe('import { live } from "fs";\nexport const foo = 42;\nexport const other = live;\n');
});
});
});

const bunTranspiler = new Bun.Transpiler({
Expand Down