From ebd1c156b47c7e4442939b6fd2f9748af0cf55f4 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Fri, 3 Dec 2021 16:23:48 -0500 Subject: [PATCH] Timeline search (#22799) Refactor SearchInput component (used in Components tree) to be generic DevTools component with two uses: ComponentSearchInput and TimelineSearchInput. Refactored Timeline Suspense to more closely match other, newer Suspense patterns (e.g. inspect component, named hooks) and colocated Susepnse code in timelineCache file. Add search by component name functionality to the Timeline. For now, searching zooms in to the component measure and you can step through each time it rendered using the next/previous arrows. --- .../views/Components/ComponentSearchInput.js | 38 ++++ .../src/devtools/views/Components/Tree.js | 4 +- .../devtools/views/Components/TreeContext.js | 2 +- .../Profiler/ClearProfilingDataButton.js | 6 +- .../src/devtools/views/Profiler/Profiler.css | 6 + .../src/devtools/views/Profiler/Profiler.js | 13 +- .../Profiler/ProfilingImportExportButtons.js | 5 +- .../views/{Components => }/SearchInput.css | 0 .../views/{Components => }/SearchInput.js | 86 ++++----- .../react-devtools-timeline/src/CanvasPage.js | 31 ++++ .../react-devtools-timeline/src/Timeline.js | 40 +++-- .../src/TimelineContext.js | 54 +++--- .../src/TimelineSearchContext.js | 166 ++++++++++++++++++ .../src/TimelineSearchInput.js | 46 +++++ .../content-views/ComponentMeasuresView.js | 50 +++++- .../src/content-views/constants.js | 4 + .../src/timelineCache.js | 105 +++++++++++ packages/react-devtools-timeline/src/types.js | 8 + 18 files changed, 573 insertions(+), 91 deletions(-) create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/ComponentSearchInput.js rename packages/react-devtools-shared/src/devtools/views/{Components => }/SearchInput.css (100%) rename packages/react-devtools-shared/src/devtools/views/{Components => }/SearchInput.js (60%) create mode 100644 packages/react-devtools-timeline/src/TimelineSearchContext.js create mode 100644 packages/react-devtools-timeline/src/TimelineSearchInput.js create mode 100644 packages/react-devtools-timeline/src/timelineCache.js diff --git a/packages/react-devtools-shared/src/devtools/views/Components/ComponentSearchInput.js b/packages/react-devtools-shared/src/devtools/views/Components/ComponentSearchInput.js new file mode 100644 index 0000000000000..3180a7ebb0702 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/ComponentSearchInput.js @@ -0,0 +1,38 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import * as React from 'react'; +import {useContext} from 'react'; +import {TreeDispatcherContext, TreeStateContext} from './TreeContext'; + +import SearchInput from '../SearchInput'; + +type Props = {||}; + +export default function ComponentSearchInput(props: Props) { + const {searchIndex, searchResults, searchText} = useContext(TreeStateContext); + const dispatch = useContext(TreeDispatcherContext); + + const search = text => dispatch({type: 'SET_SEARCH_TEXT', payload: text}); + const goToNextResult = () => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'}); + const goToPreviousResult = () => + dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'}); + + return ( + + ); +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Tree.js b/packages/react-devtools-shared/src/devtools/views/Components/Tree.js index 061ea131a9bf3..3126bfc8f0d3f 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Tree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/Tree.js @@ -27,7 +27,7 @@ import {BridgeContext, StoreContext, OptionsContext} from '../context'; import Element from './Element'; import InspectHostNodesToggle from './InspectHostNodesToggle'; import OwnersStack from './OwnersStack'; -import SearchInput from './SearchInput'; +import ComponentSearchInput from './ComponentSearchInput'; import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContextToggle'; import SelectedTreeHighlight from './SelectedTreeHighlight'; import TreeFocusedContext from './TreeFocusedContext'; @@ -343,7 +343,7 @@ export default function Tree(props: Props) { )} }> - {ownerID !== null ? : } + {ownerID !== null ? : } {showInlineWarningsAndErrors && ownerID === null && diff --git a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js index 93e680bc751af..f6854468dc6eb 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js @@ -829,7 +829,7 @@ type Props = {| defaultSelectedElementIndex?: ?number, |}; -// TODO Remove TreeContextController wrapper element once global ConsearchText.write API exists. +// TODO Remove TreeContextController wrapper element once global Context.write API exists. function TreeContextController({ children, defaultInspectedElementID, diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/ClearProfilingDataButton.js b/packages/react-devtools-shared/src/devtools/views/Profiler/ClearProfilingDataButton.js index f57b4ba8e1731..8fbebfeba86dc 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/ClearProfilingDataButton.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/ClearProfilingDataButton.js @@ -20,19 +20,19 @@ export default function ClearProfilingDataButton() { const {didRecordCommits, isProfiling, selectedTabID} = useContext( ProfilerContext, ); - const {clearTimelineData, timelineData} = useContext(TimelineContext); + const {file, setFile} = useContext(TimelineContext); const {profilerStore} = store; let doesHaveData = false; if (selectedTabID === 'timeline') { - doesHaveData = timelineData !== null; + doesHaveData = file !== null; } else { doesHaveData = didRecordCommits; } const clear = () => { if (selectedTabID === 'timeline') { - clearTimelineData(); + setFile(null); } else { profilerStore.clear(); } diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.css b/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.css index 6763564e52a29..12089b71fdc6a 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.css +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.css @@ -115,3 +115,9 @@ .Link { color: var(--color-button); } + +.TimlineSearchInputContainer { + flex: 1 1; + display: flex; + align-items: center; +} \ No newline at end of file diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js b/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js index 0bd6b0375e076..fbe8c4ac20c03 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js @@ -28,6 +28,7 @@ import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views import {SettingsModalContextController} from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContext'; import portaledContent from '../portaledContent'; import {StoreContext} from '../context'; +import {TimelineContext} from 'react-devtools-timeline/src/TimelineContext'; import styles from './Profiler.css'; @@ -43,19 +44,19 @@ function Profiler(_: {||}) { supportsProfiling, } = useContext(ProfilerContext); + const {searchInputContainerRef} = useContext(TimelineContext); + const {supportsTimeline} = useContext(StoreContext); - let isLegacyProfilerSelected = false; + const isLegacyProfilerSelected = selectedTabID !== 'timeline'; let view = null; if (didRecordCommits || selectedTabID === 'timeline') { switch (selectedTabID) { case 'flame-chart': - isLegacyProfilerSelected = true; view = ; break; case 'ranked-chart': - isLegacyProfilerSelected = true; view = ; break; case 'timeline': @@ -121,6 +122,12 @@ function Profiler(_: {||}) { />
+ {!isLegacyProfilerSelected && ( +
+ )} {isLegacyProfilerSelected && didRecordCommits && ( diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/ProfilingImportExportButtons.js b/packages/react-devtools-shared/src/devtools/views/Profiler/ProfilingImportExportButtons.js index 5b53e04410df0..afa3b91f2a12e 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/ProfilingImportExportButtons.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/ProfilingImportExportButtons.js @@ -29,7 +29,7 @@ export default function ProfilingImportExportButtons() { const {isProfiling, profilingData, rootID, selectedTabID} = useContext( ProfilerContext, ); - const {importTimelineData} = useContext(TimelineContext); + const {setFile} = useContext(TimelineContext); const store = useContext(StoreContext); const {profilerStore} = store; @@ -111,7 +111,8 @@ export default function ProfilingImportExportButtons() { const importTimelineDataWrapper = event => { const input = inputRef.current; if (input !== null && input.files.length > 0) { - importTimelineData(input.files[0]); + const file = input.files[0]; + setFile(file); } }; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/SearchInput.css b/packages/react-devtools-shared/src/devtools/views/SearchInput.css similarity index 100% rename from packages/react-devtools-shared/src/devtools/views/Components/SearchInput.css rename to packages/react-devtools-shared/src/devtools/views/SearchInput.css diff --git a/packages/react-devtools-shared/src/devtools/views/Components/SearchInput.js b/packages/react-devtools-shared/src/devtools/views/SearchInput.js similarity index 60% rename from packages/react-devtools-shared/src/devtools/views/Components/SearchInput.js rename to packages/react-devtools-shared/src/devtools/views/SearchInput.js index 736d39fc75244..1ef69ba72669f 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/SearchInput.js +++ b/packages/react-devtools-shared/src/devtools/views/SearchInput.js @@ -8,44 +8,48 @@ */ import * as React from 'react'; -import {useCallback, useContext, useEffect, useRef} from 'react'; -import {TreeDispatcherContext, TreeStateContext} from './TreeContext'; -import Button from '../Button'; -import ButtonIcon from '../ButtonIcon'; -import Icon from '../Icon'; +import {useEffect, useRef} from 'react'; +import Button from './Button'; +import ButtonIcon from './ButtonIcon'; +import Icon from './Icon'; import styles from './SearchInput.css'; -type Props = {||}; - -export default function SearchInput(props: Props) { - const {searchIndex, searchResults, searchText} = useContext(TreeStateContext); - const dispatch = useContext(TreeDispatcherContext); +type Props = {| + goToNextResult: () => void, + goToPreviousResult: () => void, + placeholder: string, + search: (text: string) => void, + searchIndex: number, + searchResultsCount: number, + searchText: string, +|}; +export default function SearchInput({ + goToNextResult, + goToPreviousResult, + placeholder, + search, + searchIndex, + searchResultsCount, + searchText, +}: Props) { const inputRef = useRef(null); - const handleTextChange = useCallback( - ({currentTarget}) => - dispatch({type: 'SET_SEARCH_TEXT', payload: currentTarget.value}), - [dispatch], - ); - const resetSearch = useCallback( - () => dispatch({type: 'SET_SEARCH_TEXT', payload: ''}), - [dispatch], - ); + const resetSearch = () => search(''); - const handleInputKeyPress = useCallback( - ({key, shiftKey}) => { - if (key === 'Enter') { - if (shiftKey) { - dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'}); - } else { - dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'}); - } + const handleChange = ({currentTarget}) => { + search(currentTarget.value); + }; + const handleKeyPress = ({key, shiftKey}) => { + if (key === 'Enter') { + if (shiftKey) { + goToPreviousResult(); + } else { + goToNextResult(); } - }, - [dispatch], - ); + } + }; // Auto-focus search input useEffect(() => { @@ -53,7 +57,7 @@ export default function SearchInput(props: Props) { return () => {}; } - const handleWindowKey = (event: KeyboardEvent) => { + const handleKeyDown = (event: KeyboardEvent) => { const {key, metaKey} = event; if (key === 'f' && metaKey) { if (inputRef.current !== null) { @@ -68,33 +72,33 @@ export default function SearchInput(props: Props) { // Here we use portals to render individual tabs (e.g. Profiler), // and the root document might belong to a different window. const ownerDocument = inputRef.current.ownerDocument; - ownerDocument.addEventListener('keydown', handleWindowKey); + ownerDocument.addEventListener('keydown', handleKeyDown); - return () => ownerDocument.removeEventListener('keydown', handleWindowKey); - }, [inputRef]); + return () => ownerDocument.removeEventListener('keydown', handleKeyDown); + }, []); return (
{!!searchText && ( - {Math.min(searchIndex + 1, searchResults.length)} |{' '} - {searchResults.length} + {Math.min(searchIndex + 1, searchResultsCount)} |{' '} + {searchResultsCount}
); -const DataResourceComponent = ({ - dataResource, +const FileLoader = ({ + file, onFileSelect, viewState, }: {| - dataResource: DataResource, + file: File | null, onFileSelect: (file: File) => void, viewState: ViewState, |}) => { - const dataOrError = dataResource.read(); + if (file === null) { + return null; + } + + const dataOrError = importFile(file); if (dataOrError instanceof Error) { return ( ); } - return ; + + return ( + + + + + ); }; diff --git a/packages/react-devtools-timeline/src/TimelineContext.js b/packages/react-devtools-timeline/src/TimelineContext.js index ebfac421e2c3e..cbf57a275c0c5 100644 --- a/packages/react-devtools-timeline/src/TimelineContext.js +++ b/packages/react-devtools-timeline/src/TimelineContext.js @@ -8,16 +8,19 @@ */ import * as React from 'react'; -import {createContext, useCallback, useMemo, useState} from 'react'; -import createDataResourceFromImportedFile from './createDataResourceFromImportedFile'; +import {createContext, useMemo, useRef, useState} from 'react'; -import type {HorizontalScrollStateChangeCallback, ViewState} from './types'; -import type {DataResource} from './createDataResourceFromImportedFile'; +import type { + HorizontalScrollStateChangeCallback, + SearchRegExpStateChangeCallback, + ViewState, +} from './types'; +import type {RefObject} from 'shared/ReactTypes'; export type Context = {| - clearTimelineData: () => void, - importTimelineData: (file: File) => void, - timelineData: DataResource | null, + file: File | null, + searchInputContainerRef: RefObject, + setFile: (file: File | null) => void, viewState: ViewState, |}; @@ -29,30 +32,28 @@ type Props = {| |}; function TimelineContextController({children}: Props) { - const [timelineData, setTimelineData] = useState(null); - - const clearTimelineData = useCallback(() => { - setTimelineData(null); - }, []); - - const importTimelineData = useCallback((file: File) => { - setTimelineData(createDataResourceFromImportedFile(file)); - }, []); + const searchInputContainerRef = useRef(null); + const [file, setFile] = useState(null); // Recreate view state any time new profiling data is imported. const viewState = useMemo(() => { const horizontalScrollStateChangeCallbacks: Set = new Set(); + const searchRegExpStateChangeCallbacks: Set = new Set(); const horizontalScrollState = { offset: 0, length: 0, }; - return { + const state: ViewState = { horizontalScrollState, onHorizontalScrollStateChange: callback => { horizontalScrollStateChangeCallbacks.add(callback); }, + onSearchRegExpStateChange: callback => { + searchRegExpStateChangeCallbacks.add(callback); + }, + searchRegExp: null, updateHorizontalScrollState: scrollState => { if ( horizontalScrollState.offset === scrollState.offset && @@ -68,18 +69,27 @@ function TimelineContextController({children}: Props) { callback(scrollState); }); }, + updateSearchRegExpState: (searchRegExp: RegExp | null) => { + state.searchRegExp = searchRegExp; + + searchRegExpStateChangeCallbacks.forEach(callback => { + callback(searchRegExp); + }); + }, viewToMutableViewStateMap: new Map(), }; - }, [timelineData]); + + return state; + }, [file]); const value = useMemo( () => ({ - clearTimelineData, - importTimelineData, - timelineData, + file, + searchInputContainerRef, + setFile, viewState, }), - [clearTimelineData, importTimelineData, timelineData, viewState], + [file, setFile, viewState], ); return ( diff --git a/packages/react-devtools-timeline/src/TimelineSearchContext.js b/packages/react-devtools-timeline/src/TimelineSearchContext.js new file mode 100644 index 0000000000000..46d05710ac091 --- /dev/null +++ b/packages/react-devtools-timeline/src/TimelineSearchContext.js @@ -0,0 +1,166 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import * as React from 'react'; +import {createContext, useMemo, useReducer} from 'react'; + +import type { + ReactComponentMeasure, + ReactProfilerData, + ViewState, +} from './types'; + +type State = {| + profilerData: ReactProfilerData, + searchIndex: number, + searchRegExp: RegExp | null, + searchResults: Array, + searchText: string, +|}; + +type ACTION_GO_TO_NEXT_SEARCH_RESULT = {| + type: 'GO_TO_NEXT_SEARCH_RESULT', +|}; +type ACTION_GO_TO_PREVIOUS_SEARCH_RESULT = {| + type: 'GO_TO_PREVIOUS_SEARCH_RESULT', +|}; +type ACTION_SET_SEARCH_TEXT = {| + type: 'SET_SEARCH_TEXT', + payload: string, +|}; + +type Action = + | ACTION_GO_TO_NEXT_SEARCH_RESULT + | ACTION_GO_TO_PREVIOUS_SEARCH_RESULT + | ACTION_SET_SEARCH_TEXT; + +type Dispatch = (action: Action) => void; + +const EMPTY_ARRAY = []; + +function reducer(state: State, action: Action): State { + let {searchIndex, searchRegExp, searchResults, searchText} = state; + + switch (action.type) { + case 'GO_TO_NEXT_SEARCH_RESULT': + if (searchResults.length > 0) { + if (searchIndex === -1 || searchIndex + 1 === searchResults.length) { + searchIndex = 0; + } else { + searchIndex++; + } + } + break; + case 'GO_TO_PREVIOUS_SEARCH_RESULT': + if (searchResults.length > 0) { + if (searchIndex === -1 || searchIndex === 0) { + searchIndex = searchResults.length - 1; + } else { + searchIndex--; + } + } + break; + case 'SET_SEARCH_TEXT': + searchText = action.payload; + searchRegExp = null; + searchResults = []; + + if (searchText !== '') { + const safeSearchText = searchText.replace( + /[.*+?^${}()|[\]\\]/g, + '\\$&', + ); + searchRegExp = new RegExp(`^${safeSearchText}`, 'i'); + + // If something is zoomed in on already, and the new search still contains it, + // don't change the selection (even if overall search results set changes). + let prevSelectedMeasure = null; + if (searchIndex >= 0 && searchResults.length > searchIndex) { + prevSelectedMeasure = searchResults[searchIndex]; + } + + const componentMeasures = state.profilerData.componentMeasures; + + let prevSelectedMeasureIndex = -1; + + for (let i = 0; i < componentMeasures.length; i++) { + const componentMeasure = componentMeasures[i]; + if (componentMeasure.componentName.match(searchRegExp)) { + searchResults.push(componentMeasure); + + if (componentMeasure === prevSelectedMeasure) { + prevSelectedMeasureIndex = searchResults.length - 1; + } + } + } + + searchIndex = + prevSelectedMeasureIndex >= 0 ? prevSelectedMeasureIndex : 0; + } + break; + } + + return { + profilerData: state.profilerData, + searchIndex, + searchRegExp, + searchResults, + searchText, + }; +} + +export type Context = {| + profilerData: ReactProfilerData, + + // Search state + dispatch: Dispatch, + searchIndex: number, + searchRegExp: null, + searchResults: Array, + searchText: string, +|}; + +const TimelineSearchContext = createContext(((null: any): Context)); +TimelineSearchContext.displayName = 'TimelineSearchContext'; + +type Props = {| + children: React$Node, + profilerData: ReactProfilerData, + viewState: ViewState, +|}; + +function TimelineSearchContextController({ + children, + profilerData, + viewState, +}: Props) { + const [state, dispatch] = useReducer(reducer, { + profilerData, + searchIndex: -1, + searchRegExp: null, + searchResults: EMPTY_ARRAY, + searchText: '', + }); + + const value = useMemo( + () => ({ + ...state, + dispatch, + }), + [state], + ); + + return ( + + {children} + + ); +} + +export {TimelineSearchContext, TimelineSearchContextController}; diff --git a/packages/react-devtools-timeline/src/TimelineSearchInput.js b/packages/react-devtools-timeline/src/TimelineSearchInput.js new file mode 100644 index 0000000000000..5a4515d1b826c --- /dev/null +++ b/packages/react-devtools-timeline/src/TimelineSearchInput.js @@ -0,0 +1,46 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import * as React from 'react'; +import {useContext} from 'react'; +import {createPortal} from 'react-dom'; +import SearchInput from 'react-devtools-shared/src/devtools/views/SearchInput'; +import {TimelineContext} from './TimelineContext'; +import {TimelineSearchContext} from './TimelineSearchContext'; + +type Props = {||}; + +export default function TimelineSearchInput(props: Props) { + const {searchInputContainerRef} = useContext(TimelineContext); + const {dispatch, searchIndex, searchResults, searchText} = useContext( + TimelineSearchContext, + ); + + if (searchInputContainerRef.current === null) { + return null; + } + + const search = text => dispatch({type: 'SET_SEARCH_TEXT', payload: text}); + const goToNextResult = () => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'}); + const goToPreviousResult = () => + dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'}); + + return createPortal( + , + searchInputContainerRef.current, + ); +} diff --git a/packages/react-devtools-timeline/src/content-views/ComponentMeasuresView.js b/packages/react-devtools-timeline/src/content-views/ComponentMeasuresView.js index a241498a05a05..2194c79e87fca 100644 --- a/packages/react-devtools-timeline/src/content-views/ComponentMeasuresView.js +++ b/packages/react-devtools-timeline/src/content-views/ComponentMeasuresView.js @@ -7,7 +7,11 @@ * @flow */ -import type {ReactComponentMeasure, ReactProfilerData} from '../types'; +import type { + ReactComponentMeasure, + ReactProfilerData, + ViewState, +} from '../types'; import type { Interaction, IntrinsicSize, @@ -31,21 +35,37 @@ import { rectIntersectsRect, intersectionOfRects, } from '../view-base'; -import {COLORS, NATIVE_EVENT_HEIGHT, BORDER_SIZE} from './constants'; +import {BORDER_SIZE, COLORS, NATIVE_EVENT_HEIGHT} from './constants'; const ROW_WITH_BORDER_HEIGHT = NATIVE_EVENT_HEIGHT + BORDER_SIZE; export class ComponentMeasuresView extends View { + _cachedSearchMatches: Map; + _cachedSearchRegExp: RegExp | null = null; _hoveredComponentMeasure: ReactComponentMeasure | null = null; _intrinsicSize: IntrinsicSize; _profilerData: ReactProfilerData; + _viewState: ViewState; onHover: ((event: ReactComponentMeasure | null) => void) | null = null; - constructor(surface: Surface, frame: Rect, profilerData: ReactProfilerData) { + constructor( + surface: Surface, + frame: Rect, + profilerData: ReactProfilerData, + viewState: ViewState, + ) { super(surface, frame); this._profilerData = profilerData; + this._viewState = viewState; + + this._cachedSearchMatches = new Map(); + this._cachedSearchRegExp = null; + + viewState.onSearchRegExpStateChange(() => { + this.setNeedsDisplay(); + }); this._intrinsicSize = { width: profilerData.duration, @@ -150,6 +170,24 @@ export class ComponentMeasuresView extends View { break; } } + + let isMatch = false; + const cachedSearchRegExp = this._cachedSearchRegExp; + if (cachedSearchRegExp !== null) { + const cachedSearchMatches = this._cachedSearchMatches; + const cachedValue = cachedSearchMatches.get(componentName); + if (cachedValue != null) { + isMatch = cachedValue; + } else { + isMatch = componentName.match(cachedSearchRegExp) !== null; + cachedSearchMatches.set(componentName, isMatch); + } + } + + if (isMatch) { + context.fillStyle = COLORS.SEARCH_RESULT_FILL; + } + context.fillRect( drawableRect.origin.x, drawableRect.origin.y, @@ -174,6 +212,12 @@ export class ComponentMeasuresView extends View { visibleArea, } = this; + const searchRegExp = this._viewState.searchRegExp; + if (this._cachedSearchRegExp !== searchRegExp) { + this._cachedSearchMatches = new Map(); + this._cachedSearchRegExp = searchRegExp; + } + context.fillStyle = COLORS.BACKGROUND; context.fillRect( visibleArea.origin.x, diff --git a/packages/react-devtools-timeline/src/content-views/constants.js b/packages/react-devtools-timeline/src/content-views/constants.js index f2920ac19b516..bf60a1fbf9a7a 100644 --- a/packages/react-devtools-timeline/src/content-views/constants.js +++ b/packages/react-devtools-timeline/src/content-views/constants.js @@ -91,6 +91,7 @@ export let COLORS = { SCROLL_CARET: '', SCRUBBER_BACKGROUND: '', SCRUBBER_BORDER: '', + SEARCH_RESULT_FILL: '', TEXT_COLOR: '', TEXT_DIM_COLOR: '', TIME_MARKER_LABEL: '', @@ -235,6 +236,9 @@ export function updateColorsToMatchTheme(element: Element): boolean { SCRUBBER_BACKGROUND: computedStyle.getPropertyValue( '--color-timeline-react-suspense-rejected', ), + SEARCH_RESULT_FILL: computedStyle.getPropertyValue( + '--color-timeline-react-suspense-rejected', + ), SCRUBBER_BORDER: computedStyle.getPropertyValue( '--color-timeline-text-color', ), diff --git a/packages/react-devtools-timeline/src/timelineCache.js b/packages/react-devtools-timeline/src/timelineCache.js new file mode 100644 index 0000000000000..4e0948a48193c --- /dev/null +++ b/packages/react-devtools-timeline/src/timelineCache.js @@ -0,0 +1,105 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Wakeable} from 'shared/ReactTypes'; +import type {ReactProfilerData} from './types'; + +import {importFile as importFileWorker} from './import-worker'; + +const Pending = 0; +const Resolved = 1; +const Rejected = 2; + +type PendingRecord = {| + status: 0, + value: Wakeable, +|}; + +type ResolvedRecord = {| + status: 1, + value: T, +|}; + +type RejectedRecord = {| + status: 2, + value: Error, +|}; + +type Record = PendingRecord | ResolvedRecord | RejectedRecord; + +// This is intentionally a module-level Map, rather than a React-managed one. +// Otherwise, refreshing the inspected element cache would also clear this cache. +// Profiler file contents are static anyway. +const fileNameToProfilerDataMap: Map< + string, + Record, +> = new Map(); + +function readRecord(record: Record): ResolvedRecord | RejectedRecord { + if (record.status === Resolved) { + // This is just a type refinement. + return record; + } else if (record.status === Rejected) { + // This is just a type refinement. + return record; + } else { + throw record.value; + } +} + +export function importFile(file: File): ReactProfilerData | Error { + const fileName = file.name; + let record = fileNameToProfilerDataMap.get(fileName); + + if (!record) { + const callbacks = new Set(); + const wakeable: Wakeable = { + then(callback) { + callbacks.add(callback); + }, + + // Optional property used by Timeline: + displayName: `Importing file "${fileName}"`, + }; + + const wake = () => { + // This assumes they won't throw. + callbacks.forEach(callback => callback()); + callbacks.clear(); + }; + + const newRecord: Record = (record = { + status: Pending, + value: wakeable, + }); + + importFileWorker(file).then(data => { + switch (data.status) { + case 'SUCCESS': + const resolvedRecord = ((newRecord: any): ResolvedRecord); + resolvedRecord.status = Resolved; + resolvedRecord.value = data.processedData; + break; + case 'INVALID_PROFILE_ERROR': + case 'UNEXPECTED_ERROR': + const thrownRecord = ((newRecord: any): RejectedRecord); + thrownRecord.status = Rejected; + thrownRecord.value = data.error; + break; + } + + wake(); + }); + + fileNameToProfilerDataMap.set(fileName, record); + } + + const response = readRecord(record).value; + return response; +} diff --git a/packages/react-devtools-timeline/src/types.js b/packages/react-devtools-timeline/src/types.js index 5a532495187ee..3e9867c8b8c65 100644 --- a/packages/react-devtools-timeline/src/types.js +++ b/packages/react-devtools-timeline/src/types.js @@ -170,6 +170,9 @@ export type Flamechart = FlamechartStackLayer[]; export type HorizontalScrollStateChangeCallback = ( scrollState: ScrollState, ) => void; +export type SearchRegExpStateChangeCallback = ( + searchRegExp: RegExp | null, +) => void; // Imperative view state that corresponds to profiler data. // This state lives outside of React's lifecycle @@ -179,7 +182,12 @@ export type ViewState = {| onHorizontalScrollStateChange: ( callback: HorizontalScrollStateChangeCallback, ) => void, + onSearchRegExpStateChange: ( + callback: SearchRegExpStateChangeCallback, + ) => void, + searchRegExp: RegExp | null, updateHorizontalScrollState: (scrollState: ScrollState) => void, + updateSearchRegExpState: (searchRegExp: RegExp | null) => void, viewToMutableViewStateMap: Map, |};