main
js 810 lines 22.5 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @flow
8 */
9
10 import {
11 getDataType,
12 getDisplayNameForReactElement,
13 getAllEnumerableKeys,
14 getInObject,
15 formatDataForPreview,
16 setInObject,
17 } from 'react-devtools-shared/src/utils';
18
19 import {REACT_LEGACY_ELEMENT_TYPE} from 'shared/ReactSymbols';
20
21 import type {
22 DehydratedData,
23 InspectedElementPath,
24 } from 'react-devtools-shared/src/frontend/types';
25
26 import noop from 'shared/noop';
27
28 export const meta = {
29 inspectable: Symbol('inspectable') as symbol,
30 inspected: Symbol('inspected') as symbol,
31 name: Symbol('name') as symbol,
32 preview_long: Symbol('preview_long') as symbol,
33 preview_short: Symbol('preview_short') as symbol,
34 readonly: Symbol('readonly') as symbol,
35 size: Symbol('size') as symbol,
36 type: Symbol('type') as symbol,
37 unserializable: Symbol('unserializable') as symbol,
38 };
39
40 export type Dehydrated = {
41 inspectable: boolean,
42 name: string | null,
43 preview_long: string | null,
44 preview_short: string | null,
45 readonly?: boolean,
46 size?: number,
47 type: string,
48 };
49
50 // Typed arrays, other complex iteratable objects (e.g. Map, Set, ImmutableJS) or Promises need special handling.
51 // These objects can't be serialized without losing type information,
52 // so a "Unserializable" type wrapper is used (with meta-data keys) to send nested values-
53 // while preserving the original type and name.
54 export type Unserializable = {
55 name: string | null,
56 preview_long: string | null,
57 preview_short: string | null,
58 readonly?: boolean,
59 size?: number,
60 type: string,
61 unserializable: boolean,
62 [string | number]: any,
63 };
64
65 // This threshold determines the depth at which the bridge "dehydrates" nested data.
66 // Dehydration means that we don't serialize the data for e.g. postMessage or stringify,
67 // unless the frontend explicitly requests it (e.g. a user clicks to expand a props object).
68 //
69 // Reducing this threshold will improve the speed of initial component inspection,
70 // but may decrease the responsiveness of expanding objects/arrays to inspect further.
71 const LEVEL_THRESHOLD = 2;
72
73 /**
74 * Generate the dehydrated metadata for complex object instances
75 */
76 function createDehydrated(
77 type: string,
78 inspectable: boolean,
79 data: Object,
80 cleaned: Array<Array<string | number>>,
81 path: Array<string | number>,
82 ): Dehydrated {
83 cleaned.push(path);
84
85 const dehydrated: Dehydrated = {
86 inspectable,
87 type,
88 preview_long: formatDataForPreview(data, true),
89 preview_short: formatDataForPreview(data, false),
90 name:
91 typeof data.constructor !== 'function' ||
92 typeof data.constructor.name !== 'string' ||
93 data.constructor.name === 'Object'
94 ? ''
95 : data.constructor.name,
96 };
97
98 if (type === 'array' || type === 'typed_array') {
99 dehydrated.size = data.length;
100 } else if (type === 'object') {
101 dehydrated.size = Object.keys(data).length;
102 }
103
104 if (type === 'iterator' || type === 'typed_array') {
105 dehydrated.readonly = true;
106 }
107
108 return dehydrated;
109 }
110
111 /**
112 * Strip out complex data (instances, functions, and data nested > LEVEL_THRESHOLD levels deep).
113 * The paths of the stripped out objects are appended to the `cleaned` list.
114 * On the other side of the barrier, the cleaned list is used to "re-hydrate" the cleaned representation into
115 * an object with symbols as attributes, so that a sanitized object can be distinguished from a normal object.
116 *
117 * Input: {"some": {"attr": fn()}, "other": AnInstance}
118 * Output: {
119 * "some": {
120 * "attr": {"name": the fn.name, type: "function"}
121 * },
122 * "other": {
123 * "name": "AnInstance",
124 * "type": "object",
125 * },
126 * }
127 * and cleaned = [["some", "attr"], ["other"]]
128 */
129 export function dehydrate(
130 data: Object,
131 cleaned: Array<Array<string | number>>,
132 unserializable: Array<Array<string | number>>,
133 path: Array<string | number>,
134 isPathAllowed: (path: Array<string | number>) => boolean,
135 level: number = 0,
136 ): DehydratedData['data'] {
137 const type = getDataType(data);
138
139 let isPathAllowedCheck;
140
141 switch (type) {
142 case 'html_element':
143 cleaned.push(path);
144 return {
145 inspectable: false,
146 preview_short: formatDataForPreview(data, false),
147 preview_long: formatDataForPreview(data, true),
148 name: data.tagName,
149 type,
150 };
151
152 case 'function':
153 cleaned.push(path);
154 return {
155 inspectable: false,
156 preview_short: formatDataForPreview(data, false),
157 preview_long: formatDataForPreview(data, true),
158 name:
159 typeof data.name === 'function' || !data.name
160 ? 'function'
161 : data.name,
162 type,
163 };
164
165 case 'string':
166 isPathAllowedCheck = isPathAllowed(path);
167 if (isPathAllowedCheck) {
168 return data;
169 } else {
170 return data.length <= 500 ? data : data.slice(0, 500) + '...';
171 }
172
173 case 'bigint':
174 cleaned.push(path);
175 return {
176 inspectable: false,
177 preview_short: formatDataForPreview(data, false),
178 preview_long: formatDataForPreview(data, true),
179 name: data.toString(),
180 type,
181 };
182
183 case 'symbol':
184 cleaned.push(path);
185 return {
186 inspectable: false,
187 preview_short: formatDataForPreview(data, false),
188 preview_long: formatDataForPreview(data, true),
189 name: data.toString(),
190 type,
191 };
192
193 case 'react_element': {
194 isPathAllowedCheck = isPathAllowed(path);
195
196 if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
197 cleaned.push(path);
198 return {
199 inspectable: true,
200 preview_short: formatDataForPreview(data, false),
201 preview_long: formatDataForPreview(data, true),
202 name: getDisplayNameForReactElement(data) || 'Unknown',
203 type,
204 };
205 }
206
207 const unserializableValue: Unserializable = {
208 unserializable: true,
209 type,
210 readonly: true,
211 preview_short: formatDataForPreview(data, false),
212 preview_long: formatDataForPreview(data, true),
213 name: getDisplayNameForReactElement(data) || 'Unknown',
214 };
215 // TODO: We can't expose type because that name is already taken on Unserializable.
216 unserializableValue.key = dehydrate(
217 data.key,
218 cleaned,
219 unserializable,
220 path.concat(['key']),
221 isPathAllowed,
222 isPathAllowedCheck ? 1 : level + 1,
223 );
224 if (data.$$typeof === REACT_LEGACY_ELEMENT_TYPE) {
225 unserializableValue.ref = dehydrate(
226 data.ref,
227 cleaned,
228 unserializable,
229 path.concat(['ref']),
230 isPathAllowed,
231 isPathAllowedCheck ? 1 : level + 1,
232 );
233 }
234 unserializableValue.props = dehydrate(
235 data.props,
236 cleaned,
237 unserializable,
238 path.concat(['props']),
239 isPathAllowed,
240 isPathAllowedCheck ? 1 : level + 1,
241 );
242
243 unserializable.push(path);
244 return unserializableValue;
245 }
246 case 'react_lazy': {
247 isPathAllowedCheck = isPathAllowed(path);
248
249 const payload = data._payload;
250
251 if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
252 cleaned.push(path);
253 const inspectable =
254 payload !== null &&
255 typeof payload === 'object' &&
256 (payload._status === 1 ||
257 payload._status === 2 ||
258 payload.status === 'fulfilled' ||
259 payload.status === 'rejected');
260 return {
261 inspectable,
262 preview_short: formatDataForPreview(data, false),
263 preview_long: formatDataForPreview(data, true),
264 name: 'lazy()',
265 type,
266 };
267 }
268
269 const unserializableValue: Unserializable = {
270 unserializable: true,
271 type: type,
272 preview_short: formatDataForPreview(data, false),
273 preview_long: formatDataForPreview(data, true),
274 name: 'lazy()',
275 };
276 // Ideally we should alias these properties to something more readable but
277 // unfortunately because of how the hydration algorithm uses a single concept of
278 // "path" we can't alias the path.
279 unserializableValue._payload = dehydrate(
280 payload,
281 cleaned,
282 unserializable,
283 path.concat(['_payload']),
284 isPathAllowed,
285 isPathAllowedCheck ? 1 : level + 1,
286 );
287 unserializable.push(path);
288 return unserializableValue;
289 }
290 // ArrayBuffers error if you try to inspect them.
291 case 'array_buffer':
292 case 'data_view':
293 cleaned.push(path);
294 return {
295 inspectable: false,
296 preview_short: formatDataForPreview(data, false),
297 preview_long: formatDataForPreview(data, true),
298 name: type === 'data_view' ? 'DataView' : 'ArrayBuffer',
299 size: data.byteLength,
300 type,
301 };
302
303 case 'array':
304 isPathAllowedCheck = isPathAllowed(path);
305 if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
306 return createDehydrated(type, true, data, cleaned, path);
307 }
308 const arr: Array<Object> = [];
309 for (let i = 0; i < data.length; i++) {
310 arr[i] = dehydrateKey(
311 data,
312 i,
313 cleaned,
314 unserializable,
315 path.concat([i]),
316 isPathAllowed,
317 isPathAllowedCheck ? 1 : level + 1,
318 );
319 }
320 return arr;
321
322 case 'html_all_collection':
323 case 'typed_array':
324 case 'iterator':
325 isPathAllowedCheck = isPathAllowed(path);
326 if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
327 return createDehydrated(type, true, data, cleaned, path);
328 } else {
329 const unserializableValue: Unserializable = {
330 unserializable: true,
331 type: type,
332 readonly: true,
333 size: type === 'typed_array' ? data.length : undefined,
334 preview_short: formatDataForPreview(data, false),
335 preview_long: formatDataForPreview(data, true),
336 name:
337 typeof data.constructor !== 'function' ||
338 typeof data.constructor.name !== 'string' ||
339 data.constructor.name === 'Object'
340 ? ''
341 : data.constructor.name,
342 };
343
344 // TRICKY
345 // Don't use [...spread] syntax for this purpose.
346 // This project uses @babel/plugin-transform-spread in "loose" mode which only works with Array values.
347 // Other types (e.g. typed arrays, Sets) will not spread correctly.
348 Array.from(data).forEach(
349 (item, i) =>
350 (unserializableValue[i] = dehydrate(
351 item,
352 cleaned,
353 unserializable,
354 path.concat([i]),
355 isPathAllowed,
356 isPathAllowedCheck ? 1 : level + 1,
357 )),
358 );
359
360 unserializable.push(path);
361
362 return unserializableValue;
363 }
364
365 case 'opaque_iterator':
366 cleaned.push(path);
367 return {
368 inspectable: false,
369 preview_short: formatDataForPreview(data, false),
370 preview_long: formatDataForPreview(data, true),
371 name: data[Symbol.toStringTag],
372 type,
373 };
374
375 case 'date':
376 cleaned.push(path);
377 return {
378 inspectable: false,
379 preview_short: formatDataForPreview(data, false),
380 preview_long: formatDataForPreview(data, true),
381 name: data.toString(),
382 type,
383 };
384
385 case 'regexp':
386 cleaned.push(path);
387 return {
388 inspectable: false,
389 preview_short: formatDataForPreview(data, false),
390 preview_long: formatDataForPreview(data, true),
391 name: data.toString(),
392 type,
393 };
394
395 case 'thenable':
396 isPathAllowedCheck = isPathAllowed(path);
397
398 if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
399 cleaned.push(path);
400 return {
401 inspectable:
402 data.status === 'fulfilled' || data.status === 'rejected',
403 preview_short: formatDataForPreview(data, false),
404 preview_long: formatDataForPreview(data, true),
405 name: data.toString(),
406 type,
407 };
408 }
409
410 if (
411 data.status === 'resolved_model' ||
412 data.status === 'resolve_module'
413 ) {
414 // This looks it's a lazy initialization pattern such in Flight.
415 // Since we're about to inspect it. Let's eagerly initialize it.
416 data.then(noop);
417 }
418
419 switch (data.status) {
420 case 'fulfilled': {
421 const unserializableValue: Unserializable = {
422 unserializable: true,
423 type: type,
424 preview_short: formatDataForPreview(data, false),
425 preview_long: formatDataForPreview(data, true),
426 name: 'fulfilled Thenable',
427 };
428
429 unserializableValue.value = dehydrate(
430 data.value,
431 cleaned,
432 unserializable,
433 path.concat(['value']),
434 isPathAllowed,
435 isPathAllowedCheck ? 1 : level + 1,
436 );
437
438 unserializable.push(path);
439
440 return unserializableValue;
441 }
442 case 'rejected': {
443 const unserializableValue: Unserializable = {
444 unserializable: true,
445 type: type,
446 preview_short: formatDataForPreview(data, false),
447 preview_long: formatDataForPreview(data, true),
448 name: 'rejected Thenable',
449 };
450
451 unserializableValue.reason = dehydrate(
452 data.reason,
453 cleaned,
454 unserializable,
455 path.concat(['reason']),
456 isPathAllowed,
457 isPathAllowedCheck ? 1 : level + 1,
458 );
459
460 unserializable.push(path);
461
462 return unserializableValue;
463 }
464 default:
465 cleaned.push(path);
466 return {
467 inspectable: false,
468 preview_short: formatDataForPreview(data, false),
469 preview_long: formatDataForPreview(data, true),
470 name: data.toString(),
471 type,
472 };
473 }
474
475 case 'object':
476 isPathAllowedCheck = isPathAllowed(path);
477
478 if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
479 return createDehydrated(type, true, data, cleaned, path);
480 } else {
481 const object: {
482 [string]: DehydratedData['data'],
483 } = {};
484 getAllEnumerableKeys(data).forEach(key => {
485 const name = key.toString();
486 object[name] = dehydrateKey(
487 data,
488 key,
489 cleaned,
490 unserializable,
491 path.concat([name]),
492 isPathAllowed,
493 isPathAllowedCheck ? 1 : level + 1,
494 );
495 });
496 return object;
497 }
498
499 case 'class_instance': {
500 isPathAllowedCheck = isPathAllowed(path);
501
502 if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
503 return createDehydrated(type, true, data, cleaned, path);
504 }
505
506 const value: Unserializable = {
507 unserializable: true,
508 type,
509 readonly: true,
510 preview_short: formatDataForPreview(data, false),
511 preview_long: formatDataForPreview(data, true),
512 name:
513 typeof data.constructor !== 'function' ||
514 typeof data.constructor.name !== 'string'
515 ? ''
516 : data.constructor.name,
517 };
518
519 getAllEnumerableKeys(data).forEach(key => {
520 const keyAsString = key.toString();
521
522 value[keyAsString] = dehydrate(
523 data[key],
524 cleaned,
525 unserializable,
526 path.concat([keyAsString]),
527 isPathAllowed,
528 isPathAllowedCheck ? 1 : level + 1,
529 );
530 });
531
532 unserializable.push(path);
533
534 return value;
535 }
536 case 'error': {
537 isPathAllowedCheck = isPathAllowed(path);
538
539 if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
540 return createDehydrated(type, true, data, cleaned, path);
541 }
542
543 const value: Unserializable = {
544 unserializable: true,
545 type,
546 readonly: true,
547 preview_short: formatDataForPreview(data, false),
548 preview_long: formatDataForPreview(data, true),
549 name: data.name,
550 };
551
552 // name, message, stack and cause are not enumerable yet still interesting.
553 value.message = dehydrate(
554 data.message,
555 cleaned,
556 unserializable,
557 path.concat(['message']),
558 isPathAllowed,
559 isPathAllowedCheck ? 1 : level + 1,
560 );
561 value.stack = dehydrate(
562 data.stack,
563 cleaned,
564 unserializable,
565 path.concat(['stack']),
566 isPathAllowed,
567 isPathAllowedCheck ? 1 : level + 1,
568 );
569
570 if ('cause' in data) {
571 value.cause = dehydrate(
572 data.cause,
573 cleaned,
574 unserializable,
575 path.concat(['cause']),
576 isPathAllowed,
577 isPathAllowedCheck ? 1 : level + 1,
578 );
579 }
580
581 getAllEnumerableKeys(data).forEach(key => {
582 const keyAsString = key.toString();
583
584 value[keyAsString] = dehydrate(
585 data[key],
586 cleaned,
587 unserializable,
588 path.concat([keyAsString]),
589 isPathAllowed,
590 isPathAllowedCheck ? 1 : level + 1,
591 );
592 });
593
594 unserializable.push(path);
595
596 return value;
597 }
598 case 'infinity':
599 case '-infinity':
600 case 'nan':
601 case 'undefined':
602 // Some values are lossy when sent through a WebSocket.
603 // We dehydrate+rehydrate them to preserve their type.
604 cleaned.push(path);
605 return {type};
606
607 default:
608 return data;
609 }
610 }
611
612 function dehydrateKey(
613 parent: Object,
614 key: number | string | symbol,
615 cleaned: Array<Array<string | number>>,
616 unserializable: Array<Array<string | number>>,
617 path: Array<string | number>,
618 isPathAllowed: (path: Array<string | number>) => boolean,
619 level: number = 0,
620 ): DehydratedData['data'] {
621 try {
622 return dehydrate(
623 parent[key],
624 cleaned,
625 unserializable,
626 path,
627 isPathAllowed,
628 level,
629 );
630 } catch (error) {
631 let preview = '';
632 if (
633 typeof error === 'object' &&
634 error !== null &&
635 typeof error.stack === 'string'
636 ) {
637 preview = error.stack;
638 } else if (typeof error === 'string') {
639 preview = error;
640 }
641 cleaned.push(path);
642 return {
643 inspectable: false,
644 preview_short: '[Exception]',
645 preview_long: preview ? '[Exception: ' + preview + ']' : '[Exception]',
646 name: preview,
647 type: 'unknown',
648 };
649 }
650 }
651
652 export function fillInPath(
653 object: Object,
654 data: DehydratedData,
655 path: InspectedElementPath,
656 value: any,
657 ) {
658 const target = getInObject(object, path);
659 if (target != null) {
660 if (!target[meta.unserializable]) {
661 delete target[meta.inspectable];
662 delete target[meta.inspected];
663 delete target[meta.name];
664 delete target[meta.preview_long];
665 delete target[meta.preview_short];
666 delete target[meta.readonly];
667 delete target[meta.size];
668 delete target[meta.type];
669 }
670 }
671
672 if (value !== null && data.unserializable.length > 0) {
673 const unserializablePath = data.unserializable[0];
674 let isMatch = unserializablePath.length === path.length;
675 for (let i = 0; i < path.length; i++) {
676 if (path[i] !== unserializablePath[i]) {
677 isMatch = false;
678 break;
679 }
680 }
681 if (isMatch) {
682 upgradeUnserializable(value, value);
683 }
684 }
685
686 setInObject(object, path, value);
687 }
688
689 export function hydrate(
690 object: any,
691 cleaned: Array<Array<string | number>>,
692 unserializable: Array<Array<string | number>>,
693 ): Object {
694 cleaned.forEach((path: Array<string | number>) => {
695 const length = path.length;
696 const last = path[length - 1];
697 const parent = getInObject(object, path.slice(0, length - 1));
698 if (!parent || !parent.hasOwnProperty(last)) {
699 return;
700 }
701
702 const value = parent[last];
703
704 if (!value) {
705 return;
706 } else if (value.type === 'infinity') {
707 parent[last] = Infinity;
708 } else if (value.type === '-infinity') {
709 parent[last] = -Infinity;
710 } else if (value.type === 'nan') {
711 parent[last] = NaN;
712 } else if (value.type === 'undefined') {
713 parent[last] = undefined;
714 } else {
715 // Replace the string keys with Symbols so they're non-enumerable.
716 const replaced: {[key: symbol]: boolean | string} = {};
717 replaced[meta.inspectable] = !!value.inspectable;
718 replaced[meta.inspected] = false;
719 replaced[meta.name] = value.name;
720 replaced[meta.preview_long] = value.preview_long;
721 replaced[meta.preview_short] = value.preview_short;
722 replaced[meta.size] = value.size;
723 replaced[meta.readonly] = !!value.readonly;
724 replaced[meta.type] = value.type;
725
726 parent[last] = replaced;
727 }
728 });
729 unserializable.forEach((path: Array<string | number>) => {
730 const length = path.length;
731 const last = path[length - 1];
732 const parent = getInObject(object, path.slice(0, length - 1));
733 if (!parent || !parent.hasOwnProperty(last)) {
734 return;
735 }
736
737 const node = parent[last];
738
739 const replacement = {
740 ...node,
741 };
742
743 upgradeUnserializable(replacement, node);
744
745 parent[last] = replacement;
746 });
747 return object;
748 }
749
750 function upgradeUnserializable(destination: Object, source: Object) {
751 Object.defineProperties(destination, {
752 // $FlowFixMe[invalid-computed-prop]
753 [meta.inspected]: {
754 configurable: true,
755 enumerable: false,
756 value: !!source.inspected,
757 },
758 // $FlowFixMe[invalid-computed-prop]
759 [meta.name]: {
760 configurable: true,
761 enumerable: false,
762 value: source.name,
763 },
764 // $FlowFixMe[invalid-computed-prop]
765 [meta.preview_long]: {
766 configurable: true,
767 enumerable: false,
768 value: source.preview_long,
769 },
770 // $FlowFixMe[invalid-computed-prop]
771 [meta.preview_short]: {
772 configurable: true,
773 enumerable: false,
774 value: source.preview_short,
775 },
776 // $FlowFixMe[invalid-computed-prop]
777 [meta.size]: {
778 configurable: true,
779 enumerable: false,
780 value: source.size,
781 },
782 // $FlowFixMe[invalid-computed-prop]
783 [meta.readonly]: {
784 configurable: true,
785 enumerable: false,
786 value: !!source.readonly,
787 },
788 // $FlowFixMe[invalid-computed-prop]
789 [meta.type]: {
790 configurable: true,
791 enumerable: false,
792 value: source.type,
793 },
794 // $FlowFixMe[invalid-computed-prop]
795 [meta.unserializable]: {
796 configurable: true,
797 enumerable: false,
798 value: !!source.unserializable,
799 },
800 });
801
802 delete destination.inspected;
803 delete destination.name;
804 delete destination.preview_long;
805 delete destination.preview_short;
806 delete destination.size;
807 delete destination.readonly;
808 delete destination.type;
809 delete destination.unserializable;
810 }