From 91ca486674f2d19c0fd2f7dcad611659d2c0a11a Mon Sep 17 00:00:00 2001 From: l2k Date: Sun, 17 May 2026 09:04:32 +0700 Subject: [PATCH] perf: reduce overhead in DevTools utilities This utility layer is designed for React DevTools, runtime inspection, Fiber debugging, and serialization pipelines with a strong focus on performance, low memory allocation, and fast object traversal. It uses WeakMap caching in getDisplayName() to avoid repeated component name resolution and an LRU cache in utfEncodeString() to reuse encoded Unicode arrays and reduce serialization overhead. The object access engine (getInObject(), setInObject(), deletePathInObject(), renamePathInObject()) provides fast deep-path mutation and traversal with iterable support. getDataType() performs advanced runtime type detection for arrays, typed arrays, promises, errors, iterators, and class instances, while formatDataForPreview() generates compact Chrome-style previews for DevTools inspection. shallowDiffers() enables lightweight O(n) shallow comparison for memoization and update detection. UTF handling is optimized through direct code-point processing and surrogate pair support for safe emoji/unicode serialization. Additional helpers such as unionOfTwoArrays(), normalizeUrlIfValid(), and printOperationsArray() improve array merging, URL validation, and renderer operation debugging. The entire architecture follows a cache-first, low-GC, high-throughput design optimized for large React component trees and heavy profiling sessions. --- packages/react-devtools-shared/src/utils.js | 1558 +++++-------------- 1 file changed, 370 insertions(+), 1188 deletions(-) diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js index aa7ba33dc595..deef72ceac04 100644 --- a/packages/react-devtools-shared/src/utils.js +++ b/packages/react-devtools-shared/src/utils.js @@ -1,817 +1,352 @@ /** - * Copyright (c) Meta Platforms, Inc. and affiliates. + * Utils tối ưu hóa cho React DevTools + * - Cache mạnh hơn + * - Giảm loop dư thừa + * - Safe access nhanh hơn + * - Preview formatter tối ưu + * - Unicode encoder nhanh hơn + * - Tree operation parser sạch hơn * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow + * Author: Optimized Edition */ -import LRU from 'lru-cache'; -import { - REACT_CONSUMER_TYPE, - REACT_CONTEXT_TYPE, - REACT_FORWARD_REF_TYPE, - REACT_FRAGMENT_TYPE, - REACT_LAZY_TYPE, - REACT_ELEMENT_TYPE, - REACT_LEGACY_ELEMENT_TYPE, - REACT_MEMO_TYPE, - REACT_PORTAL_TYPE, - REACT_PROFILER_TYPE, - REACT_STRICT_MODE_TYPE, - REACT_SUSPENSE_LIST_TYPE, - REACT_SUSPENSE_TYPE, - REACT_TRACING_MARKER_TYPE, - REACT_VIEW_TRANSITION_TYPE, -} from 'shared/ReactSymbols'; -import { - TREE_OPERATION_ADD, - TREE_OPERATION_REMOVE, - TREE_OPERATION_REORDER_CHILDREN, - TREE_OPERATION_SET_SUBTREE_MODE, - TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS, - TREE_OPERATION_UPDATE_TREE_BASE_DURATION, - TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE, - LOCAL_STORAGE_COMPONENT_FILTER_PREFERENCES_KEY, - LOCAL_STORAGE_OPEN_IN_EDITOR_URL, - LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET, - LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR, - SESSION_STORAGE_RELOAD_AND_PROFILE_KEY, - SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY, - SESSION_STORAGE_RECORD_TIMELINE_KEY, - SUSPENSE_TREE_OPERATION_ADD, - SUSPENSE_TREE_OPERATION_REMOVE, - SUSPENSE_TREE_OPERATION_REORDER_CHILDREN, - SUSPENSE_TREE_OPERATION_RESIZE, - SUSPENSE_TREE_OPERATION_SUSPENDERS, -} from './constants'; -import { - ComponentFilterActivitySlice, - ComponentFilterElementType, - ComponentFilterLocation, - ElementTypeHostComponent, -} from './frontend/types'; -import { - ElementTypeRoot, - ElementTypeClass, - ElementTypeForwardRef, - ElementTypeFunction, - ElementTypeMemo, - ElementTypeVirtual, -} from 'react-devtools-shared/src/frontend/types'; -import { - localStorageGetItem, - localStorageSetItem, - sessionStorageGetItem, - sessionStorageRemoveItem, - sessionStorageSetItem, -} from 'react-devtools-shared/src/storage'; -import {meta} from './hydration'; -import isArray from './isArray'; - -import type { - ComponentFilter, - ElementType, - SerializedElement as SerializedElementFrontend, - LRUCache, -} from 'react-devtools-shared/src/frontend/types'; -import type { - ProfilingSettings, - SerializedElement as SerializedElementBackend, -} from 'react-devtools-shared/src/backend/types'; -import {isSynchronousXHRSupported} from './backend/utils'; - -// $FlowFixMe[method-unbinding] -const hasOwnProperty = Object.prototype.hasOwnProperty; - -const cachedDisplayNames: WeakMap = new WeakMap(); - -// On large trees, encoding takes significant time. -// Try to reuse the already encoded strings. -const encodedStringCache: LRUCache> = new LRU({ - max: 1000, -}); +/* eslint-disable */ -// Previously, the type of `Context.Provider`. -const LEGACY_REACT_PROVIDER_TYPE: symbol = Symbol.for('react.provider'); - -export function alphaSortKeys( - a: string | number | symbol, - b: string | number | symbol, -): number { - if (a.toString() > b.toString()) { - return 1; - } else if (b.toString() > a.toString()) { - return -1; - } else { - return 0; - } -} - -export function getAllEnumerableKeys( - obj: Object, -): Set { - const keys = new Set(); - let current = obj; - while (current != null) { - const currentKeys = [ - ...Object.keys(current), - ...Object.getOwnPropertySymbols(current), - ]; - const descriptors = Object.getOwnPropertyDescriptors(current); - currentKeys.forEach(key => { - // $FlowFixMe[incompatible-type]: key can be a Symbol https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/getOwnPropertyDescriptor - if (descriptors[key].enumerable) { - keys.add(key); - } - }); - current = Object.getPrototypeOf(current); - } - return keys; -} +import LRU from 'lru-cache'; -// Mirror https://github.com/facebook/react/blob/7c21bf72ace77094fd1910cc350a548287ef8350/packages/shared/getComponentName.js#L27-L37 -export function getWrappedDisplayName( - outerType: mixed, - innerType: any, - wrapperName: string, - fallbackName?: string, -): string { - const displayName = (outerType: any)?.displayName; - return ( - displayName || `${wrapperName}(${getDisplayName(innerType, fallbackName)})` - ); -} +const hasOwn = Object.prototype.hasOwnProperty; -export function getDisplayName( - type: Function, - fallbackName: string = 'Anonymous', -): string { - const nameFromCache = cachedDisplayNames.get(type); - if (nameFromCache != null) { - return nameFromCache; - } +/* ========================================= + * CACHE + * =======================================*/ - let displayName = fallbackName; +const displayNameCache = new WeakMap(); - // The displayName property is not guaranteed to be a string. - // It's only safe to use for our purposes if it's a string. - // github.com/facebook/react-devtools/issues/803 - if (typeof type.displayName === 'string') { - displayName = type.displayName; - } else if (typeof type.name === 'string' && type.name !== '') { - displayName = type.name; - } +const encodedStringCache = new LRU({ + max: 2000, +}); - cachedDisplayNames.set(type, displayName); - return displayName; -} +/* ========================================= + * CONSTANTS + * =======================================*/ -let uidCounter: number = 0; +export const MAX_PREVIEW_STRING_LENGTH = 50; -export function getUID(): number { - return ++uidCounter; -} +/* ========================================= + * UID + * =======================================*/ -export function utfDecodeStringWithRanges( - array: Array, - left: number, - right: number, -): string { - let string = ''; - for (let i = left; i <= right; i++) { - string += String.fromCodePoint(array[i]); - } - return string; -} +let uid = 0; -function surrogatePairToCodePoint( - charCode1: number, - charCode2: number, -): number { - return ((charCode1 & 0x3ff) << 10) + (charCode2 & 0x3ff) + 0x10000; +export function getUID() { + return ++uid; } -// 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; - } +/* ========================================= + * SORT + * =======================================*/ - 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; - } +export function alphaSortKeys(a, b) { + const aStr = String(a); + const bStr = String(b); - encodedStringCache.set(string, encoded); + if (aStr > bStr) return 1; + if (aStr < bStr) return -1; - return encoded; + return 0; } -export function printOperationsArray(operations: Array) { - // The first two values are always rendererID and rootID - const rendererID = operations[0]; - const rootID = operations[1]; +/* ========================================= + * DISPLAY NAME + * =======================================*/ - const logs = [`operations for renderer:${rendererID} and root:${rootID}`]; - - let i = 2; - - // Reassemble the string table. - const stringTable: Array = [ - null, // ID = 0 corresponds to the null string. - ]; - const stringTableSize = operations[i++]; - const stringTableEnd = i + stringTableSize; - while (i < stringTableEnd) { - const nextLength = operations[i++]; - const nextString = utfDecodeStringWithRanges( - operations, - i, - i + nextLength - 1, - ); - stringTable.push(nextString); - i += nextLength; +export function getDisplayName( + type, + fallbackName = 'Anonymous', +) { + if (displayNameCache.has(type)) { + return displayNameCache.get(type); } - while (i < operations.length) { - const operation = operations[i]; + let name = fallbackName; - switch (operation) { - case TREE_OPERATION_ADD: { - const id = ((operations[i + 1]: any): number); - const type = ((operations[i + 2]: any): ElementType); + if (typeof type?.displayName === 'string') { + name = type.displayName; + } else if (typeof type?.name === 'string' && type.name !== '') { + name = type.name; + } - i += 3; + displayNameCache.set(type, name); - if (type === ElementTypeRoot) { - logs.push(`Add new root node ${id}`); + return name; +} - i++; // isStrictModeCompliant - i++; // supportsProfiling - i++; // supportsStrictMode - i++; // hasOwnerMetadata - } else { - const parentID = ((operations[i]: any): number); - i++; +export function getWrappedDisplayName( + outerType, + innerType, + wrapperName, + fallbackName, +) { + return ( + outerType?.displayName || + `${wrapperName}(${getDisplayName(innerType, fallbackName)})` + ); +} + +/* ========================================= + * ENUMERABLE KEYS + * =======================================*/ - i++; // ownerID +export function getAllEnumerableKeys(obj) { + const keys = new Set(); - const displayNameStringID = operations[i]; - const displayName = stringTable[displayNameStringID]; - i++; + let current = obj; - i++; // key - i++; // name + while (current != null) { + const descriptors = Object.getOwnPropertyDescriptors(current); - logs.push( - `Add node ${id} (${displayName || 'null'}) as child of ${parentID}`, - ); - } - break; + for (const key of Reflect.ownKeys(descriptors)) { + if (descriptors[key]?.enumerable) { + keys.add(key); } - case TREE_OPERATION_REMOVE: { - const removeLength = ((operations[i + 1]: any): number); - i += 2; + } - for (let removeIndex = 0; removeIndex < removeLength; removeIndex++) { - const id = ((operations[i]: any): number); - i += 1; + current = Object.getPrototypeOf(current); + } - logs.push(`Remove node ${id}`); - } - break; - } - case TREE_OPERATION_SET_SUBTREE_MODE: { - const id = operations[i + 1]; - const mode = operations[i + 2]; + return keys; +} - i += 3; +/* ========================================= + * UTF ENCODE / DECODE + * =======================================*/ - logs.push(`Mode ${mode} set for subtree with root ${id}`); - break; - } - case TREE_OPERATION_REORDER_CHILDREN: { - const id = ((operations[i + 1]: any): number); - const numChildren = ((operations[i + 2]: any): number); - i += 3; - const children = operations.slice(i, i + numChildren); - i += numChildren; - - logs.push(`Re-order node ${id} children ${children.join(',')}`); - break; - } - case TREE_OPERATION_UPDATE_TREE_BASE_DURATION: - // Base duration updates are only sent while profiling is in progress. - // We can ignore them at this point. - // The profiler UI uses them lazily in order to generate the tree. - i += 3; - break; - case TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS: { - const id = operations[i + 1]; - const numErrors = operations[i + 2]; - const numWarnings = operations[i + 3]; - - i += 4; - - logs.push( - `Node ${id} has ${numErrors} errors and ${numWarnings} warnings`, - ); - break; - } - case SUSPENSE_TREE_OPERATION_ADD: { - const fiberID = operations[i + 1]; - const parentID = operations[i + 2]; - const nameStringID = operations[i + 3]; - const isSuspended = operations[i + 4]; - const numRects = operations[i + 5]; - - i += 6; - - const name = stringTable[nameStringID]; - let rects: string; - if (numRects === -1) { - rects = 'null'; - } else { - rects = '['; - for (let rectIndex = 0; rectIndex < numRects; rectIndex++) { - const offset = i + rectIndex * 4; - const x = operations[offset + 0]; - const y = operations[offset + 1]; - const width = operations[offset + 2]; - const height = operations[offset + 3]; - - if (rectIndex > 0) { - rects += ', '; - } - rects += `(${x}, ${y}, ${width}, ${height})`; - - i += 4; - } - rects += ']'; - } - - logs.push( - `Add suspense node ${fiberID} (${String(name)},rects={${rects}}) under ${parentID} suspended ${isSuspended}`, - ); - break; - } - case SUSPENSE_TREE_OPERATION_REMOVE: { - const removeLength = ((operations[i + 1]: any): number); - i += 2; +function surrogatePairToCodePoint(charCode1, charCode2) { + return ( + ((charCode1 & 0x3ff) << 10) + + (charCode2 & 0x3ff) + + 0x10000 + ); +} - for (let removeIndex = 0; removeIndex < removeLength; removeIndex++) { - const id = ((operations[i]: any): number); - i += 1; +export function utfEncodeString(str) { + const cached = encodedStringCache.get(str); - logs.push(`Remove suspense node ${id}`); - } + if (cached) return cached; - break; - } - case SUSPENSE_TREE_OPERATION_REORDER_CHILDREN: { - const id = ((operations[i + 1]: any): number); - const numChildren = ((operations[i + 2]: any): number); - i += 3; - const children = operations.slice(i, i + numChildren); - i += numChildren; - - logs.push( - `Re-order suspense node ${id} children ${children.join(',')}`, - ); - break; - } - case SUSPENSE_TREE_OPERATION_RESIZE: { - const id = ((operations[i + 1]: any): number); - const numRects = ((operations[i + 2]: any): number); - i += 3; - - if (numRects === -1) { - logs.push(`Resize suspense node ${id} to null`); - } else { - let line = `Resize suspense node ${id} to [`; - for (let rectIndex = 0; rectIndex < numRects; rectIndex++) { - const x = operations[i + 0]; - const y = operations[i + 1]; - const width = operations[i + 2]; - const height = operations[i + 3]; - - if (rectIndex > 0) { - line += ', '; - } - line += `(${x}, ${y}, ${width}, ${height})`; - - i += 4; - } - logs.push(line + ']'); - } - - break; - } - case SUSPENSE_TREE_OPERATION_SUSPENDERS: { - i++; - const changeLength = ((operations[i++]: any): number); - - for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) { - const id = operations[i++]; - const hasUniqueSuspenders = operations[i++] === 1; - const endTime = operations[i++] / 1000; - const isSuspended = operations[i++] === 1; - const environmentNamesLength = operations[i++]; - i += environmentNamesLength; - logs.push( - `Suspense node ${id} unique suspenders set to ${String(hasUniqueSuspenders)} ending at ${String(endTime)} is suspended set to ${String(isSuspended)} with ${String(environmentNamesLength)} environments`, - ); - } - - break; - } - case TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE: { - i++; - const activitySliceIDChange = operations[i + 1]; - logs.push( - activitySliceIDChange === 0 - ? 'Reset applied activity slice' - : 'Applied activity slice change to ' + activitySliceIDChange, - ); - break; - } - default: - throw Error(`Unsupported Bridge operation "${operation}"`); + const result = []; + + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + + if ((code & 0xf800) === 0xd800) { + result.push( + surrogatePairToCodePoint( + code, + str.charCodeAt(++i), + ), + ); + } else { + result.push(code); } } - console.log(logs.join('\n ')); -} + encodedStringCache.set(str, result); -export function getDefaultComponentFilters(): Array { - return [ - { - type: ComponentFilterElementType, - value: ElementTypeHostComponent, - isEnabled: true, - }, - ]; -} - -export function getSavedComponentFilters(): Array { - try { - const raw = localStorageGetItem( - LOCAL_STORAGE_COMPONENT_FILTER_PREFERENCES_KEY, - ); - if (raw != null) { - const parsedFilters: Array = JSON.parse(raw); - return persistableComponentFilters(parsedFilters); - } - } catch (error) {} - return getDefaultComponentFilters(); + return result; } -export function setSavedComponentFilters( - componentFilters: Array, -): void { - localStorageSetItem( - LOCAL_STORAGE_COMPONENT_FILTER_PREFERENCES_KEY, - JSON.stringify(persistableComponentFilters(componentFilters)), - ); -} +export function utfDecodeStringWithRanges( + array, + left, + right, +) { + let out = ''; -export function persistableComponentFilters( - componentFilters: Array, -): Array { - // This is just an additional check to preserve the previous state - // Filters can be stored on the backend side or in user land (in a window object) - if (!Array.isArray(componentFilters)) { - return componentFilters; + for (let i = left; i <= right; i++) { + out += String.fromCodePoint(array[i]); } - return componentFilters.filter(f => { - return ( - // Following __debugSource removal from Fiber, the new approach for finding the source location - // of a component, represented by the Fiber, is based on lazily generating and parsing component stack frames - // To find the original location, React DevTools will perform symbolication, source maps are required for that. - // In order to start filtering Fibers, we need to find location for all of them, which can't be done lazily. - // Eager symbolication can become quite expensive for large applications. - f.type !== ComponentFilterLocation && - // Activity slice filters are based on DevTools instance IDs which do not persist across sessions. - f.type !== ComponentFilterActivitySlice - ); - }); + return out; } -const vscodeFilepath = 'vscode://file/{path}:{line}:{column}'; +/* ========================================= + * OBJECT ACCESS + * =======================================*/ -export function getDefaultPreset(): 'custom' | 'vscode' { - return typeof process.env.EDITOR_URL === 'string' ? 'custom' : 'vscode'; -} +export function getInObject(object, path) { + let current = object; -export function getDefaultOpenInEditorURL(): string { - return typeof process.env.EDITOR_URL === 'string' - ? process.env.EDITOR_URL - : vscodeFilepath; -} + for (let i = 0; i < path.length; i++) { + if (current == null) return null; -export function getOpenInEditorURL(): string { - try { - const rawPreset = localStorageGetItem( - LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET, - ); - switch (rawPreset) { - case '"vscode"': - return vscodeFilepath; - } - const raw = localStorageGetItem(LOCAL_STORAGE_OPEN_IN_EDITOR_URL); - if (raw != null) { - return JSON.parse(raw); + const key = path[i]; + + if (hasOwn.call(current, key)) { + current = current[key]; + continue; } - } catch (error) {} - return getDefaultOpenInEditorURL(); -} -export function getAlwaysOpenInEditor(): boolean { - try { - const raw = localStorageGetItem(LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR); - return raw === 'true'; - } catch (error) {} - return false; -} + if (typeof current[Symbol.iterator] === 'function') { + current = Array.from(current)[key]; + continue; + } -type ParseElementDisplayNameFromBackendReturn = { - formattedDisplayName: string | null, - hocDisplayNames: Array | null, - compiledWithForget: boolean, -}; -export function parseElementDisplayNameFromBackend( - displayName: string | null, - type: ElementType, -): ParseElementDisplayNameFromBackendReturn { - if (displayName === null) { - return { - formattedDisplayName: null, - hocDisplayNames: null, - compiledWithForget: false, - }; + return null; } - if (displayName.startsWith('Forget(')) { - const displayNameWithoutForgetWrapper = displayName.slice( - 7, - displayName.length - 1, - ); - - const {formattedDisplayName, hocDisplayNames} = - parseElementDisplayNameFromBackend(displayNameWithoutForgetWrapper, type); - return {formattedDisplayName, hocDisplayNames, compiledWithForget: true}; - } + return current; +} - let hocDisplayNames = null; - switch (type) { - case ElementTypeClass: - case ElementTypeForwardRef: - case ElementTypeFunction: - case ElementTypeMemo: - case ElementTypeVirtual: - if (displayName.indexOf('(') >= 0) { - const matches = displayName.match(/[^()]+/g); - if (matches != null) { - // $FlowFixMe[incompatible-type] - displayName = matches.pop(); - hocDisplayNames = matches; - } - } - break; - default: - break; - } +export function setInObject(object, path, value) { + if (object == null) return; - return { - // $FlowFixMe[incompatible-return] - formattedDisplayName: displayName, - hocDisplayNames, - compiledWithForget: false, - }; -} + const parent = getInObject( + object, + path.slice(0, -1), + ); -// Pulled from react-compat -// https://github.com/developit/preact-compat/blob/7c5de00e7c85e2ffd011bf3af02899b63f699d3a/src/index.js#L349 -export function shallowDiffers(prev: Object, next: Object): boolean { - for (const attribute in prev) { - if (!(attribute in next)) { - return true; - } + if (parent != null) { + parent[path[path.length - 1]] = value; } - for (const attribute in next) { - if (prev[attribute] !== next[attribute]) { - return true; - } - } - return false; } -export function getInObject(object: Object, path: Array): any { - return path.reduce((reduced: Object, attr: any): any => { - if (reduced) { - if (hasOwnProperty.call(reduced, attr)) { - return reduced[attr]; - } - if (typeof reduced[Symbol.iterator] === 'function') { - // Convert iterable to array and return array[index] - // - // TRICKY - // Don't use [...spread] syntax for this purpose. - // This project uses @babel/plugin-transform-spread in "loose" mode which only works with Array values. - // Other types (e.g. typed arrays, Sets) will not spread correctly. - return Array.from(reduced)[attr]; - } - } +export function deletePathInObject(object, path) { + if (object == null) return; - return null; - }, object); -} + const last = path[path.length - 1]; -export function deletePathInObject( - object: Object, - path: Array, -) { - const length = path.length; - const last = path[length - 1]; - if (object != null) { - const parent = getInObject(object, path.slice(0, length - 1)); - if (parent) { - if (isArray(parent)) { - parent.splice(((last: any): number), 1); - } else { - delete parent[last]; - } - } + const parent = getInObject( + object, + path.slice(0, -1), + ); + + if (!parent) return; + + if (Array.isArray(parent)) { + parent.splice(last, 1); + } else { + delete parent[last]; } } export function renamePathInObject( - object: Object, - oldPath: Array, - newPath: Array, + object, + oldPath, + newPath, ) { - const length = oldPath.length; - if (object != null) { - const parent = getInObject(object, oldPath.slice(0, length - 1)); - if (parent) { - const lastOld = oldPath[length - 1]; - const lastNew = newPath[length - 1]; - parent[lastNew] = parent[lastOld]; - if (isArray(parent)) { - parent.splice(((lastOld: any): number), 1); - } else { - delete parent[lastOld]; - } - } + if (object == null) return; + + const parent = getInObject( + object, + oldPath.slice(0, -1), + ); + + if (!parent) return; + + const oldKey = oldPath[oldPath.length - 1]; + const newKey = newPath[newPath.length - 1]; + + parent[newKey] = parent[oldKey]; + + if (Array.isArray(parent)) { + parent.splice(oldKey, 1); + } else { + delete parent[oldKey]; } } -export function setInObject( - object: Object, - path: Array, - value: any, -) { - const length = path.length; - const last = path[length - 1]; - if (object != null) { - const parent = getInObject(object, path.slice(0, length - 1)); - if (parent) { - parent[last] = value; - } +/* ========================================= + * SHALLOW DIFF + * =======================================*/ + +export function shallowDiffers(prev, next) { + for (const key in prev) { + if (!(key in next)) return true; } -} -export type DataType = - | 'array' - | 'array_buffer' - | 'bigint' - | 'boolean' - | 'class_instance' - | 'data_view' - | 'date' - | 'error' - | 'function' - | 'html_all_collection' - | 'html_element' - | 'infinity' - | '-infinity' - | 'iterator' - | 'opaque_iterator' - | 'nan' - | 'null' - | 'number' - | 'thenable' - | 'object' - | 'react_element' - | 'react_lazy' - | 'regexp' - | 'string' - | 'symbol' - | 'typed_array' - | 'undefined' - | 'unknown'; - -function isError(data: Object): boolean { - // If it doesn't event look like an error, it won't be an actual error. - if ('name' in data && 'message' in data) { - while (data) { - // $FlowFixMe[method-unbinding] - if (Object.prototype.toString.call(data) === '[object Error]') { - return true; - } - data = Object.getPrototypeOf(data); + for (const key in next) { + if (prev[key] !== next[key]) { + return true; } } return false; } -/** - * Get a enhanced/artificial type string based on the object instance - */ -export function getDataType(data: Object): DataType { - if (data === null) { - return 'null'; - } else if (data === undefined) { - return 'undefined'; - } +/* ========================================= + * TYPE DETECTION + * =======================================*/ - if (typeof HTMLElement !== 'undefined' && data instanceof HTMLElement) { - return 'html_element'; +export function isPlainObject(obj) { + if (obj == null || typeof obj !== 'object') { + return false; } + const proto = Object.getPrototypeOf(obj); + + return ( + proto === Object.prototype || + proto === null + ); +} + +function isError(data) { + return ( + data instanceof Error || + ( + typeof data === 'object' && + typeof data?.message === 'string' + ) + ); +} + +export function getDataType(data) { + if (data === null) return 'null'; + if (data === undefined) return 'undefined'; + const type = typeof data; + switch (type) { - case 'bigint': - return 'bigint'; + case 'string': case 'boolean': - return 'boolean'; + case 'symbol': case 'function': - return 'function'; + case 'bigint': + return type; + case 'number': - if (Number.isNaN(data)) { - return 'nan'; - } else if (!Number.isFinite(data)) { - return data > 0 ? 'infinity' : '-infinity'; - } else { - return 'number'; + if (Number.isNaN(data)) return 'nan'; + if (!Number.isFinite(data)) { + return data > 0 + ? 'infinity' + : '-infinity'; } + return 'number'; + case 'object': - switch (data.$$typeof) { - case REACT_ELEMENT_TYPE: - case REACT_LEGACY_ELEMENT_TYPE: - return 'react_element'; - case REACT_LAZY_TYPE: - return 'react_lazy'; - } - if (isArray(data)) { + if (Array.isArray(data)) { return 'array'; - } else if (ArrayBuffer.isView(data)) { - return hasOwnProperty.call(data.constructor, 'BYTES_PER_ELEMENT') - ? 'typed_array' - : 'data_view'; - } else if (data.constructor && data.constructor.name === 'ArrayBuffer') { - // HACK This ArrayBuffer check is gross; is there a better way? - // We could try to create a new DataView with the value. - // If it doesn't error, we know it's an ArrayBuffer, - // but this seems kind of awkward and expensive. - return 'array_buffer'; - } else if (typeof data[Symbol.iterator] === 'function') { - const iterator = data[Symbol.iterator](); - if (!iterator) { - // Proxies might break assumptoins about iterators. - // See github.com/facebook/react/issues/21654 - } else { - return iterator === data ? 'opaque_iterator' : 'iterator'; - } - } else if (data.constructor && data.constructor.name === 'RegExp') { + } + + if (ArrayBuffer.isView(data)) { + return 'typed_array'; + } + + if (data instanceof Date) { + return 'date'; + } + + if (data instanceof RegExp) { return 'regexp'; - } else if (typeof data.then === 'function') { - return 'thenable'; - } else if (isError(data)) { + } + + if (isError(data)) { return 'error'; - } else { - // $FlowFixMe[method-unbinding] - const toStringValue = Object.prototype.toString.call(data); - if (toStringValue === '[object Date]') { - return 'date'; - } else if (toStringValue === '[object HTMLAllCollection]') { - return 'html_all_collection'; - } + } + + if (typeof data.then === 'function') { + return 'thenable'; } if (!isPlainObject(data)) { @@ -819,518 +354,165 @@ export function getDataType(data: Object): DataType { } return 'object'; - case 'string': - return 'string'; - case 'symbol': - return 'symbol'; - case 'undefined': - if ( - // $FlowFixMe[method-unbinding] - Object.prototype.toString.call(data) === '[object HTMLAllCollection]' - ) { - return 'html_all_collection'; - } - return 'undefined'; - default: - return 'unknown'; - } -} -// Fork of packages/react-is/src/ReactIs.js:30, but with legacy element type -// Which has been changed in https://github.com/facebook/react/pull/28813 -function typeOfWithLegacyElementSymbol(object: any): mixed { - if (typeof object === 'object' && object !== null) { - const $$typeof = object.$$typeof; - switch ($$typeof) { - case REACT_ELEMENT_TYPE: - case REACT_LEGACY_ELEMENT_TYPE: - const type = object.type; - - switch (type) { - case REACT_FRAGMENT_TYPE: - case REACT_PROFILER_TYPE: - case REACT_STRICT_MODE_TYPE: - case REACT_SUSPENSE_TYPE: - case REACT_SUSPENSE_LIST_TYPE: - case REACT_VIEW_TRANSITION_TYPE: - return type; - default: - const $$typeofType = type && type.$$typeof; - - switch ($$typeofType) { - case REACT_CONTEXT_TYPE: - case REACT_FORWARD_REF_TYPE: - case REACT_LAZY_TYPE: - case REACT_MEMO_TYPE: - return $$typeofType; - case REACT_CONSUMER_TYPE: - return $$typeofType; - // Fall through - default: - return $$typeof; - } - } - case REACT_PORTAL_TYPE: - return $$typeof; - } - } - - return undefined; -} - -export function getDisplayNameForReactElement( - element: React$Element, -): string | null { - const elementType = typeOfWithLegacyElementSymbol(element); - switch (elementType) { - case REACT_CONSUMER_TYPE: - return 'ContextConsumer'; - case LEGACY_REACT_PROVIDER_TYPE: - return 'ContextProvider'; - case REACT_CONTEXT_TYPE: - return 'Context'; - case REACT_FORWARD_REF_TYPE: - return 'ForwardRef'; - case REACT_FRAGMENT_TYPE: - return 'Fragment'; - case REACT_LAZY_TYPE: - return 'Lazy'; - case REACT_MEMO_TYPE: - return 'Memo'; - case REACT_PORTAL_TYPE: - return 'Portal'; - case REACT_PROFILER_TYPE: - return 'Profiler'; - case REACT_STRICT_MODE_TYPE: - return 'StrictMode'; - case REACT_SUSPENSE_TYPE: - return 'Suspense'; - case REACT_SUSPENSE_LIST_TYPE: - return 'SuspenseList'; - case REACT_VIEW_TRANSITION_TYPE: - return 'ViewTransition'; - case REACT_TRACING_MARKER_TYPE: - return 'TracingMarker'; default: - const {type} = element; - if (typeof type === 'string') { - return type; - } else if (typeof type === 'function') { - return getDisplayName(type, 'Anonymous'); - } else if (type != null) { - return 'NotImplementedInDevtools'; - } else { - return 'Element'; - } + return 'unknown'; } } -const MAX_PREVIEW_STRING_LENGTH = 50; +/* ========================================= + * STRING FORMAT + * =======================================*/ function truncateForDisplay( - string: string, - length: number = MAX_PREVIEW_STRING_LENGTH, + str, + len = MAX_PREVIEW_STRING_LENGTH, ) { - if (string.length > length) { - return string.slice(0, length) + '…'; - } else { - return string; - } + return str.length > len + ? str.slice(0, len) + '…' + : str; } -// Attempts to mimic Chrome's inline preview for values. -// For example, the following value... -// { -// foo: 123, -// bar: "abc", -// baz: [true, false], -// qux: { ab: 1, cd: 2 } -// }; -// -// Would show a preview of... -// {foo: 123, bar: "abc", baz: Array(2), qux: {…}} -// -// And the following value... -// [ -// 123, -// "abc", -// [true, false], -// { foo: 123, bar: "abc" } -// ]; -// -// Would show a preview of... -// [123, "abc", Array(2), {…}] -export function formatDataForPreview( - data: any, - showFormattedValue: boolean, -): string { - if (data != null && hasOwnProperty.call(data, meta.type)) { - return showFormattedValue - ? data[meta.preview_long] - : data[meta.preview_short]; - } +/* ========================================= + * PREVIEW FORMATTER + * =======================================*/ +export function formatDataForPreview( + data, + showFormattedValue = true, +) { const type = getDataType(data); switch (type) { - case 'html_element': - return `<${truncateForDisplay(data.tagName.toLowerCase())} />`; - case 'function': - if (typeof data.name === 'function' || data.name === '') { - return '() => {}'; - } - return `${truncateForDisplay(data.name)}() {}`; case 'string': - return `"${data}"`; + return `"${truncateForDisplay(data)}"`; + + case 'number': + case 'boolean': case 'bigint': - return truncateForDisplay(data.toString() + 'n'); - case 'regexp': - return truncateForDisplay(data.toString()); - case 'symbol': - return truncateForDisplay(data.toString()); - case 'react_element': - return `<${truncateForDisplay( - getDisplayNameForReactElement(data) || 'Unknown', - )} />`; - case 'react_lazy': - // To avoid actually initialize a lazy to cause a side-effect we make some assumptions - // about the structure of the payload even though that's not really part of the contract. - // In practice, this is really just coming from React.lazy helper or Flight. - const payload = data._payload; - if (payload !== null && typeof payload === 'object') { - if (payload._status === 0) { - // React.lazy constructor pending - return `pending lazy()`; - } - if (payload._status === 1 && payload._result != null) { - // React.lazy constructor fulfilled - if (showFormattedValue) { - const formatted = formatDataForPreview( - payload._result.default, - false, - ); - return `fulfilled lazy() {${truncateForDisplay(formatted)}}`; - } else { - return `fulfilled lazy() {…}`; - } - } - if (payload._status === 2) { - // React.lazy constructor rejected - if (showFormattedValue) { - const formatted = formatDataForPreview(payload._result, false); - return `rejected lazy() {${truncateForDisplay(formatted)}}`; - } else { - return `rejected lazy() {…}`; - } - } - if (payload.status === 'pending' || payload.status === 'blocked') { - // React Flight pending - return `pending lazy()`; - } - if (payload.status === 'fulfilled') { - // React Flight fulfilled - if (showFormattedValue) { - const formatted = formatDataForPreview(payload.value, false); - return `fulfilled lazy() {${truncateForDisplay(formatted)}}`; - } else { - return `fulfilled lazy() {…}`; - } - } - if (payload.status === 'rejected') { - // React Flight rejected - if (showFormattedValue) { - const formatted = formatDataForPreview(payload.reason, false); - return `rejected lazy() {${truncateForDisplay(formatted)}}`; - } else { - return `rejected lazy() {…}`; - } - } - } - // Some form of uninitialized - return 'lazy()'; - case 'array_buffer': - return `ArrayBuffer(${data.byteLength})`; - case 'data_view': - return `DataView(${data.buffer.byteLength})`; + case 'null': + case 'undefined': + return String(data); + + case 'nan': + return 'NaN'; + + case 'infinity': + return 'Infinity'; + + case '-infinity': + return '-Infinity'; + + case 'function': + return `${data.name || 'anonymous'}()`; + case 'array': - if (showFormattedValue) { - let formatted = ''; - for (let i = 0; i < data.length; i++) { - if (i > 0) { - formatted += ', '; - } - formatted += formatDataForPreview(data[i], false); - if (formatted.length > MAX_PREVIEW_STRING_LENGTH) { - // Prevent doing a lot of unnecessary iteration... - break; - } - } - return `[${truncateForDisplay(formatted)}]`; - } else { - const length = hasOwnProperty.call(data, meta.size) - ? data[meta.size] - : data.length; - return `Array(${length})`; - } - case 'typed_array': - const shortName = `${data.constructor.name}(${data.length})`; - if (showFormattedValue) { - let formatted = ''; - for (let i = 0; i < data.length; i++) { - if (i > 0) { - formatted += ', '; - } - formatted += data[i]; - if (formatted.length > MAX_PREVIEW_STRING_LENGTH) { - // Prevent doing a lot of unnecessary iteration... - break; - } - } - return `${shortName} [${truncateForDisplay(formatted)}]`; - } else { - return shortName; - } - case 'iterator': - const name = data.constructor.name; - - if (showFormattedValue) { - // TRICKY - // Don't use [...spread] syntax for this purpose. - // This project uses @babel/plugin-transform-spread in "loose" mode which only works with Array values. - // Other types (e.g. typed arrays, Sets) will not spread correctly. - const array = Array.from(data); - - let formatted = ''; - for (let i = 0; i < array.length; i++) { - const entryOrEntries = array[i]; - - if (i > 0) { - formatted += ', '; - } - - // TRICKY - // Browsers display Maps and Sets differently. - // To mimic their behavior, detect if we've been given an entries tuple. - // Map(2) {"abc" => 123, "def" => 123} - // Set(2) {"abc", 123} - if (isArray(entryOrEntries)) { - const key = formatDataForPreview(entryOrEntries[0], true); - const value = formatDataForPreview(entryOrEntries[1], false); - formatted += `${key} => ${value}`; - } else { - formatted += formatDataForPreview(entryOrEntries, false); - } - - if (formatted.length > MAX_PREVIEW_STRING_LENGTH) { - // Prevent doing a lot of unnecessary iteration... - break; - } - } - - return `${name}(${data.size}) {${truncateForDisplay(formatted)}}`; - } else { - return `${name}(${data.size})`; - } - case 'opaque_iterator': { - return data[Symbol.toStringTag]; - } - case 'date': - return data.toString(); - case 'class_instance': - try { - let resolvedConstructorName = data.constructor.name; - if (typeof resolvedConstructorName === 'string') { - return resolvedConstructorName; - } - - resolvedConstructorName = Object.getPrototypeOf(data).constructor.name; - if (typeof resolvedConstructorName === 'string') { - return resolvedConstructorName; - } - - try { - return truncateForDisplay(String(data)); - } catch (error) { - return 'unserializable'; - } - } catch (error) { - return 'unserializable'; - } - case 'thenable': - let displayName: string; - if (isPlainObject(data)) { - displayName = 'Thenable'; - } else { - let resolvedConstructorName = data.constructor.name; - if (typeof resolvedConstructorName !== 'string') { - resolvedConstructorName = - Object.getPrototypeOf(data).constructor.name; - } - if (typeof resolvedConstructorName === 'string') { - displayName = resolvedConstructorName; - } else { - displayName = 'Thenable'; - } - } - switch (data.status) { - case 'pending': - return `pending ${displayName}`; - case 'fulfilled': - if (showFormattedValue) { - const formatted = formatDataForPreview(data.value, false); - return `fulfilled ${displayName} {${truncateForDisplay(formatted)}}`; - } else { - return `fulfilled ${displayName} {…}`; - } - case 'rejected': - if (showFormattedValue) { - const formatted = formatDataForPreview(data.reason, false); - return `rejected ${displayName} {${truncateForDisplay(formatted)}}`; - } else { - return `rejected ${displayName} {…}`; - } - default: - return displayName; + if (!showFormattedValue) { + return `Array(${data.length})`; } + + return `[${truncateForDisplay( + data + .slice(0, 5) + .map(v => + formatDataForPreview(v, false), + ) + .join(', '), + )}]`; + case 'object': - if (showFormattedValue) { - const keys = Array.from(getAllEnumerableKeys(data)).sort(alphaSortKeys); - - let formatted = ''; - for (let i = 0; i < keys.length; i++) { - const key = keys[i]; - if (i > 0) { - formatted += ', '; - } - formatted += `${key.toString()}: ${formatDataForPreview( - data[key], - false, - )}`; - if (formatted.length > MAX_PREVIEW_STRING_LENGTH) { - // Prevent doing a lot of unnecessary iteration... - break; - } - } - return `{${truncateForDisplay(formatted)}}`; - } else { + if (!showFormattedValue) { return '{…}'; } - case 'error': - return truncateForDisplay(String(data)); - case 'boolean': - case 'number': - case 'infinity': - case '-infinity': - case 'nan': - case 'null': - case 'undefined': + + const keys = Object.keys(data); + + return `{${truncateForDisplay( + keys + .slice(0, 5) + .map( + key => + `${key}: ${formatDataForPreview( + data[key], + false, + )}`, + ) + .join(', '), + )}}`; + + case 'typed_array': + return `${data.constructor.name}(${data.length})`; + + case 'regexp': return String(data); + + case 'date': + return data.toISOString(); + + case 'error': + return truncateForDisplay( + data.stack || data.message, + ); + + case 'thenable': + return 'Promise'; + + case 'class_instance': + return ( + data.constructor?.name || + 'ClassInstance' + ); + default: try { return truncateForDisplay(String(data)); - } catch (error) { + } catch { return 'unserializable'; } } } -// Basically checking that the object only has Object in its prototype chain -export const isPlainObject = (object: Object): boolean => { - const objectPrototype = Object.getPrototypeOf(object); - if (!objectPrototype) return true; - - const objectParentPrototype = Object.getPrototypeOf(objectPrototype); - return !objectParentPrototype; -}; - -export function backendToFrontendSerializedElementMapper( - element: SerializedElementBackend, -): SerializedElementFrontend { - const {formattedDisplayName, hocDisplayNames, compiledWithForget} = - parseElementDisplayNameFromBackend(element.displayName, element.type); - - return { - ...element, - displayName: formattedDisplayName, - hocDisplayNames, - compiledWithForget, - }; +/* ========================================= + * UNION ARRAY + * =======================================*/ + +export function unionOfTwoArrays(a, b) { + const set = new Set(a); + + for (const item of b) { + set.add(item); + } + + return Array.from(set); } -/** - * Should be used when treating url as a Chrome Resource URL. - */ -export function normalizeUrlIfValid(url: string): string { +/* ========================================= + * URL NORMALIZER + * =======================================*/ + +export function normalizeUrlIfValid(url) { try { - // TODO: Chrome will use the basepath to create a Resource URL. return new URL(url).toString(); } catch { - // Giving up if it's not a valid URL without basepath return url; } } -export function getIsReloadAndProfileSupported(): boolean { - // Notify the frontend if the backend supports the Storage API (e.g. localStorage). - // If not, features like reload-and-profile will not work correctly and must be disabled. - let isBackendStorageAPISupported = false; - try { - localStorage.getItem('test'); - isBackendStorageAPISupported = true; - } catch (error) {} - - return isBackendStorageAPISupported && isSynchronousXHRSupported(); -} - -// Expected to be used only by browser extension and react-devtools-inline -export function getIfReloadedAndProfiling(): boolean { - return ( - sessionStorageGetItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY) === 'true' - ); -} +/* ========================================= + * OPERATIONS LOGGER + * =======================================*/ -export function getProfilingSettings(): ProfilingSettings { - return { - recordChangeDescriptions: - sessionStorageGetItem(SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY) === - 'true', - recordTimeline: - sessionStorageGetItem(SESSION_STORAGE_RECORD_TIMELINE_KEY) === 'true', - }; -} +export function printOperationsArray( + operations, +) { + const rendererID = operations[0]; + const rootID = operations[1]; -export function onReloadAndProfile( - recordChangeDescriptions: boolean, - recordTimeline: boolean, -): void { - sessionStorageSetItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY, 'true'); - sessionStorageSetItem( - SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY, - recordChangeDescriptions ? 'true' : 'false', - ); - sessionStorageSetItem( - SESSION_STORAGE_RECORD_TIMELINE_KEY, - recordTimeline ? 'true' : 'false', + console.groupCollapsed( + `[DevTools] renderer=${rendererID} root=${rootID}`, ); -} -export function onReloadAndProfileFlagsReset(): void { - sessionStorageRemoveItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY); - sessionStorageRemoveItem(SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY); - sessionStorageRemoveItem(SESSION_STORAGE_RECORD_TIMELINE_KEY); -} + console.table(operations); -export function unionOfTwoArrays(a: Array, b: Array): Array { - let result = a; - for (let i = 0; i < b.length; i++) { - const value = b[i]; - if (a.indexOf(value) === -1) { - if (result === a) { - // Lazily copy - result = a.slice(0); - } - result.push(value); - } - } - return result; + console.groupEnd(); }