From d8abecdcf997390eaf22dc1bb20fa3a0e81adf0b Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Tue, 23 Apr 2019 15:25:55 +0100 Subject: [PATCH] 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. --- src/backend/agent.js | 98 +++++++++++++++++++++++++++++++++++++++-- src/backend/renderer.js | 44 +++++++++++++++++- src/backend/types.js | 14 ++++++ src/constants.js | 3 ++ 4 files changed, 154 insertions(+), 5 deletions(-) diff --git a/src/backend/agent.js b/src/backend/agent.js index 378a70f271..9f83fff38c 100644 --- a/src/backend/agent.js +++ b/src/backend/agent.js @@ -3,10 +3,19 @@ import EventEmitter from 'events'; import memoize from 'memoize-one'; import throttle from 'lodash.throttle'; -import { LOCAL_STORAGE_RELOAD_AND_PROFILE_KEY, __DEBUG__ } from '../constants'; +import { + LOCAL_STORAGE_RELOAD_AND_PROFILE_KEY, + SESSION_STORAGE_LAST_SELECTION_KEY, + __DEBUG__, +} from '../constants'; import { hideOverlay, showOverlay } from './views/Highlighter'; -import type { RendererID, RendererInterface } from './types'; +import type { + PathFrame, + PathMatch, + RendererID, + RendererInterface, +} from './types'; import type { Bridge } from '../types'; const debug = (methodName, ...args) => { @@ -20,6 +29,11 @@ const debug = (methodName, ...args) => { } }; +type OperationsParams = {| + operations: Uint32Array, + rendererID: number, +|}; + type InspectSelectParams = {| id: number, rendererID: number, @@ -46,10 +60,17 @@ type OverrideSuspenseParams = {| forceFallback: boolean, |}; +type PersistedSelection = {| + rendererID: number, + path: Array, +|}; + export default class Agent extends EventEmitter { _bridge: Bridge = ((null: any): Bridge); _isProfiling: boolean = false; _rendererInterfaces: { [key: RendererID]: RendererInterface } = {}; + _persistedSelection: PersistedSelection | null = null; + _persistedSelectionMatch: PathMatch | null = null; constructor() { super(); @@ -59,6 +80,13 @@ export default class Agent extends EventEmitter { localStorage.removeItem(LOCAL_STORAGE_RELOAD_AND_PROFILE_KEY); } + + const persistedSelectionString = sessionStorage.getItem( + SESSION_STORAGE_LAST_SELECTION_KEY + ); + if (persistedSelectionString != null) { + this._persistedSelection = JSON.parse(persistedSelectionString); + } } addBridge(bridge: Bridge) { @@ -316,6 +344,19 @@ export default class Agent extends EventEmitter { } else { renderer.selectElement(id); this._bridge.send('selectElement'); + + // When user selects an element, stop trying to restore the selection, + // and instead remember the current selection for the next reload. + if ( + this._persistedSelectionMatch === null || + this._persistedSelectionMatch.id !== id + ) { + this._persistedSelection = null; + this._persistedSelectionMatch = null; + renderer.setTrackedPath(null); + this._throttledPersistSelection(rendererID, id); + } + // TODO: If there was a way to change the selected DOM element // in native Elements tab without forcing a switch to it, we'd do it here. // For now, it doesn't seem like there is a way to do that: @@ -388,6 +429,14 @@ export default class Agent extends EventEmitter { if (this._isProfiling) { rendererInterface.startProfiling(); } + + // When the renderer is attached, we need to tell it whether + // we remember the previous selection that we'd like to restore. + // It'll start tracking mounts for matches to the last selection path. + const selection = this._persistedSelection; + if (selection !== null && selection.rendererID === rendererID) { + rendererInterface.setTrackedPath(selection.path); + } } syncSelectionFromNativeElementsPanel = () => { @@ -454,7 +503,7 @@ export default class Agent extends EventEmitter { } }; - onHookOperations = (operations: Uint32Array) => { + onHookOperations = ({ operations, rendererID }: OperationsParams) => { if (__DEBUG__) { debug('onHookOperations', operations); } @@ -480,6 +529,31 @@ export default class Agent extends EventEmitter { // // this._bridge.send('operations', operations, [operations.buffer]); this._bridge.send('operations', operations); + + if (this._persistedSelection !== null) { + if (this._persistedSelection.rendererID === rendererID) { + // Check if we can select a deeper match for the persisted selection. + const renderer = this._rendererInterfaces[rendererID]; + const prevMatch = this._persistedSelectionMatch; + const nextMatch = renderer.getBestMatchForTrackedPath(); + this._persistedSelectionMatch = nextMatch; + const prevMatchID = prevMatch !== null ? prevMatch.id : null; + const nextMatchID = nextMatch !== null ? nextMatch.id : null; + if (prevMatchID !== nextMatchID) { + if (nextMatchID !== null) { + // We moved forward, unlocking a deeper node. + this._bridge.send('selectFiber', nextMatchID); + } + } + if (nextMatch !== null && nextMatch.isFullMatch) { + // We've just unlocked the innermost selected node. + // There's no point tracking it further. + this._persistedSelection = null; + this._persistedSelectionMatch = null; + renderer.setTrackedPath(null); + } + } + } }; _onClick = (event: MouseEvent) => { @@ -530,4 +604,22 @@ export default class Agent extends EventEmitter { // because those are usually unintentional as you lift the cursor. { leading: false } ); + + _throttledPersistSelection = throttle((rendererID: number, id: number) => { + // This is throttled, so both renderer and selected ID + // might not be available by the time we read them. + // This is why we need the defensive checks here. + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + return; + } + const path = renderer.getPathForElement(id); + if (path === null) { + return; + } + sessionStorage.setItem( + SESSION_STORAGE_LAST_SELECTION_KEY, + JSON.stringify(({ rendererID, path }: PersistedSelection)) + ); + }, 1000); } diff --git a/src/backend/renderer.js b/src/backend/renderer.js index e41ea782ff..caf165dd8e 100644 --- a/src/backend/renderer.js +++ b/src/backend/renderer.js @@ -36,6 +36,8 @@ import type { Interaction, Interactions, InteractionWithCommits, + PathFrame, + PathMatch, ProfilingSummary, ReactRenderer, RendererInterface, @@ -664,7 +666,10 @@ export function attach( pendingOperationsQueue.push(ops); } else { // If we've already connected to the frontend, just pass the operations through. - hook.emit('operations', ops); + hook.emit('operations', { + operations: ops, + rendererID, + }); } pendingOperations.length = 0; @@ -1108,7 +1113,10 @@ export function attach( // We may have already queued up some operations before the frontend connected // If so, let the frontend know about them. localPendingOperationsQueue.forEach(ops => { - hook.emit('operations', ops); + hook.emit('operations', { + operations: ops, + rendererID, + }); }); } else { // 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( scheduleUpdate(fiber); } + let trackedPath: Array | null = null; + + function setTrackedPath(path: Array | null) { + trackedPath = path; + } + + function getPathForElement(id: number): Array { + // TODO: this is not a real path. + return [ + { + index: id, + key: null, + displayName: null, + }, + ]; + } + + function getBestMatchForTrackedPath(): PathMatch | null { + // TODO: this is not a real lookup. + if (trackedPath !== null) { + const id = trackedPath[0].index; + const fiber = idToFiberMap.get(id); + if (fiber !== null) { + return { id, isFullMatch: true }; + } + } + return null; + } + return { cleanup, flushInitialOperations, + getBestMatchForTrackedPath, getCommitDetails, getFiberIDFromNative, getFiberCommits, getInteractions, findNativeByFiberID, + getPathForElement, getProfilingDataForDownload, getProfilingSummary, handleCommitFiberRoot, @@ -2001,6 +2040,7 @@ export function attach( setInHook, setInProps, setInState, + setTrackedPath, startProfiling, stopProfiling, }; diff --git a/src/backend/types.js b/src/backend/types.js index bd4562eef7..d77a6c1145 100644 --- a/src/backend/types.js +++ b/src/backend/types.js @@ -90,10 +90,22 @@ export type ProfilingSummary = {| rootID: number, |}; +export type PathFrame = {| + key: string | null, + index: number, + displayName: string | null, +|}; + +export type PathMatch = {| + id: number, + isFullMatch: boolean, +|}; + export type RendererInterface = { cleanup: () => void, findNativeByFiberID: (id: number) => ?NativeType, flushInitialOperations: () => void, + getBestMatchForTrackedPath: () => PathMatch | null, getCommitDetails: (rootID: number, commitIndex: number) => CommitDetails, getFiberIDFromNative: ( component: NativeType, @@ -103,6 +115,7 @@ export type RendererInterface = { getInteractions: (rootID: number) => Interactions, getProfilingDataForDownload: (rootID: number) => Object, getProfilingSummary: (rootID: number) => ProfilingSummary, + getPathForElement: (id: number) => Array, handleCommitFiberRoot: (fiber: Object) => void, handleCommitFiberUnmount: (fiber: Object) => void, inspectElement: (id: number) => InspectedElement | null, @@ -120,6 +133,7 @@ export type RendererInterface = { ) => void, setInProps: (id: number, path: Array, value: any) => void, setInState: (id: number, path: Array, value: any) => void, + setTrackedPath: (path: Array | null) => void, startProfiling: () => void, stopProfiling: () => void, }; diff --git a/src/constants.js b/src/constants.js index d64cc5da32..588c8d4219 100644 --- a/src/constants.js +++ b/src/constants.js @@ -8,4 +8,7 @@ export const TREE_OPERATION_UPDATE_TREE_BASE_DURATION = 4; export const LOCAL_STORAGE_RELOAD_AND_PROFILE_KEY = 'React::DevTools::reloadAndProfile'; +export const SESSION_STORAGE_LAST_SELECTION_KEY = + 'React::DevTools::lastSelection'; + export const __DEBUG__ = false;