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.
This commit is contained in:
camc314 committed 2026-08-13 08:49:27 +00:00
1 parent ffa31535db
commit 5444cbfaf8
2 files changed
+33 -3

No files matched your search

+3 -3
View File
@@ -7,20 +7,20 @@ import type { State } from "../state.ts";
import type * as ESTree from "../../../../npm/oxc-types/types.d.ts";
/**
* Characters which may need escaping or special handling.
* 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]/;
/[\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 which do is all that happens on the common path.
* 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
+30
View File
@@ -214,6 +214,36 @@ describe("numbers", () => {
]);
});
// String printing has a deliberately broad fast path. In particular, a `<` by itself is harmless;
// only the case-insensitive `</script` sequence must be broken up for safe inline-script output.
describe("strings", () => {
checkCases([
["harmless less-than", e(str("a < b; <div> is text")), '("a < b; <div> is text");\n'],
["script close tag", e(str("</script")), '("<\\/script");\n'],
["mixed-case script close tag", e(str("</ScRiPt")), '("<\\/ScRiPt");\n'],
// This is a prefix check, matching the HTML parser behavior and the previous slow-path test.
["script close tag prefix", e(str("</scripture")), '("<\\/scripture");\n'],
[
"quote and controls",
e(str('"\\\0' + "1\n\r\u0007\u000b\f\u001b\u00a0\u2028\u2029")),
'("\\\"\\\\\\x001\\n\\r\\x07\\v\\f\\x1B\\xA0\\u2028\\u2029");\n',
],
["paired surrogate", e(str("\ud83d\ude00")), '("😀");\n'],
["lone high surrogate", e(str("\ud800")), '("\\ud800");\n'],
["lone low surrogate", e(str("\udc00")), '("\\udc00");\n'],
]);
test("template literal quasis also escape script close tags", () => {
const { program: parsed, errors } = parseSync(
"fixture.js",
"const value = `before </ScRiPt> after`;",
PARSE_OPTIONS,
);
if (errors.length > 0) throw new Error(`fixture parse failed: ${errors[0].message}`);
expect(printSync(parsed)).toBe("const value = `before <\\/ScRiPt> after`;\n");
});
});
// A negative bigint is parenthesized where the position binds tighter than a prefix operator, the
// same rule `printNumericLiteral` follows. `-1n` parses as a unary minus around a positive literal,
// so a `BigIntLiteral` whose text starts with `-` never comes from a parser and this whole branch