Files
camc314 5444cbfaf8 perf(codegen): skip escaping harmless < tokens (#25564)
## Summary

- Detect actual case-insensitive `</script` sequences in the initial string-escape scan.
- Keep strings containing only harmless `<` on the bulk-write fast path.
- Add regression coverage for script closers, controls, paired and lone surrogates, and template quasis.

## Benchmark methodology

No permanent microbenchmark is added. Measurements used release bundles built with `pnpm --filter oxc-codegen run build` on Node v26.5.0, a one-off focused harness over representative string-literal ASTs, and the existing `packages/codegen/bench.bench.ts` parsed-AST fixtures. The focused harness used ten warmup batches and 20 measured batches per case; Vitest fixtures used 1s warmup and 3s measurement windows (50/20 iteration minima).

The removed one-off harness is retained for reference at [bench.string.bench.ts](https://github.com/oxc-project/oxc/blob/49e7a9d43bdc55a8f01075363bb8701ca5a9380c/packages/codegen/bench.string.bench.ts).

Focused release results (ms/print, mean ± standard deviation):

| Case | Before | After | Change |
| --- | ---: | ---: | ---: |
| Short ordinary string | 0.00003 ± 0.00000 | 0.00003 ± 0.00000 | no material change |
| Long string without `<` | 0.02048 ± 0.00114 | 0.01906 ± 0.00038 | within noise / slightly faster |
| Dense harmless HTML/SVG-like `<` (15,360 chars) | 0.05625 ± 0.00152 | 0.01283 ± 0.00036 | 77% faster |
| Mixed text with harmless `<` | 0.04766 ± 0.00210 | 0.02371 ± 0.00072 | 50% faster |
| Frequent mixed-case `</script` closers | 0.04350 ± 0.00074 | 0.04366 ± 0.00079 | within noise |

Existing parsed-AST benchmark fixtures (Vitest RME 0.15–0.79%): Radix 0.0028 -> 0.0027 ms, React development 0.0981 -> 0.0976 ms. App.tsx varied from 0.6261 to 0.6657 ms after versus 0.6415 ms before, so this PR makes no throughput claim for that fixture and shows no consistent regression.

A release CPU profile of the dense harmless case reduced `printEscapedStringContents` from 1,099 of 1,459 samples, plus 124 `^</script` regex samples, to zero; the after profile spends 321 of 383 samples in the single initial scan.
2026-08-13 08:49:27 +00:00

147 lines
4.2 KiB
TypeScript

// String literal printing (port of `str.rs`, pretty mode: fixed quote).
import { CAT_OTHER, write, writeNoLast, writeWithMapNoLast } from "./write.ts";
import { debugAssert } from "../asserts.ts";
import type { State } from "../state.ts";
import type * as ESTree from "../../../../npm/oxc-types/types.d.ts";
/**
* Characters or sequences which may need escaping or special handling.
*
* The quote is fixed to `"` in pretty mode, so `'` and `` ` `` are deliberately absent -
* they are never escaped, and must not drag a string onto the slow path.
*/
const STRING_ESCAPE_REGEX =
// eslint-disable-next-line no-control-regex
/[\0\x07\b\v\f\n\r\x1B\\"\u2028\u2029\xA0\uD800-\uDFFF]|<\/script/i;
/**
* Print a string literal, quotes and all.
*
* Pretty mode always uses double quotes, matching Oxc's default options, so there is no quote to choose.
* Almost no string needs escaping, so the scan for characters or sequences which do is all that happens on the common path.
*/
export function printString(state: State, value: string, node: ESTree.Node): void {
// Quote is fixed - double, matching `oxc_codegen`'s default option
writeWithMapNoLast(state, '"', node);
// Almost no string needs escaping, so the loop which performs escaping sits in its own function
if (!STRING_ESCAPE_REGEX.test(value)) {
writeNoLast(state, value);
} else {
printEscapedStringContents(state, value);
}
write(state, '"', CAT_OTHER);
}
/**
* Print the contents of a string which contains at least one character needing an escape.
*
* Runs of ordinary characters are copied in chunks rather than one at a time, so the cost is in
* the escapes rather than the length. Kept out of `printString` because it almost never runs.
*/
function printEscapedStringContents(state: State, value: string): void {
let chunkStart = 0;
const { length } = value;
for (let i = 0; i < length; i++) {
let escape: string | null = null;
const code = value.charCodeAt(i);
switch (code) {
case 0:
if (i + 1 < length) {
const next = value.charCodeAt(i + 1);
if (next >= "0".charCodeAt(0) && next <= "9".charCodeAt(0)) {
escape = "\\x00";
break;
}
}
escape = "\\0";
break;
case 7:
escape = "\\x07";
break;
case 8:
escape = "\\b";
break;
case 11:
escape = "\\v";
break;
case 12:
escape = "\\f";
break;
case 10:
escape = "\\n";
break;
case 13:
escape = "\\r";
break;
case 27:
escape = "\\x1B";
break;
case 92:
escape = "\\\\";
break;
case 34: // "
escape = '\\"';
break;
case 60: // <
// `</script` -> `<\/script`
if (/^<\/script/i.test(value.slice(i, i + 8))) {
escape = "<\\";
break;
}
continue;
case 0x2028:
escape = "\\u2028";
break;
case 0x2029:
escape = "\\u2029";
break;
case 0xa0:
escape = "\\xA0";
break;
default:
if (code >= 0xd800 && code <= 0xdfff) {
// Escape lone surrogates. Paired surrogates pass through.
if (code <= 0xdbff && i + 1 < length) {
const next = value.charCodeAt(i + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
// Valid pair, both halves stay in the chunk
i++;
continue;
}
}
escape = "\\u" + code.toString(16);
break;
}
continue;
}
debugAssert(escape != null);
writeNoLast(state, value.slice(chunkStart, i));
writeNoLast(state, escape);
chunkStart = i + 1;
}
writeNoLast(state, value.slice(chunkStart));
}
// `</script` -> `<\/script` in template literal quasis.
/**
* Break up any `</script` in text which is printed raw, so the output cannot end an inline script
* element early.
*
* For template literal quasis, which are printed from their raw text and so never pass through the
* string escaper.
*/
export function escapeScriptCloseTag(text: string): string {
if (!text.includes("</")) return text;
return text.replace(/<\/(script)/gi, "<\\/$1");
}