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.
Brian Vaughn committed
Dec 3, 2021 at 16:23 UTC
8b8817845a8efd8cf53be395fafbb1d0f62c4368
18 files changed
+573
-91
packages/react-devtools-shared/src/devtools/views/Components/ComponentSearchInput.js
new
+38
@@ -0,0 +1,38 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+import * as React from 'react';
11
+import {useContext} from 'react';
12
+import {TreeDispatcherContext, TreeStateContext} from './TreeContext';
13
+
14
+import SearchInput from '../SearchInput';
15
+
16
+type Props = {||};
17
+
18
+export default function ComponentSearchInput(props: Props) {
19
+ const {searchIndex, searchResults, searchText} = useContext(TreeStateContext);
20
+ const dispatch = useContext(TreeDispatcherContext);
21
+
22
+ const search = text => dispatch({type: 'SET_SEARCH_TEXT', payload: text});
23
+ const goToNextResult = () => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'});
24
+ const goToPreviousResult = () =>
25
+ dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'});
26
+
27
+ return (
28
+ <SearchInput
29
+ goToNextResult={goToNextResult}
30
+ goToPreviousResult={goToPreviousResult}
31
+ placeholder="Search (text or /regex/)"
32
+ search={search}
33
+ searchIndex={searchIndex}
34
+ searchResultsCount={searchResults.length}
35
+ searchText={searchText}
36
+ />
37
+ );
38
+}
packages/react-devtools-shared/src/devtools/views/Components/Tree.js
+2
-2
@@ -27,7 +27,7 @@ import {BridgeContext, StoreContext, OptionsContext} from '../context';
27
import Element from './Element';
28
import InspectHostNodesToggle from './InspectHostNodesToggle';
29
import OwnersStack from './OwnersStack';
30
-import SearchInput from './SearchInput';
30
+import ComponentSearchInput from './ComponentSearchInput';
31
import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContextToggle';
32
import SelectedTreeHighlight from './SelectedTreeHighlight';
33
import TreeFocusedContext from './TreeFocusedContext';
@@ -343,7 +343,7 @@ export default function Tree(props: Props) {
343
</Fragment>
344
)}
345
<Suspense fallback={<Loading />}>
346
- {ownerID !== null ? <OwnersStack /> : <SearchInput />}
346
+ {ownerID !== null ? <OwnersStack /> : <ComponentSearchInput />}
347
</Suspense>
348
{showInlineWarningsAndErrors &&
349
ownerID === null &&
packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js
+1
-1
@@ -829,7 +829,7 @@ type Props = {|
829
defaultSelectedElementIndex?: ?number,
830
|};
831
832
-// TODO Remove TreeContextController wrapper element once global ConsearchText.write API exists.
832
+// TODO Remove TreeContextController wrapper element once global Context.write API exists.
833
function TreeContextController({
834
children,
835
defaultInspectedElementID,
packages/react-devtools-shared/src/devtools/views/Profiler/ClearProfilingDataButton.js
+3
-3
@@ -20,19 +20,19 @@ export default function ClearProfilingDataButton() {
20
const {didRecordCommits, isProfiling, selectedTabID} = useContext(
21
ProfilerContext,
22
);
23
- const {clearTimelineData, timelineData} = useContext(TimelineContext);
23
+ const {file, setFile} = useContext(TimelineContext);
24
const {profilerStore} = store;
25
26
let doesHaveData = false;
27
if (selectedTabID === 'timeline') {
28
- doesHaveData = timelineData !== null;
28
+ doesHaveData = file !== null;
29
} else {
30
doesHaveData = didRecordCommits;
31
}
32
33
const clear = () => {
34
if (selectedTabID === 'timeline') {
35
- clearTimelineData();
35
+ setFile(null);
36
} else {
37
profilerStore.clear();
38
}
packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.css
+6
@@ -115,3 +115,9 @@
115
.Link {
116
color: var(--color-button);
117
}
118
+
119
+.TimlineSearchInputContainer {
120
+ flex: 1 1;
121
+ display: flex;
122
+ align-items: center;
123
+}
\ No newline at end of file
packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js
+10
-3
@@ -28,6 +28,7 @@ import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views
28
import {SettingsModalContextController} from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContext';
29
import portaledContent from '../portaledContent';
30
import {StoreContext} from '../context';
31
+import {TimelineContext} from 'react-devtools-timeline/src/TimelineContext';
32
33
import styles from './Profiler.css';
34
@@ -43,19 +44,19 @@ function Profiler(_: {||}) {
44
supportsProfiling,
45
} = useContext(ProfilerContext);
46
47
+ const {searchInputContainerRef} = useContext(TimelineContext);
48
+
49
const {supportsTimeline} = useContext(StoreContext);
50
48
- let isLegacyProfilerSelected = false;
51
+ const isLegacyProfilerSelected = selectedTabID !== 'timeline';
52
53
let view = null;
54
if (didRecordCommits || selectedTabID === 'timeline') {
55
switch (selectedTabID) {
56
case 'flame-chart':
54
- isLegacyProfilerSelected = true;
57
view = <CommitFlamegraph />;
58
break;
59
case 'ranked-chart':
58
- isLegacyProfilerSelected = true;
60
view = <CommitRanked />;
61
break;
62
case 'timeline':
@@ -121,6 +122,12 @@ function Profiler(_: {||}) {
122
/>
123
<RootSelector />
124
<div className={styles.Spacer} />
125
+ {!isLegacyProfilerSelected && (
126
+ <div
127
+ ref={searchInputContainerRef}
128
+ className={styles.TimlineSearchInputContainer}
129
+ />
130
+ )}
131
<SettingsModalContextToggle />
132
{isLegacyProfilerSelected && didRecordCommits && (
133
<Fragment>
packages/react-devtools-shared/src/devtools/views/Profiler/ProfilingImportExportButtons.js
+3
-2
@@ -29,7 +29,7 @@ export default function ProfilingImportExportButtons() {
29
const {isProfiling, profilingData, rootID, selectedTabID} = useContext(
30
ProfilerContext,
31
);
32
- const {importTimelineData} = useContext(TimelineContext);
32
+ const {setFile} = useContext(TimelineContext);
33
const store = useContext(StoreContext);
34
const {profilerStore} = store;
35
@@ -111,7 +111,8 @@ export default function ProfilingImportExportButtons() {
111
const importTimelineDataWrapper = event => {
112
const input = inputRef.current;
113
if (input !== null && input.files.length > 0) {
114
- importTimelineData(input.files[0]);
114
+ const file = input.files[0];
115
+ setFile(file);
116
}
117
};
118
packages/react-devtools-shared/src/devtools/views/SearchInput.css
renamed
packages/react-devtools-shared/src/devtools/views/SearchInput.js
renamed
+45
-41
@@ -8,44 +8,48 @@
8
*/
9
10
import * as React from 'react';
11
-import {useCallback, useContext, useEffect, useRef} from 'react';
12
-import {TreeDispatcherContext, TreeStateContext} from './TreeContext';
13
-import Button from '../Button';
14
-import ButtonIcon from '../ButtonIcon';
15
-import Icon from '../Icon';
11
+import {useEffect, useRef} from 'react';
12
+import Button from './Button';
13
+import ButtonIcon from './ButtonIcon';
14
+import Icon from './Icon';
15
16
import styles from './SearchInput.css';
17
19
-type Props = {||};
20
-
21
-export default function SearchInput(props: Props) {
22
- const {searchIndex, searchResults, searchText} = useContext(TreeStateContext);
23
- const dispatch = useContext(TreeDispatcherContext);
18
+type Props = {|
19
+ goToNextResult: () => void,
20
+ goToPreviousResult: () => void,
21
+ placeholder: string,
22
+ search: (text: string) => void,
23
+ searchIndex: number,
24
+ searchResultsCount: number,
25
+ searchText: string,
26
+|};
27
28
+export default function SearchInput({
29
+ goToNextResult,
30
+ goToPreviousResult,
31
+ placeholder,
32
+ search,
33
+ searchIndex,
34
+ searchResultsCount,
35
+ searchText,
36
+}: Props) {
37
const inputRef = useRef<HTMLInputElement | null>(null);
38
27
- const handleTextChange = useCallback(
28
- ({currentTarget}) =>
29
- dispatch({type: 'SET_SEARCH_TEXT', payload: currentTarget.value}),
30
- [dispatch],
31
- );
32
- const resetSearch = useCallback(
33
- () => dispatch({type: 'SET_SEARCH_TEXT', payload: ''}),
34
- [dispatch],
35
- );
39
+ const resetSearch = () => search('');
40
37
- const handleInputKeyPress = useCallback(
38
- ({key, shiftKey}) => {
39
- if (key === 'Enter') {
40
- if (shiftKey) {
41
- dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'});
42
- } else {
43
- dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'});
44
- }
41
+ const handleChange = ({currentTarget}) => {
42
+ search(currentTarget.value);
43
+ };
44
+ const handleKeyPress = ({key, shiftKey}) => {
45
+ if (key === 'Enter') {
46
+ if (shiftKey) {
47
+ goToPreviousResult();
48
+ } else {
49
+ goToNextResult();
50
}
46
- },
47
- [dispatch],
48
- );
51
+ }
52
+ };
53
54
// Auto-focus search input
55
useEffect(() => {
@@ -53,7 +57,7 @@ export default function SearchInput(props: Props) {
57
return () => {};
58
}
59
56
- const handleWindowKey = (event: KeyboardEvent) => {
60
+ const handleKeyDown = (event: KeyboardEvent) => {
61
const {key, metaKey} = event;
62
if (key === 'f' && metaKey) {
63
if (inputRef.current !== null) {
@@ -68,33 +72,33 @@ export default function SearchInput(props: Props) {
72
// Here we use portals to render individual tabs (e.g. Profiler),
73
// and the root document might belong to a different window.
74
const ownerDocument = inputRef.current.ownerDocument;
71
- ownerDocument.addEventListener('keydown', handleWindowKey);
75
+ ownerDocument.addEventListener('keydown', handleKeyDown);
76
73
- return () => ownerDocument.removeEventListener('keydown', handleWindowKey);
74
- }, [inputRef]);
77
+ return () => ownerDocument.removeEventListener('keydown', handleKeyDown);
78
+ }, []);
79
80
return (
81
<div className={styles.SearchInput}>
82
<Icon className={styles.InputIcon} type="search" />
83
<input
84
className={styles.Input}
81
- onChange={handleTextChange}
82
- onKeyPress={handleInputKeyPress}
83
- placeholder="Search (text or /regex/)"
85
+ onChange={handleChange}
86
+ onKeyPress={handleKeyPress}
87
+ placeholder={placeholder}
88
ref={inputRef}
89
value={searchText}
90
/>
91
{!!searchText && (
92
<React.Fragment>
93
<span className={styles.IndexLabel}>
90
- {Math.min(searchIndex + 1, searchResults.length)} |{' '}
91
- {searchResults.length}
94
+ {Math.min(searchIndex + 1, searchResultsCount)} |{' '}
95
+ {searchResultsCount}
96
</span>
97
<div className={styles.LeftVRule} />
98
<Button
99
className={styles.IconButton}
100
disabled={!searchText}
97
- onClick={() => dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'})}
101
+ onClick={goToPreviousResult}
102
title={
103
<React.Fragment>
104
Scroll to previous search result (<kbd>Shift</kbd> +{' '}
@@ -106,7 +110,7 @@ export default function SearchInput(props: Props) {
110
<Button
111
className={styles.IconButton}
112
disabled={!searchText}
109
- onClick={() => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'})}
113
+ onClick={goToNextResult}
114
title={
115
<React.Fragment>
116
Scroll to next search result (<kbd>Enter</kbd>)
packages/react-devtools-timeline/src/CanvasPage.js
+31
@@ -62,6 +62,7 @@ import ContextMenuItem from 'react-devtools-shared/src/devtools/ContextMenu/Cont
62
import useContextMenu from 'react-devtools-shared/src/devtools/ContextMenu/useContextMenu';
63
import {getBatchRange} from './utils/getBatchRange';
64
import {MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL} from './view-base/constants';
65
+import {TimelineSearchContext} from './TimelineSearchContext';
66
67
import styles from './CanvasPage.css';
68
@@ -170,6 +171,35 @@ function AutoSizedCanvas({
171
[],
172
);
173
174
+ const {searchIndex, searchRegExp, searchResults} = useContext(
175
+ TimelineSearchContext,
176
+ );
177
+
178
+ // This effect searches timeline data and scrolls to the next match wen search criteria change.
179
+ useLayoutEffect(() => {
180
+ viewState.updateSearchRegExpState(searchRegExp);
181
+
182
+ const componentMeasureSearchResult =
183
+ searchResults.length > 0 ? searchResults[searchIndex] : null;
184
+ if (componentMeasureSearchResult != null) {
185
+ const scrollState = moveStateToRange({
186
+ state: viewState.horizontalScrollState,
187
+ rangeStart: componentMeasureSearchResult.timestamp,
188
+ rangeEnd:
189
+ componentMeasureSearchResult.timestamp +
190
+ componentMeasureSearchResult.duration,
191
+ contentLength: data.duration,
192
+ minContentLength: data.duration * MIN_ZOOM_LEVEL,
193
+ maxContentLength: data.duration * MAX_ZOOM_LEVEL,
194
+ containerLength: width,
195
+ });
196
+
197
+ viewState.updateHorizontalScrollState(scrollState);
198
+ }
199
+
200
+ surfaceRef.current.displayIfNeeded();
201
+ }, [searchIndex, searchRegExp, searchResults, viewState]);
202
+
203
const surfaceRef = useRef(new Surface(resetHoveredEvent));
204
const userTimingMarksViewRef = useRef(null);
205
const nativeEventsViewRef = useRef(null);
@@ -334,6 +364,7 @@ function AutoSizedCanvas({
364
surface,
365
defaultFrame,
366
data,
367
+ viewState,
368
);
369
componentMeasuresViewRef.current = componentMeasuresView;
370
componentMeasuresViewWrapper = createViewHelper(
packages/react-devtools-timeline/src/Timeline.js
+26
-14
@@ -7,7 +7,6 @@
7
* @flow
8
*/
9
10
-import type {DataResource} from './createDataResourceFromImportedFile';
10
import type {ViewState} from './types';
11
12
import {isInternalFacebookBuild} from 'react-devtools-feature-flags';
@@ -25,13 +24,14 @@ import {updateColorsToMatchTheme} from './content-views/constants';
24
import {TimelineContext} from './TimelineContext';
25
import ImportButton from './ImportButton';
26
import CanvasPage from './CanvasPage';
27
+import {importFile} from './timelineCache';
28
+import TimelineSearchInput from './TimelineSearchInput';
29
+import {TimelineSearchContextController} from './TimelineSearchContext';
30
31
import styles from './Timeline.css';
32
33
export function Timeline(_: {||}) {
32
- const {importTimelineData, timelineData, viewState} = useContext(
33
- TimelineContext,
34
- );
34
+ const {file, setFile, viewState} = useContext(TimelineContext);
35
36
const ref = useRef(null);
37
@@ -62,17 +62,17 @@ export function Timeline(_: {||}) {
62
63
return (
64
<div className={styles.Content} ref={ref}>
65
- {timelineData ? (
65
+ {file ? (
66
<Suspense fallback={<ProcessingData />}>
67
- <DataResourceComponent
68
- dataResource={timelineData}
67
+ <FileLoader
68
+ file={file}
69
key={key}
70
- onFileSelect={importTimelineData}
70
+ onFileSelect={setFile}
71
viewState={viewState}
72
/>
73
</Suspense>
74
) : (
75
- <Welcome onFileSelect={importTimelineData} />
75
+ <Welcome onFileSelect={setFile} />
76
)}
77
</div>
78
);
@@ -143,20 +143,32 @@ const CouldNotLoadProfile = ({error, onFileSelect}) => (
143
</div>
144
);
145
146
-const DataResourceComponent = ({
147
- dataResource,
146
+const FileLoader = ({
147
+ file,
148
onFileSelect,
149
viewState,
150
}: {|
151
- dataResource: DataResource,
151
+ file: File | null,
152
onFileSelect: (file: File) => void,
153
viewState: ViewState,
154
|}) => {
155
- const dataOrError = dataResource.read();
155
+ if (file === null) {
156
+ return null;
157
+ }
158
+
159
+ const dataOrError = importFile(file);
160
if (dataOrError instanceof Error) {
161
return (
162
<CouldNotLoadProfile error={dataOrError} onFileSelect={onFileSelect} />
163
);
164
}
161
- return <CanvasPage profilerData={dataOrError} viewState={viewState} />;
165
+
166
+ return (
167
+ <TimelineSearchContextController
168
+ profilerData={dataOrError}
169
+ viewState={viewState}>
170
+ <TimelineSearchInput />
171
+ <CanvasPage profilerData={dataOrError} viewState={viewState} />
172
+ </TimelineSearchContextController>
173
+ );
174
};
packages/react-devtools-timeline/src/TimelineContext.js
+32
-22
@@ -8,16 +8,19 @@
8
*/
9
10
import * as React from 'react';
11
-import {createContext, useCallback, useMemo, useState} from 'react';
12
-import createDataResourceFromImportedFile from './createDataResourceFromImportedFile';
11
+import {createContext, useMemo, useRef, useState} from 'react';
12
14
-import type {HorizontalScrollStateChangeCallback, ViewState} from './types';
15
-import type {DataResource} from './createDataResourceFromImportedFile';
13
+import type {
14
+ HorizontalScrollStateChangeCallback,
15
+ SearchRegExpStateChangeCallback,
16
+ ViewState,
17
+} from './types';
18
+import type {RefObject} from 'shared/ReactTypes';
19
20
export type Context = {|
18
- clearTimelineData: () => void,
19
- importTimelineData: (file: File) => void,
20
- timelineData: DataResource | null,
21
+ file: File | null,
22
+ searchInputContainerRef: RefObject,
23
+ setFile: (file: File | null) => void,
24
viewState: ViewState,
25
|};
26
@@ -29,30 +32,28 @@ type Props = {|
32
|};
33
34
function TimelineContextController({children}: Props) {
32
- const [timelineData, setTimelineData] = useState<DataResource | null>(null);
33
-
34
- const clearTimelineData = useCallback(() => {
35
- setTimelineData(null);
36
- }, []);
37
-
38
- const importTimelineData = useCallback((file: File) => {
39
- setTimelineData(createDataResourceFromImportedFile(file));
40
- }, []);
35
+ const searchInputContainerRef = useRef(null);
36
+ const [file, setFile] = useState<string | null>(null);
37
38
// Recreate view state any time new profiling data is imported.
39
const viewState = useMemo<ViewState>(() => {
40
const horizontalScrollStateChangeCallbacks: Set<HorizontalScrollStateChangeCallback> = new Set();
41
+ const searchRegExpStateChangeCallbacks: Set<SearchRegExpStateChangeCallback> = new Set();
42
43
const horizontalScrollState = {
44
offset: 0,
45
length: 0,
46
};
47
51
- return {
48
+ const state: ViewState = {
49
horizontalScrollState,
50
onHorizontalScrollStateChange: callback => {
51
horizontalScrollStateChangeCallbacks.add(callback);
52
},
53
+ onSearchRegExpStateChange: callback => {
54
+ searchRegExpStateChangeCallbacks.add(callback);
55
+ },
56
+ searchRegExp: null,
57
updateHorizontalScrollState: scrollState => {
58
if (
59
horizontalScrollState.offset === scrollState.offset &&
@@ -68,18 +69,27 @@ function TimelineContextController({children}: Props) {
69
callback(scrollState);
70
});
71
},
72
+ updateSearchRegExpState: (searchRegExp: RegExp | null) => {
73
+ state.searchRegExp = searchRegExp;
74
+
75
+ searchRegExpStateChangeCallbacks.forEach(callback => {
76
+ callback(searchRegExp);
77
+ });
78
+ },
79
viewToMutableViewStateMap: new Map(),
80
};
73
- }, [timelineData]);
81
+
82
+ return state;
83
+ }, [file]);
84
85
const value = useMemo(
86
() => ({
77
- clearTimelineData,
78
- importTimelineData,
79
- timelineData,
87
+ file,
88
+ searchInputContainerRef,
89
+ setFile,
90
viewState,
91
}),
82
- [clearTimelineData, importTimelineData, timelineData, viewState],
92
+ [file, setFile, viewState],
93
);
94
95
return (
packages/react-devtools-timeline/src/TimelineSearchContext.js
new
+166
@@ -0,0 +1,166 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+import * as React from 'react';
11
+import {createContext, useMemo, useReducer} from 'react';
12
+
13
+import type {
14
+ ReactComponentMeasure,
15
+ ReactProfilerData,
16
+ ViewState,
17
+} from './types';
18
+
19
+type State = {|
20
+ profilerData: ReactProfilerData,
21
+ searchIndex: number,
22
+ searchRegExp: RegExp | null,
23
+ searchResults: Array<ReactComponentMeasure>,
24
+ searchText: string,
25
+|};
26
+
27
+type ACTION_GO_TO_NEXT_SEARCH_RESULT = {|
28
+ type: 'GO_TO_NEXT_SEARCH_RESULT',
29
+|};
30
+type ACTION_GO_TO_PREVIOUS_SEARCH_RESULT = {|
31
+ type: 'GO_TO_PREVIOUS_SEARCH_RESULT',
32
+|};
33
+type ACTION_SET_SEARCH_TEXT = {|
34
+ type: 'SET_SEARCH_TEXT',
35
+ payload: string,
36
+|};
37
+
38
+type Action =
39
+ | ACTION_GO_TO_NEXT_SEARCH_RESULT
40
+ | ACTION_GO_TO_PREVIOUS_SEARCH_RESULT
41
+ | ACTION_SET_SEARCH_TEXT;
42
+
43
+type Dispatch = (action: Action) => void;
44
+
45
+const EMPTY_ARRAY = [];
46
+
47
+function reducer(state: State, action: Action): State {
48
+ let {searchIndex, searchRegExp, searchResults, searchText} = state;
49
+
50
+ switch (action.type) {
51
+ case 'GO_TO_NEXT_SEARCH_RESULT':
52
+ if (searchResults.length > 0) {
53
+ if (searchIndex === -1 || searchIndex + 1 === searchResults.length) {
54
+ searchIndex = 0;
55
+ } else {
56
+ searchIndex++;
57
+ }
58
+ }
59
+ break;
60
+ case 'GO_TO_PREVIOUS_SEARCH_RESULT':
61
+ if (searchResults.length > 0) {
62
+ if (searchIndex === -1 || searchIndex === 0) {
63
+ searchIndex = searchResults.length - 1;
64
+ } else {
65
+ searchIndex--;
66
+ }
67
+ }
68
+ break;
69
+ case 'SET_SEARCH_TEXT':
70
+ searchText = action.payload;
71
+ searchRegExp = null;
72
+ searchResults = [];
73
+
74
+ if (searchText !== '') {
75
+ const safeSearchText = searchText.replace(
76
+ /[.*+?^${}()|[\]\\]/g,
77
+ '\\$&',
78
+ );
79
+ searchRegExp = new RegExp(`^${safeSearchText}`, 'i');
80
+
81
+ // If something is zoomed in on already, and the new search still contains it,
82
+ // don't change the selection (even if overall search results set changes).
83
+ let prevSelectedMeasure = null;
84
+ if (searchIndex >= 0 && searchResults.length > searchIndex) {
85
+ prevSelectedMeasure = searchResults[searchIndex];
86
+ }
87
+
88
+ const componentMeasures = state.profilerData.componentMeasures;
89
+
90
+ let prevSelectedMeasureIndex = -1;
91
+
92
+ for (let i = 0; i < componentMeasures.length; i++) {
93
+ const componentMeasure = componentMeasures[i];
94
+ if (componentMeasure.componentName.match(searchRegExp)) {
95
+ searchResults.push(componentMeasure);
96
+
97
+ if (componentMeasure === prevSelectedMeasure) {
98
+ prevSelectedMeasureIndex = searchResults.length - 1;
99
+ }
100
+ }
101
+ }
102
+
103
+ searchIndex =
104
+ prevSelectedMeasureIndex >= 0 ? prevSelectedMeasureIndex : 0;
105
+ }
106
+ break;
107
+ }
108
+
109
+ return {
110
+ profilerData: state.profilerData,
111
+ searchIndex,
112
+ searchRegExp,
113
+ searchResults,
114
+ searchText,
115
+ };
116
+}
117
+
118
+export type Context = {|
119
+ profilerData: ReactProfilerData,
120
+
121
+ // Search state
122
+ dispatch: Dispatch,
123
+ searchIndex: number,
124
+ searchRegExp: null,
125
+ searchResults: Array<ReactComponentMeasure>,
126
+ searchText: string,
127
+|};
128
+
129
+const TimelineSearchContext = createContext<Context>(((null: any): Context));
130
+TimelineSearchContext.displayName = 'TimelineSearchContext';
131
+
132
+type Props = {|
133
+ children: React$Node,
134
+ profilerData: ReactProfilerData,
135
+ viewState: ViewState,
136
+|};
137
+
138
+function TimelineSearchContextController({
139
+ children,
140
+ profilerData,
141
+ viewState,
142
+}: Props) {
143
+ const [state, dispatch] = useReducer<State, State, Action>(reducer, {
144
+ profilerData,
145
+ searchIndex: -1,
146
+ searchRegExp: null,
147
+ searchResults: EMPTY_ARRAY,
148
+ searchText: '',
149
+ });
150
+
151
+ const value = useMemo(
152
+ () => ({
153
+ ...state,
154
+ dispatch,
155
+ }),
156
+ [state],
157
+ );
158
+
159
+ return (
160
+ <TimelineSearchContext.Provider value={value}>
161
+ {children}
162
+ </TimelineSearchContext.Provider>
163
+ );
164
+}
165
+
166
+export {TimelineSearchContext, TimelineSearchContextController};
packages/react-devtools-timeline/src/TimelineSearchInput.js
new
+46
@@ -0,0 +1,46 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+import * as React from 'react';
11
+import {useContext} from 'react';
12
+import {createPortal} from 'react-dom';
13
+import SearchInput from 'react-devtools-shared/src/devtools/views/SearchInput';
14
+import {TimelineContext} from './TimelineContext';
15
+import {TimelineSearchContext} from './TimelineSearchContext';
16
+
17
+type Props = {||};
18
+
19
+export default function TimelineSearchInput(props: Props) {
20
+ const {searchInputContainerRef} = useContext(TimelineContext);
21
+ const {dispatch, searchIndex, searchResults, searchText} = useContext(
22
+ TimelineSearchContext,
23
+ );
24
+
25
+ if (searchInputContainerRef.current === null) {
26
+ return null;
27
+ }
28
+
29
+ const search = text => dispatch({type: 'SET_SEARCH_TEXT', payload: text});
30
+ const goToNextResult = () => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'});
31
+ const goToPreviousResult = () =>
32
+ dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'});
33
+
34
+ return createPortal(
35
+ <SearchInput
36
+ goToNextResult={goToNextResult}
37
+ goToPreviousResult={goToPreviousResult}
38
+ placeholder="Search components by name"
39
+ search={search}
40
+ searchIndex={searchIndex}
41
+ searchResultsCount={searchResults.length}
42
+ searchText={searchText}
43
+ />,
44
+ searchInputContainerRef.current,
45
+ );
46
+}
packages/react-devtools-timeline/src/content-views/ComponentMeasuresView.js
+47
-3
@@ -7,7 +7,11 @@
7
* @flow
8
*/
9
10
-import type {ReactComponentMeasure, ReactProfilerData} from '../types';
10
+import type {
11
+ ReactComponentMeasure,
12
+ ReactProfilerData,
13
+ ViewState,
14
+} from '../types';
15
import type {
16
Interaction,
17
IntrinsicSize,
@@ -31,21 +35,37 @@ import {
35
rectIntersectsRect,
36
intersectionOfRects,
37
} from '../view-base';
34
-import {COLORS, NATIVE_EVENT_HEIGHT, BORDER_SIZE} from './constants';
38
+import {BORDER_SIZE, COLORS, NATIVE_EVENT_HEIGHT} from './constants';
39
40
const ROW_WITH_BORDER_HEIGHT = NATIVE_EVENT_HEIGHT + BORDER_SIZE;
41
42
export class ComponentMeasuresView extends View {
43
+ _cachedSearchMatches: Map<string, boolean>;
44
+ _cachedSearchRegExp: RegExp | null = null;
45
_hoveredComponentMeasure: ReactComponentMeasure | null = null;
46
_intrinsicSize: IntrinsicSize;
47
_profilerData: ReactProfilerData;
48
+ _viewState: ViewState;
49
50
onHover: ((event: ReactComponentMeasure | null) => void) | null = null;
51
45
- constructor(surface: Surface, frame: Rect, profilerData: ReactProfilerData) {
52
+ constructor(
53
+ surface: Surface,
54
+ frame: Rect,
55
+ profilerData: ReactProfilerData,
56
+ viewState: ViewState,
57
+ ) {
58
super(surface, frame);
59
60
this._profilerData = profilerData;
61
+ this._viewState = viewState;
62
+
63
+ this._cachedSearchMatches = new Map();
64
+ this._cachedSearchRegExp = null;
65
+
66
+ viewState.onSearchRegExpStateChange(() => {
67
+ this.setNeedsDisplay();
68
+ });
69
70
this._intrinsicSize = {
71
width: profilerData.duration,
@@ -150,6 +170,24 @@ export class ComponentMeasuresView extends View {
170
break;
171
}
172
}
173
+
174
+ let isMatch = false;
175
+ const cachedSearchRegExp = this._cachedSearchRegExp;
176
+ if (cachedSearchRegExp !== null) {
177
+ const cachedSearchMatches = this._cachedSearchMatches;
178
+ const cachedValue = cachedSearchMatches.get(componentName);
179
+ if (cachedValue != null) {
180
+ isMatch = cachedValue;
181
+ } else {
182
+ isMatch = componentName.match(cachedSearchRegExp) !== null;
183
+ cachedSearchMatches.set(componentName, isMatch);
184
+ }
185
+ }
186
+
187
+ if (isMatch) {
188
+ context.fillStyle = COLORS.SEARCH_RESULT_FILL;
189
+ }
190
+
191
context.fillRect(
192
drawableRect.origin.x,
193
drawableRect.origin.y,
@@ -174,6 +212,12 @@ export class ComponentMeasuresView extends View {
212
visibleArea,
213
} = this;
214
215
+ const searchRegExp = this._viewState.searchRegExp;
216
+ if (this._cachedSearchRegExp !== searchRegExp) {
217
+ this._cachedSearchMatches = new Map();
218
+ this._cachedSearchRegExp = searchRegExp;
219
+ }
220
+
221
context.fillStyle = COLORS.BACKGROUND;
222
context.fillRect(
223
visibleArea.origin.x,
packages/react-devtools-timeline/src/content-views/constants.js
+4
@@ -91,6 +91,7 @@ export let COLORS = {
91
SCROLL_CARET: '',
92
SCRUBBER_BACKGROUND: '',
93
SCRUBBER_BORDER: '',
94
+ SEARCH_RESULT_FILL: '',
95
TEXT_COLOR: '',
96
TEXT_DIM_COLOR: '',
97
TIME_MARKER_LABEL: '',
@@ -235,6 +236,9 @@ export function updateColorsToMatchTheme(element: Element): boolean {
236
SCRUBBER_BACKGROUND: computedStyle.getPropertyValue(
237
'--color-timeline-react-suspense-rejected',
238
),
239
+ SEARCH_RESULT_FILL: computedStyle.getPropertyValue(
240
+ '--color-timeline-react-suspense-rejected',
241
+ ),
242
SCRUBBER_BORDER: computedStyle.getPropertyValue(
243
'--color-timeline-text-color',
244
),
packages/react-devtools-timeline/src/timelineCache.js
new
+105
@@ -0,0 +1,105 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+import type {Wakeable} from 'shared/ReactTypes';
11
+import type {ReactProfilerData} from './types';
12
+
13
+import {importFile as importFileWorker} from './import-worker';
14
+
15
+const Pending = 0;
16
+const Resolved = 1;
17
+const Rejected = 2;
18
+
19
+type PendingRecord = {|
20
+ status: 0,
21
+ value: Wakeable,
22
+|};
23
+
24
+type ResolvedRecord<T> = {|
25
+ status: 1,
26
+ value: T,
27
+|};
28
+
29
+type RejectedRecord = {|
30
+ status: 2,
31
+ value: Error,
32
+|};
33
+
34
+type Record<T> = PendingRecord | ResolvedRecord<T> | RejectedRecord;
35
+
36
+// This is intentionally a module-level Map, rather than a React-managed one.
37
+// Otherwise, refreshing the inspected element cache would also clear this cache.
38
+// Profiler file contents are static anyway.
39
+const fileNameToProfilerDataMap: Map<
40
+ string,
41
+ Record<ReactProfilerData>,
42
+> = new Map();
43
+
44
+function readRecord<T>(record: Record<T>): ResolvedRecord<T> | RejectedRecord {
45
+ if (record.status === Resolved) {
46
+ // This is just a type refinement.
47
+ return record;
48
+ } else if (record.status === Rejected) {
49
+ // This is just a type refinement.
50
+ return record;
51
+ } else {
52
+ throw record.value;
53
+ }
54
+}
55
+
56
+export function importFile(file: File): ReactProfilerData | Error {
57
+ const fileName = file.name;
58
+ let record = fileNameToProfilerDataMap.get(fileName);
59
+
60
+ if (!record) {
61
+ const callbacks = new Set();
62
+ const wakeable: Wakeable = {
63
+ then(callback) {
64
+ callbacks.add(callback);
65
+ },
66
+
67
+ // Optional property used by Timeline:
68
+ displayName: `Importing file "${fileName}"`,
69
+ };
70
+
71
+ const wake = () => {
72
+ // This assumes they won't throw.
73
+ callbacks.forEach(callback => callback());
74
+ callbacks.clear();
75
+ };
76
+
77
+ const newRecord: Record<ReactProfilerData> = (record = {
78
+ status: Pending,
79
+ value: wakeable,
80
+ });
81
+
82
+ importFileWorker(file).then(data => {
83
+ switch (data.status) {
84
+ case 'SUCCESS':
85
+ const resolvedRecord = ((newRecord: any): ResolvedRecord<ReactProfilerData>);
86
+ resolvedRecord.status = Resolved;
87
+ resolvedRecord.value = data.processedData;
88
+ break;
89
+ case 'INVALID_PROFILE_ERROR':
90
+ case 'UNEXPECTED_ERROR':
91
+ const thrownRecord = ((newRecord: any): RejectedRecord);
92
+ thrownRecord.status = Rejected;
93
+ thrownRecord.value = data.error;
94
+ break;
95
+ }
96
+
97
+ wake();
98
+ });
99
+
100
+ fileNameToProfilerDataMap.set(fileName, record);
101
+ }
102
+
103
+ const response = readRecord(record).value;
104
+ return response;
105
+}
packages/react-devtools-timeline/src/types.js
+8
@@ -170,6 +170,9 @@ export type Flamechart = FlamechartStackLayer[];
170
export type HorizontalScrollStateChangeCallback = (
171
scrollState: ScrollState,
172
) => void;
173
+export type SearchRegExpStateChangeCallback = (
174
+ searchRegExp: RegExp | null,
175
+) => void;
176
177
// Imperative view state that corresponds to profiler data.
178
// This state lives outside of React's lifecycle
@@ -179,7 +182,12 @@ export type ViewState = {|
182
onHorizontalScrollStateChange: (
183
callback: HorizontalScrollStateChangeCallback,
184
) => void,
185
+ onSearchRegExpStateChange: (
186
+ callback: SearchRegExpStateChangeCallback,
187
+ ) => void,
188
+ searchRegExp: RegExp | null,
189
updateHorizontalScrollState: (scrollState: ScrollState) => void,
190
+ updateSearchRegExpState: (searchRegExp: RegExp | null) => void,
191
viewToMutableViewStateMap: Map<string, mixed>,
192
|};
193