Files
react/packages/react-devtools-shared/src/utils.js
Robert Balicki fecc288b7d [react devtools] Device storage support (#25452)
# Summary
* This PR adds support for persisting certain settings to device
storage, allowing e.g. RN apps to properly patch the console when
restarted.
* The device storage APIs have signature `getConsolePatchSettings()` and
`setConsolePatchSettings(string)`, in iOS, are thin wrappers around the
`Library/Settings` turbomodule, and wrap a new TM that uses the `SharedPreferences` class in Android.
* Pass device storage getters/setters from RN to DevTools'
`connectToDevtools`. The setters are then used to populate values on
`window`. Later, the console is patched using these values.
* If we receive a notification from DevTools that the console patching
fields have been updated, we write values back to local storage.
* See https://github.com/facebook/react-native/pull/34903

# How did you test this change?
Manual testing, `yarn run test-build-devtools`, `yarn run prettier`,
`yarn run flow dom`

## Manual testing setup:

### React DevTools Frontend
* Get the DevTools frontend in flipper:
* `nvm install -g react-devtools-core`, then replace that package with a
symlink to the local package
  * enable "use globally installed devtools" in flipper
* yarn run start in react-devtools, etc. as well

### React DevTools Backend
* `yarn run build:backend` in react-devtools-core, then copy-paste that
file to the expo app's node_modules directory

### React Native
* A local version of React Native can be patched in by modifying an expo
app's package.json, as in `"react-native":
"rbalicki2/react-native#branch-name"`

# Versioning safety
* There are three versioned modules to worry about: react native, the
devtools frontend and the devtools backend.
* The react devtools backend checks for whether a `cachedSettingsStore`
is passed from react native. If not (e.g. if React Native is outdated),
then no behavior changes.
* The devtools backend reads the patched console values from the cached
settings store. However, if nothing has been stored, for example because
the frontend is outdated or has never synced its settings, then behavior
doesn't change.
* The devtools frontend sends no new messages. However, if it did send a
new message (e.g. "store this value at this key"), and the backend was
outdated, that message would be silently ignored.
2022-10-25 13:25:33 +11:00

876 lines
23 KiB
JavaScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import LRU from 'lru-cache';
import {
isElement,
typeOf,
ContextConsumer,
ContextProvider,
ForwardRef,
Fragment,
Lazy,
Memo,
Portal,
Profiler,
StrictMode,
Suspense,
} from 'react-is';
import {
REACT_SUSPENSE_LIST_TYPE as SuspenseList,
REACT_TRACING_MARKER_TYPE as TracingMarker,
} from 'shared/ReactSymbols';
import {
TREE_OPERATION_ADD,
TREE_OPERATION_REMOVE,
TREE_OPERATION_REMOVE_ROOT,
TREE_OPERATION_REORDER_CHILDREN,
TREE_OPERATION_SET_SUBTREE_MODE,
TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS,
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
LOCAL_STORAGE_COMPONENT_FILTER_PREFERENCES_KEY,
LOCAL_STORAGE_OPEN_IN_EDITOR_URL,
LOCAL_STORAGE_SHOULD_BREAK_ON_CONSOLE_ERRORS,
LOCAL_STORAGE_SHOULD_APPEND_COMPONENT_STACK_KEY,
LOCAL_STORAGE_SHOW_INLINE_WARNINGS_AND_ERRORS_KEY,
LOCAL_STORAGE_HIDE_CONSOLE_LOGS_IN_STRICT_MODE,
} from './constants';
import {ComponentFilterElementType, ElementTypeHostComponent} from './types';
import {
ElementTypeRoot,
ElementTypeClass,
ElementTypeForwardRef,
ElementTypeFunction,
ElementTypeMemo,
} from 'react-devtools-shared/src/types';
import {localStorageGetItem, localStorageSetItem} from './storage';
import {meta} from './hydration';
import isArray from './isArray';
import type {ComponentFilter, ElementType} from './types';
import type {LRUCache} from 'react-devtools-shared/src/types';
import type {BrowserTheme} from 'react-devtools-shared/src/devtools/views/DevTools';
// $FlowFixMe[method-unbinding]
const hasOwnProperty = Object.prototype.hasOwnProperty;
const cachedDisplayNames: WeakMap<Function, string> = new WeakMap();
// On large trees, encoding takes significant time.
// Try to reuse the already encoded strings.
const encodedStringCache: LRUCache<string, Array<number>> = new LRU({
max: 1000,
});
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<string | number | symbol> {
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: 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;
}
// 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)})`
);
}
export function getDisplayName(
type: Function,
fallbackName: string = 'Anonymous',
): string {
const nameFromCache = cachedDisplayNames.get(type);
if (nameFromCache != null) {
return nameFromCache;
}
let displayName = fallbackName;
// 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;
}
cachedDisplayNames.set(type, displayName);
return displayName;
}
let uidCounter: number = 0;
export function getUID(): number {
return ++uidCounter;
}
export function utfDecodeString(array: Array<number>): 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;
}
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<number> {
const cached = encodedStringCache.get(string);
if (cached !== undefined) {
return cached;
}
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;
}
export function printOperationsArray(operations: Array<number>) {
// The first two values are always rendererID and rootID
const rendererID = operations[0];
const rootID = operations[1];
const logs = [`operations for renderer:${rendererID} and root:${rootID}`];
let i = 2;
// Reassemble the string table.
const stringTable = [
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 = utfDecodeString(
(operations.slice(i, i + nextLength): any),
);
stringTable.push(nextString);
i += nextLength;
}
while (i < operations.length) {
const operation = operations[i];
switch (operation) {
case TREE_OPERATION_ADD: {
const id = ((operations[i + 1]: any): number);
const type = ((operations[i + 2]: any): ElementType);
i += 3;
if (type === ElementTypeRoot) {
logs.push(`Add new root node ${id}`);
i++; // isStrictModeCompliant
i++; // supportsProfiling
i++; // supportsStrictMode
i++; // hasOwnerMetadata
} else {
const parentID = ((operations[i]: any): number);
i++;
i++; // ownerID
const displayNameStringID = operations[i];
const displayName = stringTable[displayNameStringID];
i++;
i++; // key
logs.push(
`Add node ${id} (${displayName || 'null'}) as child of ${parentID}`,
);
}
break;
}
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;
logs.push(`Remove node ${id}`);
}
break;
}
case TREE_OPERATION_REMOVE_ROOT: {
i += 1;
logs.push(`Remove root ${rootID}`);
break;
}
case TREE_OPERATION_SET_SUBTREE_MODE: {
const id = operations[i + 1];
const mode = operations[i + 1];
i += 3;
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;
default:
throw Error(`Unsupported Bridge operation "${operation}"`);
}
}
console.log(logs.join('\n '));
}
export function getDefaultComponentFilters(): Array<ComponentFilter> {
return [
{
type: ComponentFilterElementType,
value: ElementTypeHostComponent,
isEnabled: true,
},
];
}
export function getSavedComponentFilters(): Array<ComponentFilter> {
try {
const raw = localStorageGetItem(
LOCAL_STORAGE_COMPONENT_FILTER_PREFERENCES_KEY,
);
if (raw != null) {
return JSON.parse(raw);
}
} catch (error) {}
return getDefaultComponentFilters();
}
export function setSavedComponentFilters(
componentFilters: Array<ComponentFilter>,
): void {
localStorageSetItem(
LOCAL_STORAGE_COMPONENT_FILTER_PREFERENCES_KEY,
JSON.stringify(componentFilters),
);
}
function parseBool(s: ?string): ?boolean {
if (s === 'true') {
return true;
}
if (s === 'false') {
return false;
}
}
export function castBool(v: any): ?boolean {
if (v === true || v === false) {
return v;
}
}
export function castBrowserTheme(v: any): ?BrowserTheme {
if (v === 'light' || v === 'dark' || v === 'auto') {
return v;
}
}
export function getAppendComponentStack(): boolean {
const raw = localStorageGetItem(
LOCAL_STORAGE_SHOULD_APPEND_COMPONENT_STACK_KEY,
);
return parseBool(raw) ?? true;
}
export function getBreakOnConsoleErrors(): boolean {
const raw = localStorageGetItem(LOCAL_STORAGE_SHOULD_BREAK_ON_CONSOLE_ERRORS);
return parseBool(raw) ?? false;
}
export function getHideConsoleLogsInStrictMode(): boolean {
const raw = localStorageGetItem(
LOCAL_STORAGE_HIDE_CONSOLE_LOGS_IN_STRICT_MODE,
);
return parseBool(raw) ?? false;
}
export function getShowInlineWarningsAndErrors(): boolean {
const raw = localStorageGetItem(
LOCAL_STORAGE_SHOW_INLINE_WARNINGS_AND_ERRORS_KEY,
);
return parseBool(raw) ?? true;
}
export function getDefaultOpenInEditorURL(): string {
return typeof process.env.EDITOR_URL === 'string'
? process.env.EDITOR_URL
: '';
}
export function getOpenInEditorURL(): string {
try {
const raw = localStorageGetItem(LOCAL_STORAGE_OPEN_IN_EDITOR_URL);
if (raw != null) {
return JSON.parse(raw);
}
} catch (error) {}
return getDefaultOpenInEditorURL();
}
export function separateDisplayNameAndHOCs(
displayName: string | null,
type: ElementType,
): [string | null, Array<string> | null] {
if (displayName === null) {
return [null, null];
}
let hocDisplayNames = null;
switch (type) {
case ElementTypeClass:
case ElementTypeForwardRef:
case ElementTypeFunction:
case ElementTypeMemo:
if (displayName.indexOf('(') >= 0) {
const matches = displayName.match(/[^()]+/g);
if (matches != null) {
displayName = matches.pop();
hocDisplayNames = matches;
}
}
break;
default:
break;
}
return [displayName, hocDisplayNames];
}
// 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;
}
}
for (const attribute in next) {
if (prev[attribute] !== next[attribute]) {
return true;
}
}
return false;
}
export function getInObject(object: Object, path: Array<string | number>): 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];
}
}
return null;
}, object);
}
export function deletePathInObject(
object: Object,
path: Array<string | number>,
) {
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];
}
}
}
}
export function renamePathInObject(
object: Object,
oldPath: Array<string | number>,
newPath: Array<string | number>,
) {
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];
}
}
}
}
export function setInObject(
object: Object,
path: Array<string | number>,
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;
}
}
}
export type DataType =
| 'array'
| 'array_buffer'
| 'bigint'
| 'boolean'
| 'data_view'
| 'date'
| 'function'
| 'html_all_collection'
| 'html_element'
| 'infinity'
| 'iterator'
| 'opaque_iterator'
| 'nan'
| 'null'
| 'number'
| 'object'
| 'react_element'
| 'regexp'
| 'string'
| 'symbol'
| 'typed_array'
| 'undefined'
| 'unknown';
/**
* 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';
}
if (isElement(data)) {
return 'react_element';
}
if (typeof HTMLElement !== 'undefined' && data instanceof HTMLElement) {
return 'html_element';
}
const type = typeof data;
switch (type) {
case 'bigint':
return 'bigint';
case 'boolean':
return 'boolean';
case 'function':
return 'function';
case 'number':
if (Number.isNaN(data)) {
return 'nan';
} else if (!Number.isFinite(data)) {
return 'infinity';
} else {
return 'number';
}
case 'object':
if (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') {
return 'regexp';
} 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';
}
}
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';
}
}
export function getDisplayNameForReactElement(
element: React$Element<any>,
): string | null {
const elementType = typeOf(element);
switch (elementType) {
case ContextConsumer:
return 'ContextConsumer';
case ContextProvider:
return 'ContextProvider';
case ForwardRef:
return 'ForwardRef';
case Fragment:
return 'Fragment';
case Lazy:
return 'Lazy';
case Memo:
return 'Memo';
case Portal:
return 'Portal';
case Profiler:
return 'Profiler';
case StrictMode:
return 'StrictMode';
case Suspense:
return 'Suspense';
case SuspenseList:
return 'SuspenseList';
case TracingMarker:
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';
}
}
}
const MAX_PREVIEW_STRING_LENGTH = 50;
function truncateForDisplay(
string: string,
length: number = MAX_PREVIEW_STRING_LENGTH,
) {
if (string.length > length) {
return string.substr(0, length) + '…';
} else {
return string;
}
}
// 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];
}
const type = getDataType(data);
switch (type) {
case 'html_element':
return `<${truncateForDisplay(data.tagName.toLowerCase())} />`;
case 'function':
return truncateForDisplay(
`ƒ ${typeof data.name === 'function' ? '' : data.name}() {}`,
);
case 'string':
return `"${data}"`;
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 'array_buffer':
return `ArrayBuffer(${data.byteLength})`;
case 'data_view':
return `DataView(${data.buffer.byteLength})`;
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 '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 {
return '{…}';
}
case 'boolean':
case 'number':
case 'infinity':
case 'nan':
case 'null':
case 'undefined':
return data;
default:
try {
return truncateForDisplay(String(data));
} catch (error) {
return 'unserializable';
}
}
}