fix(codegen): wrap string literal in parens after directives (#25435)

When printing statements (`Codegen::print_directives_and_statements`), if first statement is an `ExpressionStatement` containing just a `StringLiteral`, it has to be parenthesized to avoid it becoming a directive.

Fix 4 different bugs in this logic.

## 1\. Only protected when there were no directives at all

The check was gated on `directives.is_empty()`, but the statement at risk is the first non-directive one, whatever its index. The second statement below printed bare, and re-parsing turned it into a directive - switching the program from sloppy to strict mode.

```js
// Input
"use server";
("use strict");

// Output, before
"use server";
"use strict"; // Now a directive - program is in strict mode

// Output, after this PR
"use server";
("use strict");
```

## 2\. No indent on the protected path

The path printed the statement itself instead of going through `ExpressionStatement`'s printer, and never printed the indent.

```js
// Output, before
function f() {
("x");
}

// Output, after this PR
function f() {
  ("x");
}
```

## 3\. Leading comments dropped on the protected path

Same cause - the statement's own printer is what prints its comments.

```js
// Input
// keep me
("use strict");

// Output, before
("use strict");

// Output, after this PR
// keep me
("use strict");
```

## 4\. Unprotected in minify mode

Protection was skipped entirely when minifying. A string usually minifies to a template literal, which cannot be a directive - but only where that is the shortest form. One backtick or `${` makes quotes shorter, and then it's printed as a directive.

```js
// Input
("`");

// Output, before
"`"; // Now a directive

// Output, after this PR
`\``;
```

Minified output is now always a template literal:

```js
("`");   // -> `\``;
("${}"); // -> `\${}`;
```

A template is never a directive, costs no more than `("...")`, and survives a round trip.

In the common case (no backticks or `${` in the string), printing as a template literal (`` `use strict`; ``) is more compact than a parenthesized quoted string (` ("use strict"); `).

Note: this visibly changes minified output for this construct - a template literal where there was a parenthesized string. Behavior is unchanged.
This commit is contained in:
overlookmotel committed 2026-08-09 13:26:29 +00:00
1 parent d0b8d6ff22
commit bbd6ed38df
4 files changed
+112 -15

No files matched your search

+34 -15
View File
@@ -697,6 +697,7 @@ impl<'a> Codegen<'a> {
for directive in directives {
directive.print(self, ctx);
}
let Some((first, rest)) = stmts.split_first() else {
self.print_orphan_comments_before(scope_end);
return;
@@ -704,23 +705,41 @@ impl<'a> Codegen<'a> {
self.print_orphan_comments_before(first.span().start);
// Ensure first string literal is not a directive.
let mut first_needs_parens = false;
if directives.is_empty()
&& !self.options.minify
&& let Statement::ExpressionStatement(s) = first
// If first statement is a string literal, wrap it in parentheses, to prevent it being parsed as a directive.
// Only a parenthesized string expression can reach here (a bare one would have been parsed as a directive),
// so the parentheses have to be printed back.
//
// Need to do this regardless of whether or any real directives precede it or not.
// Parentheses must be retained for any of:
// * `("use strict");`
// * `"use asm"; ("use strict");`
// * `"use server"; "use asm"; ("use strict");`
//
// The same hazard exists in minify mode.
// Usually strings are printed as template literals, which are not parsed as directives.
// But if the string contains a backtick or `${`, it's printed with `"` or `'` quotes,
// which *would* be re-parsed as a directive.
// So in minify mode, we force printing as a template literal, regardless of the string's content.
// In almost all cases, this is shorter than wrapping in parentheses.
if let Statement::ExpressionStatement(stmt) = first
&& let expr = stmt.expression.without_parentheses()
&& let Expression::StringLiteral(string) = expr
{
let s = s.expression.without_parentheses();
if matches!(s, Expression::StringLiteral(_)) {
first_needs_parens = true;
self.print_ascii_byte(b'(');
s.print_expr(self, Precedence::Lowest, ctx);
self.print_ascii_byte(b')');
self.print_semicolon_after_statement();
// Mirror `ExpressionStatement`'s printer, which this path stands in for
self.print_comments_at(stmt.span.start);
if self.indent > 0 || self.print_next_indent_as_space {
self.print_indent();
self.add_source_mapping(stmt.span);
}
}
if !first_needs_parens {
if self.options.minify {
self.print_string_literal_as_template(string);
} else {
self.print_ascii_byte(b'(');
self.print_string_literal(string, /* allow_backtick */ true);
self.print_ascii_byte(b')');
}
self.print_semicolon_after_statement();
} else {
first.print(self, ctx);
}
+24
View File
@@ -33,6 +33,16 @@ impl Codegen<'_> {
self.print_string_impl(s.value.as_str(), s.lone_surrogates, allow_backtick);
}
/// Print a [`StringLiteral`] as a template literal, whatever its contents.
///
/// A template literal is never a directive, which a string literal in the first statement position would be.
/// See `print_directives_and_statements`.
pub(crate) fn print_string_literal_as_template(&mut self, s: &StringLiteral<'_>) {
self.add_source_mapping(s.span);
Quote::Backtick.print(self);
self.print_string_body(s.value.as_str(), s.lone_surrogates, Some(Quote::Backtick), true);
}
pub(super) fn print_string_impl(
&mut self,
s: &str,
@@ -53,6 +63,20 @@ impl Codegen<'_> {
Some(quote)
};
self.print_string_body(s, lone_surrogates, quote, allow_backtick);
}
/// Print the contents of a string, and its closing quote.
///
/// `quote` is `None` where it has yet to be chosen - it is then calculated from the contents,
/// and the opening quote printed, when the first character needing an escape is found.
fn print_string_body(
&mut self,
s: &str,
lone_surrogates: bool,
quote: Option<Quote>,
allow_backtick: bool,
) {
// Loop through bytes, looking for any which need to be escaped.
// String is written to buffer in chunks.
let bytes = s.as_bytes().iter();
@@ -956,3 +956,11 @@ fn test_comment_inside_parenthesized_expression_with_pife_arrow() {
fn test_comment_inside_double_parenthesized_pife_arrow() {
test_idempotency("const x = foo ? bar : ( ( ( a ) => a ) );");
}
// A leading comment on the statement which closes the directive prologue was dropped, because the
// paren-protecting path prints that statement itself instead of going through its printer.
#[test]
fn test_comment_on_paren_protected_prologue_boundary() {
test_same("// leading comment\n(\"use strict\");\nfoo();\n");
test_same("\"use asm\";\n// leading comment\n(\"use strict\");\nfoo();\n");
}
@@ -654,6 +654,52 @@ fn directive() {
test_options(r#""'\"""#, "\"'\\\"\";\n", double_quote);
}
#[test]
fn directive_prologue_boundary() {
// A parenthesized string expression statement has to keep its parentheses while it sits at the
// end of the directive prologue, or it re-parses as a directive.
// No directives before it
test_same("(\"use strict\");\nfoo();\n");
// After real directives - printed bare, this would switch the program to strict mode
test_same("\"use asm\";\n(\"use strict\");\nfoo();\n");
// Indented, in a function body
test_same("function f() {\n\t(\"x\");\n}\n");
test_same("function f() {\n\t\"use strict\";\n\t(\"x\");\n}\n");
// A TS module block has a directive prologue too
test_same("module Foo {\n\t(\"x\");\n}\n");
// A class static block does not, so there is nothing to protect against there
test(
"class C {\n\tstatic {\n\t\t(\"x\");\n\t}\n}\n",
"class C {\n\tstatic {\n\t\t\"x\";\n\t}\n}\n",
);
// Only the statement which closes the prologue needs the parentheses - a string statement
// after it cannot be a directive
test_same("(\"a\");\n\"b\";\n");
test("foo();\n(\"a\");\n", "foo();\n\"a\";\n");
test("\"use strict\";\nfoo();\n(\"a\");\n", "\"use strict\";\nfoo();\n\"a\";\n");
}
#[test]
fn directive_prologue_boundary_minify() {
// Minified output has the same hazard. A string usually prints as a template literal, which
// cannot be a directive, but only where that is the shortest form - one backtick in the string
// is enough to make quotes shorter, and then nothing else would stop it printing as a
// directive. So the template literal is printed whatever the contents.
test_minify("(\"`\");", "`\\``;");
// `${` costs a backtick the same as a backtick does
test_minify("(\"${}\");", "`\\${}`;");
// Strings which would have chosen a template literal anyway are unaffected
test_minify("(\"use strict\");", "`use strict`;");
// After real directives
test_minify("\"use asm\"; (\"`\");", "\"use asm\";`\\``;");
// A newline pays for a backtick, so quotes were never shorter for this one
test_minify("(\"\\n`\");", "`\n\\``;");
// Only the statement which closes the prologue is at risk
test_minify("foo(); (\"`\");", "foo();\"`\";");
}
#[test]
fn getter_setter() {
test_minify("({ get [foo]() {} })", "({get[foo](){}});");