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,
|};