From 931e3cf213b2ae5af7bd8c28d42b76c4f83e7d18 Mon Sep 17 00:00:00 2001 From: greymoth-jp Date: Sun, 28 Jun 2026 22:20:58 +0900 Subject: [PATCH] fix(ui): keep truncateWithEndVisible code-point-safe in short-width fallback --- .changeset/truncate-surrogate-safe.md | 5 +++++ .../__tests__/truncateTextWithEndVisible.test.ts | 15 +++++++++++++++ .../ui/src/utils/truncateTextWithEndVisible.ts | 10 +++++----- 3 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 .changeset/truncate-surrogate-safe.md diff --git a/.changeset/truncate-surrogate-safe.md b/.changeset/truncate-surrogate-safe.md new file mode 100644 index 00000000000..6b07979059f --- /dev/null +++ b/.changeset/truncate-surrogate-safe.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Stop `truncateWithEndVisible` from splitting characters outside the BMP (such as CJK Extension B kanji and emoji) into a broken replacement character when truncating to a very small width. The short-width fallback now slices by code point, matching the main truncation path. diff --git a/packages/ui/src/utils/__tests__/truncateTextWithEndVisible.test.ts b/packages/ui/src/utils/__tests__/truncateTextWithEndVisible.test.ts index 151cdc7a429..e1c0de01ee6 100644 --- a/packages/ui/src/utils/__tests__/truncateTextWithEndVisible.test.ts +++ b/packages/ui/src/utils/__tests__/truncateTextWithEndVisible.test.ts @@ -28,6 +28,21 @@ describe('truncateWithEndVisible', () => { expect(truncateWithEndVisible('1234567890', 5, 3)).toBe('...890'); }); + test('should not split surrogate pairs when maxLength is too small', () => { + // The short-maxLength fallback must stay code-point-safe like the main path. + // Slicing on UTF-16 code units cuts characters outside the BMP (CJK Extension B, + // emoji) mid-surrogate, leaving a replacement character. + expect(truncateWithEndVisible('𠮷𠮷𠮷𠮷𠮷𠮷', 5, 5)).toBe('...𠮷𠮷𠮷𠮷𠮷'); + expect(truncateWithEndVisible('🍣🍣🍣🍣🍣🍣', 5, 5)).toBe('...🍣🍣🍣🍣🍣'); + }); + + test('should measure length by code point, not UTF-16 unit', () => { + // 5 astral characters are 10 UTF-16 code units but fit within maxLength 8, + // so the string is returned unchanged rather than truncated. + expect(truncateWithEndVisible('𠮷𠮷𠮷𠮷𠮷', 8, 5)).toBe('𠮷𠮷𠮷𠮷𠮷'); + expect(truncateWithEndVisible('🍣🍣🍣🍣🍣', 8, 5)).toBe('🍣🍣🍣🍣🍣'); + }); + test('should handle email addresses', () => { expect(truncateWithEndVisible('test@example.com', 10)).toBe('te...e.com'); }); diff --git a/packages/ui/src/utils/truncateTextWithEndVisible.ts b/packages/ui/src/utils/truncateTextWithEndVisible.ts index a09cb7dc0d7..5d117336a87 100644 --- a/packages/ui/src/utils/truncateTextWithEndVisible.ts +++ b/packages/ui/src/utils/truncateTextWithEndVisible.ts @@ -15,14 +15,10 @@ export function truncateWithEndVisible(str: string, maxLength = 20, endChars = 5 const ELLIPSIS = '...'; const ELLIPSIS_LENGTH = ELLIPSIS.length; - if (!str || str.length <= maxLength) { + if (!str) { return str; } - if (maxLength <= endChars + ELLIPSIS_LENGTH) { - return ELLIPSIS + str.slice(-endChars); - } - const chars = Array.from(str); const totalChars = chars.length; @@ -30,6 +26,10 @@ export function truncateWithEndVisible(str: string, maxLength = 20, endChars = 5 return str; } + if (maxLength <= endChars + ELLIPSIS_LENGTH) { + return ELLIPSIS + chars.slice(-endChars).join(''); + } + const beginLength = maxLength - endChars - ELLIPSIS_LENGTH; const beginPortion = chars.slice(0, beginLength).join(''); const endPortion = chars.slice(-endChars).join('');