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;