mirror of
https://github.com/facebook/react.git
synced 2026-02-24 20:53:03 +00:00
This allows us to show props in React DevTools when inspecting a Server Component. I currently drastically limit the object depth that's serialized since this is very implicit and you can have heavy objects on the server. We previously was using the general outlineModel to outline ReactComponentInfo but we weren't consistently using it everywhere which could cause some bugs with the parsing when it got deduped on the client. It also lead to the weird feature detect of `isReactComponent`. It also meant that this serialization was using the plain serialization instead of `renderConsoleValue` which means we couldn't safely serialize arbitrary debug info that isn't serializable there. So the main change here is to call `outlineComponentInfo` and have that always write every "Server Component" instance as outlined and in a way that lets its props be serialized using `renderConsoleValue`. <img width="1150" alt="Screenshot 2024-10-01 at 1 25 05 AM" src="https://github.com/user-attachments/assets/f6e7811d-51a3-46b9-bbe0-1b8276849ed4">
577 lines
16 KiB
JavaScript
577 lines
16 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 {
|
|
getDataType,
|
|
getDisplayNameForReactElement,
|
|
getAllEnumerableKeys,
|
|
getInObject,
|
|
formatDataForPreview,
|
|
setInObject,
|
|
} from 'react-devtools-shared/src/utils';
|
|
|
|
import type {
|
|
DehydratedData,
|
|
InspectedElementPath,
|
|
} from 'react-devtools-shared/src/frontend/types';
|
|
|
|
export const meta = {
|
|
inspectable: (Symbol('inspectable'): symbol),
|
|
inspected: (Symbol('inspected'): symbol),
|
|
name: (Symbol('name'): symbol),
|
|
preview_long: (Symbol('preview_long'): symbol),
|
|
preview_short: (Symbol('preview_short'): symbol),
|
|
readonly: (Symbol('readonly'): symbol),
|
|
size: (Symbol('size'): symbol),
|
|
type: (Symbol('type'): symbol),
|
|
unserializable: (Symbol('unserializable'): symbol),
|
|
};
|
|
|
|
export type Dehydrated = {
|
|
inspectable: boolean,
|
|
name: string | null,
|
|
preview_long: string | null,
|
|
preview_short: string | null,
|
|
readonly?: boolean,
|
|
size?: number,
|
|
type: string,
|
|
};
|
|
|
|
// Typed arrays and other complex iteratable objects (e.g. Map, Set, ImmutableJS) need special handling.
|
|
// These objects can't be serialized without losing type information,
|
|
// so a "Unserializable" type wrapper is used (with meta-data keys) to send nested values-
|
|
// while preserving the original type and name.
|
|
export type Unserializable = {
|
|
name: string | null,
|
|
preview_long: string | null,
|
|
preview_short: string | null,
|
|
readonly?: boolean,
|
|
size?: number,
|
|
type: string,
|
|
unserializable: boolean,
|
|
[string | number]: any,
|
|
};
|
|
|
|
// This threshold determines the depth at which the bridge "dehydrates" nested data.
|
|
// Dehydration means that we don't serialize the data for e.g. postMessage or stringify,
|
|
// unless the frontend explicitly requests it (e.g. a user clicks to expand a props object).
|
|
//
|
|
// Reducing this threshold will improve the speed of initial component inspection,
|
|
// but may decrease the responsiveness of expanding objects/arrays to inspect further.
|
|
const LEVEL_THRESHOLD = 2;
|
|
|
|
/**
|
|
* Generate the dehydrated metadata for complex object instances
|
|
*/
|
|
function createDehydrated(
|
|
type: string,
|
|
inspectable: boolean,
|
|
data: Object,
|
|
cleaned: Array<Array<string | number>>,
|
|
path: Array<string | number>,
|
|
): Dehydrated {
|
|
cleaned.push(path);
|
|
|
|
const dehydrated: Dehydrated = {
|
|
inspectable,
|
|
type,
|
|
preview_long: formatDataForPreview(data, true),
|
|
preview_short: formatDataForPreview(data, false),
|
|
name:
|
|
typeof data.constructor !== 'function' ||
|
|
typeof data.constructor.name !== 'string' ||
|
|
data.constructor.name === 'Object'
|
|
? ''
|
|
: data.constructor.name,
|
|
};
|
|
|
|
if (type === 'array' || type === 'typed_array') {
|
|
dehydrated.size = data.length;
|
|
} else if (type === 'object') {
|
|
dehydrated.size = Object.keys(data).length;
|
|
}
|
|
|
|
if (type === 'iterator' || type === 'typed_array') {
|
|
dehydrated.readonly = true;
|
|
}
|
|
|
|
return dehydrated;
|
|
}
|
|
|
|
/**
|
|
* Strip out complex data (instances, functions, and data nested > LEVEL_THRESHOLD levels deep).
|
|
* The paths of the stripped out objects are appended to the `cleaned` list.
|
|
* On the other side of the barrier, the cleaned list is used to "re-hydrate" the cleaned representation into
|
|
* an object with symbols as attributes, so that a sanitized object can be distinguished from a normal object.
|
|
*
|
|
* Input: {"some": {"attr": fn()}, "other": AnInstance}
|
|
* Output: {
|
|
* "some": {
|
|
* "attr": {"name": the fn.name, type: "function"}
|
|
* },
|
|
* "other": {
|
|
* "name": "AnInstance",
|
|
* "type": "object",
|
|
* },
|
|
* }
|
|
* and cleaned = [["some", "attr"], ["other"]]
|
|
*/
|
|
export function dehydrate(
|
|
data: Object,
|
|
cleaned: Array<Array<string | number>>,
|
|
unserializable: Array<Array<string | number>>,
|
|
path: Array<string | number>,
|
|
isPathAllowed: (path: Array<string | number>) => boolean,
|
|
level: number = 0,
|
|
): $PropertyType<DehydratedData, 'data'> {
|
|
const type = getDataType(data);
|
|
|
|
let isPathAllowedCheck;
|
|
|
|
switch (type) {
|
|
case 'html_element':
|
|
cleaned.push(path);
|
|
return {
|
|
inspectable: false,
|
|
preview_short: formatDataForPreview(data, false),
|
|
preview_long: formatDataForPreview(data, true),
|
|
name: data.tagName,
|
|
type,
|
|
};
|
|
|
|
case 'function':
|
|
cleaned.push(path);
|
|
return {
|
|
inspectable: false,
|
|
preview_short: formatDataForPreview(data, false),
|
|
preview_long: formatDataForPreview(data, true),
|
|
name:
|
|
typeof data.name === 'function' || !data.name
|
|
? 'function'
|
|
: data.name,
|
|
type,
|
|
};
|
|
|
|
case 'string':
|
|
isPathAllowedCheck = isPathAllowed(path);
|
|
if (isPathAllowedCheck) {
|
|
return data;
|
|
} else {
|
|
return data.length <= 500 ? data : data.slice(0, 500) + '...';
|
|
}
|
|
|
|
case 'bigint':
|
|
cleaned.push(path);
|
|
return {
|
|
inspectable: false,
|
|
preview_short: formatDataForPreview(data, false),
|
|
preview_long: formatDataForPreview(data, true),
|
|
name: data.toString(),
|
|
type,
|
|
};
|
|
|
|
case 'symbol':
|
|
cleaned.push(path);
|
|
return {
|
|
inspectable: false,
|
|
preview_short: formatDataForPreview(data, false),
|
|
preview_long: formatDataForPreview(data, true),
|
|
name: data.toString(),
|
|
type,
|
|
};
|
|
|
|
// React Elements aren't very inspector-friendly,
|
|
// and often contain private fields or circular references.
|
|
case 'react_element':
|
|
cleaned.push(path);
|
|
return {
|
|
inspectable: false,
|
|
preview_short: formatDataForPreview(data, false),
|
|
preview_long: formatDataForPreview(data, true),
|
|
name: getDisplayNameForReactElement(data) || 'Unknown',
|
|
type,
|
|
};
|
|
|
|
// ArrayBuffers error if you try to inspect them.
|
|
case 'array_buffer':
|
|
case 'data_view':
|
|
cleaned.push(path);
|
|
return {
|
|
inspectable: false,
|
|
preview_short: formatDataForPreview(data, false),
|
|
preview_long: formatDataForPreview(data, true),
|
|
name: type === 'data_view' ? 'DataView' : 'ArrayBuffer',
|
|
size: data.byteLength,
|
|
type,
|
|
};
|
|
|
|
case 'array':
|
|
isPathAllowedCheck = isPathAllowed(path);
|
|
if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
|
|
return createDehydrated(type, true, data, cleaned, path);
|
|
}
|
|
const arr: Array<Object> = [];
|
|
for (let i = 0; i < data.length; i++) {
|
|
arr[i] = dehydrateKey(
|
|
data,
|
|
i,
|
|
cleaned,
|
|
unserializable,
|
|
path.concat([i]),
|
|
isPathAllowed,
|
|
isPathAllowedCheck ? 1 : level + 1,
|
|
);
|
|
}
|
|
return arr;
|
|
|
|
case 'html_all_collection':
|
|
case 'typed_array':
|
|
case 'iterator':
|
|
isPathAllowedCheck = isPathAllowed(path);
|
|
if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
|
|
return createDehydrated(type, true, data, cleaned, path);
|
|
} else {
|
|
const unserializableValue: Unserializable = {
|
|
unserializable: true,
|
|
type: type,
|
|
readonly: true,
|
|
size: type === 'typed_array' ? data.length : undefined,
|
|
preview_short: formatDataForPreview(data, false),
|
|
preview_long: formatDataForPreview(data, true),
|
|
name:
|
|
typeof data.constructor !== 'function' ||
|
|
typeof data.constructor.name !== 'string' ||
|
|
data.constructor.name === 'Object'
|
|
? ''
|
|
: data.constructor.name,
|
|
};
|
|
|
|
// 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.
|
|
Array.from(data).forEach(
|
|
(item, i) =>
|
|
(unserializableValue[i] = dehydrate(
|
|
item,
|
|
cleaned,
|
|
unserializable,
|
|
path.concat([i]),
|
|
isPathAllowed,
|
|
isPathAllowedCheck ? 1 : level + 1,
|
|
)),
|
|
);
|
|
|
|
unserializable.push(path);
|
|
|
|
return unserializableValue;
|
|
}
|
|
|
|
case 'opaque_iterator':
|
|
cleaned.push(path);
|
|
return {
|
|
inspectable: false,
|
|
preview_short: formatDataForPreview(data, false),
|
|
preview_long: formatDataForPreview(data, true),
|
|
name: data[Symbol.toStringTag],
|
|
type,
|
|
};
|
|
|
|
case 'date':
|
|
cleaned.push(path);
|
|
return {
|
|
inspectable: false,
|
|
preview_short: formatDataForPreview(data, false),
|
|
preview_long: formatDataForPreview(data, true),
|
|
name: data.toString(),
|
|
type,
|
|
};
|
|
|
|
case 'regexp':
|
|
cleaned.push(path);
|
|
return {
|
|
inspectable: false,
|
|
preview_short: formatDataForPreview(data, false),
|
|
preview_long: formatDataForPreview(data, true),
|
|
name: data.toString(),
|
|
type,
|
|
};
|
|
|
|
case 'object':
|
|
isPathAllowedCheck = isPathAllowed(path);
|
|
|
|
if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
|
|
return createDehydrated(type, true, data, cleaned, path);
|
|
} else {
|
|
const object: {
|
|
[string]: $PropertyType<DehydratedData, 'data'>,
|
|
} = {};
|
|
getAllEnumerableKeys(data).forEach(key => {
|
|
const name = key.toString();
|
|
object[name] = dehydrateKey(
|
|
data,
|
|
key,
|
|
cleaned,
|
|
unserializable,
|
|
path.concat([name]),
|
|
isPathAllowed,
|
|
isPathAllowedCheck ? 1 : level + 1,
|
|
);
|
|
});
|
|
return object;
|
|
}
|
|
|
|
case 'class_instance':
|
|
isPathAllowedCheck = isPathAllowed(path);
|
|
|
|
if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
|
|
return createDehydrated(type, true, data, cleaned, path);
|
|
}
|
|
|
|
const value: Unserializable = {
|
|
unserializable: true,
|
|
type,
|
|
readonly: true,
|
|
preview_short: formatDataForPreview(data, false),
|
|
preview_long: formatDataForPreview(data, true),
|
|
name:
|
|
typeof data.constructor !== 'function' ||
|
|
typeof data.constructor.name !== 'string'
|
|
? ''
|
|
: data.constructor.name,
|
|
};
|
|
|
|
getAllEnumerableKeys(data).forEach(key => {
|
|
const keyAsString = key.toString();
|
|
|
|
value[keyAsString] = dehydrate(
|
|
data[key],
|
|
cleaned,
|
|
unserializable,
|
|
path.concat([keyAsString]),
|
|
isPathAllowed,
|
|
isPathAllowedCheck ? 1 : level + 1,
|
|
);
|
|
});
|
|
|
|
unserializable.push(path);
|
|
|
|
return value;
|
|
|
|
case 'infinity':
|
|
case 'nan':
|
|
case 'undefined':
|
|
// Some values are lossy when sent through a WebSocket.
|
|
// We dehydrate+rehydrate them to preserve their type.
|
|
cleaned.push(path);
|
|
return {type};
|
|
|
|
default:
|
|
return data;
|
|
}
|
|
}
|
|
|
|
function dehydrateKey(
|
|
parent: Object,
|
|
key: number | string | symbol,
|
|
cleaned: Array<Array<string | number>>,
|
|
unserializable: Array<Array<string | number>>,
|
|
path: Array<string | number>,
|
|
isPathAllowed: (path: Array<string | number>) => boolean,
|
|
level: number = 0,
|
|
): $PropertyType<DehydratedData, 'data'> {
|
|
try {
|
|
return dehydrate(
|
|
parent[key],
|
|
cleaned,
|
|
unserializable,
|
|
path,
|
|
isPathAllowed,
|
|
level,
|
|
);
|
|
} catch (error) {
|
|
let preview = '';
|
|
if (
|
|
typeof error === 'object' &&
|
|
error !== null &&
|
|
typeof error.stack === 'string'
|
|
) {
|
|
preview = error.stack;
|
|
} else if (typeof error === 'string') {
|
|
preview = error;
|
|
}
|
|
cleaned.push(path);
|
|
return {
|
|
inspectable: false,
|
|
preview_short: '[Exception]',
|
|
preview_long: preview ? '[Exception: ' + preview + ']' : '[Exception]',
|
|
name: preview,
|
|
type: 'unknown',
|
|
};
|
|
}
|
|
}
|
|
|
|
export function fillInPath(
|
|
object: Object,
|
|
data: DehydratedData,
|
|
path: InspectedElementPath,
|
|
value: any,
|
|
) {
|
|
const target = getInObject(object, path);
|
|
if (target != null) {
|
|
if (!target[meta.unserializable]) {
|
|
delete target[meta.inspectable];
|
|
delete target[meta.inspected];
|
|
delete target[meta.name];
|
|
delete target[meta.preview_long];
|
|
delete target[meta.preview_short];
|
|
delete target[meta.readonly];
|
|
delete target[meta.size];
|
|
delete target[meta.type];
|
|
}
|
|
}
|
|
|
|
if (value !== null && data.unserializable.length > 0) {
|
|
const unserializablePath = data.unserializable[0];
|
|
let isMatch = unserializablePath.length === path.length;
|
|
for (let i = 0; i < path.length; i++) {
|
|
if (path[i] !== unserializablePath[i]) {
|
|
isMatch = false;
|
|
break;
|
|
}
|
|
}
|
|
if (isMatch) {
|
|
upgradeUnserializable(value, value);
|
|
}
|
|
}
|
|
|
|
setInObject(object, path, value);
|
|
}
|
|
|
|
export function hydrate(
|
|
object: any,
|
|
cleaned: Array<Array<string | number>>,
|
|
unserializable: Array<Array<string | number>>,
|
|
): Object {
|
|
cleaned.forEach((path: Array<string | number>) => {
|
|
const length = path.length;
|
|
const last = path[length - 1];
|
|
const parent = getInObject(object, path.slice(0, length - 1));
|
|
if (!parent || !parent.hasOwnProperty(last)) {
|
|
return;
|
|
}
|
|
|
|
const value = parent[last];
|
|
|
|
if (!value) {
|
|
return;
|
|
} else if (value.type === 'infinity') {
|
|
parent[last] = Infinity;
|
|
} else if (value.type === 'nan') {
|
|
parent[last] = NaN;
|
|
} else if (value.type === 'undefined') {
|
|
parent[last] = undefined;
|
|
} else {
|
|
// Replace the string keys with Symbols so they're non-enumerable.
|
|
const replaced: {[key: symbol]: boolean | string} = {};
|
|
replaced[meta.inspectable] = !!value.inspectable;
|
|
replaced[meta.inspected] = false;
|
|
replaced[meta.name] = value.name;
|
|
replaced[meta.preview_long] = value.preview_long;
|
|
replaced[meta.preview_short] = value.preview_short;
|
|
replaced[meta.size] = value.size;
|
|
replaced[meta.readonly] = !!value.readonly;
|
|
replaced[meta.type] = value.type;
|
|
|
|
parent[last] = replaced;
|
|
}
|
|
});
|
|
unserializable.forEach((path: Array<string | number>) => {
|
|
const length = path.length;
|
|
const last = path[length - 1];
|
|
const parent = getInObject(object, path.slice(0, length - 1));
|
|
if (!parent || !parent.hasOwnProperty(last)) {
|
|
return;
|
|
}
|
|
|
|
const node = parent[last];
|
|
|
|
const replacement = {
|
|
...node,
|
|
};
|
|
|
|
upgradeUnserializable(replacement, node);
|
|
|
|
parent[last] = replacement;
|
|
});
|
|
return object;
|
|
}
|
|
|
|
function upgradeUnserializable(destination: Object, source: Object) {
|
|
Object.defineProperties(destination, {
|
|
// $FlowFixMe[invalid-computed-prop]
|
|
[meta.inspected]: {
|
|
configurable: true,
|
|
enumerable: false,
|
|
value: !!source.inspected,
|
|
},
|
|
// $FlowFixMe[invalid-computed-prop]
|
|
[meta.name]: {
|
|
configurable: true,
|
|
enumerable: false,
|
|
value: source.name,
|
|
},
|
|
// $FlowFixMe[invalid-computed-prop]
|
|
[meta.preview_long]: {
|
|
configurable: true,
|
|
enumerable: false,
|
|
value: source.preview_long,
|
|
},
|
|
// $FlowFixMe[invalid-computed-prop]
|
|
[meta.preview_short]: {
|
|
configurable: true,
|
|
enumerable: false,
|
|
value: source.preview_short,
|
|
},
|
|
// $FlowFixMe[invalid-computed-prop]
|
|
[meta.size]: {
|
|
configurable: true,
|
|
enumerable: false,
|
|
value: source.size,
|
|
},
|
|
// $FlowFixMe[invalid-computed-prop]
|
|
[meta.readonly]: {
|
|
configurable: true,
|
|
enumerable: false,
|
|
value: !!source.readonly,
|
|
},
|
|
// $FlowFixMe[invalid-computed-prop]
|
|
[meta.type]: {
|
|
configurable: true,
|
|
enumerable: false,
|
|
value: source.type,
|
|
},
|
|
// $FlowFixMe[invalid-computed-prop]
|
|
[meta.unserializable]: {
|
|
configurable: true,
|
|
enumerable: false,
|
|
value: !!source.unserializable,
|
|
},
|
|
});
|
|
|
|
delete destination.inspected;
|
|
delete destination.name;
|
|
delete destination.preview_long;
|
|
delete destination.preview_short;
|
|
delete destination.size;
|
|
delete destination.readonly;
|
|
delete destination.type;
|
|
delete destination.unserializable;
|
|
}
|