From dacae929aeeb81191a7b6cd80b9ea8f7c2dd4306 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:50:30 +0000 Subject: [PATCH 1/7] js_parser: accept import attributes on the next line, for await (async of, and print (let)[0] at a statement start --- src/js_parser/parse/mod.rs | 22 ++++++------ src/js_printer/lib.rs | 23 ++++++++++++ test/bundler/transpiler/transpiler.test.js | 41 +++++++++++++++++++++- 3 files changed, 75 insertions(+), 11 deletions(-) diff --git a/src/js_parser/parse/mod.rs b/src/js_parser/parse/mod.rs index e31557ff2656..b7f8ce286c50 100644 --- a/src/js_parser/parse/mod.rs +++ b/src/js_parser/parse/mod.rs @@ -1342,15 +1342,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.lexer.expect(T::TStringLiteral)?; } - if !p.lexer.has_newline_before - && ( - // Import Assertions are deprecated. - // Import Attributes are the new way to do this. - // But some code may still use "assert" - // We support both and treat them identically. - // Once Prettier & TypeScript support import attributes, we will add runtime support - p.lexer.is_contextual_keyword(b"assert") || p.lexer.token == T::TWith - ) + // Import Assertions are deprecated. + // Import Attributes are the new way to do this. + // But some code may still use "assert" + // We support both and treat them identically. + // Only "assert" has a [no LineTerminator here] restriction. "with" may + // start on the next line. + if p.lexer.token == T::TWith + || (!p.lexer.has_newline_before && p.lexer.is_contextual_keyword(b"assert")) { p.lexer.next()?; p.lexer.expect(T::TOpenBrace)?; @@ -1622,7 +1621,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // In TypeScript, "async " not followed by "=>" treats "async" as // a plain identifier (e.g. "async as T"), matching tsc's two-token // lookahead in isUnParenthesizedAsyncArrowFunctionWorker (TypeScript#8444). - let is_arrow_fn = !Self::IS_TYPESCRIPT_ENABLED + // "async of" is only an arrow function if "=>" follows, so that + // "for await (async of xs)" parses as a for-of over the identifier "async". + let is_arrow_fn = (!Self::IS_TYPESCRIPT_ENABLED + && !p.lexer.is_contextual_keyword(b"of")) || p.check_for_arrow_after_the_current_token(); if is_arrow_fn { diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 5ec6076920e5..71f1ee02b14d 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -1658,6 +1658,9 @@ pub(crate) mod __gated_printer { pub(crate) export_default_start: i32, pub(crate) arrow_expr_start: i32, pub(crate) for_of_init_start: i32, + /// Start of the expression in a for, for-in, or for-of head. No such + /// expression may begin with the tokens `let [`. + pub(crate) for_init_start: i32, pub(crate) prev_op: Op::Code, pub(crate) prev_op_end: i32, pub(crate) prev_num_end: i32, @@ -3753,7 +3756,25 @@ pub(crate) mod __gated_printer { // The index target is not directly followed by `of`. flags.remove(ExprFlag::IsFollowedByOf); + + // An expression statement or a for loop head must not start + // with the tokens "let [", so "let[0] = 1" prints as "(let)[0] = 1". + let wrap_let = { + let n = self.writer.written(); + (n == self.stmt_start || n == self.for_init_start) + && !matches!(e.index.data, ExprData::EPrivateIdentifier(_)) + && match &e.target.data { + ExprData::EIdentifier(id) => self.name_for_symbol(id.ref_) == b"let", + _ => false, + } + }; + if wrap_let { + self.print(b"("); + } self.print_expr(e.target, Level::Postfix, flags); + if wrap_let { + self.print(b")"); + } let is_optional_chain_start = e.optional_chain == Some(js_ast::OptionalChain::Start); @@ -6529,6 +6550,7 @@ pub(crate) mod __gated_printer { pub(crate) fn print_for_loop_init(&mut self, init_st: Stmt, extra_flags: ExprFlagSet) { match &init_st.data { StmtData::SExpr(s) => { + self.for_init_start = self.writer.written(); self.print_expr( s.value, Level::Lowest, @@ -7012,6 +7034,7 @@ pub(crate) mod __gated_printer { export_default_start: -1, arrow_expr_start: -1, for_of_init_start: -1, + for_init_start: -1, prev_op: Op::Code::BinAdd, prev_op_end: -1, prev_num_end: -1, diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 6ef742958718..f3627581a599 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2958,12 +2958,41 @@ console.log(
);`), expectPrinted_("for ((let) of [7]);", "for ((let) of [7])\n ;\n"); // The keyword spelling is a syntax error, which is why the parentheses matter - expect(() => parsed("for (async of [7]);", false, false)).toThrow(); + expectParseError("for (async of [7]);", 'For loop initializers cannot start with "async of"'); expectParseError("for (async\nof [7]);", 'For loop initializers cannot start with "async of"'); expectPrinted_( "async function f() { for await (async\nof [7]); }", "async function f() {\n for await ((async) of [7])\n ;\n}", ); + + // "for await (async of" has no such restriction: "async" is a plain identifier there + expectPrinted_( + "async function f() { for await (async of [7]); }", + "async function f() {\n for await ((async) of [7])\n ;\n}", + ); + expectPrinted_("for (async of => {};;);", "for (async (of) => {};; )\n ;\n"); + expectPrinted_("x = async of => of", "x = async (of) => of"); + expectParseError("async of", 'Expected ";" but found "of"'); + }); + + it("let [ at the start of a statement keeps its parentheses", () => { + // An expression statement and a for loop head must not start with "let [". + expectPrinted_("var let = []; (let)[0] = 1;", "var let = [];\n(let)[0] = 1;\n"); + expectPrinted_("if (1) (let)[0] = 2;", "if (1)\n (let)[0] = 2;\n"); + expectPrinted_("(let)[0].x = 1;", "(let)[0].x = 1;\n"); + expectPrinted_("(let)[0]();", "(let)[0]();\n"); + expectPrinted_("(let)[0]++;", "(let)[0]++;\n"); + expectPrinted_("for ((let)[0] in x);", "for ((let)[0] in x)\n ;\n"); + expectPrinted_("for ((let)[0] of x);", "for ((let)[0] of x)\n ;\n"); + expectPrinted_("for ((let)[0];;);", "for ((let)[0];; )\n ;\n"); + + // No parentheses are needed anywhere else + expectPrinted_("var let = {}; (let).x = 1;", "var let = {};\nlet.x = 1;\n"); + expectPrinted_("x = (let)[0];", "x = let[0];\n"); + expectPrinted_("x, (let)[0] = 1;", "x, let[0] = 1;\n"); + expectPrinted_("for (x; (let)[0];);", "for (x;let[0]; )\n ;\n"); + expectPrinted_("for (x in (let)[0]);", "for (x in let[0])\n ;\n"); + expectPrinted_("(lett)[0] = 1;", "lett[0] = 1;\n"); }); it("await", () => { @@ -2995,6 +3024,16 @@ console.log(
);`), expectPrinted_(`import("./foo.json", { type: "json" });`, `import("./foo.json", { type: \"json\" })`); }); + it("import attributes on the next line", () => { + // "with" has no [no LineTerminator here] restriction, unlike the legacy "assert" + expectPrinted_(`import json from "./foo.json"\nwith { type: "json" };`, `import json from "./foo.json"`); + expectPrinted_(`import json from "./foo.json"\n with { type: "json" };`, `import json from "./foo.json"`); + expectPrinted_(`import "./foo.json"\nwith { type: "json" };`, `import"./foo.json"`); + expectPrinted_(`export { a } from "./foo.json"\nwith { type: "json" };`, `export { a } from "./foo.json"`); + expectPrinted_(`export * from "./foo.json"\nwith { type: "json" };`, `export * from "./foo.json"`); + expect(() => parsed(`import json from "./foo.json"\nassert { type: "json" };`, false, false)).toThrow(); + }); + it("import with unicode", () => { expectPrinted_(`import { name } from 'modထ';`, `import { name } from "modထ"`); expectPrinted_(`import { name } from 'mod\\u1011';`, `import { name } from "modထ"`); From 209bfe3de897e02ece5a73d8637182da6fafb0d9 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:07:19 +0000 Subject: [PATCH 2/7] [autofix.ci] apply automated fixes --- src/js_printer/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 71f1ee02b14d..4dd095411657 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -3764,7 +3764,9 @@ pub(crate) mod __gated_printer { (n == self.stmt_start || n == self.for_init_start) && !matches!(e.index.data, ExprData::EPrivateIdentifier(_)) && match &e.target.data { - ExprData::EIdentifier(id) => self.name_for_symbol(id.ref_) == b"let", + ExprData::EIdentifier(id) => { + self.name_for_symbol(id.ref_) == b"let" + } _ => false, } }; From 206ebc616bd12c5af2d2d3278073103ea9dbe512 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:05:32 +0000 Subject: [PATCH 3/7] js_parser: shorten comments --- src/js_parser/parse/mod.rs | 11 +++-------- src/js_printer/lib.rs | 6 ++---- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/js_parser/parse/mod.rs b/src/js_parser/parse/mod.rs index b7f8ce286c50..8a69226c9b5f 100644 --- a/src/js_parser/parse/mod.rs +++ b/src/js_parser/parse/mod.rs @@ -1342,12 +1342,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.lexer.expect(T::TStringLiteral)?; } - // Import Assertions are deprecated. - // Import Attributes are the new way to do this. - // But some code may still use "assert" - // We support both and treat them identically. - // Only "assert" has a [no LineTerminator here] restriction. "with" may - // start on the next line. + // Import attributes ("with") and the deprecated import assertions ("assert") + // are treated identically. Only "assert" has a [no LineTerminator here] restriction. if p.lexer.token == T::TWith || (!p.lexer.has_newline_before && p.lexer.is_contextual_keyword(b"assert")) { @@ -1621,8 +1617,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // In TypeScript, "async " not followed by "=>" treats "async" as // a plain identifier (e.g. "async as T"), matching tsc's two-token // lookahead in isUnParenthesizedAsyncArrowFunctionWorker (TypeScript#8444). - // "async of" is only an arrow function if "=>" follows, so that - // "for await (async of xs)" parses as a for-of over the identifier "async". + // "async of" needs the same lookahead: "for await (async of xs)". let is_arrow_fn = (!Self::IS_TYPESCRIPT_ENABLED && !p.lexer.is_contextual_keyword(b"of")) || p.check_for_arrow_after_the_current_token(); diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 4dd095411657..06524ae46852 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -1658,8 +1658,7 @@ pub(crate) mod __gated_printer { pub(crate) export_default_start: i32, pub(crate) arrow_expr_start: i32, pub(crate) for_of_init_start: i32, - /// Start of the expression in a for, for-in, or for-of head. No such - /// expression may begin with the tokens `let [`. + /// Start of the expression in a for, for-in, or for-of head. pub(crate) for_init_start: i32, pub(crate) prev_op: Op::Code, pub(crate) prev_op_end: i32, @@ -3757,8 +3756,7 @@ pub(crate) mod __gated_printer { // The index target is not directly followed by `of`. flags.remove(ExprFlag::IsFollowedByOf); - // An expression statement or a for loop head must not start - // with the tokens "let [", so "let[0] = 1" prints as "(let)[0] = 1". + // A statement or for loop head must not start with "let [". let wrap_let = { let n = self.writer.written(); (n == self.stmt_start || n == self.for_init_start) From a610eb1b47a4dcb90e5bf1e621ee975b58db6ddb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:13:26 +0000 Subject: [PATCH 4/7] js_parser: one line comment --- src/js_parser/parse/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/js_parser/parse/mod.rs b/src/js_parser/parse/mod.rs index 8a69226c9b5f..474f39133118 100644 --- a/src/js_parser/parse/mod.rs +++ b/src/js_parser/parse/mod.rs @@ -1342,8 +1342,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.lexer.expect(T::TStringLiteral)?; } - // Import attributes ("with") and the deprecated import assertions ("assert") - // are treated identically. Only "assert" has a [no LineTerminator here] restriction. + // Only the deprecated "assert" form has a [no LineTerminator here] restriction. if p.lexer.token == T::TWith || (!p.lexer.has_newline_before && p.lexer.is_contextual_keyword(b"assert")) { From 7ea4e15635a11c60d4bbfac04383c2d05ea1f868 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:20:45 +0000 Subject: [PATCH 5/7] ci: retrigger From d20756d24e36227e2f117761dff2dfdf41193d53 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 15 Sep 2026 05:01:51 +0000 Subject: [PATCH 6/7] test: cover (let) at the file start and before a line break --- test/bundler/transpiler/transpiler.test.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 5196a381681a..0a6903ecd6e6 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -3058,6 +3058,11 @@ console.log(
);`), // An expression statement and a for loop head must not start with "let [". expectPrinted_("var let = []; (let)[0] = 1;", "var let = [];\n(let)[0] = 1;\n"); expectPrinted_("if (1) (let)[0] = 2;", "if (1)\n (let)[0] = 2;\n"); + expectPrinted_("(let)[0] = 1;", "(let)[0] = 1;\n"); + expectPrinted_("x;\n(let)[0] = 1;", "x;\n(let)[0] = 1;\n"); + // Without the parentheses this one still parses, as a destructuring declaration + expectPrinted_("(let)\n[a] = b;", "(let)[a] = b;\n"); + expectPrinted_("x;\n(let)\n[a] = b;", "x;\n(let)[a] = b;\n"); expectPrinted_("(let)[0].x = 1;", "(let)[0].x = 1;\n"); expectPrinted_("(let)[0]();", "(let)[0]();\n"); expectPrinted_("(let)[0]++;", "(let)[0]++;\n"); From 7298519890c96fb8f53757118b1c4221e046dc15 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 15 Sep 2026 05:34:19 +0000 Subject: [PATCH 7/7] js_printer: keep the for head start mark across a leading pure comment --- src/js_printer/lib.rs | 7 ++++++- test/bundler/bundler_edgecase.test.ts | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 1f818278b66c..50b45c3fd750 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -3517,11 +3517,16 @@ pub(crate) mod __gated_printer { } if has_pure_comment { - let was_stmt_start = self.stmt_start == self.writer.written(); + let n = self.writer.written(); + let was_stmt_start = self.stmt_start == n; + let was_for_init_start = self.for_init_start == n; self.print_pure(); if was_stmt_start { self.stmt_start = self.writer.written(); } + if was_for_init_start { + self.for_init_start = self.writer.written(); + } } // We only want to generate an unbound eval() in CommonJS self.call_target = Some(e.target.data); diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index 54a0113d02f2..d3fdcbb36468 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -3515,6 +3515,23 @@ describe("bundler", () => { `); }, }); + itBundled("edgecase/LetIndexAfterPureCommentKeepsParens", { + files: { + "/entry.cjs": ` + globalThis.let = [() => 1]; + for (/* @__PURE__ */ (let)[0]();;) break; + console.log(typeof let); + `, + }, + outfile: "out.cjs", + target: "node", + format: "cjs", + emitDCEAnnotations: true, + onAfterBundle(api) { + api.expectFile("out.cjs").toContain("for (/* @__PURE__ */ (let)[0]();;"); + }, + run: { stdout: "object" }, + }); itBundled("edgecase/NonAsciiIdentifierPreserved", { files: { "/entry.js": /* js */ `