fix(codegen): remove redundant dollar escapes in minified templates (#26924)

Remove redundant dollar escapes from untagged templates when minifying:

```js
// Before
`^${pattern}\$`
// After
`^${pattern}$`
```

Preserve escapes that prevent `${` interpolation and account for escaped backslashes. Tagged templates retain their raw spelling, and non-minified output keeps its existing behavior.

The normal minsize corpus shrinks by 11 raw bytes and 8 gzip bytes across Ant Design and TypeScript; Brotli grows by 2 bytes.
This commit is contained in:
camc314 committed 2026-09-22 12:58:57 +00:00
1 parent 2bc4765cc0
commit d79ad19796
5 files changed
+128 -2

No files matched your search

+29 -1
View File
@@ -450,11 +450,39 @@ impl<'a> Codegen<'a> {
unsafe { self.code.print_bytes_unchecked(&bytes) };
}
/// Print a template literal quasi's raw text. Only untagged templates are escaped under
/// Print a template literal quasi's raw text, removing redundant dollar escapes when minifying
/// untagged templates. Only untagged templates are escaped under
/// [CodegenOptions::ascii_only]: a tag function (e.g. `String.raw`) can observe the raw
/// text, which escaping would change.
#[inline]
pub(crate) fn print_template_quasi_raw(&mut self, raw: &str, tagged: bool) {
let mut start = 0;
if self.options.minify && !tagged {
let bytes = raw.as_bytes();
for (index, _) in raw.match_indices("\\$") {
// Keep escapes which prevent interpolation.
if bytes.get(index + 2) == Some(&b'{') {
continue;
}
// Keep backslashes which are themselves escaped.
let preceding_backslashes =
bytes[..index].iter().rev().take_while(|&&b| b == b'\\').count();
if preceding_backslashes % 2 != 0 {
continue;
}
// The preserved chunk ends with an even backslash run, and the next starts
// with `$`, so printing them separately preserves escape handling.
self.print_template_quasi_raw_chunk(&raw[start..index], tagged);
start = index + 1;
}
}
self.print_template_quasi_raw_chunk(&raw[start..], tagged);
}
#[inline]
fn print_template_quasi_raw_chunk(&mut self, raw: &str, tagged: bool) {
if !self.options.ascii_only || tagged || raw.is_ascii() {
self.print_str_escaping_script_close_tag(raw);
} else {
@@ -1133,3 +1133,47 @@ fn html_comments() {
"const x = 1;\n--> comment\nconst y = 2;\n",
);
}
#[test]
fn template_literal_dollar_escapes() {
for (source, expected) in [
(r"`\$`;", r"`$`;"),
(r"`^${pattern}\$`;", r"`^${pattern}$`;"),
(r"`\$a\$${value}\$`;", r"`$a$${value}$`;"),
(r"`\${value}`;", r"`\${value}`;"),
(r"`\$\{value}`;", r"`$\{value}`;"),
(r"`\$\u007bvalue}`;", r"`$\u007bvalue}`;"),
(r"`\\$`;", r"`\\$`;"),
(r"`\\\$`;", r"`\\$`;"),
(r"`\\\\$`;", r"`\\\\$`;"),
(r"`\\\\\$`;", r"`\\\\$`;"),
(r"`\\\${value}`;", r"`\\\${value}`;"),
(r"`\\${value}\$`;", r"`\\${value}$`;"),
(r"`\$${{value}}\$`;", r"`$${{value}}$`;"),
(r"tag`\$${value}\$`;", r"tag`\$${value}\$`;"),
(r"String.raw`\$${value}\$`;", r"String.raw`\$${value}\$`;"),
(r"tag`\$${`\$`}\$`;", r"tag`\$${`$`}\$`;"),
] {
test_minify(source, expected);
test_minify_same(expected);
}
test_same("`^${pattern}\\$`;\n");
test_same("tag`\\$`;\n");
for ascii_only in [false, true] {
test_options(
r"`é\$</script>${value}\$é`;",
if ascii_only {
r"`\u00E9$<\/script>${value}$\u00E9`;"
} else {
r"`é$<\/script>${value}$é`;"
},
CodegenOptions { minify: true, ascii_only, ..CodegenOptions::default() },
);
test_options(
r"String.raw`é\$`;",
r"String.raw`é\$`;",
CodegenOptions { minify: true, ascii_only, ..CodegenOptions::default() },
);
}
}
@@ -530,3 +530,28 @@ fn execute_with_node(code: &str, sourcemap_url: &str) -> String {
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn minified_template_dollar_escapes() {
let source = r"`\$${first}\$${second}\$`;after();";
let allocator = Allocator::default();
let ret = Parser::new(&allocator, source, SourceType::mjs()).parse();
assert!(ret.diagnostics.is_empty());
let result = Codegen::new()
.with_options(CodegenOptions { minify: true, ..default_options() })
.build(&ret.program);
assert_eq!(result.code, r"`$${first}$${second}$`;after();");
let map = result.map.unwrap();
for name in ["first", "second", "after"] {
assert!(
map.get_tokens().any(|token| {
token.get_src_line() == 0
&& token.get_dst_line() == 0
&& token.get_src_col() == u32::try_from(source.find(name).unwrap()).unwrap()
&& token.get_dst_col()
== u32::try_from(result.code.find(name).unwrap()).unwrap()
}),
"missing mapping for {name}"
);
}
}
+29
View File
@@ -167,3 +167,32 @@ fn run_with_iterations(
.code;
(code, iterations)
}
#[test]
fn minified_template_dollar_escapes() {
fn minify(source: &str) -> String {
let allocator = Allocator::default();
let ret = Parser::new(&allocator, source, SourceType::mjs()).parse();
assert!(ret.diagnostics.is_empty());
let mut program = ret.program;
let ret = oxc_minifier::Minifier::new(oxc_minifier::MinifierOptions::default())
.minify(&allocator, &mut program);
Codegen::new()
.with_options(CodegenOptions::minify())
.with_scoping(ret.scoping)
.build(&program)
.code
}
for (source, expected) in [
(r"use(`^${pattern}\$`)", r"use(`^${pattern}$`);"),
(r"use(`\$${pattern}\$`)", r"use(`$${pattern}$`);"),
(r"use(`\${pattern}${suffix}`)", r"use(`\${pattern}${suffix}`);"),
(r"use(String.raw`\$${pattern}\$`)", r"use(String.raw`\$${pattern}\$`);"),
(r"use(`\\\$${pattern}\$`)", r"use(`\\$${pattern}$`);"),
] {
let output = minify(source);
assert_eq!(output, expected);
assert_eq!(minify(&output), output);
}
}
+1 -1
View File
@@ -23,5 +23,5 @@ Original | minified | minified | gzip | gzip | Iterations | Fi
6.69 MB | 2.12 MB | 2.31 MB | 453.42 kB | 488.28 kB | 4 | antd.js
10.95 MB | 3.31 MB | 3.49 MB | 849.97 kB | 915.50 kB | 4 | typescript.js
10.95 MB | 3.31 MB | 3.49 MB | 849.96 kB | 915.50 kB | 4 | typescript.js