@samitouri / QOS-React-2 / commits / cfd8c1bd43

DevTools: Restore inspect-element bridge optimizations (#20789)

* Restore inspect-element bridge optimizations When the new Suspense cache was integrated (so that startTransition could be used) I removed a couple of optimizations between the backend and frontend that reduced bridge traffic when e.g. dehydrated paths were inspected for elements that had not rendered since previously inspected. This commit re-adds those optimizations as well as an additional test with a bug fix that I noticed while reading the backend code. There are two remaining TODO items as of this commit: - Make inspected element edits and deletes also use transition API - Don't over-eagerly refresh the cache in our ping-for-updates handler I will addres both in subsequent commits. * Poll for update only refreshes cache when there's an update * Added inline comment

Brian Vaughn committed Feb 22, 2021 at 14:04 UTC cfd8c1bd43fc4fcf708a584924f27a6c79803eae
13 files changed +475 -170
packages/react-devtools-shared/src/__tests__/inspectedElement-test.js
+150 -12
@@ -65,7 +65,9 @@ describe('InspectedElement', () => {
65 .TreeContextController;
66
67 // Used by inspectElementAtIndex() helper function
68 - testRendererInstance = TestRenderer.create(null);
68 + testRendererInstance = TestRenderer.create(null, {
69 + unstable_isConcurrent: true,
70 + });
71 });
72
73 afterEach(() => {
@@ -259,7 +261,9 @@ describe('InspectedElement', () => {
261 // from props like defaultSelectedElementID and it's easier to reset here than
262 // to read the TreeDispatcherContext and update the selected ID that way.
263 // We're testing the inspected values here, not the context wiring, so that's ok.
262 - testRendererInstance = TestRenderer.create(null);
264 + testRendererInstance = TestRenderer.create(null, {
265 + unstable_isConcurrent: true,
266 + });
267
268 const inspectedElement = await inspectElementAtIndex(index);
269
@@ -1197,10 +1201,9 @@ describe('InspectedElement', () => {
1201 "1": 2,
1202 "2": 3,
1203 },
1200 - "1": Object {
1201 - "0": "a",
1202 - "1": "b",
1203 - "2": "c",
1204 + "1": Dehydrated {
1205 + "preview_short": Set(3),
1206 + "preview_long": Set(3) {"a", "b", "c"},
1207 },
1208 },
1209 }
@@ -1283,12 +1286,9 @@ describe('InspectedElement', () => {
1286 },
1287 "value": 1,
1288 },
1286 - "c": Object {
1287 - "d": Dehydrated {
1288 - "preview_short": {…},
1289 - "preview_long": {e: {…}, value: 1},
1290 - },
1291 - "value": 1,
1289 + "c": Dehydrated {
1290 + "preview_short": {…},
1291 + "preview_long": {d: {…}, value: 1},
1292 },
1293 },
1294 }
@@ -1377,6 +1377,143 @@ describe('InspectedElement', () => {
1377 done();
1378 });
1379
1380 + it('should return a full update if a path is inspected for an object that has other pending changes', async done => {
1381 + const Example = () => null;
1382 +
1383 + const container = document.createElement('div');
1384 + await utils.actAsync(() =>
1385 + ReactDOM.render(
1386 + <Example
1387 + nestedObject={{
1388 + a: {
1389 + value: 1,
1390 + b: {
1391 + value: 1,
1392 + },
1393 + },
1394 + c: {
1395 + value: 1,
1396 + d: {
1397 + value: 1,
1398 + e: {
1399 + value: 1,
1400 + },
1401 + },
1402 + },
1403 + }}
1404 + />,
1405 + container,
1406 + ),
1407 + );
1408 +
1409 + let inspectedElement = null;
1410 + let inspectElementPath = null;
1411 +
1412 + // Render once to get a handle on inspectElementPath()
1413 + inspectedElement = await inspectElementAtIndex(0, () => {
1414 + inspectElementPath = useInspectElementPath();
1415 + });
1416 +
1417 + async function loadPath(path) {
1418 + TestUtilsAct(() => {
1419 + TestRendererAct(() => {
1420 + inspectElementPath(path);
1421 + jest.runOnlyPendingTimers();
1422 + });
1423 + });
1424 +
1425 + inspectedElement = await inspectElementAtIndex(0);
1426 + }
1427 +
1428 + expect(inspectedElement.props).toMatchInlineSnapshot(`
1429 + Object {
1430 + "nestedObject": Object {
1431 + "a": Dehydrated {
1432 + "preview_short": {…},
1433 + "preview_long": {b: {…}, value: 1},
1434 + },
1435 + "c": Dehydrated {
1436 + "preview_short": {…},
1437 + "preview_long": {d: {…}, value: 1},
1438 + },
1439 + },
1440 + }
1441 + `);
1442 +
1443 + await loadPath(['props', 'nestedObject', 'a']);
1444 +
1445 + expect(inspectedElement.props).toMatchInlineSnapshot(`
1446 + Object {
1447 + "nestedObject": Object {
1448 + "a": Object {
1449 + "b": Object {
1450 + "value": 1,
1451 + },
1452 + "value": 1,
1453 + },
1454 + "c": Dehydrated {
1455 + "preview_short": {…},
1456 + "preview_long": {d: {…}, value: 1},
1457 + },
1458 + },
1459 + }
1460 + `);
1461 +
1462 + TestRendererAct(() => {
1463 + TestUtilsAct(() => {
1464 + ReactDOM.render(
1465 + <Example
1466 + nestedObject={{
1467 + a: {
1468 + value: 2,
1469 + b: {
1470 + value: 2,
1471 + },
1472 + },
1473 + c: {
1474 + value: 2,
1475 + d: {
1476 + value: 2,
1477 + e: {
1478 + value: 2,
1479 + },
1480 + },
1481 + },
1482 + }}
1483 + />,
1484 + container,
1485 + );
1486 + });
1487 + });
1488 +
1489 + await loadPath(['props', 'nestedObject', 'c']);
1490 +
1491 + expect(inspectedElement.props).toMatchInlineSnapshot(`
1492 + Object {
1493 + "nestedObject": Object {
1494 + "a": Object {
1495 + "b": Object {
1496 + "value": 2,
1497 + },
1498 + "value": 2,
1499 + },
1500 + "c": Object {
1501 + "d": Object {
1502 + "e": Dehydrated {
1503 + "preview_short": {…},
1504 + "preview_long": {value: 2},
1505 + },
1506 + "value": 2,
1507 + },
1508 + "value": 2,
1509 + },
1510 + },
1511 + }
1512 + `);
1513 +
1514 + done();
1515 + });
1516 +
1517 it('should not tear if hydration is requested after an update', async done => {
1518 const Example = () => null;
1519
@@ -1936,6 +2073,7 @@ describe('InspectedElement', () => {
2073 <Suspender target={id} />
2074 </React.Suspense>
2075 </Contexts>,
2076 + {unstable_isConcurrent: true},
2077 );
2078 }, false);
2079 await utils.actAsync(() => {
packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js
+13 -10
@@ -26,15 +26,14 @@ describe('InspectedElementContext', () => {
26
27 async function read(
28 id: number,
29 - inspectedPaths?: Object = {},
29 + path?: Array<string | number> = null,
30 ): Promise<Object> {
31 const rendererID = ((store.getRendererIDForElement(id): any): number);
32 const promise = backendAPI
33 .inspectElement({
34 bridge,
35 - forceUpdate: true,
35 id,
37 - inspectedPaths,
36 + path,
37 rendererID,
38 })
39 .then(data =>
@@ -686,7 +685,7 @@ describe('InspectedElementContext', () => {
685 }
686 `);
687
689 - inspectedElement = await read(id, {props: {nestedObject: {a: {}}}});
688 + inspectedElement = await read(id, ['props', 'nestedObject', 'a']);
689 expect(inspectedElement.props).toMatchInlineSnapshot(`
690 Object {
691 "nestedObject": Object {
@@ -702,9 +701,7 @@ describe('InspectedElementContext', () => {
701 }
702 `);
703
705 - inspectedElement = await read(id, {
706 - props: {nestedObject: {a: {b: {c: {}}}}},
707 - });
704 + inspectedElement = await read(id, ['props', 'nestedObject', 'a', 'b', 'c']);
705 expect(inspectedElement.props).toMatchInlineSnapshot(`
706 Object {
707 "nestedObject": Object {
@@ -724,9 +721,15 @@ describe('InspectedElementContext', () => {
721 }
722 `);
723
727 - inspectedElement = await read(id, {
728 - props: {nestedObject: {a: {b: {c: {0: {d: {}}}}}}},
729 - });
724 + inspectedElement = await read(id, [
725 + 'props',
726 + 'nestedObject',
727 + 'a',
728 + 'b',
729 + 'c',
730 + 0,
731 + 'd',
732 + ]);
733 expect(inspectedElement.props).toMatchInlineSnapshot(`
734 Object {
735 "nestedObject": Object {
packages/react-devtools-shared/src/backend/agent.js
+3 -5
@@ -70,8 +70,7 @@ type CopyElementParams = {|
70
71 type InspectElementParams = {|
72 id: number,
73 - inspectedPaths: Object,
74 - forceUpdate: boolean,
73 + path: Array<string | number> | null,
74 rendererID: number,
75 requestID: number,
76 |};
@@ -332,8 +331,7 @@ export default class Agent extends EventEmitter<{|
331
332 inspectElement = ({
333 id,
335 - inspectedPaths,
336 - forceUpdate,
334 + path,
335 rendererID,
336 requestID,
337 }: InspectElementParams) => {
@@ -343,7 +341,7 @@ export default class Agent extends EventEmitter<{|
341 } else {
342 this._bridge.send(
343 'inspectedElement',
346 - renderer.inspectElement(requestID, id, inspectedPaths, forceUpdate),
344 + renderer.inspectElement(requestID, id, path),
345 );
346
347 // When user selects an element, stop trying to restore the selection,
packages/react-devtools-shared/src/backend/legacy/renderer.js
+24 -6
@@ -584,12 +584,25 @@ export function attach(
584 }
585
586 let currentlyInspectedElementID: number | null = null;
587 + let currentlyInspectedPaths: Object = {};
588 +
589 + // Track the intersection of currently inspected paths,
590 + // so that we can send their data along if the element is re-rendered.
591 + function mergeInspectedPaths(path: Array<string | number>) {
592 + let current = currentlyInspectedPaths;
593 + path.forEach(key => {
594 + if (!current[key]) {
595 + current[key] = {};
596 + }
597 + current = current[key];
598 + });
599 + }
600
588 - function createIsPathAllowed(key: string, inspectedPaths: Object) {
601 + function createIsPathAllowed(key: string) {
602 // This function helps prevent previously-inspected paths from being dehydrated in updates.
603 // This is important to avoid a bad user experience where expanded toggles collapse on update.
604 return function isPathAllowed(path: Array<string | number>): boolean {
592 - let current = inspectedPaths[key];
605 + let current = currentlyInspectedPaths[key];
606 if (!current) {
607 return false;
608 }
@@ -680,10 +693,11 @@ export function attach(
693 function inspectElement(
694 requestID: number,
695 id: number,
683 - inspectedPaths: Object,
696 + path: Array<string | number> | null,
697 ): InspectedElementPayload {
698 if (currentlyInspectedElementID !== id) {
699 currentlyInspectedElementID = id;
700 + currentlyInspectedPaths = {};
701 }
702
703 const inspectedElement = inspectElementRaw(id);
@@ -695,6 +709,10 @@ export function attach(
709 };
710 }
711
712 + if (path !== null) {
713 + mergeInspectedPaths(path);
714 + }
715 +
716 // Any time an inspected element has an update,
717 // we should update the selected $r value as wel.
718 // Do this before dehyration (cleanForBridge).
@@ -702,15 +720,15 @@ export function attach(
720
721 inspectedElement.context = cleanForBridge(
722 inspectedElement.context,
705 - createIsPathAllowed('context', inspectedPaths),
723 + createIsPathAllowed('context'),
724 );
725 inspectedElement.props = cleanForBridge(
726 inspectedElement.props,
709 - createIsPathAllowed('props', inspectedPaths),
727 + createIsPathAllowed('props'),
728 );
729 inspectedElement.state = cleanForBridge(
730 inspectedElement.state,
713 - createIsPathAllowed('state', inspectedPaths),
731 + createIsPathAllowed('state'),
732 );
733
734 return {
packages/react-devtools-shared/src/backend/renderer.js
+70 -28
@@ -2458,8 +2458,7 @@ export function attach(
2458 id: number,
2459 path: Array<string | number>,
2460 ): void {
2461 - const isCurrent = isMostRecentlyInspectedElementCurrent(id);
2462 - if (isCurrent) {
2461 + if (isMostRecentlyInspectedElement(id)) {
2462 window.$attribute = getInObject(
2463 ((mostRecentlyInspectedElement: any): InspectedElement),
2464 path,
@@ -2766,19 +2765,36 @@ export function attach(
2765
2766 let mostRecentlyInspectedElement: InspectedElement | null = null;
2767 let hasElementUpdatedSinceLastInspected: boolean = false;
2768 + let currentlyInspectedPaths: Object = {};
2769
2770 - function isMostRecentlyInspectedElementCurrent(id: number): boolean {
2770 + function isMostRecentlyInspectedElement(id: number): boolean {
2771 return (
2772 mostRecentlyInspectedElement !== null &&
2773 - mostRecentlyInspectedElement.id === id &&
2774 - !hasElementUpdatedSinceLastInspected
2773 + mostRecentlyInspectedElement.id === id
2774 );
2775 }
2776
2777 + function isMostRecentlyInspectedElementCurrent(id: number): boolean {
2778 + return (
2779 + isMostRecentlyInspectedElement(id) && !hasElementUpdatedSinceLastInspected
2780 + );
2781 + }
2782 +
2783 + // Track the intersection of currently inspected paths,
2784 + // so that we can send their data along if the element is re-rendered.
2785 + function mergeInspectedPaths(path: Array<string | number>) {
2786 + let current = currentlyInspectedPaths;
2787 + path.forEach(key => {
2788 + if (!current[key]) {
2789 + current[key] = {};
2790 + }
2791 + current = current[key];
2792 + });
2793 + }
2794 +
2795 function createIsPathAllowed(
2796 key: string | null,
2797 secondaryCategory: 'hooks' | null,
2781 - inspectedPaths: Object,
2798 ) {
2799 // This function helps prevent previously-inspected paths from being dehydrated in updates.
2800 // This is important to avoid a bad user experience where expanded toggles collapse on update.
@@ -2803,7 +2819,8 @@ export function attach(
2819 break;
2820 }
2821
2806 - let current = key === null ? inspectedPaths : inspectedPaths[key];
2822 + let current =
2823 + key === null ? currentlyInspectedPaths : currentlyInspectedPaths[key];
2824 if (!current) {
2825 return false;
2826 }
@@ -2870,9 +2887,7 @@ export function attach(
2887 path: Array<string | number>,
2888 count: number,
2889 ): void {
2873 - const isCurrent = isMostRecentlyInspectedElementCurrent(id);
2874 -
2875 - if (isCurrent) {
2890 + if (isMostRecentlyInspectedElement(id)) {
2891 const value = getInObject(
2892 ((mostRecentlyInspectedElement: any): InspectedElement),
2893 path,
@@ -2887,9 +2902,7 @@ export function attach(
2902 }
2903
2904 function copyElementPath(id: number, path: Array<string | number>): void {
2890 - const isCurrent = isMostRecentlyInspectedElementCurrent(id);
2891 -
2892 - if (isCurrent) {
2905 + if (isMostRecentlyInspectedElement(id)) {
2906 copyToClipboard(
2907 getInObject(
2908 ((mostRecentlyInspectedElement: any): InspectedElement),
@@ -2902,19 +2915,48 @@ export function attach(
2915 function inspectElement(
2916 requestID: number,
2917 id: number,
2905 - inspectedPaths: Object,
2906 - forceUpdate: boolean,
2918 + path: Array<string | number> | null,
2919 ): InspectedElementPayload {
2908 - const isCurrent = !forceUpdate && isMostRecentlyInspectedElementCurrent(id);
2920 + if (path !== null) {
2921 + mergeInspectedPaths(path);
2922 + }
2923
2910 - if (isCurrent) {
2911 - // If this element has not been updated since it was last inspected, we don't need to return it.
2912 - // Instead we can just return the ID to indicate that it has not changed.
2913 - return {
2914 - id,
2915 - responseID: requestID,
2916 - type: 'no-change',
2917 - };
2924 + if (isMostRecentlyInspectedElement(id)) {
2925 + if (!hasElementUpdatedSinceLastInspected) {
2926 + if (path !== null) {
2927 + let secondaryCategory = null;
2928 + if (path[0] === 'hooks') {
2929 + secondaryCategory = 'hooks';
2930 + }
2931 +
2932 + // If this element has not been updated since it was last inspected,
2933 + // we can just return the subset of data in the newly-inspected path.
2934 + return {
2935 + id,
2936 + responseID: requestID,
2937 + type: 'hydrated-path',
2938 + path,
2939 + value: cleanForBridge(
2940 + getInObject(
2941 + ((mostRecentlyInspectedElement: any): InspectedElement),
2942 + path,
2943 + ),
2944 + createIsPathAllowed(null, secondaryCategory),
2945 + path,
2946 + ),
2947 + };
2948 + } else {
2949 + // If this element has not been updated since it was last inspected, we don't need to return it.
2950 + // Instead we can just return the ID to indicate that it has not changed.
2951 + return {
2952 + id,
2953 + responseID: requestID,
2954 + type: 'no-change',
2955 + };
2956 + }
2957 + }
2958 + } else {
2959 + currentlyInspectedPaths = {};
2960 }
2961
2962 hasElementUpdatedSinceLastInspected = false;
@@ -2939,19 +2981,19 @@ export function attach(
2981 const cleanedInspectedElement = {...mostRecentlyInspectedElement};
2982 cleanedInspectedElement.context = cleanForBridge(
2983 cleanedInspectedElement.context,
2942 - createIsPathAllowed('context', null, inspectedPaths),
2984 + createIsPathAllowed('context', null),
2985 );
2986 cleanedInspectedElement.hooks = cleanForBridge(
2987 cleanedInspectedElement.hooks,
2946 - createIsPathAllowed('hooks', 'hooks', inspectedPaths),
2988 + createIsPathAllowed('hooks', 'hooks'),
2989 );
2990 cleanedInspectedElement.props = cleanForBridge(
2991 cleanedInspectedElement.props,
2950 - createIsPathAllowed('props', null, inspectedPaths),
2992 + createIsPathAllowed('props', null),
2993 );
2994 cleanedInspectedElement.state = cleanForBridge(
2995 cleanedInspectedElement.state,
2954 - createIsPathAllowed('state', null, inspectedPaths),
2996 + createIsPathAllowed('state', null),
2997 );
2998
2999 return {
packages/react-devtools-shared/src/backend/types.js
+12 -4
@@ -258,20 +258,28 @@ export const InspectElementFullDataType = 'full-data';
258 export const InspectElementNoChangeType = 'no-change';
259 export const InspectElementNotFoundType = 'not-found';
260
261 -type InspectElementFullData = {|
261 +export type InspectElementFullData = {|
262 id: number,
263 responseID: number,
264 type: 'full-data',
265 value: InspectedElement,
266 |};
267
268 -type InspectElementNoChange = {|
268 +export type InspectElementHydratedPath = {|
269 + id: number,
270 + responseID: number,
271 + type: 'hydrated-path',
272 + path: Array<string | number>,
273 + value: any,
274 +|};
275 +
276 +export type InspectElementNoChange = {|
277 id: number,
278 responseID: number,
279 type: 'no-change',
280 |};
281
274 -type InspectElementNotFound = {|
282 +export type InspectElementNotFound = {|
283 id: number,
284 responseID: number,
285 type: 'not-found',
@@ -279,6 +287,7 @@ type InspectElementNotFound = {|
287
288 export type InspectedElementPayload =
289 | InspectElementFullData
290 + | InspectElementHydratedPath
291 | InspectElementNoChange
292 | InspectElementNotFound;
293
@@ -316,7 +325,6 @@ export type RendererInterface = {
325 requestID: number,
326 id: number,
327 inspectedPaths: Object,
319 - forceUpdate: boolean,
328 ) => InspectedElementPayload,
329 logElementToConsole: (id: number) => void,
330 overrideSuspense: (id: number, forceFallback: boolean) => void,
packages/react-devtools-shared/src/backendAPI.js
+4 -7
@@ -86,15 +86,13 @@ export function copyInspectedElementPath({
86
87 export function inspectElement({
88 bridge,
89 - forceUpdate,
89 id,
91 - inspectedPaths,
90 + path,
91 rendererID,
92 }: {|
93 bridge: FrontendBridge,
95 - forceUpdate: boolean,
94 id: number,
97 - inspectedPaths: Object,
95 + path: Array<string | number> | null,
96 rendererID: number,
97 |}): Promise<InspectedElementPayload> {
98 const requestID = requestCounter++;
@@ -105,9 +103,8 @@ export function inspectElement({
103 );
104
105 bridge.send('inspectElement', {
108 - forceUpdate,
106 id,
110 - inspectedPaths,
107 + path,
108 rendererID,
109 requestID,
110 });
@@ -254,7 +251,7 @@ export function convertInspectedElementBackendToFrontend(
251 return inspectedElement;
252 }
253
257 -function hydrateHelper(
254 +export function hydrateHelper(
255 dehydratedData: DehydratedData | null,
256 path?: Array<string | number>,
257 ): Object | null {
packages/react-devtools-shared/src/bridge.js
+1 -2
@@ -89,8 +89,7 @@ type ViewAttributeSourceParams = {|
89
90 type InspectElementParams = {|
91 ...ElementAndRendererID,
92 - forceUpdate: boolean,
93 - inspectedPaths: Object,
92 + path: Array<number | string> | null,
93 requestID: number,
94 |};
95
packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js
+2
@@ -27,6 +27,8 @@ import type {InspectedElement} from './types';
27
28 export type Props = {||};
29
30 +// TODO Make edits and deletes also use transition API!
31 +
32 export default function InspectedElementWrapper(_: Props) {
33 const {inspectedElementID} = useContext(TreeStateContext);
34 const dispatch = useContext(TreeDispatcherContext);
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js
+25 -35
@@ -16,7 +16,6 @@ import {
16 useContext,
17 useEffect,
18 useMemo,
19 - useRef,
19 useState,
20 } from 'react';
21 import {TreeStateContext} from './TreeContext';
@@ -57,16 +56,16 @@ export function InspectedElementContextController({children}: Props) {
56
57 const refresh = useCacheRefresh();
58
60 - // Track when insepected paths have changed; we need to force the backend to send an udpate then.
61 - const forceUpdateRef = useRef<boolean>(true);
62 -
63 - // Track the paths insepected for the currently selected element.
59 + // Temporarily stores most recently-inspected (hydrated) path.
60 + // The transition that updates this causes the component to re-render and ask the cache->backend for the new path.
61 + // When a path is sent along with an "inspectElement" request,
62 + // the backend knows to send its dehydrated data even if the element hasn't updated since the last request.
63 const [state, setState] = useState<{|
64 element: Element | null,
66 - inspectedPaths: Object,
65 + path: Array<number | string> | null,
66 |}>({
67 element: null,
69 - inspectedPaths: {},
68 + path: null,
69 });
70
71 const element =
@@ -78,54 +77,45 @@ export function InspectedElementContextController({children}: Props) {
77 if (elementHasChanged) {
78 setState({
79 element,
81 - inspectedPaths: {},
80 + path: null,
81 });
82 }
83
84 // Don't load a stale element from the backend; it wastes bridge bandwidth.
86 - const inspectedElement =
87 - !elementHasChanged && element !== null
88 - ? inspectElement(
89 - element,
90 - state.inspectedPaths,
91 - forceUpdateRef.current,
92 - store,
93 - bridge,
94 - )
95 - : null;
85 + let inspectedElement = null;
86 + if (!elementHasChanged && element !== null) {
87 + inspectedElement = inspectElement(element, state.path, store, bridge);
88 + }
89
90 const inspectPaths: InspectPathFunction = useCallback<InspectPathFunction>(
91 (path: Path) => {
92 startTransition(() => {
100 - forceUpdateRef.current = true;
101 - setState(prevState => {
102 - const cloned = {...prevState};
103 - let current = cloned.inspectedPaths;
104 - path.forEach(key => {
105 - if (!current[key]) {
106 - current[key] = {};
107 - }
108 - current = current[key];
109 - });
110 - return cloned;
93 + setState({
94 + element: state.element,
95 + path,
96 });
97 refresh();
98 });
99 },
115 - [setState],
100 + [setState, state],
101 );
102
118 - // Force backend update when inspected paths change.
103 + // Reset path now that we've asked the backend to hydrate it.
104 + // The backend is stateful, so we don't need to remember this path the next time we inspect.
105 useEffect(() => {
120 - forceUpdateRef.current = false;
121 - }, [element, state]);
106 + if (state.path !== null) {
107 + setState({
108 + element: state.element,
109 + path: null,
110 + });
111 + }
112 + }, [state]);
113
114 // Periodically poll the selected element for updates.
115 useEffect(() => {
116 if (element !== null) {
126 - const inspectedPaths = state.inspectedPaths;
117 const checkForUpdateWrapper = () => {
128 - checkForUpdate({bridge, element, inspectedPaths, refresh, store});
118 + checkForUpdate({bridge, element, refresh, store});
119 timeoutID = setTimeout(checkForUpdateWrapper, POLL_INTERVAL);
120 };
121 let timeoutID = setTimeout(checkForUpdateWrapper, POLL_INTERVAL);
packages/react-devtools-shared/src/devtools/views/Components/types.js
+6
@@ -56,6 +56,12 @@ export type OwnersList = {|
56 owners: Array<Owner> | null,
57 |};
58
59 +export type InspectedElementResponseType =
60 + | 'full-data'
61 + | 'hydrated-path'
62 + | 'no-change'
63 + | 'not-found';
64 +
65 export type InspectedElement = {|
66 id: number,
67
packages/react-devtools-shared/src/inspectedElementCache.js
+33 -61
@@ -12,20 +12,14 @@ import {
12 unstable_startTransition as startTransition,
13 } from 'react';
14 import Store from './devtools/store';
15 -import {
16 - convertInspectedElementBackendToFrontend,
17 - inspectElement as inspectElementAPI,
18 -} from './backendAPI';
15 +import {inspectElement as inspectElementMutableSource} from './inspectedElementMutableSource';
16
17 import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
18 import type {Wakeable} from 'shared/ReactTypes';
22 -import type {
23 - InspectedElement as InspectedElementBackend,
24 - InspectedElementPayload,
25 -} from 'react-devtools-shared/src/backend/types';
19 import type {
20 Element,
21 InspectedElement as InspectedElementFrontend,
22 + InspectedElementResponseType,
23 } from 'react-devtools-shared/src/devtools/views/Components/types';
24
25 const Pending = 0;
@@ -88,13 +82,13 @@ function createCacheSeed(
82 */
83 export function inspectElement(
84 element: Element,
91 - inspectedPaths: Object,
92 - forceUpdate: boolean,
85 + path: Array<string | number> | null,
86 store: Store,
87 bridge: FrontendBridge,
88 ): InspectedElementFrontend | null {
89 const map = getRecordMap();
90 let record = map.get(element);
91 +
92 if (!record) {
93 const callbacks = new Set();
94 const wakeable: Wakeable = {
@@ -116,50 +110,31 @@ export function inspectElement(
110 if (rendererID == null) {
111 const rejectedRecord = ((newRecord: any): RejectedRecord);
112 rejectedRecord.status = Rejected;
119 - rejectedRecord.value = 'Inspected element not found.';
113 + rejectedRecord.value = `Could not inspect element with id ${element.id}`;
114 +
115 + map.set(element, record);
116 +
117 return null;
118 }
119
123 - inspectElementAPI({
120 + inspectElementMutableSource({
121 bridge,
125 - forceUpdate: true,
126 - id: element.id,
127 - inspectedPaths,
122 + element,
123 + path,
124 rendererID: ((rendererID: any): number),
125 }).then(
130 - (data: InspectedElementPayload) => {
131 - if (newRecord.status === Pending) {
132 - switch (data.type) {
133 - case 'no-change':
134 - // This response type should never be received.
135 - // We always send forceUpdate:true when we have a cache miss.
136 - break;
137 -
138 - case 'not-found':
139 - const notFoundRecord = ((newRecord: any): RejectedRecord);
140 - notFoundRecord.status = Rejected;
141 - notFoundRecord.value = 'Inspected element not found.';
142 - wake();
143 - break;
144 -
145 - case 'full-data':
146 - const resolvedRecord = ((newRecord: any): ResolvedRecord<InspectedElementFrontend>);
147 - resolvedRecord.status = Resolved;
148 - resolvedRecord.value = convertInspectedElementBackendToFrontend(
149 - ((data.value: any): InspectedElementBackend),
150 - );
151 - wake();
152 - break;
153 - }
154 - }
126 + ([inspectedElement: InspectedElementFrontend]) => {
127 + const resolvedRecord = ((newRecord: any): ResolvedRecord<InspectedElementFrontend>);
128 + resolvedRecord.status = Resolved;
129 + resolvedRecord.value = inspectedElement;
130 + wake();
131 },
132
157 - () => {
158 - // Timed out without receiving a response.
133 + error => {
134 if (newRecord.status === Pending) {
160 - const timedOutRecord = ((newRecord: any): RejectedRecord);
161 - timedOutRecord.status = Rejected;
162 - timedOutRecord.value = 'Inspected element timed out.';
135 + const rejectedRecord = ((newRecord: any): RejectedRecord);
136 + rejectedRecord.status = Rejected;
137 + rejectedRecord.value = `Could not inspect element with id ${element.id}`;
138 wake();
139 }
140 },
@@ -184,37 +159,34 @@ type RefreshFunction = (
159 export function checkForUpdate({
160 bridge,
161 element,
187 - inspectedPaths,
162 refresh,
163 store,
164 }: {
165 bridge: FrontendBridge,
166 element: Element,
193 - inspectedPaths: Object,
167 refresh: RefreshFunction,
168 store: Store,
169 }): void {
170 const {id} = element;
171 const rendererID = store.getRendererIDForElement(id);
172 if (rendererID != null) {
200 - inspectElementAPI({
173 + inspectElementMutableSource({
174 bridge,
202 - forceUpdate: false,
203 - id,
204 - inspectedPaths,
205 - rendererID,
206 - }).then((data: InspectedElementPayload) => {
207 - switch (data.type) {
208 - case 'full-data':
209 - const inspectedElement = convertInspectedElementBackendToFrontend(
210 - ((data.value: any): InspectedElementBackend),
211 - );
175 + element,
176 + path: null,
177 + rendererID: ((rendererID: any): number),
178 + }).then(
179 + ([
180 + inspectedElement: InspectedElementFrontend,
181 + responseType: InspectedElementResponseType,
182 + ]) => {
183 + if (responseType === 'full-data') {
184 startTransition(() => {
185 const [key, value] = createCacheSeed(element, inspectedElement);
186 refresh(key, value);
187 });
216 - break;
217 - }
218 - });
188 + }
189 + },
190 + );
191 }
192 }
packages/react-devtools-shared/src/inspectedElementMutableSource.js new
+132
@@ -0,0 +1,132 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 + convertInspectedElementBackendToFrontend,
12 + hydrateHelper,
13 + inspectElement as inspectElementAPI,
14 +} from 'react-devtools-shared/src/backendAPI';
15 +import {fillInPath} from 'react-devtools-shared/src/hydration';
16 +
17 +import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
18 +import type {
19 + InspectElementFullData,
20 + InspectElementHydratedPath,
21 +} from 'react-devtools-shared/src/backend/types';
22 +import type {
23 + Element,
24 + InspectedElement as InspectedElementFrontend,
25 + InspectedElementResponseType,
26 +} from 'react-devtools-shared/src/devtools/views/Components/types';
27 +
28 +// Map an Element in the Store to the most recent copy of its inspected data.
29 +// As updates comes from the backend, inspected data is updated.
30 +// Both this map and the inspected objects in it are mutable.
31 +// They should never be read from directly during render;
32 +// Use a Suspense cache to ensure that transitions work correctly and there is no tearing.
33 +const inspectedElementMap: WeakMap<
34 + Element,
35 + InspectedElementFrontend,
36 +> = new WeakMap();
37 +
38 +type Path = Array<string | number>;
39 +
40 +type InspectElementReturnType = [
41 + InspectedElementFrontend,
42 + InspectedElementResponseType,
43 +];
44 +
45 +export function inspectElement({
46 + bridge,
47 + element,
48 + path,
49 + rendererID,
50 +}: {|
51 + bridge: FrontendBridge,
52 + element: Element,
53 + path: Path | null,
54 + rendererID: number,
55 +|}): Promise<InspectElementReturnType> {
56 + const {id} = element;
57 + return inspectElementAPI({
58 + bridge,
59 + id,
60 + path,
61 + rendererID,
62 + }).then((data: any) => {
63 + const {type} = data;
64 +
65 + let inspectedElement;
66 + switch (type) {
67 + case 'no-change':
68 + // This is a no-op for the purposes of our cache.
69 + inspectedElement = inspectedElementMap.get(element);
70 + if (inspectedElement != null) {
71 + return [inspectedElement, type];
72 + }
73 + break;
74 +
75 + case 'not-found':
76 + // This is effectively a no-op.
77 + // If the Element is still in the Store, we can eagerly remove it from the Map.
78 + inspectedElementMap.delete(element);
79 +
80 + throw Error(`Element ${id} not found`);
81 +
82 + case 'full-data':
83 + const fullData = ((data: any): InspectElementFullData);
84 +
85 + // New data has come in.
86 + // We should replace the data in our local mutable copy.
87 + inspectedElement = convertInspectedElementBackendToFrontend(
88 + fullData.value,
89 + );
90 +
91 + inspectedElementMap.set(element, inspectedElement);
92 +
93 + return [inspectedElement, type];
94 +
95 + case 'hydrated-path':
96 + const hydratedPathData = ((data: any): InspectElementHydratedPath);
97 + const {value} = hydratedPathData;
98 +
99 + // A path has been hydrated.
100 + // Merge it with the latest copy we have locally and resolve with the merged value.
101 + inspectedElement = inspectedElementMap.get(element) || null;
102 + if (inspectedElement !== null) {
103 + // Clone element
104 + inspectedElement = {...inspectedElement};
105 +
106 + // Merge hydrated data
107 + fillInPath(
108 + inspectedElement,
109 + value,
110 + ((path: any): Path),
111 + hydrateHelper(value, ((path: any): Path)),
112 + );
113 +
114 + inspectedElementMap.set(element, inspectedElement);
115 +
116 + return [inspectedElement, type];
117 + }
118 + break;
119 +
120 + default:
121 + // Should never happen.
122 + if (__DEV__) {
123 + console.error(
124 + `Unexpected inspected element response data: "${type}"`,
125 + );
126 + }
127 + break;
128 + }
129 +
130 + throw Error(`Unable to inspect element with id ${id}`);
131 + });
132 +}