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;