@samitouri / QOS-React / commits / 2d68007288

Initial profiling shell added

Lots of TODOs and unfinished views here.

Brian Vaughn committed Mar 10, 2019 at 09:01 UTC 2d6800728889a5141b7a1c08842f2e90ffc9448a
28 files changed +938 -185
OVERVIEW.md
+1 -1
@@ -175,7 +175,7 @@ Profiling information is stored on the backend. The backend push-notifies the fr
175
176 When profiling begins, the frontend takes a snapshot/copy of each root. This snapshot includes the id, name, key, and child IDs for each node in the tree. (This information is already present on the frontend, so it does not require any additional bridge traffic.) While profiling is active, each time React commits– the frontend also stores a copy of the "_operations_" message (described above). Once profiling has finished, the frontend can use the original snapshot along with each of the stored "_operations_" messages to reconstruct the tree for each of the profiled commits.
177
178 -While profiling is in progress, the backend also stores some information <sup>1</sup> about each commit:
178 +When profiling begins, the backend records the base durations of each fiber currently in the tree. While profiling is in progress, the backend also stores some information <sup>1</sup> about each commit:
179 * Commit time and duration
180 * Which elements were rendered during that commit.
181 * Which interactions (if any) were part of the commit.
src/backend/agent.js
+33 -2
@@ -40,11 +40,13 @@ type SetInParams = {|
40
41 export default class Agent extends EventEmitter {
42 _bridge: Bridge = ((null: any): Bridge);
43 + _isProfiling: boolean = false;
44 _rendererInterfaces: { [key: RendererID]: RendererInterface } = {};
45
46 addBridge(bridge: Bridge) {
47 this._bridge = bridge;
48
49 + bridge.addListener('getProfilingStatus', this.getProfilingStatus);
50 bridge.addListener('highlightElementInDOM', this.highlightElementInDOM);
51 bridge.addListener('inspectElement', this.inspectElement);
52 bridge.addListener('overrideContext', this.overrideContext);
@@ -53,7 +55,9 @@ export default class Agent extends EventEmitter {
55 bridge.addListener('overrideState', this.overrideState);
56 bridge.addListener('selectElement', this.selectElement);
57 bridge.addListener('startInspectingDOM', this.startInspectingDOM);
58 + bridge.addListener('startProfiling', this.startProfiling);
59 bridge.addListener('stopInspectingDOM', this.stopInspectingDOM);
60 + bridge.addListener('stopProfiling', this.stopProfiling);
61 bridge.addListener('shutdown', this.shutdown);
62 bridge.addListener('viewElementSource', this.viewElementSource);
63 }
@@ -62,14 +66,19 @@ export default class Agent extends EventEmitter {
66 for (let rendererID in this._rendererInterfaces) {
67 // A renderer will throw if it can't find a fiber for the specified node.
68 try {
65 - // $FlowFixMe
66 - const renderer = this._rendererInterfaces[rendererID];
69 + const renderer = ((this._rendererInterfaces[
70 + (rendererID: any)
71 + ]: any): RendererInterface);
72 return renderer.getFiberIDFromNative(node, true);
73 } catch (e) {}
74 }
75 return null;
76 }
77
78 + getProfilingStatus = () => {
79 + this._bridge.send('profilingStatus', this._isProfiling);
80 + };
81 +
82 highlightElementInDOM = ({
83 displayName,
84 id,
@@ -182,6 +191,17 @@ export default class Agent extends EventEmitter {
191 window.addEventListener('mouseover', this._onMouseOver, true);
192 };
193
194 + startProfiling = () => {
195 + this._isProfiling = true;
196 + for (let rendererID in this._rendererInterfaces) {
197 + const renderer = ((this._rendererInterfaces[
198 + (rendererID: any)
199 + ]: any): RendererInterface);
200 + renderer.startProfiling();
201 + }
202 + this._bridge.send('profilingStatus', this._isProfiling);
203 + };
204 +
205 stopInspectingDOM = () => {
206 hideOverlay();
207
@@ -190,6 +210,17 @@ export default class Agent extends EventEmitter {
210 window.removeEventListener('mouseover', this._onMouseOver, true);
211 };
212
213 + stopProfiling = () => {
214 + this._isProfiling = false;
215 + for (let rendererID in this._rendererInterfaces) {
216 + const renderer = ((this._rendererInterfaces[
217 + (rendererID: any)
218 + ]: any): RendererInterface);
219 + renderer.startProfiling();
220 + }
221 + this._bridge.send('profilingStatus', this._isProfiling);
222 + };
223 +
224 viewElementSource = ({ id, rendererID }: InspectSelectParams) => {
225 const renderer = this._rendererInterfaces[rendererID];
226 if (renderer == null) {
src/backend/renderer.js
+102 -6
@@ -26,11 +26,12 @@ import { inspectHooksOfFiber } from './ReactDebugHooks';
26 import type {
27 DevToolsHook,
28 Fiber,
29 + Interaction,
30 ReactRenderer,
31 FiberData,
32 RendererInterface,
33 } from './types';
33 -import type { InspectedElement } from 'src/devtools/types';
34 +import type { InspectedElement } from 'src/devtools/views/elements/types';
35
36 function getInternalReactConstants(version) {
37 const ReactSymbols = {
@@ -459,6 +460,12 @@ export function attach(
460 const idToFiberMap: Map<number, Fiber> = new Map();
461 const primaryFibers: Set<Fiber> = new Set();
462
463 + // When profiling is supported, we store the latest tree base durations for each Fiber.
464 + // This is so that we can quickly capture a snapshot of those values if profiling starts.
465 + // If we didn't store these values, we'd have to crawl the tree when profiling started,
466 + // and use a slow path to find each of the current Fibers.
467 + const idToTreeBaseDurationMap: Map<number, number> = new Map();
468 +
469 function getFiberID(primaryFiber: Fiber): number {
470 if (!fiberToIDMap.has(primaryFiber)) {
471 const id = getUID();
@@ -525,7 +532,12 @@ export function attach(
532
533 function flushPendingEvents(root: Object): void {
534 if (pendingOperations.length === 0) {
528 - return;
535 + // If we're currently profiling, send an "operations" method even if there are no mutations to the tree.
536 + // The frontend needs this no-op info to know how to reconstruct the tree for each commit,
537 + // even if a particular commit didn't change the shape of the tree.
538 + if (!isProfiling) {
539 + return;
540 + }
541 }
542
543 // Identify which renderer this update is coming from.
@@ -546,11 +558,17 @@ export function attach(
558 const isRoot = fiber.tag === HostRoot;
559 const id = getFiberID(getPrimaryFiber(fiber));
560
561 + const isProfilingSupported = fiber.hasOwnProperty('treeBaseDuration');
562 + if (isProfilingSupported) {
563 + idToTreeBaseDurationMap.set(id, fiber.treeBaseDuration);
564 + }
565 +
566 if (isRoot) {
550 - const operation = new Uint32Array(3);
567 + const operation = new Uint32Array(4);
568 operation[0] = TREE_OPERATION_ADD;
569 operation[1] = id;
570 operation[2] = ElementTypeRoot;
571 + operation[3] = isProfilingSupported ? 1 : 0;
572 addOperation(operation);
573 } else {
574 const { displayName, key, type } = getDataForFiber(fiber);
@@ -616,8 +634,9 @@ export function attach(
634 operation[1] = id;
635 addOperation(operation, true);
636 }
619 - idToFiberMap.delete(id);
637 fiberToIDMap.delete(primaryFiber);
638 + idToFiberMap.delete(id);
639 + idToTreeBaseDurationMap.delete(primaryFiber);
640 primaryFibers.delete(primaryFiber);
641 }
642
@@ -645,6 +664,26 @@ export function attach(
664 ) {
665 debug('enqueueUpdateIfNecessary()', fiber);
666
667 + if (isProfiling) {
668 + if (haveProfilerTimesChanged(fiber.alternate, fiber)) {
669 + const id = getFiberID(getPrimaryFiber(fiber));
670 +
671 + idToTreeBaseDurationMap.set(id, fiber.treeBaseDuration);
672 +
673 + if (fiber.actualDuration > 0) {
674 + // If profiling is active, store durations for elements that were rendered during the commit.
675 + ((currentCommitProfilingMetadata: any): CommitProfilingData).committedFibers.push(
676 + {
677 + id,
678 + actualDuration: fiber.actualDuration,
679 + selfDuration: fiber.selfDuration,
680 + treeBaseDuration: fiber.treeBaseDuration,
681 + }
682 + );
683 + }
684 + }
685 + }
686 +
687 // The frontend only really cares about the displayName, key, and children.
688 // The first two don't really change, so we are only concerned with the order of children here.
689 // This is trickier than a simple comparison though, since certain types of fibers are filtered.
@@ -667,8 +706,6 @@ export function attach(
706 operation.set(nextChildren, 3);
707 addOperation(operation);
708 }
670 -
671 - // TODO (profiling) If we're profiling, also check to see if that data has changed.
709 }
710
711 function findReorderedChildren(fiber: Fiber, nextChildren: Array<number>) {
@@ -798,6 +835,21 @@ export function attach(
835 const current = root.current;
836 const alternate = current.alternate;
837
838 + if (isProfiling) {
839 + // If profiling is active, store commit time and duration, and the current interactions.
840 + // The frontend may request this information after profiling has stopped.
841 + currentCommitProfilingMetadata = {
842 + committedFibers: [],
843 + commitTime: performance.now() - profilingStartTime,
844 + interactions: Array.from(root.memoizedInteractions).map(
845 + ({ name, timestamp }: Interaction) => ({
846 + name,
847 + timestamp: timestamp - profilingStartTime,
848 + })
849 + ),
850 + };
851 + }
852 +
853 if (alternate) {
854 // TODO: relying on this seems a bit fishy.
855 const wasMounted =
@@ -819,6 +871,13 @@ export function attach(
871 // Mount a new root.
872 mountFiber(current, null);
873 }
874 +
875 + if (isProfiling) {
876 + ((commitProfilingMetadata: any): Array<CommitProfilingData>).push(
877 + ((currentCommitProfilingMetadata: any): CommitProfilingData)
878 + );
879 + }
880 +
881 // We're done here.
882 flushPendingEvents(root);
883 }
@@ -1263,6 +1322,41 @@ export function attach(
1322 }
1323 }
1324
1325 + type CommittedFiber = {|
1326 + actualDuration: number,
1327 + id: number,
1328 + selfDuration: number,
1329 + treeBaseDuration: number,
1330 + |};
1331 +
1332 + type CommitProfilingData = {|
1333 + committedFibers: Array<CommittedFiber>,
1334 + commitTime: number,
1335 + interactions: Array<Interaction>,
1336 + |};
1337 +
1338 + let commitProfilingMetadata: Array<CommitProfilingData> | null = null;
1339 + let currentCommitProfilingMetadata: CommitProfilingData | null = null;
1340 + let initialTreeBaseDurations: Array<number> | null = null;
1341 + let isProfiling: boolean = false;
1342 + let profilingStartTime: number = 0;
1343 +
1344 + function startProfiling() {
1345 + commitProfilingMetadata = [];
1346 + initialTreeBaseDurations = [];
1347 + isProfiling = true;
1348 + profilingStartTime = performance.now();
1349 +
1350 + // Save initial treeBaseDurations; we'll need to send them later.
1351 + idToTreeBaseDurationMap.forEach((treeBaseDuration: number, id: number) => {
1352 + initialTreeBaseDurations = [id, treeBaseDuration];
1353 + });
1354 + }
1355 +
1356 + function stopProfiling() {
1357 + isProfiling = false;
1358 + }
1359 +
1360 return {
1361 cleanup,
1362 getFiberIDFromNative,
@@ -1277,6 +1371,8 @@ export function attach(
1371 setInHook,
1372 setInProps,
1373 setInState,
1374 + startProfiling,
1375 + stopProfiling,
1376 walkTree,
1377 };
1378 }
src/backend/types.js
+4 -1
@@ -1,6 +1,7 @@
1 // @flow
2
3 -import type { ElementType, InspectedElement } from 'src/devtools/types';
3 +import type { ElementType } from 'src/devtools/types';
4 +import type { InspectedElement } from 'src/devtools/views/elements/types';
5
6 type BundleType =
7 | 0 // PROD
@@ -75,6 +76,8 @@ export type RendererInterface = {
76 ) => void,
77 setInProps: (id: number, path: Array<string | number>, value: any) => void,
78 setInState: (id: number, path: Array<string | number>, value: any) => void,
79 + startProfiling: () => void,
80 + stopProfiling: () => void,
81 walkTree: () => void,
82 };
83
src/backend/utils.js
+1 -1
@@ -2,7 +2,7 @@
2
3 import { dehydrate } from '../hydration';
4
5 -import type { DehydratedData } from 'src/devtools/types';
5 +import type { DehydratedData } from 'src/devtools/views/elements/types';
6
7 export function cleanForBridge(data: Object | null): DehydratedData | null {
8 if (data !== null) {
src/devtools/store.js
+28 -1
@@ -10,7 +10,8 @@ import { ElementTypeRoot } from './types';
10 import { utfDecodeString } from '../utils';
11 import { __DEBUG__ } from '../constants';
12
13 -import type { Element, ElementType } from './types';
13 +import type { ElementType } from './types';
14 +import type { Element } from './views/elements/types';
15 import type { Bridge } from '../types';
16
17 const debug = (methodName, ...args) => {
@@ -24,6 +25,10 @@ const debug = (methodName, ...args) => {
25 }
26 };
27
28 +export type Capabilities = {|
29 + supportsProfiling: boolean,
30 +|};
31 +
32 /**
33 * The store is the single source of truth for updates from the backend.
34 * ContextProviders can subscribe to the Store for specific things they want to provide.
@@ -47,9 +52,13 @@ export default class Store extends EventEmitter {
52 // Passive effects will check it for changes between render and mount.
53 _roots: $ReadOnlyArray<number> = [];
54
55 + _rootIDToCapabilities: Map<number, Capabilities> = new Map();
56 +
57 // Renderer ID is needed to support inspection fiber props, state, and hooks.
58 _rootIDToRendererID: Map<number, number> = new Map();
59
60 + _supportsProfiling: boolean = false;
61 +
62 constructor(bridge: Bridge) {
63 super();
64
@@ -72,6 +81,10 @@ export default class Store extends EventEmitter {
81 return this._roots;
82 }
83
84 + get supportsProfiling(): boolean {
85 + return this._supportsProfiling;
86 + }
87 +
88 getElementAtIndex(index: number): Element | null {
89 if (index < 0 || index >= this.numElements) {
90 console.warn(
@@ -245,8 +258,12 @@ export default class Store extends EventEmitter {
258 // For now, we avoid adding it to the tree twice by checking if it's already been mounted.
259 // Maybe in the future we'll revisit this.
260 } else {
261 + const supportsProfiling = operations[i] > 0;
262 + i++;
263 +
264 this._roots = this._roots.concat(id);
265 this._rootIDToRendererID.set(id, rendererID);
266 + this._rootIDToCapabilities.set(id, { supportsProfiling });
267
268 this._idToElement.set(id, {
269 children: [],
@@ -341,6 +358,9 @@ export default class Store extends EventEmitter {
358
359 this._roots = this._roots.filter(rootID => rootID !== id);
360 this._rootIDToRendererID.delete(id);
361 + this._rootIDToCapabilities.delete(id);
362 +
363 + haveRootsChanged = true;
364 } else {
365 debug('Remove', `fiber ${id} from parent ${parentID}`);
366
@@ -399,6 +419,13 @@ export default class Store extends EventEmitter {
419 this._revision++;
420
421 if (haveRootsChanged) {
422 + this._supportsProfiling = false;
423 + this._rootIDToCapabilities.forEach(({ supportsProfiling }) => {
424 + if (supportsProfiling) {
425 + this._supportsProfiling = true;
426 + }
427 + });
428 +
429 this.emit('roots');
430 }
431
src/devtools/types.js
-62
@@ -14,65 +14,3 @@ export const ElementTypeSuspense = 9;
14 // These types may be used to visually distinguish types,
15 // or to enable/disable certain functionality.
16 export type ElementType = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
17 -
18 -// Each element on the frontend corresponds to a Fiber on the backend.
19 -// Some of its information (e.g. id, type, displayName) come from the backend.
20 -// Other bits (e.g. weight and depth) are computed on the frontend for windowing and display purposes.
21 -// Elements are udpated on a push basis– meaning the backend pushes updates to the frontend when needed.
22 -export type Element = {|
23 - id: number,
24 - parentID: number,
25 - children: Array<number>,
26 - type: ElementType,
27 - displayName: string | null,
28 - key: number | string | null,
29 -
30 - // Owner (if available)
31 - ownerID: number,
32 -
33 - // How many levels deep within the tree is this element?
34 - // This determines how much indentation (left padding) should be used in the Elements tree.
35 - depth: number,
36 -
37 - // How many nodes (including itself) are below this Element within the tree.
38 - // This property is used to quickly determine the total number of Elements,
39 - // and the Element at any given index (for windowing purposes).
40 - weight: number,
41 -|};
42 -
43 -export type Owner = {|
44 - displayName: string,
45 - id: number,
46 -|};
47 -
48 -export type InspectedElement = {|
49 - id: number,
50 -
51 - // Does the current renderer support editable hooks?
52 - canEditHooks: boolean,
53 -
54 - // Does the current renderer support editable function props?
55 - canEditFunctionProps: boolean,
56 -
57 - // Can view component source location.
58 - canViewSource: boolean,
59 -
60 - // Inspectable properties.
61 - context: Object | null,
62 - hooks: Object | null,
63 - props: Object | null,
64 - state: Object | null,
65 -
66 - // List of owners
67 - owners: Array<Owner> | null,
68 -
69 - // Location of component in source coude.
70 - source: Object | null,
71 -|};
72 -
73 -// TODO: Add profiling type
74 -
75 -export type DehydratedData = {|
76 - cleaned: Array<Array<string>>,
77 - data: Object,
78 -|};
src/devtools/views/ButtonIcon.js
+35 -11
@@ -3,18 +3,23 @@
3 import React from 'react';
4 import styles from './ButtonIcon.css';
5
6 +export type IconType =
7 + | 'back'
8 + | 'close'
9 + | 'copy'
10 + | 'down'
11 + | 'filter'
12 + | 'more'
13 + | 'record'
14 + | 'reload'
15 + | 'search'
16 + | 'undo'
17 + | 'up'
18 + | 'view-dom'
19 + | 'view-source';
20 +
21 type Props = {|
7 - type:
8 - | 'back'
9 - | 'close'
10 - | 'copy'
11 - | 'down'
12 - | 'more'
13 - | 'search'
14 - | 'undo'
15 - | 'up'
16 - | 'view-dom'
17 - | 'view-source',
22 + type: IconType,
23 |};
24
25 export default function ButtonIcon({ type }: Props) {
@@ -32,9 +37,18 @@ export default function ButtonIcon({ type }: Props) {
37 case 'down':
38 pathData = PATH_DOWN;
39 break;
40 + case 'filter':
41 + pathData = PATH_FILTER;
42 + break;
43 case 'more':
44 pathData = PATH_MORE;
45 break;
46 + case 'record':
47 + pathData = PATH_RECORD;
48 + break;
49 + case 'reload':
50 + pathData = PATH_RELOAD;
51 + break;
52 case 'search':
53 pathData = PATH_SEARCH;
54 break;
@@ -85,6 +99,8 @@ const PATH_COPY = `
99
100 const PATH_DOWN = 'M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z';
101
102 +const PATH_FILTER = 'M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z';
103 +
104 const PATH_MORE = `
105 M22 3H7c-.69 0-1.23.35-1.59.88L0 12l5.41 8.11c.36.53.97.89 1.66.89H22c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9
106 13.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm5 0c-.83
@@ -92,6 +108,14 @@ const PATH_MORE = `
108 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z
109 `;
110
111 +const PATH_RECORD = 'M4,12a8,8 0 1,0 16,0a8,8 0 1,0 -16,0';
112 +
113 +const PATH_RELOAD = `
114 + M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0
115 + 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0
116 + 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z
117 +`;
118 +
119 const PATH_SEARCH = `
120 M20.94 11c-.46-4.17-3.77-7.48-7.94-7.94V1h-2v2.06C6.83 3.52 3.52 6.83 3.06 11H1v2h2.06c.46
121 4.17 3.77 7.48 7.94 7.94V23h2v-2.06c4.17-.46 7.48-3.77 7.94-7.94H23v-2h-2.06zM12 19c-3.87
src/devtools/views/DevTools.css
+21
@@ -9,9 +9,30 @@
9
10 .TabBar {
11 flex: 0 0 auto;
12 + display: flex;
13 + align-items: center;
14 + padding: 0 0.5rem;
15 + background-color: var(--color-background);
16 + border-top: 1px solid var(--color-border);
17 + font-family: var(--font-family-sans);
18 + font-size: var(--font-size-sans-large);
19 +}
20 +
21 +.Spacer {
22 + flex: 1;
23 }
24
25 .TabContent {
26 flex: 1 1 100%;
27 overflow: auto;
28 }
29 +
30 +.DevToolsVersion:before {
31 + content: 'DevTools ';
32 +}
33 +
34 +@media screen and (max-width: 350px) {
35 + .DevToolsVersion:before {
36 + content: '';
37 + }
38 +}
src/devtools/views/DevTools.js
+59 -2
@@ -1,6 +1,6 @@
1 // @flow
2
3 -import React, { useState } from 'react';
3 +import React, { useEffect, useState } from 'react';
4 import Store from '../store';
5 import { BridgeContext, StoreContext } from './context';
6 import Elements from './Elements/Elements';
@@ -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 ReactLogo from './ReactLogo';
13
14 import styles from './DevTools.css';
15
@@ -30,6 +31,28 @@ export type Props = {|
31 viewElementSource?: ?Function,
32 |};
33
34 +const elementTab = {
35 + id: ('elements': TabID),
36 + icon: 'elements',
37 + label: 'Elements',
38 + title: 'React Elements',
39 +};
40 +const profilerTab = {
41 + id: ('profiler': TabID),
42 + icon: 'profiler',
43 + label: 'Profiler',
44 + title: 'React Profiler',
45 +};
46 +const settingsTab = {
47 + id: ('settings': TabID),
48 + icon: 'settings',
49 + label: 'Settings',
50 + title: 'React Settings',
51 +};
52 +
53 +const tabsWithProfiler = [elementTab, profilerTab, settingsTab];
54 +const tabsWithoutProfiler = [elementTab, settingsTab];
55 +
56 export default function DevTools({
57 bridge,
58 browserName,
@@ -40,6 +63,27 @@ export default function DevTools({
63 viewElementSource = null,
64 }: Props) {
65 const [tab, setTab] = useState(defaultTab);
66 + const [supportsProfiling, setSupportsProfiling] = useState(
67 + store.supportsProfiling
68 + );
69 +
70 + // Show/hide the "Profiler" button depending on if profiling is supported.
71 + useEffect(() => {
72 + if (supportsProfiling !== store.supportsProfiling) {
73 + setSupportsProfiling(store.supportsProfiling);
74 + }
75 +
76 + const handleRoots = () => {
77 + if (supportsProfiling !== store.supportsProfiling) {
78 + setSupportsProfiling(store.supportsProfiling);
79 + }
80 + };
81 +
82 + store.addListener('roots', handleRoots);
83 + return () => {
84 + store.removeListener('roots', handleRoots);
85 + };
86 + }, [store, supportsProfiling]);
87
88 let tabElement;
89 switch (tab) {
@@ -63,7 +107,20 @@ export default function DevTools({
107 <div className={styles.DevTools}>
108 {showTabBar && (
109 <div className={styles.TabBar}>
66 - <TabBar currentTab={tab} selectTab={setTab} />
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>
src/devtools/views/Elements/Element.js
+1 -1
@@ -12,7 +12,7 @@ import { ElementTypeClass, ElementTypeFunction } from 'src/devtools/types';
12 import { createRegExp } from '../utils';
13 import { TreeContext } from './TreeContext';
14
15 -import type { Element } from '../../types';
15 +import type { Element } from './types';
16
17 import styles from './Element.css';
18
src/devtools/views/Elements/OwnersStack.js
+1 -1
@@ -6,7 +6,7 @@ import ButtonIcon from '../ButtonIcon';
6 import { TreeContext } from './TreeContext';
7 import { StoreContext } from '../context';
8
9 -import type { Element } from '../../types';
9 +import type { Element } from './types';
10
11 import styles from './OwnersStack.css';
12
src/devtools/views/Elements/SelectedElement.js
+2 -2
@@ -17,8 +17,8 @@ import { hydrate } from 'src/hydration';
17 import styles from './SelectedElement.css';
18 import { ElementTypeClass, ElementTypeFunction } from '../../types';
19
20 -import type { InspectedElement } from '../../types';
21 -import type { DehydratedData, Element } from 'src/devtools/types';
20 +import type { InspectedElement } from './types';
21 +import type { DehydratedData, Element } from './types';
22
23 export type Props = {||};
24
src/devtools/views/Elements/TreeContext.js
+1 -1
@@ -29,7 +29,7 @@ import { createRegExp } from '../utils';
29 import { BridgeContext, StoreContext } from '../context';
30 import Store from '../../store';
31
32 -import type { Element } from 'src/devtools/types';
32 +import type { Element } from './types';
33
34 type Context = {|
35 // Tree
src/devtools/views/Elements/types.js new
+65
@@ -0,0 +1,65 @@
1 +// @flow
2 +
3 +import type { ElementType } from '../../types';
4 +
5 +// Each element on the frontend corresponds to a Fiber on the backend.
6 +// Some of its information (e.g. id, type, displayName) come from the backend.
7 +// Other bits (e.g. weight and depth) are computed on the frontend for windowing and display purposes.
8 +// Elements are udpated on a push basis– meaning the backend pushes updates to the frontend when needed.
9 +export type Element = {|
10 + id: number,
11 + parentID: number,
12 + children: Array<number>,
13 + type: ElementType,
14 + displayName: string | null,
15 + key: number | string | null,
16 +
17 + // Owner (if available)
18 + ownerID: number,
19 +
20 + // How many levels deep within the tree is this element?
21 + // This determines how much indentation (left padding) should be used in the Elements tree.
22 + depth: number,
23 +
24 + // How many nodes (including itself) are below this Element within the tree.
25 + // This property is used to quickly determine the total number of Elements,
26 + // and the Element at any given index (for windowing purposes).
27 + weight: number,
28 +|};
29 +
30 +export type Owner = {|
31 + displayName: string,
32 + id: number,
33 +|};
34 +
35 +export type InspectedElement = {|
36 + id: number,
37 +
38 + // Does the current renderer support editable hooks?
39 + canEditHooks: boolean,
40 +
41 + // Does the current renderer support editable function props?
42 + canEditFunctionProps: boolean,
43 +
44 + // Can view component source location.
45 + canViewSource: boolean,
46 +
47 + // Inspectable properties.
48 + context: Object | null,
49 + hooks: Object | null,
50 + props: Object | null,
51 + state: Object | null,
52 +
53 + // List of owners
54 + owners: Array<Owner> | null,
55 +
56 + // Location of component in source coude.
57 + source: Object | null,
58 +|};
59 +
60 +// TODO: Add profiling type
61 +
62 +export type DehydratedData = {|
63 + cleaned: Array<Array<string>>,
64 + data: Object,
65 +|};
src/devtools/views/Icon.js
+47 -1
@@ -3,9 +3,19 @@
3 import React from 'react';
4 import styles from './Icon.css';
5
6 +export type IconType =
7 + | 'arrow'
8 + | 'elements'
9 + | 'flame-chart'
10 + | 'interactions'
11 + | 'profiler'
12 + | 'ranked-chart'
13 + | 'search'
14 + | 'settings';
15 +
16 type Props = {|
17 className?: string,
8 - type: 'arrow' | 'elements' | 'profiler' | 'search' | 'settings',
18 + type: IconType,
19 |};
20
21 export default function Icon({ className = '', type }: Props) {
@@ -17,9 +27,18 @@ export default function Icon({ className = '', type }: Props) {
27 case 'elements':
28 pathData = PATH_ELEMENTS;
29 break;
30 + case 'flame-chart':
31 + pathData = PATH_FLAME_CHART;
32 + break;
33 + case 'interactions':
34 + pathData = PATH_INTERACTIONS;
35 + break;
36 case 'profiler':
37 pathData = PATH_PROFILER;
38 break;
39 + case 'ranked-chart':
40 + pathData = PATH_RANKED_CHART;
41 + break;
42 case 'search':
43 pathData = PATH_SEARCH;
44 break;
@@ -46,14 +65,41 @@ export default function Icon({ className = '', type }: Props) {
65 }
66
67 const PATH_ARROW = 'M8 5v14l11-7z';
68 +
69 const PATH_ELEMENTS =
70 'M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z';
71 +
72 +const PATH_FLAME_CHART = `
73 + M10.0650893,21.5040462 C7.14020814,20.6850349 5,18.0558698 5,14.9390244 C5,14.017627
74 + 5,9.81707317 7.83333333,7.37804878 C7.83333333,7.37804878 7.58333333,11.199187 10,
75 + 10.6300813 C11.125,10.326087 13.0062497,7.63043487 8.91666667,2.5 C14.1666667,3.06910569
76 + 19,9.32926829 19,14.9390244 C19,18.0558698 16.8597919,20.6850349 13.9349107,21.5040462
77 + C14.454014,21.0118505 14.7765152,20.3233394 14.7765152,19.5613412 C14.7765152,17.2826087
78 + 12,15.0875871 12,15.0875871 C12,15.0875871 9.22348485,17.2826087 9.22348485,19.5613412
79 + C9.22348485,20.3233394 9.54598603,21.0118505 10.0650893,21.5040462 Z M12.0833333,20.6514763
80 + C11.3814715,20.6514763 10.8125,20.1226027 10.8125,19.4702042 C10.8125,18.6069669
81 + 12.0833333,16.9347829 12.0833333,16.9347829 C12.0833333,16.9347829 13.3541667,18.6069669
82 + 13.3541667,19.4702042 C13.3541667,20.1226027 12.7851952,20.6514763 12.0833333,20.6514763 Z
83 +`;
84 +
85 +const PATH_INTERACTIONS = `
86 + M23 8c0 1.1-.9 2-2 2-.18 0-.35-.02-.51-.07l-3.56 3.55c.05.16.07.34.07.52 0 1.1-.9 2-2
87 + 2s-2-.9-2-2c0-.18.02-.36.07-.52l-2.55-2.55c-.16.05-.34.07-.52.07s-.36-.02-.52-.07l-4.55
88 + 4.56c.05.16.07.33.07.51 0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2c.18 0 .35.02.51.07l4.56-4.55C8.02
89 + 9.36 8 9.18 8 9c0-1.1.9-2 2-2s2 .9 2 2c0 .18-.02.36-.07.52l2.55
90 + 2.55c.16-.05.34-.07.52-.07s.36.02.52.07l3.55-3.56C19.02 8.35 19 8.18 19 8c0-1.1.9-2 2-2s2 .9 2 2z
91 +`;
92 +
93 const PATH_PROFILER = 'M5 9.2h3V19H5zM10.6 5h2.8v14h-2.8zm5.6 8H19v6h-2.8z';
94 +
95 const PATH_SEARCH = `
96 M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91
97 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99
98 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z
99 `;
100 +
101 +const PATH_RANKED_CHART = 'M3 5h18v3H3zM3 10.5h13v3H3zM3 16h8v3H3z';
102 +
103 const PATH_SETTINGS = `
104 M15.95 10.78c.03-.25.05-.51.05-.78s-.02-.53-.06-.78l1.69-1.32c.15-.12.19-.34.1-.51l-1.6-2.77c-.1-.18-.31-.24-.49-.18l-1.99.8c-.42-.32-.86-.58-1.35-.78L12
105 2.34c-.03-.2-.2-.34-.4-.34H8.4c-.2 0-.36.14-.39.34l-.3 2.12c-.49.2-.94.47-1.35.78l-1.99-.8c-.18-.07-.39
src/devtools/views/Profiler/Profiler.css
+54 -3
@@ -2,11 +2,62 @@
2 width: 100%;
3 height: 100%;
4 display: flex;
5 - align-items: center;
6 - justify-content: center;
7 - border-top: 1px solid var(--color-border);
5 + flex-direction: column;
6 font-family: var(--font-family-sans);
7 font-size: var(--font-size-sans-normal);
8 background-color: var(--color-background);
9 color: var(--color-text-color);
10 }
11 +
12 +.Content {
13 + flex: 1 1 auto;
14 + display: flex;
15 + flex-direction: row;
16 + align-items: center;
17 + justify-content: center;
18 +}
19 +
20 +.Column {
21 + display: flex;
22 + flex-direction: column;
23 + align-items: center;
24 + justify-content: center;
25 +}
26 +
27 +.Row {
28 + display: flex;
29 + flex-direction: row;
30 + align-items: center;
31 + justify-content: center;
32 +}
33 +
34 +.Header {
35 + font-size: var(--font-size-sans-large);
36 + margin-bottom: 0.5rem;
37 +}
38 +
39 +.Toolbar {
40 + padding: 0 0.5rem;
41 + flex: 0 0 auto;
42 + display: flex;
43 + align-items: center;
44 + border-bottom: 1px solid var(--color-border);
45 + border-top: 1px solid var(--color-border);
46 + color: var(--color-button);
47 +}
48 +
49 +.VRule {
50 + height: 20px;
51 + width: 1px;
52 + background-color: var(--color-border);
53 + margin: 0 0.25rem;
54 +}
55 +
56 +.Spacer {
57 + flex: 1;
58 +}
59 +
60 +.StopRecordingButton {
61 + font-size: var(--font-size-sans-large);
62 + padding: 0.5rem;
63 +}
src/devtools/views/Profiler/Profiler.js
+81 -3
@@ -1,11 +1,89 @@
1 // @flow
2
3 -import React from 'react';
3 +import React, { useContext, useState } from 'react';
4 +import { ProfilerContext, ProfilerContextController } from './ProfilerContext';
5 +import Button from '../Button';
6 +import ButtonIcon from '../ButtonIcon';
7 +import TabBar from '../TabBar';
8 +import RecordToggle from './RecordToggle';
9
10 import styles from './Profiler.css';
11
12 export type Props = {||};
13
9 -export default function Profiler(_: Props) {
10 - return <div className={styles.Profiler}>Under development...</div>;
14 +export default function ProfilerOuter(_: Props) {
15 + return (
16 + <ProfilerContextController>
17 + <ProfilerInner />
18 + </ProfilerContextController>
19 + );
20 }
21 +
22 +function ProfilerInner(_: Props) {
23 + const { hasProfilingData, isProfiling } = useContext(ProfilerContext);
24 + const [tab, setTab] = useState('flame-chart');
25 +
26 + let view = null;
27 + if (isProfiling) {
28 + view = <RecortdingInProgress />;
29 + } else if (!hasProfilingData) {
30 + view = <NoProfilingData />;
31 + } else {
32 + // TODO (profiling) Differentiate between no data and no data for the current root
33 + // TODO (profiling) Show selected "tab" view
34 + view = <div>Coming soon...</div>;
35 + }
36 +
37 + return (
38 + <div className={styles.Profiler}>
39 + <div className={styles.Toolbar}>
40 + <RecordToggle />
41 + <Button disabled title="Reload and start profiling">
42 + {/* TODO (profiling) Wire up reload button */}
43 + <ButtonIcon type="reload" />
44 + </Button>
45 + <div className={styles.VRule} />
46 + <TabBar
47 + currentTab={tab}
48 + disabled={isProfiling || !hasProfilingData}
49 + id="Profiler"
50 + selectTab={setTab}
51 + size="small"
52 + tabs={tabs}
53 + />
54 + <div className={styles.Spacer} />
55 + <Button disabled title="Filter commits by duration">
56 + {/* TODO (profiling) Wire up filter button */}
57 + <ButtonIcon type="filter" />
58 + </Button>
59 + </div>
60 + <div className={styles.Content}>{view}</div>
61 + </div>
62 + );
63 +}
64 +
65 +const tabs = [
66 + { id: 'flame-chart', icon: 'flame-chart', label: 'Flamegraph' },
67 + { id: 'ranked-chart', icon: 'ranked-chart', label: 'Ranked' },
68 + { id: 'interactions', icon: 'interactions', label: 'Interactions' },
69 +];
70 +
71 +const NoProfilingData = () => (
72 + <div className={styles.Column}>
73 + <div className={styles.Header}>No profiling data has been recorded.</div>
74 + <div className={styles.Row}>
75 + Click the record button <RecordToggle /> to start recording.
76 + </div>
77 + </div>
78 +);
79 +
80 +const RecortdingInProgress = () => {
81 + return (
82 + <div className={styles.Column}>
83 + <div className={styles.Header}>Profiling is in progress...</div>
84 + <div className={styles.Row}>
85 + Click the record button <RecordToggle /> to stop recording.
86 + </div>
87 + </div>
88 + );
89 +};
src/devtools/views/Profiler/ProfilerContext.js new
+105
@@ -0,0 +1,105 @@
1 +// @flow
2 +
3 +import React, {
4 + createContext,
5 + useCallback,
6 + useContext,
7 + useLayoutEffect,
8 + useMemo,
9 + useReducer,
10 +} from 'react';
11 +import { BridgeContext, StoreContext } from '../context';
12 +import reducer from './reducer';
13 +
14 +import type {
15 + HANDLE_OPERATIONS_ACTION,
16 + HANDLE_PROFILING_STATUS_CHANGE_ACTION,
17 + SEND_START_PROFILING_ACTION,
18 + SEND_STOP_PROFILING_ACTION,
19 +} from './reducer';
20 +
21 +type Context = {|
22 + hasProfilingData: boolean,
23 + isProfiling: boolean,
24 + startProfiling(value: boolean): void,
25 + stopProfiling(value: boolean): void,
26 +|};
27 +
28 +const ProfilerContext = createContext<Context>(((null: any): Context));
29 +ProfilerContext.displayName = 'ProfilerContext';
30 +
31 +type Props = {|
32 + children: React$Node,
33 +|};
34 +
35 +function ProfilerContextController({ children }: Props) {
36 + const bridge = useContext(BridgeContext);
37 + const store = useContext(StoreContext);
38 +
39 + // Some of this reducer's actions require access to the store.
40 + // The store is mutable, but the Store itself is global and lives for the lifetime of the DevTools,
41 + // so we don't need to re-init the reducer in any special way.
42 + const [state, dispatch] = useReducer(reducer, {
43 + hasProfilingData: false,
44 + isProfiling: false,
45 + _operations: [],
46 + _snapshot: new Map(),
47 + });
48 +
49 + const startProfiling = useCallback(() => {
50 + bridge.send('startProfiling');
51 + dispatch(({ type: 'SEND_START_PROFILING' }: SEND_START_PROFILING_ACTION));
52 + }, [bridge, dispatch]);
53 + const stopProfiling = useCallback(() => {
54 + bridge.send('stopProfiling');
55 + dispatch(({ type: 'SEND_STOP_PROFILING' }: SEND_STOP_PROFILING_ACTION));
56 + }, [bridge, dispatch]);
57 +
58 + const value = useMemo(
59 + () => ({
60 + hasProfilingData: state.hasProfilingData,
61 + isProfiling: state.isProfiling,
62 + startProfiling,
63 + stopProfiling,
64 + }),
65 + [state, startProfiling, stopProfiling]
66 + );
67 +
68 + useLayoutEffect(() => {
69 + const handleOperations = (operations: Uint32Array) =>
70 + dispatch(
71 + ({
72 + type: 'HANDLE_OPERATIONS',
73 + payload: operations,
74 + }: HANDLE_OPERATIONS_ACTION)
75 + );
76 +
77 + const handleProfilingStatus = (isProfiling: boolean) =>
78 + dispatch(
79 + ({
80 + type: 'HANDLE_PROFILING_STATUS_CHANGE',
81 + payload: { isProfiling, store },
82 + }: HANDLE_PROFILING_STATUS_CHANGE_ACTION)
83 + );
84 +
85 + bridge.addListener('operations', handleOperations);
86 + bridge.addListener('profilingStatus', handleProfilingStatus);
87 +
88 + // It's possible that profiling has already started (e.g. "reload and start profiling")
89 + // so the frontend needs to ask the backend for its status after mounting.
90 + bridge.send('getProfilingStatus');
91 +
92 + return () => {
93 + bridge.removeListener('operations', handleOperations);
94 + bridge.removeListener('profilingStatus', handleProfilingStatus);
95 + };
96 + }, [bridge, dispatch, store]);
97 +
98 + return (
99 + <ProfilerContext.Provider value={value}>
100 + {children}
101 + </ProfilerContext.Provider>
102 + );
103 +}
104 +
105 +export { ProfilerContext, ProfilerContextController };
src/devtools/views/Profiler/RecordToggle.css new
+21
@@ -0,0 +1,21 @@
1 +.ActiveRecordToggle {
2 + color: var(--color-record-active);
3 +}
4 +.ActiveRecordToggle:hover,
5 +.ActiveRecordToggle:focus {
6 + color: var(--color-record-active);
7 +}
8 +.ActiveRecordToggle:active {
9 + color: var(--color-record-hover);
10 +}
11 +
12 +.InactiveRecordToggle {
13 + color: var(--color-record-inactive);
14 +}
15 +.InactiveRecordToggle:hover,
16 +.InactiveRecordToggle:focus {
17 + color: var(--color-record-inactive);
18 +}
19 +.InactiveRecordToggle:active {
20 + color: var(--color-record-hover);
21 +}
src/devtools/views/Profiler/RecordToggle.js new
+28
@@ -0,0 +1,28 @@
1 +// @flow
2 +
3 +import React, { useContext } from 'react';
4 +import Button from '../Button';
5 +import ButtonIcon from '../ButtonIcon';
6 +import { ProfilerContext } from './ProfilerContext';
7 +
8 +import styles from './RecordToggle.css';
9 +
10 +export type Props = {||};
11 +
12 +export default function RecordToggle(_: Props) {
13 + const { isProfiling, startProfiling, stopProfiling } = useContext(
14 + ProfilerContext
15 + );
16 +
17 + return (
18 + <Button
19 + className={
20 + isProfiling ? styles.ActiveRecordToggle : styles.InactiveRecordToggle
21 + }
22 + onClick={isProfiling ? stopProfiling : startProfiling}
23 + title={isProfiling ? 'Stop profiling' : 'Start profiling'}
24 + >
25 + <ButtonIcon type="record" />
26 + </Button>
27 + );
28 +}
src/devtools/views/Profiler/reducer.js new
+112
@@ -0,0 +1,112 @@
1 +// @flow
2 +
3 +import Store from '../../store';
4 +
5 +type Node = {|
6 + id: number,
7 + children: Array<number>,
8 + displayName: string | null,
9 + key: number | string | null,
10 +|};
11 +
12 +export type State = {|
13 + hasProfilingData: boolean,
14 + isProfiling: boolean,
15 +
16 + // List of tree mutation that occur during profiling.
17 + // Once profiling is finished, these mutations can be used, along with the initial tree snapshots,
18 + // to reconstruct the state of each root for each commit.
19 + _operations: Array<Uint32Array>,
20 +
21 + // Snapshot of the state of the main Store (including all roots) when profiling started.
22 + // Once profiling is finished, this snapshot can be used along with "operations" messages emitted during profiling,
23 + // to reconstruct the state of each root for each commit.
24 + // It's okay to use a single root to store this information because node IDs are unique across all roots.
25 + _snapshot: Map<number, Node>,
26 +|};
27 +
28 +export type HANDLE_OPERATIONS_ACTION = {|
29 + type: 'HANDLE_OPERATIONS',
30 + payload: Uint32Array,
31 +|};
32 +
33 +export type HANDLE_PROFILING_STATUS_CHANGE_ACTION = {|
34 + type: 'HANDLE_PROFILING_STATUS_CHANGE',
35 + payload: {
36 + isProfiling: boolean,
37 + store: Store,
38 + },
39 +|};
40 +
41 +export type SEND_START_PROFILING_ACTION = {|
42 + type: 'SEND_START_PROFILING',
43 +|};
44 +
45 +export type SEND_STOP_PROFILING_ACTION = {|
46 + type: 'SEND_STOP_PROFILING',
47 +|};
48 +
49 +type Action =
50 + | HANDLE_OPERATIONS_ACTION
51 + | HANDLE_PROFILING_STATUS_CHANGE_ACTION
52 + | SEND_START_PROFILING_ACTION
53 + | SEND_STOP_PROFILING_ACTION;
54 +
55 +export default function reducer(state: State, action: Action): State {
56 + const { type } = action;
57 + switch (type) {
58 + case 'HANDLE_OPERATIONS':
59 + if (state.isProfiling) {
60 + const operations = ((action: any): HANDLE_OPERATIONS_ACTION).payload;
61 + return {
62 + ...state,
63 + hasProfilingData: true,
64 + _operations: state._operations.concat(operations),
65 + };
66 + } else {
67 + return state;
68 + }
69 + case 'HANDLE_PROFILING_STATUS_CHANGE':
70 + const {
71 + isProfiling,
72 + store,
73 + } = ((action: any): HANDLE_PROFILING_STATUS_CHANGE_ACTION).payload;
74 +
75 + if (isProfiling) {
76 + const snapshot = new Map();
77 +
78 + const recursiveSnapshot = id => {
79 + const element = store.getElementByID(id);
80 + if (element !== null) {
81 + snapshot.set(id, {
82 + id,
83 + children: element.children.slice(0),
84 + displayName: element.displayName,
85 + key: element.key,
86 + });
87 + element.children.forEach(id => recursiveSnapshot(id));
88 + }
89 + };
90 +
91 + store.roots.forEach(rootID => recursiveSnapshot(rootID));
92 +
93 + return {
94 + ...state,
95 + isProfiling,
96 + _operations: [],
97 + _snapshot: snapshot,
98 + };
99 + } else {
100 + return {
101 + ...state,
102 + isProfiling,
103 + };
104 + }
105 + case 'SEND_START_PROFILING':
106 + return { ...state, hasProfilingData: false, isProfiling: true };
107 + case 'SEND_STOP_PROFILING':
108 + return { ...state, isProfiling: false };
109 + default:
110 + throw new Error(`Unrecognized action "${type}"`);
111 + }
112 +}
src/devtools/views/Profiler/types.js new
+29
@@ -0,0 +1,29 @@
1 +// @flow
2 +
3 +// Map of root ID to commit durations.
4 +// Roots that were committed during the profiling session will have a non-empty array of values.
5 +// Each value in the array represents the total amount of time the commit took.
6 +// More detailed information about a commit must be requested separately.
7 +export type CommitDurationsMap = Map<number, Array<number>>;
8 +
9 +export type Interaction = {|
10 + id: number,
11 + timestamp: number,
12 + label: string,
13 +|};
14 +
15 +export type Node = {|
16 + actualDuration: number,
17 + baseDuration: number,
18 + displayName: string,
19 + id: number,
20 + parentID: number,
21 + selfDuration: number,
22 +|};
23 +
24 +export type CommitDetails = {|
25 + duration: number,
26 + interactions: Array<Interaction>,
27 + root: Node,
28 + timestamp: number,
29 +|};
src/devtools/views/ReactLogo.css
+1
@@ -2,4 +2,5 @@
2 width: 1.75rem;
3 height: 1.75rem;
4 margin: 0 0.75rem 0 0.25rem;
5 + color: var(--color-button);
6 }
src/devtools/views/Settings/SettingsContext.js
+3
@@ -141,6 +141,9 @@ function updateThemeVariables(theme: Theme): void {
141 updateStyleHelper(theme, 'color-dimmest');
142 updateStyleHelper(theme, 'color-jsx-arrow-brackets');
143 updateStyleHelper(theme, 'color-jsx-arrow-brackets-inverted');
144 + updateStyleHelper(theme, 'color-record-active');
145 + updateStyleHelper(theme, 'color-record-hover');
146 + updateStyleHelper(theme, 'color-record-inactive');
147 updateStyleHelper(theme, 'color-tree-node-selected');
148 updateStyleHelper(theme, 'color-tree-node-hover');
149 updateStyleHelper(theme, 'color-search-match');
src/devtools/views/TabBar.css
+34 -36
@@ -1,40 +1,17 @@
1 -.TabBar {
2 - display: flex;
3 - align-items: center;
4 - padding: 0 0.5rem;
5 - background-color: var(--color-background);
6 - color: var(--color-button);
7 - border-top: 1px solid var(--color-border);
8 - font-family: var(--font-family-sans);
9 - font-size: var(--font-size-sans-large);
10 -}
11 -
12 -.DevToolsVersion:before {
13 - content: 'DevTools ';
14 -}
15 -
16 -.Tabs {
17 - flex: 1 1 auto;
18 - display: flex;
19 - flex-direction: row;
20 - align-items: center;
21 - justify-content: flex-end;
22 - overflow-x: auto;
23 -}
24 -
1 +.Tab,
2 .TabCurrent,
26 -.Tab {
3 +.TabDisabled {
4 display: flex;
5 align-items: center;
29 - padding: 0.5rem 1rem;
6 cursor: pointer;
7 border-top: 3px solid transparent;
8 border-bottom: 3px solid transparent;
9 cursor: pointer;
10 user-select: none;
11 + color: var(--color-text-color);
12 }
36 -.TabCurrent:hover,
37 -.Tab:hover {
13 +.Tab:hover,
14 +.TabCurrent:hover {
15 background-color: var(--color-tree-node-hover);
16 }
17
@@ -42,23 +19,50 @@
19 border-bottom: 3px solid var(--color-tree-node-selected);
20 }
21
22 +.TabDisabled {
23 + color: var(--color-dim);
24 + cursor: default;
25 +}
26 +
27 +.TabSizeLarge {
28 + padding: 0.5rem 1rem;
29 +}
30 +.TabSizeSmall {
31 + padding: 0.25rem 0.5rem;
32 +}
33 +
34 .Input {
35 width: 0;
36 margin: 0;
37 opacity: 0;
38 }
39
51 -.Icon {
40 +.IconSizeLarge,
41 +.IconSizeSmall {
42 margin-right: 0.5rem;
43 + color: var(--color-button);
44 +}
45 +
46 +.IconDisabled {
47 + color: var(--color-dim);
48 +}
49 +
50 +.IconSizeLarge {
51 width: 1.5rem;
52 height: 1.5rem;
53 }
54
55 +.IconSizeSmall {
56 + width: 1rem;
57 + height: 1rem;
58 +}
59 +
60 .TabLabel {
61 }
62
63 @media screen and (max-width: 600px) {
61 - .Icon {
64 + .IconSizeLarge,
65 + .IconSizeSmall {
66 margin-right: 0;
67 }
68
@@ -66,9 +70,3 @@
70 display: none;
71 }
72 }
69 -
70 -@media screen and (max-width: 350px) {
71 - .DevToolsVersion:before {
72 - content: '';
73 - }
74 -}
src/devtools/views/TabBar.js
+63 -50
@@ -1,77 +1,90 @@
1 // @flow
2
3 -import React, { useCallback } from 'react';
3 +import React, { Fragment, useCallback } from 'react';
4 import Icon from './Icon';
5 -import ReactLogo from './ReactLogo';
5
6 import styles from './TabBar.css';
7
9 -import type { TabID } from './DevTools';
8 +import type { IconType } from './Icon';
9 +
10 +type TabInfo = {|
11 + icon: IconType,
12 + id: string,
13 + label: string,
14 + title?: string,
15 +|};
16
17 export type Props = {|
12 - currentTab: TabID,
13 - selectTab: (tabID: TabID) => void,
18 + currentTab: any,
19 + disabled?: boolean,
20 + id: string,
21 + selectTab: (tabID: any) => void,
22 + size: 'large' | 'small',
23 + tabs: Array<TabInfo>,
24 |};
25
16 -export default function TabBar({ currentTab, selectTab }: Props) {
26 +export default function TabBar({
27 + currentTab,
28 + disabled = false,
29 + id: groupName,
30 + selectTab,
31 + size,
32 + tabs,
33 +}: Props) {
34 + if (!tabs.some(tab => tab.id === currentTab)) {
35 + selectTab(tabs[0].id);
36 + }
37 +
38 const onChange = useCallback(
39 ({ currentTarget }) => selectTab(currentTarget.value),
40 [selectTab]
41 );
42
43 + const handleKeyDown = useCallback(event => {
44 + switch (event.key) {
45 + case 'ArrowDown':
46 + case 'ArrowLeft':
47 + case 'ArrowRight':
48 + case 'ArrowUp':
49 + event.stopPropagation();
50 + break;
51 + default:
52 + break;
53 + }
54 + }, []);
55 +
56 + const tabClassName =
57 + size === 'large' ? styles.TabSizeLarge : styles.TabSizeSmall;
58 +
59 return (
23 - <div className={styles.TabBar}>
24 - <ReactLogo />
25 - <span className={styles.DevToolsVersion}>
26 - {process.env.DEVTOOLS_VERSION}
27 - </span>
28 - <div className={styles.Tabs}>
29 - <label
30 - className={currentTab === 'elements' ? styles.TabCurrent : styles.Tab}
31 - title="React Elements"
32 - >
33 - <input
34 - type="radio"
35 - name="TabBar-tab"
36 - className={styles.Input}
37 - checked={currentTab === 'elements'}
38 - value="elements"
39 - onChange={onChange}
40 - />
41 - <Icon className={styles.Icon} type="elements" />
42 - <span className={styles.TabLabel}>Elements</span>
43 - </label>
60 + <Fragment>
61 + {tabs.map(({ icon, id, label, title }) => (
62 <label
45 - className={currentTab === 'profiler' ? styles.TabCurrent : styles.Tab}
46 - title="React Profiler"
63 + className={`${tabClassName} ${
64 + disabled ? styles.TabDisabled : styles.Tab
65 + } ${!disabled && currentTab === id ? styles.TabCurrent : ''}`}
66 + key={id}
67 + onKeyDown={handleKeyDown}
68 + title={title || label}
69 >
70 <input
71 type="radio"
50 - name="TabBar-tab"
72 className={styles.Input}
52 - checked={currentTab === 'profiler'}
53 - value="profiler"
73 + checked={currentTab === id}
74 + disabled={disabled}
75 + name={groupName}
76 + value={id}
77 onChange={onChange}
78 />
56 - <Icon className={styles.Icon} type="profiler" />
57 - <span className={styles.TabLabel}>Profiler</span>
58 - </label>
59 - <label
60 - className={currentTab === 'settings' ? styles.TabCurrent : styles.Tab}
61 - title="React Settings"
62 - >
63 - <input
64 - type="radio"
65 - name="TabBar-tab"
66 - className={styles.Input}
67 - checked={currentTab === 'settings'}
68 - value="settings"
69 - onChange={onChange}
79 + <Icon
80 + className={`${disabled ? styles.IconDisabled : ''} ${
81 + size === 'large' ? styles.IconSizeLarge : styles.IconSizeSmall
82 + }`}
83 + type={icon}
84 />
71 - <Icon className={styles.Icon} type="settings" />
72 - <span className={styles.TabLabel}>Settings</span>
85 + <span className={styles.TabLabel}>{label}</span>
86 </label>
74 - </div>
75 - </div>
87 + ))}
88 + </Fragment>
89 );
90 }
src/devtools/views/root.css
+6
@@ -23,6 +23,9 @@
23 --light-color-dimmest: #eff0f1;
24 --light-color-jsx-arrow-brackets: #333333;
25 --light-color-jsx-arrow-brackets-inverted: rgba(255, 255, 255, 0.7);
26 + --light-color-record-active: #fc3a4b;
27 + --light-color-record-hover: #000000;
28 + --light-color-record-inactive: #cfd1d5;
29 --light-color-search-match: yellow;
30 --light-color-search-match-current: #f7923b;
31 --light-color-text-color: #000000;
@@ -49,6 +52,9 @@
52 --dark-color-dimmest: #4f5766;
53 --dark-color-jsx-arrow-brackets: #777d88;
54 --dark-color-jsx-arrow-brackets-inverted: rgba(255, 255, 255, 0.7);
55 + --dark-color-record-active: #fc3a4b;
56 + --dark-color-record-hover: #ffffff;
57 + --dark-color-record-inactive: #777d88;
58 --dark-color-search-match: yellow;
59 --dark-color-search-match-current: #f7923b;
60 --dark-color-text-color: #ffffff;