diff --git a/packages/react-devtools-shared/src/__tests__/setupEnv.js b/packages/react-devtools-shared/src/__tests__/setupEnv.js index 85fb329ec83c9..e8b0fa74c8116 100644 --- a/packages/react-devtools-shared/src/__tests__/setupEnv.js +++ b/packages/react-devtools-shared/src/__tests__/setupEnv.js @@ -24,6 +24,3 @@ global.process.env.DARK_MODE_DIMMED_LOG_COLOR = DARK_MODE_DIMMED_LOG_COLOR; global.process.env.LIGHT_MODE_DIMMED_WARNING_COLOR = LIGHT_MODE_DIMMED_WARNING_COLOR; global.process.env.LIGHT_MODE_DIMMED_ERROR_COLOR = LIGHT_MODE_DIMMED_ERROR_COLOR; global.process.env.LIGHT_MODE_DIMMED_LOG_COLOR = LIGHT_MODE_DIMMED_LOG_COLOR; - -global.TextEncoder = require('util').TextEncoder; -global.TextDecoder = require('util').TextDecoder; 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 19279cbaf565e..41dce1f4c4ff2 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -1513,11 +1513,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; @@ -1735,13 +1740,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 < encodedString.length; j++) { + operations[i + j] = encodedString[j]; } - i += key.length; + + i += length; }); if (numUnmountIDs > 0) { @@ -1788,21 +1799,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 e6967dedaa93c..1c763ec01d1f0 100644 --- a/packages/react-devtools-shared/src/utils.js +++ b/packages/react-devtools-shared/src/utils.js @@ -7,7 +7,6 @@ * @flow */ -import LRU from 'lru-cache'; import { isElement, typeOf, @@ -50,19 +49,9 @@ import {localStorageGetItem, localStorageSetItem} from './storage'; import {meta} from './hydration'; import type {ComponentFilter, ElementType} from './types'; -import type {LRUCache} from 'react-devtools-shared/src/types'; const cachedDisplayNames: WeakMap = new WeakMap(); -// On large trees, encoding takes significant time. -// Try to reuse the already encoded strings. -const encodedStringCache: LRUCache< - string, - Array | Uint8Array, -> = new LRU({ - max: 1000, -}); - export function alphaSortKeys( a: string | number | Symbol, b: string | number | Symbol, @@ -128,47 +117,44 @@ export function getUID(): number { return ++uidCounter; } -const isTextEncoderSupported = - typeof TextDecoder === 'function' && typeof TextEncoder === 'function'; - export function utfDecodeString(array: Array): string { - if (isTextEncoderSupported) { - // Handles multi-byte characters; use if available. - return new TextDecoder().decode(new Uint8Array(array)); - } else { - // Avoid spreading the array (e.g. String.fromCodePoint(...array)) - // Functions arguments are first placed on the stack before the function is called - // which throws a RangeError for large arrays. - // See github.com/facebook/react/issues/22293 - let string = ''; - for (let i = 0; i < array.length; i++) { - const char = array[i]; - string += String.fromCodePoint(char); - } - return string; + // Avoid spreading the array (e.g. String.fromCodePoint(...array)) + // Functions arguments are first placed on the stack before the function is called + // which throws a RangeError for large arrays. + // See github.com/facebook/react/issues/22293 + let string = ''; + for (let i = 0; i < array.length; i++) { + const char = array[i]; + string += String.fromCodePoint(char); } + return string; } -export function utfEncodeString(string: string): Array | Uint8Array { - const cached = encodedStringCache.get(string); - if (cached !== undefined) { - return cached; - } +function surrogatePairToCodePoint( + charCode1: number, + charCode2: number, +): number { + return ((charCode1 & 0x3ff) << 10) + (charCode2 & 0x3ff) + 0x10000; +} - let encoded; - if (isTextEncoderSupported) { - // Handles multi-byte characters; use if available. - encoded = new TextEncoder().encode(string); - } else { - encoded = new Array(string.length); - for (let i = 0; i < string.length; i++) { - encoded[i] = string.codePointAt(i); +// 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 codePoints = []; + let i = 0; + let charCode; + while (i < string.length) { + charCode = string.charCodeAt(i); + if ((charCode & 0xf800) === 0xd800) { + codePoints.push( + surrogatePairToCodePoint(charCode, string.charCodeAt(++i)), + ); + } else { + codePoints.push(charCode); } + ++i; } - - encodedStringCache.set(string, encoded); - - return encoded; + return codePoints; } export function printOperationsArray(operations: Array) {