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 61% 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..5edb915793cb1 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,9 +72,9 @@ 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); + return () => ownerDocument.removeEventListener('keydown', handleKeyDown); }, [inputRef]); return ( @@ -78,23 +82,23 @@ export default function SearchInput(props: Props) { {!!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..923d483b5f768 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,7 +35,7 @@ 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; @@ -39,13 +43,24 @@ export class ComponentMeasuresView extends View { _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; + + viewState.onSearchRegExpStateChange(() => { + this.setNeedsDisplay(); + }); this._intrinsicSize = { width: profilerData.duration, @@ -150,6 +165,15 @@ export class ComponentMeasuresView extends View { break; } } + + const searchRegExp = this._viewState.searchRegExp; + const isSearchMatch = + searchRegExp !== null && + componentMeasure.componentName.match(searchRegExp); + if (isSearchMatch) { + context.fillStyle = COLORS.SEARCH_RESULT_FILL; + } + context.fillRect( drawableRect.origin.x, drawableRect.origin.y, 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, |};