diff --git a/packages/react-devtools-shared/src/__tests__/store-test.js b/packages/react-devtools-shared/src/__tests__/store-test.js index 02cea3d925d5d..53a531d51bf29 100644 --- a/packages/react-devtools-shared/src/__tests__/store-test.js +++ b/packages/react-devtools-shared/src/__tests__/store-test.js @@ -101,6 +101,19 @@ describe('Store', () => { `); }); + it('should handle multibyte character strings', () => { + const Component = () => null; + Component.displayName = '🟩💜🔵'; + + const container = document.createElement('div'); + + act(() => legacyRender(, container)); + expect(store).toMatchInlineSnapshot(` + [root] + <🟩💜🔵> + `); + }); + describe('collapseNodesByDefault:false', () => { beforeEach(() => { store.collapseNodesByDefault = false; diff --git a/packages/react-devtools-shared/src/backend/renderer.js b/packages/react-devtools-shared/src/backend/renderer.js index 24a00583e7e68..a2469577a0a38 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -1514,11 +1514,16 @@ export function attach( type OperationsArray = Array; + type StringTableEntry = {| + encodedString: Array, + id: number, + |}; + const pendingOperations: OperationsArray = []; const pendingRealUnmountedIDs: Array = []; const pendingSimulatedUnmountedIDs: Array = []; let pendingOperationsQueue: Array | null = []; - const pendingStringTable: Map = new Map(); + const pendingStringTable: Map = new Map(); let pendingStringTableLength: number = 0; let pendingUnmountedRootID: number | null = null; @@ -1736,13 +1741,19 @@ export function attach( // Now fill in the string table. // [stringTableLength, str1Length, ...str1, str2Length, ...str2, ...] operations[i++] = pendingStringTableLength; - pendingStringTable.forEach((value, key) => { - operations[i++] = key.length; - const encodedKey = utfEncodeString(key); - for (let j = 0; j < encodedKey.length; j++) { - operations[i + j] = encodedKey[j]; + pendingStringTable.forEach((entry, stringKey) => { + const encodedString = entry.encodedString; + + // Don't use the string length. + // It won't work for multibyte characters (like emoji). + const length = encodedString.length; + + operations[i++] = length; + for (let j = 0; j < length; j++) { + operations[i + j] = encodedString[j]; } - i += key.length; + + i += length; }); if (numUnmountIDs > 0) { @@ -1789,21 +1800,31 @@ export function attach( pendingStringTableLength = 0; } - function getStringID(str: string | null): number { - if (str === null) { + function getStringID(string: string | null): number { + if (string === null) { return 0; } - const existingID = pendingStringTable.get(str); - if (existingID !== undefined) { - return existingID; - } - const stringID = pendingStringTable.size + 1; - pendingStringTable.set(str, stringID); - // The string table total length needs to account - // both for the string length, and for the array item - // that contains the length itself. Hence + 1. - pendingStringTableLength += str.length + 1; - return stringID; + const existingEntry = pendingStringTable.get(string); + if (existingEntry !== undefined) { + return existingEntry.id; + } + + const id = pendingStringTable.size + 1; + const encodedString = utfEncodeString(string); + + pendingStringTable.set(string, { + encodedString, + id, + }); + + // The string table total length needs to account both for the string length, + // and for the array item that contains the length itself. + // + // Don't use string length for this table. + // It won't work for multibyte characters (like emoji). + pendingStringTableLength += encodedString.length + 1; + + return id; } function recordMount(fiber: Fiber, parentFiber: Fiber | null) { diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js index 9ec19e3afde41..771c18688ab38 100644 --- a/packages/react-devtools-shared/src/utils.js +++ b/packages/react-devtools-shared/src/utils.js @@ -138,17 +138,37 @@ export function utfDecodeString(array: Array): string { return string; } +function surrogatePairToCodePoint( + charCode1: number, + charCode2: number, +): number { + return ((charCode1 & 0x3ff) << 10) + (charCode2 & 0x3ff) + 0x10000; +} + +// Credit for this encoding approach goes to Tim Down: +// https://stackoverflow.com/questions/4877326/how-can-i-tell-if-a-string-contains-multibyte-characters-in-javascript export function utfEncodeString(string: string): Array { const cached = encodedStringCache.get(string); if (cached !== undefined) { return cached; } - const encoded = new Array(string.length); - for (let i = 0; i < string.length; i++) { - encoded[i] = string.codePointAt(i); + const encoded = []; + let i = 0; + let charCode; + while (i < string.length) { + charCode = string.charCodeAt(i); + // Handle multibyte unicode characters (like emoji). + if ((charCode & 0xf800) === 0xd800) { + encoded.push(surrogatePairToCodePoint(charCode, string.charCodeAt(++i))); + } else { + encoded.push(charCode); + } + ++i; } + encodedStringCache.set(string, encoded); + return encoded; }