fix(CommandPalette): keep astral characters intact when truncating search results - #347
fix(CommandPalette): keep astral characters intact when truncating search results#347arsalan507 wants to merge 2 commits into
Conversation
…arch 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. Closes bitrix24#339
ee48021 to
f08572d
Compare
IgorShevchik
left a comment
There was a problem hiding this comment.
Thanks for this — and in particular for going upstream first, for reproducing the sweep before touching anything, and for flagging the second hunk as a judgement call instead of burying it. That is the right shape for a fix in a synced file.
I put the patch through five independent reviews: Unicode correctness, security, test quality, upstream-sync risk, and performance. The fix is correct and we want it — with two changes before merge, and one caveat about what it closes.
Confirmed
- The bug reproduces on
mainexactly as you reported: lone surrogates from filler length 7 onward, and for every length after. - Your patch removes them — 0 introduced lone surrogates across ~200k fuzzed inputs, against 687 for
mainon the same corpus. - BMP-only behaviour is byte-identical to
main(18k+ cases, 0 differences). - Security-neutral. 512 adversarial cases plus 60k fuzz cases rendered through a DOM: no element other than
<mark>, no attributes, no stray angle brackets, no unbalanced tags — in both branches. Your "not a security issue" reading in #339 holds. - Your two new tests do fail against unpatched
search.tswith the error you quoted, andtest/utils/+ CommandPalette pass with the patch (296 tests).LONE_SURROGATEis correct — brute-forced over 780 strings against a code-point ground truth, 0 mismatches.
Please change 1 — keep the loop's early break
Array.from(html) materialises the whole string, which throws away the early break. The function was O(budget); it becomes O(full length), and that is worst in exactly the case it exists for — a match far from the start.
Measured on a 979-char description with a late match: 39 loop iterations either way, but 0 → 1003 array elements, and 0.32 µs → 6.66 µs (20.6×). At resultLimit: 1000 with 206-char descriptions that is +1.5 ms per throttled update (6.2×). The default resultLimit: 12 is unaffected in practice (<0.5% of a frame), but this repo ships a virtualize example with resultLimit: 1000, and processGroupItems highlights all of them before virtualization decides what to render.
Coalescing the pair in place keeps the break, and was verified byte-identical to your version over 200k randomised inputs (lone surrogates, ZWJ and flag sequences, CJK Ext B, <mark> fragments):
for (let i = html.length - 1; i >= 0; i--) {
let char = html[i]
const code = html.charCodeAt(i)
if (code >= 0xDC00 && code <= 0xDFFF && i > 0) {
const hi = html.charCodeAt(i - 1)
if (hi >= 0xD800 && hi <= 0xDBFF) { char = html.slice(i - 1, i + 1); i-- }
}
// ... rest unchanged
}Cost against main: 1.06–1.28× instead of 2–20×. Same idea for the caller — an allocation-free code-point count avoids building the tail array twice.
Please change 2 — the tests do not fence the fix
They catch the regression, but they pass against four different wrong "fixes", including skip truncation entirely when the string contains astral characters and delete every astral character before truncating. still truncates a long prefix down to an ellipsis is meant to guard the first of those, but its filler is 'a', so it only exercises the BMP path the patch never touches.
Hunk 2 also has no coverage: in every new test the text from <mark> onward is pure ASCII, so both budget formulas return the same number. Revert hunk 2 alone and all three tests stay green.
The smallest change that closes both: one exact-string assertion with an emoji filler, plus a fixture with emoji inside the marked region. Two smaller things while you are there — expect(result).not.toContain('�') can never fail (the bug emits raw surrogates, never U+FFFD), and the ?? '' in the sweep means a highlight() returning undefined for everything would pass.
Caveat — this closes half of #339
The same class of bug lives one layer up, and the patch does not reach it. Fuse's indices are code-unit offsets, so value.substring() at search.ts:92,94 can insert <mark> inside an astral character. Reproduced with real fuse.js at this component's shipped defaults, with an ordinary typo as the search term:
label "deployment 😀😀 pipeline", term "zployment 😀"
fuse indices [[1,13]] — 13 lands between \uD83D and \uDE00
→ d<mark>eployment 😀\uD83D</mark>\uDE00 pipeline
Pre-existing and out of scope here, but it means this should not auto-close #339. Keep Closes #339 if you would rather split the remainder into a follow-up — flagging it so the issue is not closed as fully fixed.
On the byte-identical claim
This is the one thing worth restating. highlight() here takes a fifth useTokenSearch argument that upstream does not have. It was added in c502157b and 6743f793, both against the file's old name fuse.ts and both without an Upstream: trailer, and shipped in v2.8.0. The rename in 557a5178 carried it across, which is why git log on search.ts looks clean unless you pass --follow. Our own sync ledger scopes its claim to "matches upstream's behavior 1:1 … without token search".
git apply succeeding proves the hunk contexts matched, not that the files match — and both your hunks sit away from those lines, so a clean apply is fully consistent with the files differing. I have not inspected nuxt/ui, so this is not a claim about what is there; only that the premise "the two stay byte-for-byte in sync" does not hold on our side.
It does not change the answer to your "now or wait" question: land it here now. The divergence is now recorded in .sync/PORTING.md so a future port does not silently revert it.
On hunk 2 — keep it
Worth knowing what it actually does. Because the tail appears in both the budget and the counter, it cancels: the remaining budget when the walk reaches the mark is always exactly the tag characters — 13 code points per <mark>…</mark> pair, regardless of tail length or content. So the real effect is that the retained window is 13 characters rather than 13 UTF-16 units. That is the change we want; the comment just overstates it as tracking the tail. A named constant would read better than Array.from(content.slice(markIndex)).length.
Notes, no action needed
- Grapheme clusters are still split — this moves code unit → code point, not to user-perceived character. Worst case is flags:
🇺🇸×7 drops one regional indicator and the run re-pairs shifted, rendering a different country. Silently wrong rather than visibly broken. Follow-up material (Intl.Segmenter), not this PR. - Entities are still cut mid-sequence (
&→mp;). Pre-existing and inert — no suffix of any escape is a valid entity prefix. - Roughly half the outputs this changes were well-formed before and simply get a longer prefix, so this is not purely a bug fix — worth a changelog line. Wider prefixes also interact with the CSS
truncateonitemLabel/itemDescription: the<mark>sits further right and is marginally more likely to be clipped.
Generated by Claude Code
|
Follow-up to the review above — we've taken the two changes on ourselves rather than sending you another round trip, since this had already been waiting three days on our side. #365 carries your commit For what it's worth, your instinct on the second hunk was right and we kept it. It turned out to be load-bearing in a way neither of us said explicitly: because the tail appears in both the budget and the counter, it cancels, and the retained window is always exactly the tag characters — so the real effect of that hunk is that the window is 13 characters rather than 13 UTF-16 units. Reverting it alone left every test green, which is why the new suite has a case with emoji inside the marked region. Three things this surfaced are now filed separately, none of them yours to fix:
Thank you for this — going upstream first, reproducing the sweep before changing anything, and flagging the second hunk as a judgement call instead of burying it is the standard we'd want from a regular contributor, let alone a first PR here. Leaving this open for a maintainer to close alongside #365 rather than closing it out from under you. Generated by Claude Code |
…e range bounds
Hardens the cases added by the previous commit. No implementation change —
`src/runtime/utils/search.ts` is untouched.
Three gaps, found by re-running the suite against deliberately wrong
implementations:
The fixture used one astral character, U+1F600, which sits comfortably inside
both surrogate ranges — so any implementation whose range bounds are off by one
passed. The sweep now runs over U+10000 and U+10FFFF (the first and last astral
code points), U+20000 (low surrogate U+DC00, the lower edge), U+1F3FF (low
surrogate U+DFFF, the upper edge, and a skin-tone modifier that appears in
ordinary UI text) and U+1F600.
The sweep asserted only the absence of a lone surrogate, which an implementation
that deletes every astral character satisfies trivially — while carrying a name
that says it does not. It now asserts how many characters survived, which also
closes the `?? ''` coalesce that would have let a `highlight()` returning
`undefined` throughout pass vacuously.
`expect(result).not.toContain('�')` could never fail: the bug emits raw
unpaired surrogates, and U+FFFD only appears after a lossy re-encode that does
not happen on this path. Dropped.
Containment checks are replaced by exact-output assertions, and a new case
places astral content after the match so the caller's half of the fix — the
budget measured in code points — is covered; reverting that half alone
previously left every test green. Its fixture uses the indices real fuse.js
returns for the search term, `[[40, 44]]`.
The retained-prefix length is now derived rather than hardcoded. It is
invariantly `'<mark>'.length + '</mark>'.length`: `maxLength` counts the tag
characters that the counter inside `truncateHTMLFromStart` skips, so the two
cancel regardless of the match, the filler, or whether the content is BMP or
astral.
Verified against four wrong implementations — skip truncation when astral
content is present, strip astral characters first, revert the caller's half, and
revert to code-unit iteration — each of which now fails, and all but one of
which passed the previous cases.
Refs #339, #347
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWWrBHgfqGSbeU3V6UuMF8
…rting invariant `highlight()` in `src/runtime/utils/search.ts` takes a fifth `useTokenSearch` argument, and carries the token-search logic around it, that no port brought in. `c502157b` added `tokens`/`minTokenLength` and `6743f793` the parameter itself, both against the file's old name `src/runtime/utils/fuse.ts`; both shipped in v2.8.0. Immediately before `c502157b` the function took four parameters and computed no `minTokenLength` at all, which is what establishes the divergence as locally authored. The port `557a5178` then renamed `fuse.ts` to `search.ts` and carried the divergence across, so a pickaxe on the current path returns only `557a5178` — a genuine upstream port — and `--follow` is needed to see the two commits that actually introduced it. That rename is what hid the divergence; `Upstream:` trailers are too rare here to support an inference either way (16 of ~3200 commits carry one, and `559a5cdb`, this file's own most recent port, does not). Replaying upstream's four-parameter signature wholesale would drop behaviour shipped in v2.8.0 while nothing goes red — `useTokenSearch` appears nowhere under `test/`. #363 tracks that gap. Also corrects `.sync/nuxt-ui.json`'s summary for `2a172ef`, which asserted "highlight signature matches 1:1" without qualification. The `.md` log for the same port was already accurate, scoping its claim to behaviour "without token search"; the machine-readable half was not, and a porter consulting it would have got the wrong answer. The invariant states plainly that upstream has not been re-inspected, so the claim "upstream has no such parameter" reads as an inference from b24ui's own history rather than a verified fact. On provenance: the "byte-identical with upstream" premise originated here, in `595923b9` (PR #338), was repeated in #339, and was inherited in good faith by the external contributor whose PR prompted this check. Refs #339, #347, #363 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LWWrBHgfqGSbeU3V6UuMF8
|
Corrections. I put my own review and the superseding PR through five independent checks, and several things I told you were wrong. Your implementation ships as you wrote it. #365 no longer contains my replacement commit. I had swapped your "This closes half of #339" was wrong. #339 is scoped entirely to Which makes my instruction about the trailer backwards. I wrote "Keep On byte-identical — the error was ours, not yours. That premise originated in this repository: commit Two smaller things: the PR had been open under two days when I said three, and the SHA I quoted as "your commit" was the post-cherry-pick one — yours is What remains on top of your commit in #365 is test hardening only, The substantive part of my review stands: the three cases as shipped passed against four different wrong implementations. Everything else above, I got wrong. Thanks for your patience with it. Generated by Claude Code |
…rting invariant (#366) `highlight()` in `src/runtime/utils/search.ts` takes a fifth `useTokenSearch` argument, and carries the token-search logic around it, that no port brought in. `c502157b` added `tokens`/`minTokenLength` and `6743f793` the parameter itself, both against the file's old name `src/runtime/utils/fuse.ts`; both shipped in v2.8.0. Immediately before `c502157b` the function took four parameters and computed no `minTokenLength` at all, which is what establishes the divergence as locally authored. The port `557a5178` then renamed `fuse.ts` to `search.ts` and carried the divergence across, so a pickaxe on the current path returns only `557a5178` — a genuine upstream port — and `--follow` is needed to see the two commits that actually introduced it. That rename is what hid the divergence; `Upstream:` trailers are too rare here to support an inference either way. Replaying upstream's four-parameter signature wholesale would drop behaviour shipped in v2.8.0 while nothing goes red — `useTokenSearch` appears nowhere under `test/`. #363 tracks that gap. Also corrects `.sync/nuxt-ui.json`'s summary for `2a172ef`, which asserted "highlight signature matches 1:1" without qualification. The `.md` log for the same port was already accurate, scoping its claim to behaviour "without token search"; the machine-readable half was not. The invariant states plainly that upstream has not been re-inspected, so "upstream has no such parameter" reads as an inference from b24ui's own history rather than a verified fact. Refs #339, #347, #363
|
Merged — your fix is on The implementation that shipped is yours, unchanged — both hunks, including the caller half you flagged as a judgement call. What we added on top was test hardening, and every gap it closed was one your work made visible rather than one you introduced. Two things this PR is worth remembering for beyond the fix itself. It surfaced a divergence from upstream that had been sitting in
Thanks for a genuinely good first contribution here — going upstream first, reproducing the reported sweep before changing anything, and flagging the debatable half instead of quietly shipping it. That is the part that made everything downstream possible, including catching our own errors. Please do send more. Generated by Claude Code |
|
Thank you — for the depth of the review, for preserving authorship through #365, and for correcting the record publicly when the errors turned out to be on the repo's side. That's not something every maintainer would do, and I learned a lot from the five-lens review alone. Taking you up on "please do send more": I'll pick up #362 next. |
Closes #339.
Upstream first
Per the note at the end of #339 —
src/runtime/utils/search.tsis byte-identical between this repo andnuxt/ui(re-verified bydiffagainstnuxt/ui@v4today, 3459 bytes both), and so istest/utils/search.spec.ts.So the fix went upstream first: nuxt/ui#6817. This PR is the identical patch, applied here with
git applyfrom the upstream commit so the two files stay in sync byte-for-byte.Entirely your call whether to land this now or wait for upstream and take it through the normal sync — the issue left that open ("unless it is wanted here sooner"). If you'd rather wait, close this and it'll arrive on its own; nothing here diverges from upstream.
The fix
Two hunks, exactly as #339 prescribed:
truncateHTMLFromStart— iterate withArray.from(html)so the loop advances one code point at a time and never slices a surrogate pair in half. TheinsideTagtracking is untouched, since<and>are always single code units.Worth flagging for review: hunk 1 alone is sufficient to remove the lone surrogates; hunk 2 is the consistency half. It does mean an emoji now counts as one character against the truncation budget rather than two, so emoji-containing snippets truncate slightly later than before. That reads as closer to the intent of a visible-length budget, but it is a behaviour change and I'm happy to drop it if you'd prefer the minimal diff.
BMP-only content is byte-identical either way, which is why all pre-existing tests pass untouched.
Verification
Reproduced your sweep first, before changing anything — lone surrogates appear from filler length 7 onward and for every length after, exactly as reported:
Three tests added to
test/utils/search.spec.ts:never splits an astral character, at any truncation boundary— the 1–40 sweep, asserting no lone surrogate at any lengthkeeps emoji before the match intactstill truncates a long prefix down to an ellipsis— guards against over-correcting into "never truncate"The first two were verified failing against unmodified
search.ts(expected [ 7, 8, 9, … ] to deeply equal []) and passing with the fix. The third passes both before and after by design.Consumers checked:
src/runtime/components/CommandPalette.vueandsrc/runtime/composables/useContentSearch.tsare the only two touchingutils/search.