From f631b882caae2a381ed72802d21730f6e12e10f3 Mon Sep 17 00:00:00 2001 From: Shuvam Kumar Date: Wed, 29 Jul 2026 19:48:19 +0530 Subject: [PATCH] fix(page-tree): map NULL to U+FFFD in escapeCssIdentifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit escapeCssIdentifier vendors the CSS.escape algorithm but omitted its first step — a NULL (U+0000) must serialize to the U+FFFD replacement character. The control-character branch starts at U+0001, so a NULL fell through to the generic case and was emitted as a backslash followed by a literal NULL byte, diverging from the browser's native CSS.escape. Add the missing branch and cover escapeCssIdentifier directly (it had no tests): NULL, control chars, leading digit, leading dash, and passthrough. Co-Authored-By: Claude Opus 4.8 --- .../page-tree/__tests__/cssIdentifier.test.ts | 41 +++++++++++++++++++ src/core/page-tree/cssIdentifier.ts | 6 +++ 2 files changed, 47 insertions(+) create mode 100644 src/core/page-tree/__tests__/cssIdentifier.test.ts diff --git a/src/core/page-tree/__tests__/cssIdentifier.test.ts b/src/core/page-tree/__tests__/cssIdentifier.test.ts new file mode 100644 index 000000000..f15508fa6 --- /dev/null +++ b/src/core/page-tree/__tests__/cssIdentifier.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'bun:test' +import { escapeCssIdentifier } from '../cssIdentifier' + +const ch = (code: number) => String.fromCharCode(code) +const NULL = ch(0x0000) +const REPLACEMENT = ch(0xfffd) + +// Vendored implementation of the CSS.escape algorithm +// (https://drafts.csswg.org/cssom/#serialize-an-identifier). These assertions +// match what the browser's native CSS.escape returns for the same inputs. +describe('escapeCssIdentifier', () => { + it('replaces NULL (U+0000) with the replacement character U+FFFD', () => { + expect(escapeCssIdentifier(NULL)).toBe(REPLACEMENT) + expect(escapeCssIdentifier('a' + NULL + 'b')).toBe('a' + REPLACEMENT + 'b') + }) + + it('escapes control characters and DEL as a hex code point', () => { + expect(escapeCssIdentifier(ch(0x0001))).toBe('\\1 ') + expect(escapeCssIdentifier(ch(0x001f))).toBe('\\1f ') + expect(escapeCssIdentifier(ch(0x007f))).toBe('\\7f ') + }) + + it('escapes a leading digit, and a digit after a leading dash, as a hex code point', () => { + expect(escapeCssIdentifier('9lives')).toBe('\\39 lives') + expect(escapeCssIdentifier('-9')).toBe('-\\39 ') + }) + + it('escapes a lone leading dash', () => { + expect(escapeCssIdentifier('-')).toBe('\\-') + }) + + it('backslash-escapes other non-identifier characters', () => { + expect(escapeCssIdentifier('a b')).toBe('a\\ b') + expect(escapeCssIdentifier('a.b')).toBe('a\\.b') + }) + + it('passes identifier-safe characters (incl. non-ASCII) through unchanged', () => { + expect(escapeCssIdentifier('ok-name_1')).toBe('ok-name_1') + expect(escapeCssIdentifier('café')).toBe('café') + }) +}) diff --git a/src/core/page-tree/cssIdentifier.ts b/src/core/page-tree/cssIdentifier.ts index 08b22e905..1a36fa909 100644 --- a/src/core/page-tree/cssIdentifier.ts +++ b/src/core/page-tree/cssIdentifier.ts @@ -16,6 +16,12 @@ export function escapeCssIdentifier(value: string): string { const codeUnit = value.charCodeAt(index) const char = value.charAt(index) + // NULL (U+0000) serializes to the replacement character U+FFFD. + if (codeUnit === 0x0000) { + escaped += '\uFFFD' + continue + } + if ( (codeUnit >= 0x0001 && codeUnit <= 0x001f) || codeUnit === 0x007f ||