fix(minifier): avoid invalid octal escapes in template folds (#25495)

Inlining constant template expressions can join a decimal digit onto a
preceding raw `\0`. The resulting `\00` is a forbidden octal escape
inside template literals, so DCE can emit JavaScript that fails to
parse. This surfaced in rolldown/rolldown#10661.

When a fold would create that boundary, serialize the null as `\x00` and
continue merging the template fragments. This preserves the cooked value
and the optimization, matches Terser and esbuild, and leaves
escaped-backslash cases unchanged.

## Example

Input:

```js
return `${messageId}\0${correlationId}\0${0}`;
```

Before:

```js
return `${messageId}\0${correlationId}\00`;
```

After:

```js
return `${messageId}\0${correlationId}\x000`;
```

Verified with `cargo test -p oxc_minifier` (643 passed, 35 ignored), the
DCE example's `--twice` idempotency check, a runtime equivalence check,
`just minsize` with unchanged size/allocation snapshots, Clippy with
warnings denied, and formatting. `just ready` additionally passed
dependency, typo, generation, formatting, workspace checks, and all
minifier tests; the remaining workspace run stopped on three unrelated
Oxlint ANSI-color snapshots because the environment sets `NO_COLOR=1`,
and a color-enabled rerun was blocked by the filesystem running out of
space.

Refs rolldown/rolldown#10661.

Implemented with AI assistance (OpenAI Codex). The contributor remains
responsible for reviewing, understanding, and submitting the change
under the Oxc AI usage policy.
This commit is contained in:
Dunqing authored and GitHub committed 2026-08-12 09:46:26 +08:00
1 parent b63e88a0b4
commit c3e99d160d
2 files changed
+44 -2

No files matched your search

@@ -973,8 +973,22 @@ impl<'a> PeepholeOptimizations {
let quasi = &mut t.quasis[idx];
let escaped = Self::escape_string_for_template_literal(&str);
let next_raw = next_quasi.as_ref().map(|q| q.value.raw.as_str()).unwrap_or_default();
quasi.value.raw =
Str::from_strs_array_in([quasi.value.raw.as_str(), &escaped, next_raw], ctx);
let raw = quasi.value.raw.as_str();
let starts_with_digit = escaped
.as_bytes()
.first()
.or_else(|| next_raw.as_bytes().first())
.is_some_and(u8::is_ascii_digit);
let cooked_ends_with_null =
quasi.value.cooked.is_some_and(|cooked| cooked.as_str().ends_with('\0'));
quasi.value.raw = if starts_with_digit
&& cooked_ends_with_null
&& let Some(prefix) = raw.strip_suffix("\\0")
{
Str::from_strs_array_in([prefix, "\\x00", &escaped, next_raw], ctx)
} else {
Str::from_strs_array_in([raw, &escaped, next_raw], ctx)
};
let new_cooked = if let (Some(cooked1), Some(cooked2)) =
(quasi.value.cooked, next_quasi.as_ref().map(|q| q.value.cooked))
{
@@ -1061,3 +1061,31 @@ fn dce_keeps_implicitly_observable_bindings() {
options,
);
}
#[test]
fn dce_inline_template_literal_does_not_create_octal_escape() {
// https://github.com/rolldown/rolldown/issues/10661
test(
r"export function makeKey(messageId, correlationId) { return `${messageId}\0${correlationId}\0${0}`; }",
r"export function makeKey(messageId, correlationId) { return `${messageId}\0${correlationId}\x000`; }",
);
// An escaped backslash before `0` is not a null escape, so this boundary is safe to fold.
test(
r"export function makeKey(messageId) { return `${messageId}\\0${0}`; }",
r"export function makeKey(messageId) { return `${messageId}\\00`; }",
);
// Empty folds can expose the same boundary directly or across adjacent expressions.
test(
r"export function makeKey(messageId) { return `${messageId}\0${''}0`; }",
r"export function makeKey(messageId) { return `${messageId}\x000`; }",
);
test(
r"export function makeKey(messageId) { return `${messageId}\0${''}${0}`; }",
r"export function makeKey(messageId) { return `${messageId}\x000`; }",
);
// Expressions with side effects must still remain interpolation expressions.
test_same(r"export function makeKey(messageId) { return `${messageId}\0${sideEffect()}`; }");
}