Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 9 additions & 0 deletions src/ast/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ impl Expr {
// https://github.com/oven-sh/bun/issues/2594
Data::ESpread(_) => false,
Data::EMissing(_) => false,
// Inlining `a?.b` as the target of a surrounding member/call
// expression can splice two unrelated optional chains together:
// `[[a?.b]][0]?.[0].c` would become `a?.b.c`, which short-circuits
// past `.c` instead of throwing. The printer can only insert the
// `(a?.b)` wrapper when the parent's own optional_chain is None,
// which we cannot observe here, so bail out conservatively.
Comment thread
robobun marked this conversation as resolved.
Outdated
Data::EDot(e) => e.optional_chain.is_none(),
Data::EIndex(e) => e.optional_chain.is_none(),
Data::ECall(e) => e.optional_chain.is_none(),
_ => true,
}
}
Expand Down
7 changes: 4 additions & 3 deletions src/js_parser/visit/visit_expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1105,9 +1105,10 @@
*e = p.new_expr(E::Undefined {}, inlined.loc);
return;
}
debug_assert!(inlined.can_be_inlined_from_property_access());
*e = inlined;
return;
if inlined.can_be_inlined_from_property_access() {
*e = inlined;
return;
}

Check notice on line 1111 in src/js_parser/visit/visit_expr.rs

View check run for this annotation

Claude / Claude Code Review

[x][0] fold ignores call/delete/assign parent context

Pre-existing (not introduced here), but same bug class at the same fold: the `[x][0] -> x` fold at lines 1091 and 1108 doesn't consult `is_call_target`, `is_delete_target`, or `in_.assign_target`, so `[obj.m][0]()` becomes `obj.m()` (wrong `this`), `delete [obj.p][0]` becomes `delete obj.p`, and `[obj.p][0] = 5` becomes `obj.p = 5` — all observable diffs vs Node. Every sibling fold (comma, `??`, `||`, `&&`, ternary) already guards `is_call_target && x.has_value_for_this_in_call()` and emits `(0,
Comment thread
claude[bot] marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
}
}
}
Expand Down
50 changes: 50 additions & 0 deletions test/bundler/transpiler/transpiler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,56 @@ describe("Bun.Transpiler", () => {
it("works nested", () => {
ts.expectPrintedMin_('const a = ["hey"][0][0];', 'const a = "h"');
});
it("bails out when the array item is an optional chain", () => {
// Folding `[a?.b][0]` to `a?.b` is unsafe when the result lands as the
// target of a surrounding optional-chain continuation: the two chains
// would be spliced into one. `[[a?.b]][0]?.[0].c` must not become
// `a?.b.c`, which short-circuits to `undefined` for `a == null` instead
// of throwing on the trailing `.c`.
ts.expectPrintedMin_("x = [[a?.b]][0]?.[0].c", "x = [a?.b]?.[0].c");
ts.expectPrintedMin_("x = [[a?.b]][0]?.[0]()", "x = [a?.b]?.[0]()");
ts.expectPrintedMin_("x = [[a?.b]][0]?.[0][c]", "x = [a?.b]?.[0][c]");
ts.expectPrintedMin_("x = ({ f: [a?.b] }).f?.[0].c", "x = [a?.b]?.[0].c");
ts.expectPrintedMin_("x = [[a?.[b]]][0]?.[0].c", "x = [a?.[b]]?.[0].c");
ts.expectPrintedMin_("x = [[a?.()]][0]?.[0].c", "x = [a?.()]?.[0].c");

// The outer `?.` on an array literal is dropped at parse time, so these
// reach the fold with `optional_chain == None` on the index and the
// printer adds the `(a?.b)` wrapper itself. Keep bailing on the fold so
// the wrapper isn't load-bearing.
ts.expectPrintedMin_("x = [a?.b][0]", "x = [a?.b][0]");
ts.expectPrintedMin_("x = [a?.b]?.[0]", "x = [a?.b][0]");
ts.expectPrintedMin_("x = [a?.b][0].c", "x = [a?.b][0].c");
ts.expectPrintedMin_("x = [a?.b]?.[0].c", "x = [a?.b][0].c");
ts.expectPrintedMin_("x = [a?.b][0]()", "x = [a?.b][0]()");

// Non-chain items are still inlined.
ts.expectPrintedMin_("x = [[y]][0]?.[0].c", "x = y.c");
ts.expectPrintedMin_("x = [a.b][0].c", "x = a.b.c");
ts.expectPrintedMin_("x = [(a?.b)][0]", "x = [a?.b][0]");
});
it("preserves the TypeError when an inlined optional chain is followed by a non-optional access", async () => {
const cases = [
"[[a?.b]][0]?.[0].c",
"[[a?.b]][0]?.[0]()",
"[[a?.b]][0]?.[0][0]",
"({ f: [a?.b] }).f?.[0].c",
"[a?.b][0].c",
"[a?.b]?.[0].c",
];
const src = cases
.map(e => `try { void (${e}); console.log("no throw"); } catch (e) { console.log(e.constructor.name); }`)
.join("\n");
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", `var a = null;\n${src}`],
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
Comment thread
robobun marked this conversation as resolved.
expect(stdout.trim().split("\n")).toEqual(cases.map(() => "TypeError"));
expect(exitCode).toBe(0);
});
it("bails out on optional-chain index into enum", () => {
const pre = "enum Foo { A }\nenum Bar { 'a-b' = 1 }\n";
const lastLine = out => out.trimEnd().split("\n").at(-1);
Expand Down
Loading