@samitouri / QOS-React / commits / d8abecdcf9

Persist and restore selection in agent

This implements the infrastructure for saving and restoring renderer-specific selection state in the session storage. Note this doesn't actually implement the calculation and tracking of paths in the renderer. It only simulates that the renderer can do it. The actual implementation will come in a later commit.

Dan Abramov committed Apr 23, 2019 at 15:25 UTC d8abecdcf997390eaf22dc1bb20fa3a0e81adf0b
4 files changed +154 -5
src/backend/agent.js
+95 -3
@@ -3,10 +3,19 @@
3 import EventEmitter from 'events';
4 import memoize from 'memoize-one';
5 import throttle from 'lodash.throttle';
6 -import { LOCAL_STORAGE_RELOAD_AND_PROFILE_KEY, __DEBUG__ } from '../constants';
6 +import {
7 + LOCAL_STORAGE_RELOAD_AND_PROFILE_KEY,
8 + SESSION_STORAGE_LAST_SELECTION_KEY,
9 + __DEBUG__,
10 +} from '../constants';
11 import { hideOverlay, showOverlay } from './views/Highlighter';
12
9 -import type { RendererID, RendererInterface } from './types';
13 +import type {
14 + PathFrame,
15 + PathMatch,
16 + RendererID,
17 + RendererInterface,
18 +} from './types';
19 import type { Bridge } from '../types';
20
21 const debug = (methodName, ...args) => {
@@ -20,6 +29,11 @@ const debug = (methodName, ...args) => {
29 }
30 };
31
32 +type OperationsParams = {|
33 + operations: Uint32Array,
34 + rendererID: number,
35 +|};
36 +
37 type InspectSelectParams = {|
38 id: number,
39 rendererID: number,
@@ -46,10 +60,17 @@ type OverrideSuspenseParams = {|
60 forceFallback: boolean,
61 |};
62
63 +type PersistedSelection = {|
64 + rendererID: number,
65 + path: Array<PathFrame>,
66 +|};
67 +
68 export default class Agent extends EventEmitter {
69 _bridge: Bridge = ((null: any): Bridge);
70 _isProfiling: boolean = false;
71 _rendererInterfaces: { [key: RendererID]: RendererInterface } = {};
72 + _persistedSelection: PersistedSelection | null = null;
73 + _persistedSelectionMatch: PathMatch | null = null;
74
75 constructor() {
76 super();
@@ -59,6 +80,13 @@ export default class Agent extends EventEmitter {
80
81 localStorage.removeItem(LOCAL_STORAGE_RELOAD_AND_PROFILE_KEY);
82 }
83 +
84 + const persistedSelectionString = sessionStorage.getItem(
85 + SESSION_STORAGE_LAST_SELECTION_KEY
86 + );
87 + if (persistedSelectionString != null) {
88 + this._persistedSelection = JSON.parse(persistedSelectionString);
89 + }
90 }
91
92 addBridge(bridge: Bridge) {
@@ -316,6 +344,19 @@ export default class Agent extends EventEmitter {
344 } else {
345 renderer.selectElement(id);
346 this._bridge.send('selectElement');
347 +
348 + // When user selects an element, stop trying to restore the selection,
349 + // and instead remember the current selection for the next reload.
350 + if (
351 + this._persistedSelectionMatch === null ||
352 + this._persistedSelectionMatch.id !== id
353 + ) {
354 + this._persistedSelection = null;
355 + this._persistedSelectionMatch = null;
356 + renderer.setTrackedPath(null);
357 + this._throttledPersistSelection(rendererID, id);
358 + }
359 +
360 // TODO: If there was a way to change the selected DOM element
361 // in native Elements tab without forcing a switch to it, we'd do it here.
362 // For now, it doesn't seem like there is a way to do that:
@@ -388,6 +429,14 @@ export default class Agent extends EventEmitter {
429 if (this._isProfiling) {
430 rendererInterface.startProfiling();
431 }
432 +
433 + // When the renderer is attached, we need to tell it whether
434 + // we remember the previous selection that we'd like to restore.
435 + // It'll start tracking mounts for matches to the last selection path.
436 + const selection = this._persistedSelection;
437 + if (selection !== null && selection.rendererID === rendererID) {
438 + rendererInterface.setTrackedPath(selection.path);
439 + }
440 }
441
442 syncSelectionFromNativeElementsPanel = () => {
@@ -454,7 +503,7 @@ export default class Agent extends EventEmitter {
503 }
504 };
505
457 - onHookOperations = (operations: Uint32Array) => {
506 + onHookOperations = ({ operations, rendererID }: OperationsParams) => {
507 if (__DEBUG__) {
508 debug('onHookOperations', operations);
509 }
@@ -480,6 +529,31 @@ export default class Agent extends EventEmitter {
529 //
530 // this._bridge.send('operations', operations, [operations.buffer]);
531 this._bridge.send('operations', operations);
532 +
533 + if (this._persistedSelection !== null) {
534 + if (this._persistedSelection.rendererID === rendererID) {
535 + // Check if we can select a deeper match for the persisted selection.
536 + const renderer = this._rendererInterfaces[rendererID];
537 + const prevMatch = this._persistedSelectionMatch;
538 + const nextMatch = renderer.getBestMatchForTrackedPath();
539 + this._persistedSelectionMatch = nextMatch;
540 + const prevMatchID = prevMatch !== null ? prevMatch.id : null;
541 + const nextMatchID = nextMatch !== null ? nextMatch.id : null;
542 + if (prevMatchID !== nextMatchID) {
543 + if (nextMatchID !== null) {
544 + // We moved forward, unlocking a deeper node.
545 + this._bridge.send('selectFiber', nextMatchID);
546 + }
547 + }
548 + if (nextMatch !== null && nextMatch.isFullMatch) {
549 + // We've just unlocked the innermost selected node.
550 + // There's no point tracking it further.
551 + this._persistedSelection = null;
552 + this._persistedSelectionMatch = null;
553 + renderer.setTrackedPath(null);
554 + }
555 + }
556 + }
557 };
558
559 _onClick = (event: MouseEvent) => {
@@ -530,4 +604,22 @@ export default class Agent extends EventEmitter {
604 // because those are usually unintentional as you lift the cursor.
605 { leading: false }
606 );
607 +
608 + _throttledPersistSelection = throttle((rendererID: number, id: number) => {
609 + // This is throttled, so both renderer and selected ID
610 + // might not be available by the time we read them.
611 + // This is why we need the defensive checks here.
612 + const renderer = this._rendererInterfaces[rendererID];
613 + if (renderer == null) {
614 + return;
615 + }
616 + const path = renderer.getPathForElement(id);
617 + if (path === null) {
618 + return;
619 + }
620 + sessionStorage.setItem(
621 + SESSION_STORAGE_LAST_SELECTION_KEY,
622 + JSON.stringify(({ rendererID, path }: PersistedSelection))
623 + );
624 + }, 1000);
625 }
src/backend/renderer.js
+42 -2
@@ -36,6 +36,8 @@ import type {
36 Interaction,
37 Interactions,
38 InteractionWithCommits,
39 + PathFrame,
40 + PathMatch,
41 ProfilingSummary,
42 ReactRenderer,
43 RendererInterface,
@@ -664,7 +666,10 @@ export function attach(
666 pendingOperationsQueue.push(ops);
667 } else {
668 // If we've already connected to the frontend, just pass the operations through.
667 - hook.emit('operations', ops);
669 + hook.emit('operations', {
670 + operations: ops,
671 + rendererID,
672 + });
673 }
674
675 pendingOperations.length = 0;
@@ -1108,7 +1113,10 @@ export function attach(
1113 // We may have already queued up some operations before the frontend connected
1114 // If so, let the frontend know about them.
1115 localPendingOperationsQueue.forEach(ops => {
1111 - hook.emit('operations', ops);
1116 + hook.emit('operations', {
1117 + operations: ops,
1118 + rendererID,
1119 + });
1120 });
1121 } else {
1122 // If we have not been profiling, then we can just walk the tree and build up its current state as-is.
@@ -1979,14 +1987,45 @@ export function attach(
1987 scheduleUpdate(fiber);
1988 }
1989
1990 + let trackedPath: Array<PathFrame> | null = null;
1991 +
1992 + function setTrackedPath(path: Array<PathFrame> | null) {
1993 + trackedPath = path;
1994 + }
1995 +
1996 + function getPathForElement(id: number): Array<PathFrame> {
1997 + // TODO: this is not a real path.
1998 + return [
1999 + {
2000 + index: id,
2001 + key: null,
2002 + displayName: null,
2003 + },
2004 + ];
2005 + }
2006 +
2007 + function getBestMatchForTrackedPath(): PathMatch | null {
2008 + // TODO: this is not a real lookup.
2009 + if (trackedPath !== null) {
2010 + const id = trackedPath[0].index;
2011 + const fiber = idToFiberMap.get(id);
2012 + if (fiber !== null) {
2013 + return { id, isFullMatch: true };
2014 + }
2015 + }
2016 + return null;
2017 + }
2018 +
2019 return {
2020 cleanup,
2021 flushInitialOperations,
2022 + getBestMatchForTrackedPath,
2023 getCommitDetails,
2024 getFiberIDFromNative,
2025 getFiberCommits,
2026 getInteractions,
2027 findNativeByFiberID,
2028 + getPathForElement,
2029 getProfilingDataForDownload,
2030 getProfilingSummary,
2031 handleCommitFiberRoot,
@@ -2001,6 +2040,7 @@ export function attach(
2040 setInHook,
2041 setInProps,
2042 setInState,
2043 + setTrackedPath,
2044 startProfiling,
2045 stopProfiling,
2046 };
src/backend/types.js
+14
@@ -90,10 +90,22 @@ export type ProfilingSummary = {|
90 rootID: number,
91 |};
92
93 +export type PathFrame = {|
94 + key: string | null,
95 + index: number,
96 + displayName: string | null,
97 +|};
98 +
99 +export type PathMatch = {|
100 + id: number,
101 + isFullMatch: boolean,
102 +|};
103 +
104 export type RendererInterface = {
105 cleanup: () => void,
106 findNativeByFiberID: (id: number) => ?NativeType,
107 flushInitialOperations: () => void,
108 + getBestMatchForTrackedPath: () => PathMatch | null,
109 getCommitDetails: (rootID: number, commitIndex: number) => CommitDetails,
110 getFiberIDFromNative: (
111 component: NativeType,
@@ -103,6 +115,7 @@ export type RendererInterface = {
115 getInteractions: (rootID: number) => Interactions,
116 getProfilingDataForDownload: (rootID: number) => Object,
117 getProfilingSummary: (rootID: number) => ProfilingSummary,
118 + getPathForElement: (id: number) => Array<PathFrame>,
119 handleCommitFiberRoot: (fiber: Object) => void,
120 handleCommitFiberUnmount: (fiber: Object) => void,
121 inspectElement: (id: number) => InspectedElement | null,
@@ -120,6 +133,7 @@ export type RendererInterface = {
133 ) => void,
134 setInProps: (id: number, path: Array<string | number>, value: any) => void,
135 setInState: (id: number, path: Array<string | number>, value: any) => void,
136 + setTrackedPath: (path: Array<PathFrame> | null) => void,
137 startProfiling: () => void,
138 stopProfiling: () => void,
139 };
src/constants.js
+3
@@ -8,4 +8,7 @@ export const TREE_OPERATION_UPDATE_TREE_BASE_DURATION = 4;
8 export const LOCAL_STORAGE_RELOAD_AND_PROFILE_KEY =
9 'React::DevTools::reloadAndProfile';
10
11 +export const SESSION_STORAGE_LAST_SELECTION_KEY =
12 + 'React::DevTools::lastSelection';
13 +
14 export const __DEBUG__ = false;