Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
4 changes: 4 additions & 0 deletions src/js_parser/p.rs
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,9 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> {
// If this is true, then all top-level statements are wrapped in a try/catch
pub(crate) will_wrap_module_in_try_catch_for_using: bool,

/// The statement in visit is the export that an entry of `exports.replace` made, so no entry applies to it.
pub(crate) is_visiting_replaced_export: bool,

/// Used for react refresh, it must be able to insert `const _s = $RefreshSig$();`
pub(crate) nearest_stmt_list: Option<NonNull<ListManaged<'a, Stmt>>>,
// Lifetime caution: points at a stack local saved/restored across calls.
Expand Down Expand Up @@ -9908,6 +9911,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
top_level_enums: BumpVec::new_in(arena),
scopes_in_order_for_enum: Default::default(),
will_wrap_module_in_try_catch_for_using: false,
is_visiting_replaced_export: false,
nearest_stmt_list: None,
decorator_class_name: None,

Expand Down
57 changes: 45 additions & 12 deletions src/js_parser/visit/visit_stmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
use crate::Error;
use crate::lexer as js_lexer;
use crate::p::{P, ReactRefreshExportKind};
use crate::parser::Runtime::ReplaceableExport;
use crate::parser::{
PrependTempRefsOpts, ReactRefresh, Ref, RelocateVarsMode, SideEffects, StmtsKind,
statement_cares_about_scope,
Expand Down Expand Up @@ -265,6 +266,47 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
Ok(())
}

/// Puts the export of `entry` in the place of `alias`, a name that a re-export exports and the module does not bind.
#[cold]
#[inline(never)]
fn replace_reexport(
p: &mut Self,
stmts: &mut StmtList<'a>,
alias: &'a [u8],
entry: &ReplaceableExport,
) {
let exported_name: &[u8] = match entry {
ReplaceableExport::Inject { name, .. } => name,
ReplaceableExport::Replace(_) | ReplaceableExport::Delete => alias,
};
match entry {
ReplaceableExport::Replace(value) | ReplaceableExport::Inject { value, .. }
if exported_name == js_ast::ClauseItem::DEFAULT_ALIAS =>
{
let mut export_default = p.s(
S::ExportDefault {
default_name: js_ast::LocRef::default(),
value: js_ast::StmtOrExpr::Expr(*value),
},
bun_ast::Loc::EMPTY,
);
p.is_visiting_replaced_export = true;
p.visit_and_append_stmt(stmts, &mut export_default)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
.expect("unreachable");
p.is_visiting_replaced_export = false;
}
ReplaceableExport::Replace(_) => {
let declared = p
.declare_symbol(js_ast::symbol::Kind::Other, bun_ast::Loc::EMPTY, alias)
.expect("unreachable");
let _ = p.inject_replacement_export(stmts, declared, bun_ast::Loc::EMPTY, entry);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
ReplaceableExport::Delete | ReplaceableExport::Inject { .. } => {
let _ = p.inject_replacement_export(stmts, Ref::NONE, bun_ast::Loc::EMPTY, entry);
Comment thread
robobun marked this conversation as resolved.
Resolved
}
}
}

fn s_export_from(
p: &mut Self,
stmts: &mut StmtList<'a>,
Expand All @@ -289,8 +331,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
// alias is arena-owned (`ArenaStr`), valid for 'a.
let alias = items[i].alias.slice();
if let Some(entry) = p.options.features.replace_exports.get_ptr(alias).cloned() {
let _ =
p.inject_replacement_export(stmts, old_ref, bun_ast::Loc::EMPTY, &entry);
Self::replace_reexport(p, stmts, alias, &entry);
continue;
}

Comment thread
robobun marked this conversation as resolved.
Resolved
Expand Down Expand Up @@ -345,15 +386,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
.get_ptr(alias_name)
.cloned()
{
let declared = p
.declare_symbol(
js_ast::symbol::Kind::Other,
bun_ast::Loc::EMPTY,
alias_name,
)
.expect("unreachable");
let _ =
p.inject_replacement_export(stmts, declared, bun_ast::Loc::EMPTY, &entry);
Self::replace_reexport(p, stmts, alias_name, &entry);
return Ok(());
}
}
Expand Down Expand Up @@ -384,7 +417,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
let mut mark_for_replace: bool = false;

let orig_dead = p.is_control_flow_dead;
if p.options.features.replace_exports.count() > 0 {
if p.options.features.replace_exports.count() > 0 && !p.is_visiting_replaced_export {
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();
Expand Down
190 changes: 190 additions & 0 deletions test/bundler/transpiler/transpiler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2330,6 +2330,196 @@ export default class {
trimUnusedImports: true,
});

describe("a name that a re-export exports", () => {
const replace = (entries, options) => ({ exports: { replace: entries }, ...options });
const printed = stdout => ({ stdout, stderr: "", exitCode: 0, signalCode: null });

// [options, source, printed module, exports that scan() reports]
const rows = [
[
replace({ ReadStream: "x" }),
`export { ReadStream, WriteStream } from "node:fs"`,
`export var ReadStream = "x";\nexport { WriteStream } from "node:fs";\n`,
["ReadStream", "WriteStream"],
],
[
replace({ a: 1, c: 3 }),
`export { a, b, c } from "x"`,
`export var a = 1;\nexport var c = 3;\nexport { b } from "x";\n`,
["a", "b", "c"],
],
[
replace({ bb: 1 }),
`export { a as bb, b } from "x"`,
`export var bb = 1;\nexport { b } from "x";\n`,
["b", "bb"],
],
[replace({ a: 1 }), `export { a as bb } from "x"`, `export { a as bb } from "x";\n`, ["bb"]],
[
replace({ b: 1 }),
`export { "s t" as b, c } from "x"`,
`export var b = 1;\nexport { c } from "x";\n`,
["b", "c"],
],
[
replace({ abc: 1 }),
`export { \\u0061bc, c } from "x"`,
`export var abc = 1;\nexport { c } from "x";\n`,
["abc", "c"],
],
[
replace({ a: 1 }),
`export { default as a, b } from "x"`,
`export var a = 1;\nexport { b } from "x";\n`,
["a", "b"],
],
[
replace({ default: 1 }),
`export { default, b } from "x"`,
`export default 1;\nexport { b } from "x";\n`,
["b", "default"],
],
[
replace({ default: 1 }),
`export { a as default, b } from "x"`,
`export default 1;\nexport { b } from "x";\n`,
["b", "default"],
],
[
replace({ a: ["default", 1] }),
`export { a, b } from "x"`,
`export default 1;\nexport { b } from "x";\n`,
["b", "default"],
],
// The entry that makes `export default` is the only entry that applies to it.
[
replace({ a: ["default", 1], default: 2 }),
`export { a, b } from "x"`,
`export default 1;\nexport { b } from "x";\n`,
["b", "default"],
],
[
replace({ a: ["default", 1], default: ["N", 2] }),
`export { a, b } from "x"`,
`export default 1;\nexport { b } from "x";\n`,
["b", "default"],
],
[
replace({ a: ["N", true] }),
`export { a, b } from "x"`,
`export var N = true;\nexport { b } from "x";\n`,
["N", "b"],
],
[
{ exports: { eliminate: ["bb", "default"] } },
`export { a as bb, c, default } from "x"`,
`export { c } from "x";\n`,
["c"],
],
[replace({ ns: 1 }), `export * as ns from "x"`, `export var ns = 1;\n`, ["ns"]],
[replace({ default: 1 }), `export * as default from "x"`, `export default 1;\n`, ["default"]],
[replace({ ns: ["default", 1] }), `export * as ns from "x"`, `export default 1;\n`, ["default"]],
// An entry that eliminates or injects does not declare the name of the re-export.
[{ exports: { eliminate: ["ns"] } }, `const ns = 1; export * as ns from "x"`, `const ns = 1;\n`, []],
[
replace({ ns: ["N", 2] }),
`const ns = 1; export * as ns from "x"`,
`const ns = 1;\nexport var N = 2;\n`,
["N"],
],
[
replace({ ReadStream: 1 }, { minify: { identifiers: true } }),
`const local = f(); g(local); export { ReadStream, WriteStream } from "node:fs"`,
`const e = f();\ng(e);\nexport var ReadStream = 1;\nexport { WriteStream } from "node:fs";\n`,
["ReadStream", "WriteStream"],
],
[
replace({ a: 1 }, { treeShaking: false }),
`export { a, b } from "x"`,
`export var a = 1;\nexport { b } from "x";\n`,
["a", "b"],
],
];

// [options, source, exports of the printed module]
// The lowering of a top-level `using` prints helper names, so these rows compare what the module exports.
const rowsWithUsing = [
[
replace({ default: 1, b: 2 }),
`using r = null; export { a as default, b } from "./x.mjs"`,
{ b: 2, default: 1 },
],
[replace({ default: 1 }), `await using r = null; export * as default from "./x.mjs"`, { default: 1 }],
[replace({ a: ["default", 1] }), `using r = null; export { a, b } from "./x.mjs"`, { b: "b", default: 1 }],
];

// [options, source, exports of the printed module]. The comment of a row is what it does today.
const rowsThatCannotBind = [
// prints: export var class = 1;
[replace({ class: 1 }), `export { a as class, b } from "x"`, ["b", "class"]],
// throws: "bb" has already been declared
[replace({ bb: 1 }), `let bb = 2; export { a as bb } from "x"`, ["bb"]],
// prints: export var default = 1;
[replace({ g: ["default", 1] }), `export function g() {}`, ["default"]],
// prints: export const default = 1;
[replace({ x: ["default", 1] }), `export const x = 5`, ["default"]],
];

// The first row aborted the process, so the rows run in one subprocess. It prints one line for each test.
let subprocess;
const run = () =>
(subprocess ??= (async () => {
using dir = tempDir("exports-replace-reexport", {
"x.mjs": `export const a = "a", b = "b"; export default "x";`,
});
const print = async (rows, rowsWithUsing, rowsThatCannotBind, dir) => {
const modules = [];
for (const [options, source] of rows) {
const transpiler = new Bun.Transpiler({ loader: "ts", ...options });
modules.push([transpiler.transformSync(source), transpiler.scan(source).exports]);
}
const [options, source] = rows[0];
modules.push(new Bun.Transpiler({ loader: "js", ...options }).transformSync(source));
modules.push(await new Bun.Transpiler({ loader: "ts", ...options }).transform(source));
for (const [i, [options, source]] of rowsWithUsing.entries()) {
const file = require("path").join(dir, "module-" + i + ".mjs");
await Bun.write(file, new Bun.Transpiler({ loader: "ts", ...options }).transformSync(source));
modules.push({ ...(await import(file)) });
}
console.log(JSON.stringify(modules));

const names = rowsThatCannotBind.map(([options, source]) => {
try {
const module = new Bun.Transpiler({ loader: "ts", ...options }).transformSync(source);
return new Bun.Transpiler({ loader: "ts" }).scan(module).exports;
} catch (error) {
return error.message;
}
});
console.log(JSON.stringify(names));
};
const args = [rows, rowsWithUsing, rowsThatCannotBind, String(dir)];
return await bunRun(["-e", `await (${print})(...${JSON.stringify(args)})`]);
})());

it("prints the export under its exported name", async () => {
const modules = rows.map(([, , module, names]) => [module, names]);
const exported = rowsWithUsing.map(([, , exported]) => exported);
const result = await run();
expect({ ...result, stdout: result.stdout.split("\n")[0] }).toEqual(
printed(JSON.stringify([...modules, rows[0][2], rows[0][2], ...exported])),
);
});

it.failing("a name that the module cannot bind", async () => {
const result = await run();
const [, names] = result.stdout.split("\n");
// A subprocess that did not print the names is not the failure that this test expects.
if (result.exitCode !== 0 || names === undefined) return;
expect(names).toBe(JSON.stringify(rowsThatCannotBind.map(([, , names]) => names)));
});
Comment thread
robobun marked this conversation as resolved.
Resolved
});

it("a deletes dead exports and any imports only referenced in dead regions", () => {
const out = transpiler.transformSync(`
import {getUserById} from './my-database';
Expand Down
Loading