@samitouri / QOS-React / commits / 585c1cb3d2

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(() => {