Skip to content
Closed
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/truncate-surrogate-safe.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ 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('𠮷𠮷𠮷𠮷𠮷', 8, 5)).toBe('...𠮷𠮷𠮷𠮷𠮷');
expect(truncateWithEndVisible('🍣🍣🍣🍣🍣', 8, 5)).toBe('...🍣🍣🍣🍣🍣');
});

test('should handle email addresses', () => {
expect(truncateWithEndVisible('test@example.com', 10)).toBe('te...e.com');
});
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/utils/truncateTextWithEndVisible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function truncateWithEndVisible(str: string, maxLength = 20, endChars = 5
}

if (maxLength <= endChars + ELLIPSIS_LENGTH) {
return ELLIPSIS + str.slice(-endChars);
return ELLIPSIS + Array.from(str).slice(-endChars).join('');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Hoist the code-point length check before this fallback.

Line 23 still runs before the code-point-aware totalChars <= maxLength guard, so astral-only strings that already fit are still truncated. For example, truncateWithEndVisible('🍣🍣🍣🍣🍣', 8, 5) now returns '...🍣🍣🍣🍣🍣' because Line 18 compares UTF-16 length. That also makes the new assertions at Lines 35-36 validate the wrong behavior; once this is fixed, they should either expect the original string or use a smaller maxLength to exercise the fallback.

Suggested fix
 export function truncateWithEndVisible(str: string, maxLength = 20, endChars = 5): string {
   const ELLIPSIS = '...';
   const ELLIPSIS_LENGTH = ELLIPSIS.length;
 
-  if (!str || str.length <= maxLength) {
+  if (!str) {
     return str;
   }
+
+  const chars = Array.from(str);
+  const totalChars = chars.length;
+
+  if (totalChars <= maxLength) {
+    return str;
+  }
 
   if (maxLength <= endChars + ELLIPSIS_LENGTH) {
-    return ELLIPSIS + Array.from(str).slice(-endChars).join('');
+    return ELLIPSIS + chars.slice(-endChars).join('');
   }
-
-  const chars = Array.from(str);
-  const totalChars = chars.length;
-
-  if (totalChars <= maxLength) {
-    return str;
-  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return ELLIPSIS + Array.from(str).slice(-endChars).join('');
export function truncateWithEndVisible(str: string, maxLength = 20, endChars = 5): string {
const ELLIPSIS = '...';
const ELLIPSIS_LENGTH = ELLIPSIS.length;
if (!str) {
return str;
}
const chars = Array.from(str);
const totalChars = chars.length;
if (totalChars <= maxLength) {
return str;
}
if (maxLength <= endChars + ELLIPSIS_LENGTH) {
return ELLIPSIS + chars.slice(-endChars).join('');
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/utils/truncateTextWithEndVisible.ts` at line 23, The fallback
in truncateTextWithEndVisible is running before the code-point-aware fit check,
so astral-only strings that already fit still get truncated. Move the
Array.from(str).slice(-endChars) / ELLIPSIS fallback to execute only after the
totalChars <= maxLength guard (or equivalent code-point-length check) in
truncateWithEndVisible, and update the related tests so they either expect the
original string when it fits or use a smaller maxLength to cover the truncation
path.

}

const chars = Array.from(str);
Expand Down