Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/native-string-escaping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'devalue': patch
---

perf: use native `JSON.stringify` for strings that need escaping, instead of a per-character loop
69 changes: 16 additions & 53 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,70 +119,33 @@ export function get_name(index) {
return reserved_names.test(name) ? `${name}0` : name;
}

/** @param {string} char */
function get_escaped_char(char) {
switch (char) {
case '"':
return '\\"';
case '<':
return '\\u003C';
case '\\':
return '\\\\';
case '\n':
return '\\n';
case '\r':
return '\\r';
case '\t':
return '\\t';
case '\b':
return '\\b';
case '\f':
return '\\f';
case '\u2028':
return '\\u2028';
case '\u2029':
return '\\u2029';
default:
return char < ' ' ? `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}` : '';
}
}

// characters `stringify_string` always escapes: quote, backslash, `<`, control
// characters and the line/paragraph separators. Surrogates are only escaped
// when unpaired, which `isWellFormed` detects natively, so well-formed pairs
// such as emoji stay on the fast path
const needs_escape = /["<\\\u0000-\u001f\u2028\u2029]/;

// `JSON.stringify` already emits every escape we need except these three,
// which it leaves raw: `<` (so output is safe inside a `<script>` tag) and
// the line/paragraph separators (kept escaped for engines predating ES2019,
// which rejected them in string literals). It also escapes unpaired
// surrogates as `\\udXXX`, so the result survives UTF-8 transport
const json_leaves_raw = /[<\u2028\u2029]/g;

/** @type {Record<string, string>} */
const raw_escapes = { '<': '\\u003C', '\u2028': '\\u2028', '\u2029': '\\u2029' };

/** @param {string} char */
function escape_raw(char) {
return raw_escapes[char];
}

/** @param {string} str */
export function stringify_string(str) {
// the common case: nothing to escape, no per-character work
if (!needs_escape.test(str) && str.isWellFormed()) return `"${str}"`;

let result = '';
let last_pos = 0;
const len = str.length;

for (let i = 0; i < len; i += 1) {
const code = str.charCodeAt(i);
if (code >= 0xd800 && code <= 0xdfff) {
// A well-formed surrogate pair passes through untouched; an unpaired
// surrogate cannot survive UTF-8 transport, so it is escaped.
if (code <= 0xdbff && i + 1 < len && (str.charCodeAt(i + 1) & 0xfc00) === 0xdc00) {
i += 1;
continue;
}
result += str.slice(last_pos, i) + `\\u${code.toString(16)}`;
last_pos = i + 1;
continue;
}
const replacement = get_escaped_char(str[i]);
if (replacement) {
result += str.slice(last_pos, i) + replacement;
last_pos = i + 1;
}
}

return `"${last_pos === 0 ? str : result + str.slice(last_pos)}"`;
return JSON.stringify(str).replace(json_leaves_raw, escape_raw);
Comment thread
http-samc marked this conversation as resolved.
}

// Property names repeat across the values of one call and across calls, so
Expand Down
12 changes: 12 additions & 0 deletions src/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ describe('stringify_string', () => {
}
);

test('escapes `<` and line/paragraph separators alongside JSON escapes', () => {
const value = '<\u2028"\\\n\t\u0001\u2029>\ud800';
Comment thread
http-samc marked this conversation as resolved.
const source = stringify_string(value);
expect(source).toBe('"\\u003C\\u2028\\"\\\\\\n\\t\\u0001\\u2029>\\ud800"');
expect((0, eval)(source)).toBe(value);
});

test('leaves characters JSON.stringify leaves raw untouched', () => {
// `/`, DEL, non-ASCII and `>` all pass through on the escaping path too
expect(stringify_string('"a/b\u007f\u00e9>c')).toBe('"\\"a/b\u007f\u00e9>c"');
});

test('leaves well-formed surrogate pairs untouched', () => {
expect(stringify_string('\ud83d\ude00')).toBe('"\ud83d\ude00"');
expect((0, eval)(stringify_string('a\ud83d\ude00b'))).toBe('a\ud83d\ude00b');
Expand Down
Loading