From 6e97151023b464464667c0722eaf69cda02d7104 Mon Sep 17 00:00:00 2001 From: Arsalan Ahmed Date: Sun, 9 Aug 2026 19:05:02 +0530 Subject: [PATCH] fix(CommandPalette): keep astral characters intact when truncating search results `truncateHTMLFromStart` walked the highlighted snippet one UTF-16 code unit at a time. An astral character (emoji, most CJK extension blocks) occupies two code units, so a truncation boundary landing between them sliced the pair in half and emitted an unpaired surrogate, rendering as ``. Iterate by code point instead, and measure the length budget the same way so both sides stay in the same units. Behaviour for BMP-only content is unchanged. --- src/runtime/utils/search.ts | 25 ++++++++++++++++++------- test/utils/search.spec.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/runtime/utils/search.ts b/src/runtime/utils/search.ts index 084f39fa66..1a5bd4f5a5 100644 --- a/src/runtime/utils/search.ts +++ b/src/runtime/utils/search.ts @@ -18,13 +18,21 @@ function truncateHTMLFromStart(html: string, maxLength: number) { let totalLength = 0 let insideTag = false - // Iterate through the HTML string in reverse order - for (let i = html.length - 1; i >= 0; i--) { - if (html[i] === '>') { + // Iterate through the HTML string in reverse order, one code point at a time. + // Indexing by UTF-16 code unit would slice an astral character (emoji, most + // CJK extension blocks) in half when the truncation boundary lands between + // its surrogates, emitting an unpaired surrogate that renders as `�`. + // `<` and `>` are always single code units, so tag tracking is unaffected. + const chars = Array.from(html) + + for (let i = chars.length - 1; i >= 0; i--) { + const char = chars[i]! + + if (char === '>') { insideTag = true - } else if (html[i] === '<') { + } else if (char === '<') { insideTag = false - truncated = html[i] + truncated + truncated = char + truncated continue } @@ -33,7 +41,7 @@ function truncateHTMLFromStart(html: string, maxLength: number) { } if (totalLength <= maxLength) { - truncated = html[i] + truncated + truncated = char + truncated } else { // If we've reached the max length, we break out of the loop // to prevent further processing of the string @@ -94,7 +102,10 @@ export function highlight(item: T & { matches?: FuseResult['matches'] }, s const markIndex = content.indexOf('') if (markIndex !== -1) { - content = truncateHTMLFromStart(content, content.length - markIndex) + // Measure the budget in code points too, so it stays in the same units as + // the counter inside `truncateHTMLFromStart`. Identical to `.length` for + // BMP-only content. + content = truncateHTMLFromStart(content, Array.from(content.slice(markIndex)).length) } return content diff --git a/test/utils/search.spec.ts b/test/utils/search.spec.ts index 4cdb92f618..92e50f567e 100644 --- a/test/utils/search.spec.ts +++ b/test/utils/search.spec.ts @@ -60,4 +60,41 @@ describe('highlight', () => { expect(highlight({ label: 'foo', matches: [] }, 'foo', 'label')).toBeUndefined() expect(highlight({ label: 'foo' }, 'foo', 'label')).toBeUndefined() }) + + describe('truncation from the start', () => { + // Matches a high surrogate not followed by a low one, or a low surrogate not + // preceded by a high one — i.e. half of an astral character. + const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/ + + function highlightAfterFiller(filler: string, count: number) { + const value = filler.repeat(count) + 'match' + const index = value.indexOf('match') + + return highlight({ label: value, matches: [{ key: 'label', value, indices: [[index, index + 4]] }] }, 'match', 'label') + } + + it('never splits an astral character, at any truncation boundary', () => { + // Truncating by UTF-16 code unit used to slice the pair in half whenever the + // boundary landed between its surrogates — from 7 emoji onward, and every + // length after that. + const split = Array.from({ length: 40 }, (_, i) => i + 1) + .filter(count => LONE_SURROGATE.test(highlightAfterFiller('\u{1F600}', count) ?? '')) + + expect(split).toEqual([]) + }) + + it('keeps emoji before the match intact', () => { + const result = highlightAfterFiller('\u{1F600}', 20) + + expect(result).toContain('match') + expect(result).not.toContain('�') + expect(result?.replace(/^\.\.\./, '')).not.toMatch(LONE_SURROGATE) + }) + + it('still truncates a long prefix down to an ellipsis', () => { + const result = highlightAfterFiller('a', 50) + + expect(result).toMatch(/^\.\.\.a+match<\/mark>$/) + }) + }) })