Refactored Profiler tree to better work with suspense
Brian Vaughn committed
Mar 15, 2019 at 10:26 UTC
585c1cb3d2bacd4aa5ccca51b7f9824a07818e71
13 files changed
+391
-333
src/devtools/views/DevTools.js
+26
-21
@@ -9,6 +9,7 @@ import Settings from './Settings/Settings';
9
import TabBar from './TabBar';
10
import { SettingsContextController } from './Settings/SettingsContext';
11
import { TreeContextController } from './Elements/TreeContext';
12
+import { ProfilerContextController } from './Profiler/ProfilerContext';
13
import ReactLogo from './ReactLogo';
14
15
import styles from './DevTools.css';
@@ -104,27 +105,31 @@ export default function DevTools({
105
<StoreContext.Provider value={store}>
106
<SettingsContextController browserTheme={browserTheme}>
107
<TreeContextController viewElementSource={viewElementSource}>
107
- <div className={styles.DevTools}>
108
- {showTabBar && (
109
- <div className={styles.TabBar}>
110
- <ReactLogo />
111
- <span className={styles.DevToolsVersion}>
112
- {process.env.DEVTOOLS_VERSION}
113
- </span>
114
- <div className={styles.Spacer} />
115
- <TabBar
116
- currentTab={tab}
117
- id="DevTools"
118
- selectTab={setTab}
119
- size="large"
120
- tabs={
121
- supportsProfiling ? tabsWithProfiler : tabsWithoutProfiler
122
- }
123
- />
124
- </div>
125
- )}
126
- <div className={styles.TabContent}>{tabElement}</div>
127
- </div>
108
+ <ProfilerContextController>
109
+ <div className={styles.DevTools}>
110
+ {showTabBar && (
111
+ <div className={styles.TabBar}>
112
+ <ReactLogo />
113
+ <span className={styles.DevToolsVersion}>
114
+ {process.env.DEVTOOLS_VERSION}
115
+ </span>
116
+ <div className={styles.Spacer} />
117
+ <TabBar
118
+ currentTab={tab}
119
+ id="DevTools"
120
+ selectTab={setTab}
121
+ size="large"
122
+ tabs={
123
+ supportsProfiling
124
+ ? tabsWithProfiler
125
+ : tabsWithoutProfiler
126
+ }
127
+ />
128
+ </div>
129
+ )}
130
+ <div className={styles.TabContent}>{tabElement}</div>
131
+ </div>
132
+ </ProfilerContextController>
133
</TreeContextController>
134
</SettingsContextController>
135
</StoreContext.Provider>
src/devtools/views/Profiler/FilterModal.js
+7
-8
@@ -1,7 +1,8 @@
1
// @flow
2
3
-import React, { useCallback, useEffect, useRef } from 'react';
4
-import { useLocalStorage, useModalDismissSignal } from '../hooks';
3
+import React, { useCallback, useContext, useEffect, useRef } from 'react';
4
+import { ProfilerContext } from './ProfilerContext';
5
+import { useModalDismissSignal } from '../hooks';
6
7
import styles from './FilterModal.css';
8
@@ -10,14 +11,12 @@ type Props = {|
11
|};
12
13
export default function FilterModal({ dismissModal }: Props) {
13
- const [
14
+ const {
15
isCommitFilterEnabled,
16
+ minCommitDuration,
17
setIsCommitFilterEnabled,
16
- ] = useLocalStorage<boolean>('isCommitFilterEnabled', false);
17
- const [minCommitDuration, setMinCommitDuration] = useLocalStorage<number>(
18
- 'minCommitDuration',
19
- 0
20
- );
18
+ setMinCommitDuration,
19
+ } = useContext(ProfilerContext);
20
21
const handleNumberChange = useCallback(
22
({ currentTarget }) => {
src/devtools/views/Profiler/Profiler.js
+54
-39
@@ -1,11 +1,7 @@
1
// @flow
2
3
import React, { Suspense, useCallback, useContext, useState } from 'react';
4
-import { ProfilerDataContextController } from './ProfilerDataContext';
5
-import {
6
- ProfilerStatusContext,
7
- ProfilerStatusContextController,
8
-} from './ProfilerStatusContext';
4
+import { ProfilerContext } from './ProfilerContext';
5
import Button from '../Button';
6
import ButtonIcon from '../ButtonIcon';
7
import TabBar from '../TabBar';
@@ -15,46 +11,68 @@ import SnapshotSelector from './SnapshotSelector';
11
12
import styles from './Profiler.css';
13
18
-export type Props = {||};
14
+export default function Profiler(_: {||}) {
15
+ const { hasProfilingData, isProfiling } = useContext(ProfilerContext);
16
20
-export default function ProfilerOuter(_: Props) {
21
- return (
22
- <ProfilerStatusContextController>
17
+ if (isProfiling || !hasProfilingData) {
18
+ return <NonSuspendingProfiler isProfiling={isProfiling} />;
19
+ } else {
20
+ return (
21
<Suspense fallback={<ProfilerFallback />}>
24
- <ProfilerDataContextController>
25
- <ProfilerInner />
26
- </ProfilerDataContextController>
22
+ <SuspendingProfiler />
23
</Suspense>
28
- </ProfilerStatusContextController>
24
+ );
25
+ }
26
+}
27
+
28
+// This view is rendered when there is no profiler data (either we haven't profiled yet or we're currently profiling).
29
+// Nothing in this view's subtree suspends.
30
+// By not suspending while profiling is in progress, we avoid potential cache invalidation trickiness.
31
+function NonSuspendingProfiler({ isProfiling }: {| isProfiling: boolean |}) {
32
+ const view = isProfiling ? <RecortdingInProgress /> : <NoProfilingData />;
33
+
34
+ return (
35
+ <div className={styles.Profiler}>
36
+ <div className={styles.LeftColumn}>
37
+ <div className={styles.Toolbar}>
38
+ <RecordToggle />
39
+ <Button disabled title="Reload and start profiling">
40
+ {/* TODO (profiling) Wire up reload button */}
41
+ <ButtonIcon type="reload" />
42
+ </Button>
43
+ <div className={styles.VRule} />
44
+ <TabBar
45
+ currentTab={null}
46
+ disabled
47
+ id="Profiler"
48
+ selectTab={() => {}}
49
+ size="small"
50
+ tabs={tabs}
51
+ />
52
+ </div>
53
+ <div className={styles.Content}>{view}</div>
54
+ </div>
55
+ </div>
56
);
57
}
58
59
+// TODO (profiling) Real fallback UI
60
function ProfilerFallback() {
33
- // TODO (profiling) Real fallback UI
34
- return null;
61
+ return <div>Loading...</div>;
62
}
63
37
-function ProfilerInner(_: Props) {
38
- const { hasProfilingData, isProfiling } = useContext(ProfilerStatusContext);
39
-
40
- const showProfilingControls = !isProfiling && hasProfilingData;
41
-
64
+// This view is rendered when there is profiler data (even though there may not be any for the currently selected root).
65
+// This view's subtree uses suspense to request profiler data from the backend.
66
+function SuspendingProfiler(_: {||}) {
67
const [tab, setTab] = useState('flame-chart');
68
const [isFilterModalShowing, setIsFilterModalShowing] = useState(false);
69
70
const showFilterModal = useCallback(() => setIsFilterModalShowing(true));
71
const dismissFilterModal = useCallback(() => setIsFilterModalShowing(false));
72
48
- let view = null;
49
- if (isProfiling) {
50
- view = <RecortdingInProgress />;
51
- } else if (!hasProfilingData) {
52
- view = <NoProfilingData />;
53
- } else {
54
- // TODO (profiling) Differentiate between no data and no data for the current root
55
- // TODO (profiling) Show selected "tab" view
56
- view = <div>Coming soon...</div>;
57
- }
73
+ // TODO (profiling) Differentiate between no data and no data for the current root
74
+ // TODO (profiling) Show selected "tab" view
75
+ const view = <div>Coming soon...</div>;
76
77
return (
78
<div className={styles.Profiler}>
@@ -68,7 +86,6 @@ function ProfilerInner(_: Props) {
86
<div className={styles.VRule} />
87
<TabBar
88
currentTab={tab}
71
- disabled={!showProfilingControls}
89
id="Profiler"
90
selectTab={setTab}
91
size="small"
@@ -78,7 +95,7 @@ function ProfilerInner(_: Props) {
95
<Button onClick={showFilterModal} title="Filter commits by duration">
96
<ButtonIcon type="filter" />
97
</Button>
81
- {showProfilingControls && <SnapshotSelector />}
98
+ <SnapshotSelector />
99
</div>
100
<div className={styles.Content}>
101
{view}
@@ -87,13 +104,11 @@ function ProfilerInner(_: Props) {
104
)}
105
</div>
106
</div>
90
- {showProfilingControls && (
91
- <div className={styles.RightColumn}>
92
- {/* TODO (profiler) Dynamic information */}
93
- <div className={styles.Toolbar}>Commit information</div>
94
- <div className={styles.InspectedProperties} />
95
- </div>
96
- )}
107
+ <div className={styles.RightColumn}>
108
+ {/* TODO (profiler) Dynamic information */}
109
+ <div className={styles.Toolbar}>Commit information</div>
110
+ <div className={styles.InspectedProperties} />
111
+ </div>
112
</div>
113
);
114
}
src/devtools/views/Profiler/ProfilerContext.js
new
+159
@@ -0,0 +1,159 @@
1
+// @flow
2
+
3
+import React, {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useMemo,
8
+ useState,
9
+} from 'react';
10
+import { useLocalStorage, useSubscription } from '../hooks';
11
+import { TreeContext } from '../Elements/TreeContext';
12
+import { StoreContext } from '../context';
13
+import Store from '../../store';
14
+
15
+type Context = {|
16
+ // Have we recorded any profiling data?
17
+ // Are we currently profiling?
18
+ // This value may be modified by the record button in the Profiler toolbar,
19
+ // or from the backend itself (after a reload-and-profile action).
20
+ // It is synced between the backend and frontend via a Store subscription.
21
+ hasProfilingData: boolean,
22
+ isProfiling: boolean,
23
+ startProfiling(value: boolean): void,
24
+ stopProfiling(value: boolean): void,
25
+
26
+ // Which renderer and root should profiling data be shown for?
27
+ // Often this will correspond to the selected renderer and root in the Elements panel.
28
+ // If nothing is selected though, this will default to the first root.
29
+ rendererID: number | null,
30
+ rootID: number | null,
31
+
32
+ // Controls whether commits are filtered by duration.
33
+ // This value is controlled by a filter toggle UI in the Profiler toolbar.
34
+ // It impacts the commit selector UI as well as the fiber commits bar chart.
35
+ isCommitFilterEnabled: boolean,
36
+ setIsCommitFilterEnabled: (value: boolean) => void,
37
+ minCommitDuration: number,
38
+ setMinCommitDuration: (value: number) => void,
39
+
40
+ // Which commit is currently selected in the commit selector UI.
41
+ // Note that this is the index of the commit in all commits (non-filtered) that were profiled.
42
+ // This value is controlled by the commit selector UI in the Profiler toolbar.
43
+ // It impacts the flame graph and ranked charts.
44
+ selectedCommitIndex: number | null,
45
+ setSelectedCommitIndex: (value: number | null) => void,
46
+|};
47
+
48
+const ProfilerContext = createContext<Context>(((null: any): Context));
49
+ProfilerContext.displayName = 'ProfilerContext';
50
+
51
+type StoreProfilingState = {|
52
+ hasProfilingData: boolean,
53
+ isProfiling: boolean,
54
+|};
55
+
56
+type Props = {|
57
+ children: React$Node,
58
+|};
59
+
60
+function ProfilerContextController({ children }: Props) {
61
+ const store = useContext(StoreContext);
62
+ const { selectedElementID } = useContext(TreeContext);
63
+
64
+ const subscription = useMemo(
65
+ () => ({
66
+ getCurrentValue: () => ({
67
+ hasProfilingData: store.hasProfilingData,
68
+ isProfiling: store.isProfiling,
69
+ }),
70
+ subscribe: (callback: Function) => {
71
+ store.addListener('isProfiling', callback);
72
+ return () => store.removeListener('isProfiling', callback);
73
+ },
74
+ }),
75
+ [store]
76
+ );
77
+ const { isProfiling, hasProfilingData } = useSubscription<
78
+ StoreProfilingState,
79
+ Store
80
+ >(subscription);
81
+
82
+ // TODO (profiling) The browser extension is a multi-root app,
83
+ // so it won't work for the "Profiling" root to depend on a value that's set by the "Elements" root.
84
+ // We'll either need to lift that state up into the (shared) Store,
85
+ // or use a portal to share the contexts themselves between Chrome tabs.
86
+ let rendererID = null;
87
+ let rootID = null;
88
+ if (selectedElementID) {
89
+ rendererID = store.getRendererIDForElement(
90
+ ((selectedElementID: any): number)
91
+ );
92
+ rootID = store.getRootIDForElement(((selectedElementID: any): number));
93
+ } else if (store.roots.length > 0) {
94
+ // If no root is selected, assume the first root; many React apps are single root anyway.
95
+ rootID = store.roots[0];
96
+ rendererID = store.getRendererIDForElement(((rootID: any): number));
97
+ }
98
+
99
+ const startProfiling = useCallback(() => store.startProfiling(), [store]);
100
+ const stopProfiling = useCallback(() => store.stopProfiling(), [store]);
101
+
102
+ const [
103
+ isCommitFilterEnabled,
104
+ setIsCommitFilterEnabled,
105
+ ] = useLocalStorage<boolean>('isCommitFilterEnabled', false);
106
+ const [minCommitDuration, setMinCommitDuration] = useLocalStorage<number>(
107
+ 'minCommitDuration',
108
+ 0
109
+ );
110
+
111
+ const [selectedCommitIndex, setSelectedCommitIndex] = useState<number | null>(
112
+ null
113
+ );
114
+
115
+ const value = useMemo(
116
+ () => ({
117
+ hasProfilingData,
118
+ isProfiling,
119
+ startProfiling,
120
+ stopProfiling,
121
+
122
+ rendererID,
123
+ rootID,
124
+
125
+ isCommitFilterEnabled,
126
+ setIsCommitFilterEnabled,
127
+ minCommitDuration,
128
+ setMinCommitDuration,
129
+
130
+ selectedCommitIndex,
131
+ setSelectedCommitIndex,
132
+ }),
133
+ [
134
+ hasProfilingData,
135
+ isProfiling,
136
+ startProfiling,
137
+ stopProfiling,
138
+
139
+ rendererID,
140
+ rootID,
141
+
142
+ isCommitFilterEnabled,
143
+ setIsCommitFilterEnabled,
144
+ minCommitDuration,
145
+ setMinCommitDuration,
146
+
147
+ selectedCommitIndex,
148
+ setSelectedCommitIndex,
149
+ ]
150
+ );
151
+
152
+ return (
153
+ <ProfilerContext.Provider value={value}>
154
+ {children}
155
+ </ProfilerContext.Provider>
156
+ );
157
+}
158
+
159
+export { ProfilerContext, ProfilerContextController };
src/devtools/views/Profiler/ProfilerDataContext.js
deleted
-94
@@ -1,94 +0,0 @@
1
-// @flow
2
-
3
-import React, { createContext, useContext, useMemo, useState } from 'react';
4
-import { TreeContext } from 'src/devtools/views/Elements/TreeContext';
5
-import { StoreContext } from '../context';
6
-import { useLocalStorage } from '../hooks';
7
-import { ProfilerStatusContext } from './ProfilerStatusContext';
8
-
9
-type Context = {|
10
- commitIndex: number | null,
11
- filteredCommitIndices: Array<number>,
12
- rendererID: number | null,
13
- rootID: number | null,
14
- setCommitIndex: (value: number) => void,
15
-|};
16
-
17
-const ProfilerDataContext = createContext<Context>(((null: any): Context));
18
-ProfilerDataContext.displayName = 'ProfilerDataContext';
19
-
20
-type Props = {|
21
- children: React$Node,
22
-|};
23
-
24
-function ProfilerDataContextController({ children }: Props) {
25
- const store = useContext(StoreContext);
26
-
27
- // TODO (profiling) The browser extension is a multi-root app,
28
- // so it won't work for the "Profiling" root to depend on a value that's set by the "Elements" root.
29
- // We'll either need to lift that state up into the (shared) Store,
30
- // or use a portal to share the contexts themselves between Chrome tabs.
31
- const { selectedElementID } = useContext(TreeContext);
32
-
33
- // If no root is selected, assume the first root; many React apps are single root anyway.
34
- let rendererID = null;
35
- let rootID = null;
36
- if (selectedElementID) {
37
- rendererID = store.getRendererIDForElement(
38
- ((selectedElementID: any): number)
39
- );
40
- rootID = store.getRootIDForElement(((selectedElementID: any): number));
41
- } else if (store.roots.length > 0) {
42
- rootID = store.roots[0];
43
- rendererID = store.getRendererIDForElement(((rootID: any): number));
44
- }
45
-
46
- // This value is important because it ensure we re-render after our suspense cache has been cleared.
47
- const { isProfiling } = useContext(ProfilerStatusContext);
48
-
49
- const profilingSummary = store.profilingCache.ProfilingSummary.read({
50
- rendererID: ((rendererID: any): number),
51
- rootID: ((rootID: any): number),
52
- });
53
-
54
- const [isCommitFilterEnabled] = useLocalStorage<boolean>(
55
- 'isCommitFilterEnabled',
56
- false
57
- );
58
- const [minCommitDuration] = useLocalStorage<number>('minCommitDuration', 0);
59
- const { commitDurations } = profilingSummary;
60
- const filteredCommitIndices = useMemo(() => {
61
- const array = [];
62
- if (!isProfiling) {
63
- for (let i = 0; i < commitDurations.length; i++) {
64
- if (!isCommitFilterEnabled || commitDurations[i] >= minCommitDuration) {
65
- array.push(i);
66
- }
67
- }
68
- }
69
- return array;
70
- }, [commitDurations, isCommitFilterEnabled, isProfiling, minCommitDuration]);
71
-
72
- const [commitIndex, setCommitIndex] = useState<number | null>(
73
- commitDurations.length > 0 ? 0 : null
74
- );
75
-
76
- const value = useMemo(
77
- () => ({
78
- commitIndex,
79
- filteredCommitIndices,
80
- rendererID,
81
- rootID,
82
- setCommitIndex,
83
- }),
84
- [commitIndex, filteredCommitIndices, rendererID, rootID, setCommitIndex]
85
- );
86
-
87
- return (
88
- <ProfilerDataContext.Provider value={value}>
89
- {children}
90
- </ProfilerDataContext.Provider>
91
- );
92
-}
93
-
94
-export { ProfilerDataContext, ProfilerDataContextController };
src/devtools/views/Profiler/ProfilerStatusContext.js
deleted
-69
@@ -1,69 +0,0 @@
1
-// @flow
2
-
3
-import React, { createContext, useCallback, useContext, useMemo } from 'react';
4
-import { useSubscription } from '../hooks';
5
-import { StoreContext } from '../context';
6
-import Store from '../../store';
7
-
8
-type Context = {|
9
- hasProfilingData: boolean,
10
- isProfiling: boolean,
11
- startProfiling(value: boolean): void,
12
- stopProfiling(value: boolean): void,
13
-|};
14
-
15
-const ProfilerStatusContext = createContext<Context>(((null: any): Context));
16
-ProfilerStatusContext.displayName = 'ProfilerStatusContext';
17
-
18
-type StoreProfilingState = {|
19
- hasProfilingData: boolean,
20
- isProfiling: boolean,
21
-|};
22
-
23
-type Props = {|
24
- children: React$Node,
25
-|};
26
-
27
-function ProfilerStatusContextController({ children }: Props) {
28
- const store = useContext(StoreContext);
29
-
30
- const subscription = useMemo(
31
- () => ({
32
- getCurrentValue: () => ({
33
- hasProfilingData: store.hasProfilingData,
34
- isProfiling: store.isProfiling,
35
- }),
36
- subscribe: (callback: Function) => {
37
- store.addListener('isProfiling', callback);
38
- return () => store.removeListener('isProfiling', callback);
39
- },
40
- }),
41
- [store]
42
- );
43
-
44
- const { isProfiling, hasProfilingData } = useSubscription<
45
- StoreProfilingState,
46
- Store
47
- >(subscription);
48
-
49
- const startProfiling = useCallback(() => store.startProfiling(), [store]);
50
- const stopProfiling = useCallback(() => store.stopProfiling(), [store]);
51
-
52
- const value = useMemo(
53
- () => ({
54
- hasProfilingData,
55
- isProfiling,
56
- startProfiling,
57
- stopProfiling,
58
- }),
59
- [hasProfilingData, isProfiling, startProfiling, stopProfiling]
60
- );
61
-
62
- return (
63
- <ProfilerStatusContext.Provider value={value}>
64
- {children}
65
- </ProfilerStatusContext.Provider>
66
- );
67
-}
68
-
69
-export { ProfilerStatusContext, ProfilerStatusContextController };
src/devtools/views/Profiler/RecordToggle.js
+2
-2
@@ -3,7 +3,7 @@
3
import React, { useContext } from 'react';
4
import Button from '../Button';
5
import ButtonIcon from '../ButtonIcon';
6
-import { ProfilerStatusContext } from './ProfilerStatusContext';
6
+import { ProfilerContext } from './ProfilerContext';
7
8
import styles from './RecordToggle.css';
9
@@ -11,7 +11,7 @@ export type Props = {||};
11
12
export default function RecordToggle(_: Props) {
13
const { isProfiling, startProfiling, stopProfiling } = useContext(
14
- ProfilerStatusContext
14
+ ProfilerContext
15
);
16
17
return (
src/devtools/views/Profiler/SnapshotCommitList.js
+57
-39
@@ -2,7 +2,6 @@
2
3
import React, {
4
useCallback,
5
- useContext,
5
useEffect,
6
useMemo,
7
useRef,
@@ -12,72 +11,91 @@ import AutoSizer from 'react-virtualized-auto-sizer';
11
import { FixedSizeList } from 'react-window';
12
import SnapshotCommitListItem from './SnapshotCommitListItem';
13
import { minBarWidth } from './constants';
15
-import { ProfilerDataContext } from './ProfilerDataContext';
16
-import { StoreContext } from '../context';
14
15
export type ItemData = {|
16
commitDurations: Array<number>,
20
- commitIndex: number | null,
17
commitTimes: Array<number>,
18
filteredCommitIndices: Array<number>,
19
isMouseDown: boolean,
20
maxDuration: number,
25
- setCommitIndex: (index: number) => void,
21
+ selectedCommitIndex: number | null,
22
+ setSelectedCommitIndex: (index: number) => void,
23
|};
24
28
-type Props = {||};
25
+type Props = {|
26
+ commitDurations: Array<number>,
27
+ commitTimes: Array<number>,
28
+ filteredCommitIndices: Array<number>,
29
+ selectedCommitIndex: number | null,
30
+ setSelectedCommitIndex: (index: number) => void,
31
+|};
32
30
-export default function SnapshotCommitList(_: Props) {
33
+export default function SnapshotCommitList({
34
+ commitDurations,
35
+ commitTimes,
36
+ filteredCommitIndices,
37
+ selectedCommitIndex,
38
+ setSelectedCommitIndex,
39
+}: Props) {
40
return (
41
<AutoSizer>
33
- {({ height, width }) => <List height={height} width={width} />}
42
+ {({ height, width }) => (
43
+ <List
44
+ commitDurations={commitDurations}
45
+ commitTimes={commitTimes}
46
+ height={height}
47
+ filteredCommitIndices={filteredCommitIndices}
48
+ selectedCommitIndex={selectedCommitIndex}
49
+ setSelectedCommitIndex={setSelectedCommitIndex}
50
+ width={width}
51
+ />
52
+ )}
53
</AutoSizer>
54
);
55
}
56
57
type ListProps = {|
58
+ commitDurations: Array<number>,
59
+ commitTimes: Array<number>,
60
height: number,
61
+ filteredCommitIndices: Array<number>,
62
+ selectedCommitIndex: number | null,
63
+ setSelectedCommitIndex: (index: number) => void,
64
width: number,
65
|};
66
43
-function List({ height, width }: ListProps) {
67
+function List({
68
+ commitDurations,
69
+ selectedCommitIndex,
70
+ commitTimes,
71
+ height,
72
+ filteredCommitIndices,
73
+ setSelectedCommitIndex,
74
+ width,
75
+}: ListProps) {
76
const listRef = useRef<FixedSizeList<ItemData> | null>(null);
45
- const [isMouseDown, setIsMouseDown] = useState(false);
77
const prevCommitIndexRef = useRef<number | null>(null);
78
48
- const { profilingCache } = useContext(StoreContext);
49
- const {
50
- commitIndex,
51
- filteredCommitIndices,
52
- rendererID,
53
- rootID,
54
- setCommitIndex,
55
- } = useContext(ProfilerDataContext);
56
-
57
- const { commitDurations, commitTimes } = profilingCache.ProfilingSummary.read(
58
- {
59
- rendererID: ((rendererID: any): number),
60
- rootID: ((rootID: any): number),
61
- }
62
- );
63
-
64
- // Make sure any newly selected snapshot is visible within the list.
79
+ // Make sure a newly selected snapshot is fully visible within the list.
80
useEffect(() => {
66
- if (commitIndex !== prevCommitIndexRef.current) {
67
- prevCommitIndexRef.current = commitIndex;
68
- if (commitIndex !== null && listRef.current !== null) {
69
- listRef.current.scrollToItem(commitIndex);
81
+ if (selectedCommitIndex !== prevCommitIndexRef.current) {
82
+ prevCommitIndexRef.current = selectedCommitIndex;
83
+ if (selectedCommitIndex !== null && listRef.current !== null) {
84
+ listRef.current.scrollToItem(selectedCommitIndex);
85
}
86
}
72
- }, [listRef, commitIndex]);
87
+ }, [listRef, selectedCommitIndex]);
88
89
+ // When the mouse is down, dragging over a commit should auto-select it.
90
+ // This provides a nice way for users to swipe across a range of commits to compare them.
91
+ // TODO (profiling) This interaction may not feel as nice with suspense; reconsider it?
92
+ const [isMouseDown, setIsMouseDown] = useState(false);
93
const handleMouseDown = useCallback(() => {
94
setIsMouseDown(true);
95
}, []);
96
const handleMouseUp = useCallback(() => {
97
setIsMouseDown(false);
98
}, []);
80
-
99
useEffect(() => {
100
window.addEventListener('mouseup', handleMouseUp);
101
return () => {
@@ -86,8 +104,8 @@ function List({ height, width }: ListProps) {
104
}, [handleMouseUp]);
105
106
const itemSize = useMemo(
89
- () => Math.max(minBarWidth, width / commitDurations.length),
90
- [commitDurations, width]
107
+ () => Math.max(minBarWidth, width / filteredCommitIndices.length),
108
+ [filteredCommitIndices, width]
109
);
110
const maxDuration = useMemo(
111
() =>
@@ -102,21 +120,21 @@ function List({ height, width }: ListProps) {
120
const itemData = useMemo<ItemData>(
121
() => ({
122
commitDurations,
105
- commitIndex,
123
commitTimes,
124
filteredCommitIndices,
125
isMouseDown,
126
maxDuration,
110
- setCommitIndex,
127
+ selectedCommitIndex,
128
+ setSelectedCommitIndex,
129
}),
130
[
131
commitDurations,
114
- commitIndex,
132
commitTimes,
133
filteredCommitIndices,
134
isMouseDown,
135
maxDuration,
119
- setCommitIndex,
136
+ selectedCommitIndex,
137
+ setSelectedCommitIndex,
138
]
139
);
140
src/devtools/views/Profiler/SnapshotCommitListItem.js
+5
-5
@@ -17,12 +17,12 @@ type Props = {
17
function SnapshotCommitListItem({ data: itemData, index, style }: Props) {
18
const {
19
commitDurations,
20
- commitIndex,
20
commitTimes,
21
filteredCommitIndices,
22
isMouseDown,
23
maxDuration,
25
- setCommitIndex,
24
+ selectedCommitIndex,
25
+ setSelectedCommitIndex,
26
} = itemData;
27
28
index = filteredCommitIndices[index];
@@ -30,15 +30,15 @@ function SnapshotCommitListItem({ data: itemData, index, style }: Props) {
30
const commitDuration = commitDurations[index];
31
const commitTime = commitTimes[index];
32
33
- const handleClick = useCallback(() => setCommitIndex(index), [
33
+ const handleClick = useCallback(() => setSelectedCommitIndex(index), [
34
index,
35
- setCommitIndex,
35
+ setSelectedCommitIndex,
36
]);
37
38
// Guard against commits with duration 0
39
const percentage =
40
Math.min(1, Math.max(0, commitDuration / maxDuration)) || 0;
41
- const isSelected = commitIndex === index;
41
+ const isSelected = selectedCommitIndex === index;
42
43
// Leave a 1px gap between snapshots
44
const width = parseFloat(style.width) - 1;
src/devtools/views/Profiler/SnapshotSelector.css
+5
-12
@@ -1,21 +1,13 @@
1
-.SnapshotSelector {
2
- flex: 1 1 auto;
3
- display: flex;
4
- flex-direction: row;
5
- align-items: center;
6
- color: var(--color-text-color);
7
- margin-left: 0.5rem;
8
-}
9
-
1
.Button {
2
+ flex: 0 0 auto;
3
margin-left: 0.25rem;
4
}
5
6
.Commits {
15
- flex: 1 1 auto;
7
height: 2.25rem;
17
- min-width: 50px;
8
+ min-width: 30px;
9
margin-left: 0.25rem;
10
+ overflow: hidden;
11
}
12
13
.VRule {
@@ -25,7 +17,8 @@
17
margin: 0 0.25rem;
18
}
19
28
-.Number {
20
+.IndexLabel {
21
+ flex: 0 0 auto;
22
white-space: nowrap;
23
font-family: var(--font-family-monospace);
24
}
src/devtools/views/Profiler/SnapshotSelector.js
+74
-37
@@ -1,10 +1,12 @@
1
// @flow
2
3
-import React, { Fragment, useCallback, useContext } from 'react';
3
+import React, { Fragment, useCallback, useContext, useMemo } from 'react';
4
import Button from '../Button';
5
import ButtonIcon from '../ButtonIcon';
6
-import { ProfilerDataContext } from './ProfilerDataContext';
6
+import { ProfilerContext } from './ProfilerContext';
7
import SnapshotCommitList from './SnapshotCommitList';
8
+import { maxBarWidth } from './constants';
9
+import { StoreContext } from '../context';
10
11
import styles from './SnapshotSelector.css';
12
@@ -12,17 +14,37 @@ export type Props = {||};
14
15
export default function SnapshotSelector(_: Props) {
16
const {
15
- commitIndex,
16
- filteredCommitIndices,
17
+ isCommitFilterEnabled,
18
+ minCommitDuration,
19
rendererID,
20
rootID,
19
- setCommitIndex,
20
- } = useContext(ProfilerDataContext);
21
+ selectedCommitIndex,
22
+ setSelectedCommitIndex,
23
+ } = useContext(ProfilerContext);
24
+
25
+ const { profilingCache } = useContext(StoreContext);
26
+ const { commitDurations, commitTimes } = profilingCache.ProfilingSummary.read(
27
+ {
28
+ rendererID: ((rendererID: any): number),
29
+ rootID: ((rootID: any): number),
30
+ }
31
+ );
32
+
33
+ const filteredCommitIndices = useMemo(
34
+ () =>
35
+ commitDurations.reduce((reduced, commitDuration, index) => {
36
+ if (!isCommitFilterEnabled || commitDuration >= minCommitDuration) {
37
+ reduced.push(index);
38
+ }
39
+ return reduced;
40
+ }, []),
41
+ [commitDurations, isCommitFilterEnabled, minCommitDuration]
42
+ );
43
44
const numCommits = filteredCommitIndices.length;
45
let currentCommitNumber = '-';
46
if (numCommits > 0) {
25
- currentCommitNumber = `${commitIndex + 1}`.padStart(
47
+ currentCommitNumber = `${selectedCommitIndex + 1}`.padStart(
48
`${numCommits}`.length,
49
'0'
50
);
@@ -30,15 +52,18 @@ export default function SnapshotSelector(_: Props) {
52
53
const viewNextCommit = useCallback(() => {
54
const nextCommitIndex = Math.min(
33
- ((commitIndex: any): number) + 1,
55
+ ((selectedCommitIndex: any): number) + 1,
56
filteredCommitIndices.length - 1
57
);
36
- setCommitIndex(filteredCommitIndices[nextCommitIndex]);
37
- }, [commitIndex, filteredCommitIndices, setCommitIndex]);
58
+ setSelectedCommitIndex(filteredCommitIndices[nextCommitIndex]);
59
+ }, [selectedCommitIndex, filteredCommitIndices, setSelectedCommitIndex]);
60
const viewPrevCommit = useCallback(() => {
39
- const nextCommitIndex = Math.max(((commitIndex: any): number) - 1, 0);
40
- setCommitIndex(filteredCommitIndices[nextCommitIndex]);
41
- }, [commitIndex, filteredCommitIndices, setCommitIndex]);
61
+ const nextCommitIndex = Math.max(
62
+ ((selectedCommitIndex: any): number) - 1,
63
+ 0
64
+ );
65
+ setSelectedCommitIndex(filteredCommitIndices[nextCommitIndex]);
66
+ }, [selectedCommitIndex, filteredCommitIndices, setSelectedCommitIndex]);
67
68
if (rendererID === null || rootID === null) {
69
return null;
@@ -47,31 +72,43 @@ export default function SnapshotSelector(_: Props) {
72
return (
73
<Fragment>
74
<div className={styles.VRule} />
50
- <div className={styles.SnapshotSelector}>
51
- <span className={styles.Number}>
52
- {numCommits > 0 ? `${currentCommitNumber} / ${numCommits}` : '-'}
53
- </span>
54
- <Button
55
- className={styles.Button}
56
- disabled={commitIndex === null || commitIndex <= 0}
57
- onClick={viewPrevCommit}
58
- >
59
- <ButtonIcon type="previous" />
60
- </Button>
61
- <div className={styles.Commits}>
62
- {numCommits > 0 && <SnapshotCommitList />}
63
- {numCommits === 0 && (
64
- <div className={styles.NoCommits}>No commits</div>
65
- )}
66
- </div>
67
- <Button
68
- className={styles.Button}
69
- disabled={commitIndex === null || commitIndex >= numCommits - 1}
70
- onClick={viewNextCommit}
71
- >
72
- <ButtonIcon type="next" />
73
- </Button>
75
+ <span className={styles.IndexLabel}>
76
+ {numCommits > 0 ? `${currentCommitNumber} / ${numCommits}` : '-'}
77
+ </span>
78
+ <Button
79
+ className={styles.Button}
80
+ disabled={selectedCommitIndex === 0 || numCommits === 0}
81
+ onClick={viewPrevCommit}
82
+ >
83
+ <ButtonIcon type="previous" />
84
+ </Button>
85
+ <div
86
+ className={styles.Commits}
87
+ style={{
88
+ flex: numCommits > 0 ? '1 1 auto' : '0 0 auto',
89
+ maxWidth: numCommits > 0 ? numCommits * maxBarWidth : undefined,
90
+ }}
91
+ >
92
+ {numCommits > 0 && (
93
+ <SnapshotCommitList
94
+ commitDurations={commitDurations}
95
+ commitTimes={commitTimes}
96
+ filteredCommitIndices={filteredCommitIndices}
97
+ selectedCommitIndex={selectedCommitIndex}
98
+ setSelectedCommitIndex={setSelectedCommitIndex}
99
+ />
100
+ )}
101
+ {numCommits === 0 && <div className={styles.NoCommits}>No commits</div>}
102
</div>
103
+ <Button
104
+ className={styles.Button}
105
+ disabled={
106
+ selectedCommitIndex === null || selectedCommitIndex >= numCommits - 1
107
+ }
108
+ onClick={viewNextCommit}
109
+ >
110
+ <ButtonIcon type="next" />
111
+ </Button>
112
</Fragment>
113
);
114
}
src/devtools/views/Profiler/constants.js
+1
@@ -1,5 +1,6 @@
1
// @flow
2
3
+export const maxBarWidth = 30;
4
export const minBarHeight = 5;
5
export const minBarWidth = 5;
6
export const textHeight = 18;
src/devtools/views/hooks.js
+1
-7
@@ -2,15 +2,9 @@
2
3
import { useCallback, useEffect, useLayoutEffect, useState } from 'react';
4
5
-type LocalStorageKey =
6
- | 'displayDensity'
7
- | 'isCommitFilterEnabled'
8
- | 'minCommitDuration'
9
- | 'theme';
10
-
5
// Forked from https://usehooks.com/useLocalStorage/
6
export function useLocalStorage<T>(
13
- key: LocalStorageKey,
7
+ key: string,
8
initialValue: T
9
): [T, (value: T | (() => T)) => void] {
10
const getValueFromLocalStorage = useCallback(() => {