Skip to content
1 change: 1 addition & 0 deletions src/js_parser/fold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
&& !identifier_opts.is_delete_target()
&& identifier_opts.assign_target() == js_ast::AssignTarget::None
&& !identifier_opts.is_call_target()
&& !identifier_opts.is_template_tag()
{
let prop: &G::Property = &obj.properties.slice()[0];
if let (Some(value), Some(key)) = (prop.value, prop.key) {
Expand Down
2 changes: 2 additions & 0 deletions src/js_parser/p.rs
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> {
// syntactic constructs as appropriate.
pub(crate) stmt_expr_value: js_ast::ExprData,
pub(crate) call_target: js_ast::ExprData,
pub(crate) template_tag: js_ast::ExprData,
Comment thread
robobun marked this conversation as resolved.
pub(crate) delete_target: js_ast::ExprData,
pub(crate) loop_body: js_ast::StmtData,
pub(crate) module_scope: js_ast::StoreRef<js_ast::Scope>,
Expand Down Expand Up @@ -8710,6 +8711,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
allow_in: true,

call_target: null_expr_data(),
template_tag: null_expr_data(),
delete_target: null_expr_data(),
stmt_expr_value: null_expr_data(),
loop_body: null_stmt_data(),
Expand Down
16 changes: 13 additions & 3 deletions src/js_parser/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -993,9 +993,9 @@ impl AsyncPrefixExpression {
}

// Packed u8 — assign_target:u2, is_delete_target:b1,
// was_originally_identifier:b1, is_call_target:b1, _padding:u3 (LSB-first).
// Not all-bool (assign_target is a 2-bit enum), so per PORTING.md we use a
// transparent u8 with manual shift accessors.
// was_originally_identifier:b1, is_call_target:b1, is_template_tag:b1,
// _padding:u2 (LSB-first). Not all-bool (assign_target is a 2-bit enum),
// so per PORTING.md we use a transparent u8 with manual shift accessors.
Comment thread
robobun marked this conversation as resolved.
#[repr(transparent)]
#[derive(Clone, Copy, Default, PartialEq, Eq)]
pub struct IdentifierOpts(u8);
Expand All @@ -1005,6 +1005,7 @@ impl IdentifierOpts {
const IS_DELETE_TARGET: u8 = 1 << 2;
const WAS_ORIGINALLY_IDENTIFIER: u8 = 1 << 3;
const IS_CALL_TARGET: u8 = 1 << 4;
const IS_TEMPLATE_TAG: u8 = 1 << 5;

#[inline]
pub(crate) const fn assign_target(self) -> js_ast::AssignTarget {
Expand Down Expand Up @@ -1032,6 +1033,10 @@ impl IdentifierOpts {
pub(crate) const fn is_call_target(self) -> bool {
self.0 & Self::IS_CALL_TARGET != 0
}
#[inline]
pub(crate) const fn is_template_tag(self) -> bool {
self.0 & Self::IS_TEMPLATE_TAG != 0
}

// Builder-style helpers (this stays a packed u8 rather than a
// named-field struct).
Expand Down Expand Up @@ -1059,6 +1064,11 @@ impl IdentifierOpts {
self.0 = (self.0 & !Self::IS_CALL_TARGET) | ((v as u8) << 4);
self
}
#[inline]
pub(crate) const fn with_is_template_tag(mut self, v: bool) -> Self {
self.0 = (self.0 & !Self::IS_TEMPLATE_TAG) | ((v as u8) << 5);
self
}
}

pub(crate) fn statement_cares_about_scope(stmt: &Stmt) -> bool {
Expand Down
21 changes: 17 additions & 4 deletions src/js_parser/visit/visit_binary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ impl BinaryExpressionVisitor {

let is_call_target =
matches!(p.call_target, ExprData::EBinary(ptr) if core::ptr::eq(ptr.as_ptr(), e_ptr));
let is_template_tag =
matches!(p.template_tag, ExprData::EBinary(ptr) if core::ptr::eq(ptr.as_ptr(), e_ptr));
Comment thread
robobun marked this conversation as resolved.
Outdated
let was_anonymous_named_expr = e_.right.is_anonymous_named();
let prev_decorator_class_name = p.decorator_class_name;

Expand Down Expand Up @@ -225,7 +227,9 @@ impl BinaryExpressionVisitor {
} else {
// The left operand has no side effects, but we need to preserve
// the comma operator semantics when used as a call target
if is_call_target && e_.right.has_value_for_this_in_call() {
if (is_call_target || is_template_tag)
&& e_.right.has_value_for_this_in_call()
{
// Keep the comma expression to strip "this" binding
e_.left = Expr {
data: prefill::data::ZERO,
Expand Down Expand Up @@ -369,7 +373,10 @@ impl BinaryExpressionVisitor {
// "(null ?? fn)()" => "fn()"
// "(null ?? this.fn)" => "this.fn"
// "(null ?? this.fn)()" => "(0, this.fn)()"
if is_call_target && e_.right.has_value_for_this_in_call() {
// "(null ?? this.fn)`x`" => "(0, this.fn)`x`"
if (is_call_target || is_template_tag)
&& e_.right.has_value_for_this_in_call()
{
return Expr::join_with_comma(
Expr {
data: ExprData::ENumber(E::Number::new(0.0)),
Expand All @@ -392,7 +399,10 @@ impl BinaryExpressionVisitor {
// "(0 || fn)()" => "fn()"
// "(0 || this.fn)" => "this.fn"
// "(0 || this.fn)()" => "(0, this.fn)()"
if is_call_target && e_.right.has_value_for_this_in_call() {
// "(0 || this.fn)`x`" => "(0, this.fn)`x`"
if (is_call_target || is_template_tag)
&& e_.right.has_value_for_this_in_call()
{
return Expr::join_with_comma(
Expr {
data: prefill::data::ZERO,
Expand All @@ -414,7 +424,10 @@ impl BinaryExpressionVisitor {
// "(1 && fn)()" => "fn()"
// "(1 && this.fn)" => "this.fn"
// "(1 && this.fn)()" => "(0, this.fn)()"
if is_call_target && e_.right.has_value_for_this_in_call() {
// "(1 && this.fn)`x`" => "(0, this.fn)`x`"
if (is_call_target || is_template_tag)
&& e_.right.has_value_for_this_in_call()
{
return Expr::join_with_comma(
Expr {
data: prefill::data::ZERO,
Comment thread
robobun marked this conversation as resolved.
Expand Down
35 changes: 25 additions & 10 deletions src/js_parser/visit/visit_expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -692,8 +692,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
let expr = *e;
let _ = in_;
let mut e_ = expr.data.e_template().expect("infallible: variant checked");
if e_.tag.is_some() {
p.visit_expr(e_.tag.as_mut().unwrap());
if let Some(tag) = e_.tag.as_mut() {
p.template_tag = tag.data;
p.visit_expr(tag);
}

// Visit the interpolation values before the macro dispatch below: its
Expand Down Expand Up @@ -885,6 +886,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
let expr = *e;
let mut e_ = expr.data.e_index().expect("infallible: variant checked");
let is_call_target = matches!(p.call_target, Data::EIndex(ct) if core::ptr::eq(&raw const *e_, &raw const *ct));
let is_template_tag = matches!(p.template_tag, Data::EIndex(tt) if core::ptr::eq(&raw const *e_, &raw const *tt));
let is_delete_target = matches!(p.delete_target, Data::EIndex(dt) if core::ptr::eq(&raw const *e_, &raw const *dt));

// "a['b']" => "a.b"
Expand All @@ -905,6 +907,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
if is_call_target {
p.call_target = dot.data;
}
if is_template_tag {
p.template_tag = dot.data;
}
if is_delete_target {
p.delete_target = dot.data;
}
Expand Down Expand Up @@ -1020,6 +1025,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
if is_call_target {
p.call_target = dot.data;
}
if is_template_tag {
p.template_tag = dot.data;
}
if is_delete_target {
p.delete_target = dot.data;
}
Expand All @@ -1041,7 +1049,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
unwrapped.loc,
IdentifierOpts::default()
.with_is_call_target(is_call_target)
// .is_template_tag = is_template_tag,
.with_is_template_tag(is_template_tag)
.with_is_delete_target(is_delete_target)
.with_assign_target(in_.assign_target),
) {
Expand All @@ -1057,7 +1065,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
let target = e_.target.unwrap_inlined();
let index = e_.index.unwrap_inlined();

if p.options.features.minify_syntax {
// `[obj.m][0]` / `"s"[n]` are property references into a temporary;
// folding them to a value in tag position would rebind `this`.
Comment thread
robobun marked this conversation as resolved.
if p.options.features.minify_syntax && !is_template_tag {
Comment thread
robobun marked this conversation as resolved.
if let Some(number) = index.data.as_e_number() {
if number.value() >= 0.0
&& number.value() < (usize::MAX as f64)
Expand Down Expand Up @@ -1335,6 +1345,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
let mut e_ = expr.data.e_dot().expect("infallible: variant checked");
let is_delete_target = matches!(p.delete_target, Data::EDot(dt) if core::ptr::eq(&raw const *e_, &raw const *dt));
let is_call_target = matches!(p.call_target, Data::EDot(ct) if core::ptr::eq(&raw const *e_, &raw const *ct));
let is_template_tag = matches!(p.template_tag, Data::EDot(tt) if core::ptr::eq(&raw const *e_, &raw const *tt));

// `p.define: &'a Define` is `Copy`; hoist so the `dots.get` borrow is
// tied to `'a`, not `&*p`, and `&mut self` helpers below can be called
Expand Down Expand Up @@ -1427,9 +1438,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
e_.name_loc,
IdentifierOpts::default()
.with_is_call_target(is_call_target)
.with_is_template_tag(is_template_tag)
.with_assign_target(in_.assign_target)
.with_is_delete_target(is_delete_target),
// .is_template_tag = p.template_tag != null,
) {
*e = _expr;
return;
Expand Down Expand Up @@ -1460,6 +1471,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
let mut e_ = e.data.e_if().expect("infallible: variant checked");
let is_call_target =
matches!(p.call_target, Data::EIf(ct) if core::ptr::eq(&raw const *e_, &raw const *ct));
let is_template_tag =
matches!(p.template_tag, Data::EIf(tt) if core::ptr::eq(&raw const *e_, &raw const *tt));

let prev_in_branch = p.in_branch_condition;
p.in_branch_condition = true;
Expand Down Expand Up @@ -1493,7 +1506,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
// "(1 ? fn : 2)()" => "fn()"
// "(1 ? this.fn : 2)" => "this.fn"
// "(1 ? this.fn : 2)()" => "(0, this.fn)()"
if is_call_target && e_.yes.has_value_for_this_in_call() {
// "(1 ? this.fn : 2)`x`" => "(0, this.fn)`x`"
if (is_call_target || is_template_tag) && e_.yes.has_value_for_this_in_call() {
*e = p
.new_expr(E::Number::new(0.0), e_.test.loc)
.join_with_comma(e_.yes);
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
Expand All @@ -1518,10 +1532,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
return;
}

// "(1 ? fn : 2)()" => "fn()"
// "(1 ? this.fn : 2)" => "this.fn"
// "(1 ? this.fn : 2)()" => "(0, this.fn)()"
if is_call_target && e_.no.has_value_for_this_in_call() {
// "(0 ? 1 : fn)()" => "fn()"
// "(0 ? 1 : this.fn)" => "this.fn"
// "(0 ? 1 : this.fn)()" => "(0, this.fn)()"
// "(0 ? 1 : this.fn)`x`" => "(0, this.fn)`x`"
Comment thread
robobun marked this conversation as resolved.
if (is_call_target || is_template_tag) && e_.no.has_value_for_this_in_call() {
*e = p
.new_expr(E::Number::new(0.0), e_.test.loc)
.join_with_comma(e_.no);
Expand Down
5 changes: 4 additions & 1 deletion src/jsc/RuntimeTranspilerCache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ bun_core::declare_scope!(cache, visible);
/// bindings from the compiled bytecode after the module-loader rewrite, so the
/// record no longer carries them; blobs written in the old numbering must not
/// be read back.
const EXPECTED_VERSION: u32 = 24;
/// Version 25: `p.template_tag` is tracked during the visit pass, so wrapper
/// folds (`(0, a.b)`, `cond ? a.b : x`, `a.b ?? x`, `[a.b][0]`, `{f:a.b}.f`)
/// no longer strip the indirection when the result is a tagged-template tag.
Comment thread
robobun marked this conversation as resolved.
const EXPECTED_VERSION: u32 = 25;

/// Source files smaller than this are not written to / read from the on-disk
/// transpiler cache. Originally 50 KiB, which excluded almost every file in a
Expand Down
58 changes: 58 additions & 0 deletions test/bundler/transpiler/transpiler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3919,6 +3919,64 @@ console.log(foo, array);
expectPrinted("(0, func())", "func()");
});

it("tagged-template tag folds preserve `this`", () => {
const expectPrinted = (code, out) => {
expect(parsed(code, true, true, transpilerMinifySyntax)).toBe(out);
};

// A tagged template binds `this` the same way a call does: folding a
// wrapper away so that a member expression lands directly in tag
// position would change the receiver. These match the call-target
// cases above, emitting `(0, obj.m)` to strip `this`.
expectPrinted("(0, obj.m)`x`", "(0, obj.m)`x`");
expectPrinted("(0, obj[k])`x`", "(0, obj[k])`x`");
expectPrinted("(1 ? obj.m : 0)`x`", "(0, obj.m)`x`");
expectPrinted("(0 ? 0 : obj.m)`x`", "(0, obj.m)`x`");
expectPrinted("(null ?? obj.m)`x`", "(0, obj.m)`x`");
expectPrinted("(1 && obj.m)`x`", "(0, obj.m)`x`");
expectPrinted("(0 || obj.m)`x`", "(0, obj.m)`x`");
// Property-access folds that replace a reference with its value bail
// out entirely in tag position.
expectPrinted("[obj.m][0]`x`", "[obj.m][0]`x`");
expectPrinted("({ m: obj.m }).m`x`", "{ m: obj.m }.m`x`");
expectPrinted('({ m: obj.m })["m"]`x`', "{ m: obj.m }.m`x`");

// Still folded when the wrapped value carries no `this`.
expectPrinted("(0, fn)`x`", "fn`x`");
expectPrinted("(1 ? fn : 0)`x`", "fn`x`");
expectPrinted("(null ?? fn)`x`", "fn`x`");
expectPrinted("(1 && fn)`x`", "fn`x`");
expectPrinted("(0 || fn)`x`", "fn`x`");

// Still folded outside call/tag position.
expectPrinted("(0, obj.m)", "obj.m");
expectPrinted("[obj.m][0]", "obj.m");
expectPrinted("({ m: obj.m }).m", "obj.m");
});

it("tagged-template tag `this` matches node at runtime", async () => {
const src = `
var obj = { m() { return this === obj; } };
console.log(JSON.stringify([
(0, obj.m)\`x\`,
(1 ? obj.m : 0)\`x\`,
(0 ? 0 : obj.m)\`x\`,
(null ?? obj.m)\`x\`,
(true && obj.m)\`x\`,
(false || obj.m)\`x\`,
[obj.m][0]\`x\`,
({ m: obj.m }).m\`x\`,
({ m: obj.m })["m"]\`x\`,
obj.m\`x\`,
]));
`;
await using proc = Bun.spawn({ cmd: [bunExe(), "-e", src], env: bunEnv, stderr: "pipe" });
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout).toBe("[false,false,false,false,false,false,false,false,false,true]\n");
expect(exitCode).toBe(0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it("constant folding", () => {
const expectPrinted = (code, out) => {
expect(parsed(code, true, true, transpilerMinifySyntax)).toBe(out);
Expand Down
Loading