@samitouri / QOS-React / commits / b8a52078c6

Added initial Suspense cache and loaded commit metadata

Brian Vaughn committed Mar 12, 2019 at 15:50 UTC b8a52078c6cd273a981caf3dc9d7762624d77391
13 files changed +622 -78
OVERVIEW.md
+4 -4
@@ -14,7 +14,7 @@ The old DevTools also rendered the entire application tree in the form of a larg
14
15 Every React commit that changes the tree in a way DevTools cares about results in an "_operations_" message being sent across the bridge. These messages are lightweight patches that describe the changes that were made. (We don't resend the full tree structure like in legacy DevTools.)
16
17 -The payload for each message is a typed array. The first entry is a number identifying which renderer the update belongs to (for multi-root support). The rest of the array depends on the operations being made to the tree.
17 +The payload for each message is a typed array. The first two entries are numbers that identify which renderer and root the update belongs to (for multi-root support). The rest of the array depends on the operations being made to the tree.
18
19 We only send the following bits of information: element type, id, parent id, owner id, name, and key. Additional information (e.g. props, state) requires a separate "_inspectElement_" message.
20
@@ -188,7 +188,7 @@ This information is kept on the backend until requested by the frontend (as desc
188
189 The profiling tab shows information for the currently-selected React root. When profiling completes (or when a new root is selected) the frontend first checks to see if there is any profiling data for the selected root. (Has it cached any "_operations_"?)
190
191 -If so, then it sends a "_profileSummary_" message with an id that identifies the root. The backend then returns the following information:
191 +If so, then it sends a "_getProfilingSummary_" message with an id that identifies the root. The backend then returns a "_profilingSummary_" message with the following information:
192
193 * root id (to match request and response)
194 * number of interactions that were traced for this root
@@ -216,7 +216,7 @@ Here is an example profile summary:
216 ],
217
218 // Tuples of fiber id and initial tree base duration
219 - treeBaseDuration: [
219 + initialTreeBaseDurations: [
220 1, // fiber id
221 11, // tree base duration when profiling started
222
@@ -335,4 +335,4 @@ Here is an example of a profiling session consisting of two interactions:
335 }
336 ```
337
338 -The backend does not need to resend the timestamp for each of the commits because that was already sent as part of the "_profileSummary_" message.
\ No newline at end of file
338 +The backend does not need to resend the timestamp for each of the commits because that was already sent as part of the "_profilingSummary_" message.
\ No newline at end of file
src/backend/agent.js
+20 -1
@@ -47,6 +47,7 @@ export default class Agent extends EventEmitter {
47 this._bridge = bridge;
48
49 bridge.addListener('getProfilingStatus', this.getProfilingStatus);
50 + bridge.addListener('getProfilingSummary', this.getProfilingSummary);
51 bridge.addListener('highlightElementInDOM', this.highlightElementInDOM);
52 bridge.addListener('inspectElement', this.inspectElement);
53 bridge.addListener('overrideContext', this.overrideContext);
@@ -79,6 +80,24 @@ export default class Agent extends EventEmitter {
80 this._bridge.send('profilingStatus', this._isProfiling);
81 };
82
83 + getProfilingSummary = ({
84 + rendererID,
85 + rootID,
86 + }: {
87 + rendererID: number,
88 + rootID: number,
89 + }) => {
90 + const renderer = this._rendererInterfaces[rendererID];
91 + if (renderer == null) {
92 + console.warn(`Invalid renderer id "${rendererID}"`);
93 + } else {
94 + this._bridge.send(
95 + 'profilingSummary',
96 + renderer.getProfilingSummary(rootID)
97 + );
98 + }
99 + };
100 +
101 highlightElementInDOM = ({
102 displayName,
103 id,
@@ -216,7 +235,7 @@ export default class Agent extends EventEmitter {
235 const renderer = ((this._rendererInterfaces[
236 (rendererID: any)
237 ]: any): RendererInterface);
219 - renderer.startProfiling();
238 + renderer.stopProfiling();
239 }
240 this._bridge.send('profilingStatus', this._isProfiling);
241 };
src/backend/renderer.js
+88 -21
@@ -26,9 +26,10 @@ import { inspectHooksOfFiber } from './ReactDebugHooks';
26 import type {
27 DevToolsHook,
28 Fiber,
29 + FiberData,
30 Interaction,
31 + ProfilingSummary,
32 ReactRenderer,
31 - FiberData,
33 RendererInterface,
34 } from './types';
35 import type { InspectedElement } from 'src/devtools/views/elements/types';
@@ -466,6 +467,13 @@ export function attach(
467 // and use a slow path to find each of the current Fibers.
468 const idToTreeBaseDurationMap: Map<number, number> = new Map();
469
470 + // When profiling is supported, we store the latest tree base durations for each Fiber.
471 + // This map enables us to filter these times by root when sending them to the frontend.
472 + const idToRootMap: Map<number, number> = new Map();
473 +
474 + // When a mount or update is in progress, this value tracks the root that is being operated on.
475 + let currentRootID: number = -1;
476 +
477 function getFiberID(primaryFiber: Fiber): number {
478 if (!fiberToIDMap.has(primaryFiber)) {
479 const id = getUID();
@@ -543,8 +551,9 @@ export function attach(
551 // Identify which renderer this update is coming from.
552 // This enables roots to be mapped to renderers,
553 // Which in turn enables fiber props, states, and hooks to be inspected.
546 - const idArray = new Uint32Array(1);
554 + const idArray = new Uint32Array(2);
555 idArray[0] = rendererID;
556 + idArray[1] = getFiberID(getPrimaryFiber(root.current));
557 addOperation(idArray, true);
558
559 // Let the frontend know about tree operations.
@@ -560,6 +569,7 @@ export function attach(
569
570 const isProfilingSupported = fiber.hasOwnProperty('treeBaseDuration');
571 if (isProfilingSupported) {
572 + idToRootMap.set(id, currentRootID);
573 idToTreeBaseDurationMap.set(id, fiber.treeBaseDuration);
574 }
575
@@ -636,8 +646,13 @@ export function attach(
646 }
647 fiberToIDMap.delete(primaryFiber);
648 idToFiberMap.delete(id);
639 - idToTreeBaseDurationMap.delete(primaryFiber);
649 primaryFibers.delete(primaryFiber);
650 +
651 + const isProfilingSupported = fiber.hasOwnProperty('treeBaseDuration');
652 + if (isProfilingSupported) {
653 + idToRootMap.delete(id);
654 + idToTreeBaseDurationMap.delete(id);
655 + }
656 }
657
658 function mountFiber(fiber: Fiber, parentFiber: Fiber | null) {
@@ -672,13 +687,16 @@ export function attach(
687
688 if (fiber.actualDuration > 0) {
689 // 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 - }
690 + const metadata = ((currentCommitProfilingMetadata: any): CommitProfilingData);
691 + metadata.committedFibers.push({
692 + id,
693 + actualDuration: fiber.actualDuration,
694 + selfDuration: fiber.selfDuration,
695 + treeBaseDuration: fiber.treeBaseDuration,
696 + });
697 + metadata.maxActualDuration = Math.max(
698 + metadata.maxActualDuration,
699 + fiber.actualDuration
700 );
701 }
702 }
@@ -819,8 +837,10 @@ export function attach(
837 function walkTree() {
838 // Hydrate all the roots for the first time.
839 hook.getFiberRoots(rendererID).forEach(root => {
840 + currentRootID = getFiberID(getPrimaryFiber(root.current));
841 mountFiber(root.current, null);
842 flushPendingEvents(root);
843 + currentRootID = -1;
844 });
845 }
846
@@ -835,6 +855,8 @@ export function attach(
855 const current = root.current;
856 const alternate = current.alternate;
857
858 + currentRootID = getFiberID(getPrimaryFiber(current));
859 +
860 if (isProfiling) {
861 // If profiling is active, store commit time and duration, and the current interactions.
862 // The frontend may request this information after profiling has stopped.
@@ -847,6 +869,7 @@ export function attach(
869 timestamp: timestamp - profilingStartTime,
870 })
871 ),
872 + maxActualDuration: 0,
873 };
874 }
875
@@ -873,13 +896,25 @@ export function attach(
896 }
897
898 if (isProfiling) {
876 - ((commitProfilingMetadata: any): Array<CommitProfilingData>).push(
877 - ((currentCommitProfilingMetadata: any): CommitProfilingData)
899 + const commitProfilingMetadata = ((rootToCommitProfilingMetadataMap: any): CommitProfilingMetadataMap).get(
900 + currentRootID
901 );
902 + if (commitProfilingMetadata != null) {
903 + commitProfilingMetadata.push(
904 + ((currentCommitProfilingMetadata: any): CommitProfilingData)
905 + );
906 + } else {
907 + ((rootToCommitProfilingMetadataMap: any): CommitProfilingMetadataMap).set(
908 + currentRootID,
909 + [((currentCommitProfilingMetadata: any): CommitProfilingData)]
910 + );
911 + }
912 }
913
914 // We're done here.
915 flushPendingEvents(root);
916 +
917 + currentRootID = -1;
918 }
919
920 // The naming is confusing.
@@ -1333,24 +1368,55 @@ export function attach(
1368 committedFibers: Array<CommittedFiber>,
1369 commitTime: number,
1370 interactions: Array<Interaction>,
1371 + maxActualDuration: number,
1372 |};
1373
1338 - let commitProfilingMetadata: Array<CommitProfilingData> | null = null;
1374 + type CommitProfilingMetadataMap = Map<number, Array<CommitProfilingData>>;
1375 +
1376 let currentCommitProfilingMetadata: CommitProfilingData | null = null;
1340 - let initialTreeBaseDurations: Array<number> | null = null;
1377 + let initialTreeBaseDurationsMap: Map<number, number> | null = null;
1378 let isProfiling: boolean = false;
1379 let profilingStartTime: number = 0;
1380 + let rootToCommitProfilingMetadataMap: CommitProfilingMetadataMap | null = null;
1381 +
1382 + function getProfilingSummary(rootID: number): ProfilingSummary {
1383 + const interactions = new Set();
1384 + const commits = [];
1385 +
1386 + const commitProfilingMetadata = ((rootToCommitProfilingMetadataMap: any): CommitProfilingMetadataMap).get(
1387 + rootID
1388 + );
1389 + if (commitProfilingMetadata != null) {
1390 + commitProfilingMetadata.forEach(metadata => {
1391 + commits.push(metadata.commitTime, metadata.maxActualDuration);
1392 + metadata.interactions.forEach(({ name, timestamp }) => {
1393 + interactions.add(`${timestamp}:${name}`);
1394 + });
1395 + });
1396 + }
1397 +
1398 + const initialTreeBaseDurations = [];
1399 + ((initialTreeBaseDurationsMap: any): Map<number, number>).forEach(
1400 + (treeBaseDuration, id) => {
1401 + if (idToRootMap.get(id) === rootID) {
1402 + initialTreeBaseDurations.push(id, treeBaseDuration);
1403 + }
1404 + }
1405 + );
1406 +
1407 + return {
1408 + commits,
1409 + initialTreeBaseDurations,
1410 + interactionCount: interactions.size,
1411 + rootID,
1412 + };
1413 + }
1414
1415 function startProfiling() {
1345 - commitProfilingMetadata = [];
1346 - initialTreeBaseDurations = [];
1416 + initialTreeBaseDurationsMap = new Map(idToTreeBaseDurationMap);
1417 isProfiling = true;
1418 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 - });
1419 + rootToCommitProfilingMetadataMap = new Map();
1420 }
1421
1422 function stopProfiling() {
@@ -1361,6 +1427,7 @@ export function attach(
1427 cleanup,
1428 getFiberIDFromNative,
1429 getNativeFromReactElement,
1430 + getProfilingSummary,
1431 handleCommitFiberRoot,
1432 handleCommitFiberUnmount,
1433 inspectElement,
src/backend/types.js
+8
@@ -54,6 +54,13 @@ export type ReactRenderer = {
54 currentDispatcherRef?: {| current: null | Dispatcher |},
55 };
56
57 +export type ProfilingSummary = {|
58 + commits: Array<number>,
59 + initialTreeBaseDurations: Array<number>,
60 + interactionCount: number,
61 + rootID: number,
62 +|};
63 +
64 export type RendererInterface = {
65 cleanup: () => void,
66 getNativeFromReactElement?: ?(component: Fiber) => ?NativeType,
@@ -61,6 +68,7 @@ export type RendererInterface = {
68 component: NativeType,
69 findNearestUnfilteredAncestor?: boolean
70 ) => number | null,
71 + getProfilingSummary: (rootID: number) => ProfilingSummary,
72 handleCommitFiberRoot: (fiber: Object) => void,
73 handleCommitFiberUnmount: (fiber: Object) => void,
74 inspectElement: (id: number) => InspectedElement | null,
src/devtools/ProfilingCache.js new
+77
@@ -0,0 +1,77 @@
1 +// @flow
2 +
3 +import { createResource, invalidateResources } from './cache';
4 +import Store from './store';
5 +
6 +import type { Resource } from './cache';
7 +import type { Bridge } from '../types';
8 +import type { ProfilingSummary as ProfilingSummaryBackend } from 'src/backend/types';
9 +import type { ProfilingSummary as ProfilingSummaryFrontend } from 'src/devtools/views/Profiler/types';
10 +
11 +type AAA = {|
12 + rootID: number,
13 + rendererID: number,
14 +|};
15 +
16 +export default class ProfilingCache {
17 + _pendingProfileSummaryMap: Map<
18 + number,
19 + (profilingSummary: ProfilingSummaryFrontend) => void
20 + > = new Map();
21 +
22 + ProfilingSummary: Resource<AAA, ProfilingSummaryFrontend>;
23 +
24 + // TODO (profiling) renderer + root
25 +
26 + constructor(bridge: Bridge, store: Store) {
27 + this.ProfilingSummary = createResource(
28 + ({ rendererID, rootID }: AAA) => {
29 + return new Promise(resolve => {
30 + if (!store._profilingOperations.has(rootID)) {
31 + // If no profiling data was recorded for this root, skip the round trip.
32 + resolve({
33 + commits: [],
34 + initialTreeBaseDurations: new Map(),
35 + interactionCount: 0,
36 + });
37 + } else {
38 + this._pendingProfileSummaryMap.set(rootID, resolve);
39 + bridge.send('getProfilingSummary', { rendererID, rootID });
40 + }
41 + });
42 + },
43 + ({ rendererID, rootID }: AAA) => rootID
44 + );
45 +
46 + bridge.addListener('profilingSummary', this.onProfileSummary);
47 + }
48 +
49 + invalidate() {
50 + invalidateResources();
51 + this._pendingProfileSummaryMap = new Map();
52 + }
53 +
54 + onProfileSummary = ({
55 + commits,
56 + initialTreeBaseDurations,
57 + interactionCount,
58 + rootID,
59 + }: ProfilingSummaryBackend) => {
60 + const resolve = this._pendingProfileSummaryMap.get(rootID);
61 + if (resolve != null) {
62 + const initialTreeBaseDurationsMap = new Map();
63 + for (let i = 0; i < initialTreeBaseDurations.length; i++) {
64 + initialTreeBaseDurationsMap.set(
65 + initialTreeBaseDurations[i],
66 + initialTreeBaseDurations[i + 1]
67 + );
68 + }
69 +
70 + resolve({
71 + commits,
72 + initialTreeBaseDurations: initialTreeBaseDurationsMap,
73 + interactionCount,
74 + });
75 + }
76 + };
77 +}
src/devtools/cache.js new
+146
@@ -0,0 +1,146 @@
1 +// @flow
2 +
3 +import { createContext, useContext } from 'react';
4 +
5 +// Cache implementation was forked from the React repo:
6 +// https://github.com/facebook/react/blob/master/packages/react-cache/src/ReactCache.js
7 +//
8 +// This cache is simpler than react-cache in that:
9 +// 1. Individual items don't need to be invalidated.
10 +// Profiling data is invalidated as a whole.
11 +// 2. We didn't need the added overhead of an LRU cache.
12 +// The size of this cache is bounded by how many renders were profiled,
13 +// and it will be fully reset between profiling sessions.
14 +
15 +type Thenable<T> = {
16 + then(resolve: (T) => mixed, reject: (mixed) => mixed): mixed,
17 +};
18 +
19 +type Suspender = {
20 + then(resolve: () => mixed, reject: () => mixed): mixed,
21 +};
22 +
23 +type PendingResult = {|
24 + status: 0,
25 + value: Suspender,
26 +|};
27 +
28 +type ResolvedResult<V> = {|
29 + status: 1,
30 + value: V,
31 +|};
32 +
33 +type RejectedResult = {|
34 + status: 2,
35 + value: mixed,
36 +|};
37 +
38 +type Result<V> = PendingResult | ResolvedResult<V> | RejectedResult;
39 +
40 +export type Resource<I, V> = {
41 + read(I): V,
42 + preload(I): void,
43 +};
44 +
45 +const Pending = 0;
46 +const Resolved = 1;
47 +const Rejected = 2;
48 +
49 +function identityHashFn(input) {
50 + return input;
51 +}
52 +
53 +const CacheContext = createContext(null);
54 +
55 +const entries: Map<Resource<any, any>, Map<any, any>> = new Map();
56 +
57 +function accessResult<I, K, V>(
58 + resource: any,
59 + fetch: I => Thenable<V>,
60 + input: I,
61 + key: K
62 +): Result<V> {
63 + let entriesForResource = entries.get(resource);
64 + if (entriesForResource === undefined) {
65 + entriesForResource = new Map();
66 + entries.set(resource, entriesForResource);
67 + }
68 + let entry = entriesForResource.get(key);
69 + if (entry === undefined) {
70 + const thenable = fetch(input);
71 + thenable.then(
72 + value => {
73 + if (newResult.status === Pending) {
74 + const resolvedResult: ResolvedResult<V> = (newResult: any);
75 + resolvedResult.status = Resolved;
76 + resolvedResult.value = value;
77 + }
78 + },
79 + error => {
80 + if (newResult.status === Pending) {
81 + const rejectedResult: RejectedResult = (newResult: any);
82 + rejectedResult.status = Rejected;
83 + rejectedResult.value = error;
84 + }
85 + }
86 + );
87 + const newResult: PendingResult = {
88 + status: Pending,
89 + value: thenable,
90 + };
91 + entriesForResource.set(key, newResult);
92 + return newResult;
93 + } else {
94 + return entry;
95 + }
96 +}
97 +
98 +export function createResource<I, K: string | number, V>(
99 + fetch: I => Thenable<V>,
100 + maybeHashInput?: I => K
101 +): Resource<I, V> {
102 + const hashInput: I => K =
103 + maybeHashInput !== undefined ? maybeHashInput : (identityHashFn: any);
104 +
105 + const resource = {
106 + read(input: I): V {
107 + // Prevent access outside of render.
108 + // eslint-disable-next-line react-hooks/rules-of-hooks
109 + useContext(CacheContext);
110 +
111 + const key = hashInput(input);
112 + const result: Result<V> = accessResult(resource, fetch, input, key);
113 + switch (result.status) {
114 + case Pending: {
115 + const suspender = result.value;
116 + throw suspender;
117 + }
118 + case Resolved: {
119 + const value = result.value;
120 + return value;
121 + }
122 + case Rejected: {
123 + const error = result.value;
124 + throw error;
125 + }
126 + default:
127 + // Should be unreachable
128 + return (undefined: any);
129 + }
130 + },
131 +
132 + preload(input: I): void {
133 + // Prevent access outside of render.
134 + // eslint-disable-next-line react-hooks/rules-of-hooks
135 + useContext(CacheContext);
136 +
137 + const key = hashInput(input);
138 + accessResult(resource, fetch, input, key);
139 + },
140 + };
141 + return resource;
142 +}
143 +
144 +export function invalidateResources(): void {
145 + entries.clear();
146 +}
src/devtools/store.js
+55 -7
@@ -9,6 +9,7 @@ import {
9 import { ElementTypeRoot } from './types';
10 import { utfDecodeString } from '../utils';
11 import { __DEBUG__ } from '../constants';
12 +import ProfilingCache from './ProfilingCache';
13
14 import type { ElementType } from './types';
15 import type { Element } from './views/elements/types';
@@ -47,6 +48,7 @@ export default class Store extends EventEmitter {
48 // Elements are mutable (for now) to avoid excessive cloning during tree updates.
49 _idToElement: Map<number, Element> = new Map();
50
51 + // The backend is currently profiling.
52 // When profiling is in progress, operations are stored so that we can later reconstruct past commit trees.
53 _isProfiling: boolean = false;
54
@@ -54,7 +56,10 @@ export default class Store extends EventEmitter {
56 // Used for windowing purposes.
57 _numElements: number = 0;
58
57 - // List of tree mutation that occur during profiling.
59 + // Suspense cache for reading profilign data.
60 + _profilingCache: ProfilingCache;
61 +
62 + // Map of root (id) to a list of tree mutation that occur during profiling.
63 // Once profiling is finished, these mutations can be used, along with the initial tree snapshots,
64 // to reconstruct the state of each root for each commit.
65 _profilingOperations: Map<number, Array<Uint32Array>> = new Map();
@@ -93,12 +98,27 @@ export default class Store extends EventEmitter {
98 // It's possible that profiling has already started (e.g. "reload and start profiling")
99 // so the frontend needs to ask the backend for its status after mounting.
100 bridge.send('getProfilingStatus');
101 +
102 + this._profilingCache = new ProfilingCache(bridge, this);
103 + }
104 +
105 + // Profiling data has been recorded for at least one root.
106 + get hasProfilingData(): boolean {
107 + return this._profilingOperations.size > 0;
108 + }
109 +
110 + get isProfiling(): boolean {
111 + return this._isProfiling;
112 }
113
114 get numElements(): number {
115 return this._numElements;
116 }
117
118 + get profilingCache(): ProfilingCache {
119 + return this._profilingCache;
120 + }
121 +
122 get revision(): number {
123 return this._revision;
124 }
@@ -242,6 +262,30 @@ export default class Store extends EventEmitter {
262 return null;
263 }
264
265 + getRootIDForElement(id: number): number | null {
266 + let current = this._idToElement.get(id);
267 + while (current != null) {
268 + if (current.parentID === 0) {
269 + return current.id;
270 + } else {
271 + current = this._idToElement.get(current.parentID);
272 + }
273 + }
274 + return null;
275 + }
276 +
277 + startProfiling(): void {
278 + this._bridge.send('startProfiling');
279 + this._isProfiling = false;
280 + this.emit('isProfiling');
281 + }
282 +
283 + stopProfiling(): void {
284 + this._bridge.send('stopProfiling');
285 + this._isProfiling = false;
286 + this.emit('isProfiling');
287 + }
288 +
289 _takeProfilingSnapshotRecursive = (id: number) => {
290 const element = this.getElementByID(id);
291 if (element !== null) {
@@ -267,11 +311,12 @@ export default class Store extends EventEmitter {
311 let haveRootsChanged = false;
312
313 const rendererID = operations[0];
314 + const rootID = operations[1];
315
316 if (this._isProfiling) {
272 - const profilingOperations = this._profilingOperations.get(rendererID);
317 + const profilingOperations = this._profilingOperations.get(rootID);
318 if (profilingOperations == null) {
274 - this._profilingOperations.set(rendererID, [operations]);
319 + this._profilingOperations.set(rootID, [operations]);
320 } else {
321 profilingOperations.push(operations);
322 }
@@ -280,7 +325,7 @@ export default class Store extends EventEmitter {
325 let addedElementIDs: Uint32Array = new Uint32Array(0);
326 let removedElementIDs: Uint32Array = new Uint32Array(0);
327
283 - let i = 1;
328 + let i = 2;
329 while (i < operations.length) {
330 let id: number = ((null: any): number);
331 let element: Element = ((null: any): Element);
@@ -482,13 +527,16 @@ export default class Store extends EventEmitter {
527 };
528
529 onProfilingStatus = (isProfiling: boolean) => {
485 - this._isProfiling = isProfiling;
486 -
530 if (isProfiling) {
531 + this._profilingOperations = new Map();
532 this._profilingSnapshot = new Map();
489 -
533 this.roots.forEach(this._takeProfilingSnapshotRecursive);
534 }
535 +
536 + if (this._isProfiling !== isProfiling) {
537 + this._isProfiling = isProfiling;
538 + this.emit('isProfiling');
539 + }
540 };
541
542 onBridgeShutdown = () => {
src/devtools/views/Profiler/Profiler.js
+8 -17
@@ -1,6 +1,6 @@
1 // @flow
2
3 -import React, { Fragment, useCallback, useContext, useState } from 'react';
3 +import React, { useCallback, useContext, useState } from 'react';
4 import { ProfilerContext, ProfilerContextController } from './ProfilerContext';
5 import Button from '../Button';
6 import ButtonIcon from '../ButtonIcon';
@@ -23,15 +23,15 @@ export default function ProfilerOuter(_: Props) {
23
24 function ProfilerInner(_: Props) {
25 const { hasProfilingData, isProfiling } = useContext(ProfilerContext);
26 +
27 + const showProfilingControls = !isProfiling && hasProfilingData;
28 +
29 const [tab, setTab] = useState('flame-chart');
30 const [isFilterModalShowing, setIsFilterModalShowing] = useState(false);
31
32 const showFilterModal = useCallback(() => setIsFilterModalShowing(true));
33 const dismissFilterModal = useCallback(() => setIsFilterModalShowing(false));
34
32 - // TODO (profiling) Maybe a smarter check here
33 - const showProfilingData = !isProfiling && hasProfilingData;
34 -
35 let view = null;
36 if (isProfiling) {
37 view = <RecortdingInProgress />;
@@ -55,7 +55,7 @@ function ProfilerInner(_: Props) {
55 <div className={styles.VRule} />
56 <TabBar
57 currentTab={tab}
58 - disabled={!showProfilingData}
58 + disabled={!showProfilingControls}
59 id="Profiler"
60 selectTab={setTab}
61 size="small"
@@ -65,12 +65,7 @@ function ProfilerInner(_: Props) {
65 <Button onClick={showFilterModal} title="Filter commits by duration">
66 <ButtonIcon type="filter" />
67 </Button>
68 - {showProfilingData && (
69 - <Fragment>
70 - <div className={styles.VRule} />
71 - <SnapshotSelector />
72 - </Fragment>
73 - )}
68 + {showProfilingControls && <SnapshotSelector />}
69 </div>
70 <div className={styles.Content}>
71 {view}
@@ -79,15 +74,11 @@ function ProfilerInner(_: Props) {
74 )}
75 </div>
76 </div>
82 - {showProfilingData && (
77 + {showProfilingControls && (
78 <div className={styles.RightColumn}>
79 {/* TODO (profiler) Dynamic information */}
80 <div className={styles.Toolbar}>Commit information</div>
86 - <div className={styles.InspectedProperties}>
87 - Committed at: 0.8s <br />
88 - <br />
89 - Render duration: 2.1ms
90 - </div>
81 + <div className={styles.InspectedProperties} />
82 </div>
83 )}
84 </div>
src/devtools/views/Profiler/ProfilerContext.js
+76 -15
@@ -7,13 +7,18 @@ import React, {
7 useMemo,
8 useState,
9 } from 'react';
10 -import { BridgeContext, StoreContext } from '../context';
11 -
12 -// TODO (profiling) Connect to store and listen for new roots and load data.
10 +import { useSubscription } from '../hooks';
11 +import { TreeContext } from 'src/devtools/views/Elements/TreeContext';
12 +import { StoreContext } from '../context';
13 +import Store from '../../store';
14
15 type Context = {|
16 + commitIndex: number,
17 hasProfilingData: boolean,
18 isProfiling: boolean,
19 + rendererID: number | null,
20 + rootID: number | null,
21 + setCommitIndex: (value: number) => void,
22 startProfiling(value: boolean): void,
23 stopProfiling(value: boolean): void,
24 |};
@@ -21,33 +26,89 @@ type Context = {|
26 const ProfilerContext = createContext<Context>(((null: any): Context));
27 ProfilerContext.displayName = 'ProfilerContext';
28
29 +type StoreProfilingState = {|
30 + hasProfilingData: boolean,
31 + isProfiling: boolean,
32 +|};
33 +
34 type Props = {|
35 children: React$Node,
36 |};
37
38 function ProfilerContextController({ children }: Props) {
29 - const bridge = useContext(BridgeContext);
39 const store = useContext(StoreContext);
40
32 - const [isProfiling, setIsProfiling] = useState(false);
41 + const { isProfiling, hasProfilingData } = useSubscription<
42 + StoreProfilingState,
43 + Store
44 + >(
45 + useMemo(
46 + () => ({
47 + source: store,
48 + getCurrentValue: (store: Store) => ({
49 + hasProfilingData: store.hasProfilingData,
50 + isProfiling: store.isProfiling,
51 + }),
52 + subscribe: (store: Store, callback: Function) => {
53 + store.addListener('isProfiling', callback);
54 + return () => store.removeListener('isProfiling', callback);
55 + },
56 + }),
57 + [store]
58 + )
59 + );
60 +
61 + const startProfiling = useCallback(() => store.startProfiling(), [store]);
62 + const stopProfiling = useCallback(() => store.stopProfiling(), [store]);
63 +
64 + // TODO (profiling) The browser extension is a multi-root app,
65 + // so ti won't work for the "Profiling" root to depend on a value that's set by the "Elements" root.
66 + // We'll either need to lift that state up into the (shared) Store,
67 + // or use a portal to share the contexts themselves between Chrome tabs.
68 + const { selectedElementID } = useContext(TreeContext);
69 +
70 + // If no root is selected, assume the first root.
71 + // Many React apps are single root anyway.
72 + let rendererID = null;
73 + let rootID = null;
74 + if (selectedElementID) {
75 + rendererID = store.getRendererIDForElement(
76 + ((selectedElementID: any): number)
77 + );
78 + rootID = store.getRootIDForElement(((selectedElementID: any): number));
79 + } else if (store.roots.length > 0) {
80 + rootID = store.roots[0];
81 + rendererID = store.getRendererIDForElement(((rootID: any): number));
82 + }
83
34 - const startProfiling = useCallback(() => {
35 - bridge.send('startProfiling');
36 - setIsProfiling(true);
37 - }, [bridge]);
38 - const stopProfiling = useCallback(() => {
39 - bridge.send('stopProfiling');
40 - setIsProfiling(false);
41 - }, [bridge]);
84 + const [commitIndex, setCommitIndex] = useState(0);
85 + const [prevRootID, setPrevRootID] = useState(rootID);
86 + if (prevRootID !== rootID) {
87 + setPrevRootID(rootID);
88 + setCommitIndex(0);
89 + }
90
91 const value = useMemo(
92 () => ({
45 - hasProfilingData: false, // TODO (profiling) Connect to store and listen for new roots and load data.
93 + commitIndex,
94 + hasProfilingData,
95 isProfiling,
96 + rendererID,
97 + rootID,
98 + setCommitIndex,
99 startProfiling,
100 stopProfiling,
101 }),
50 - [isProfiling, startProfiling, stopProfiling]
102 + [
103 + commitIndex,
104 + hasProfilingData,
105 + isProfiling,
106 + rendererID,
107 + rootID,
108 + setCommitIndex,
109 + startProfiling,
110 + stopProfiling,
111 + ]
112 );
113
114 return (
src/devtools/views/Profiler/SnapshotSelector.css
+7
@@ -12,3 +12,10 @@
12 .Commits {
13 margin-left: 0.25rem;
14 }
15 +
16 +.VRule {
17 + height: 20px;
18 + width: 1px;
19 + background-color: var(--color-border);
20 + margin: 0 0.25rem;
21 +}
src/devtools/views/Profiler/SnapshotSelector.js
+67 -13
@@ -1,26 +1,80 @@
1 // @flow
2
3 -import React from 'react';
3 +import React, { Fragment, Suspense, useContext } from 'react';
4 import Button from '../Button';
5 import ButtonIcon from '../ButtonIcon';
6 +import { StoreContext } from '../context';
7 +import { ProfilerContext } from './ProfilerContext';
8
9 import styles from './SnapshotSelector.css';
10
11 export type Props = {||};
12
11 -export default function SnapshotSelector(_: Props) {
13 +export default function SnapshotSelectorSuspense(_: Props) {
14 return (
13 - <div className={styles.SnapshotSelector}>
14 - 1 / 3
15 - <Button className={styles.Button}>
16 - <ButtonIcon type="previous" />
17 - </Button>
18 - <div className={styles.Commits}>
19 - [] {/* TODO (profiling) Add FixedSizeList selector */}
15 + <Suspense fallback={<SnapshotSelectorFallback />}>
16 + <SnapshotSelector />
17 + </Suspense>
18 + );
19 +}
20 +
21 +function SnapshotSelector(_: Props) {
22 + const { profilingCache } = useContext(StoreContext);
23 + const { commitIndex, rendererID, rootID, setCommitIndex } = useContext(
24 + ProfilerContext
25 + );
26 +
27 + if (rendererID === null || rootID === null) {
28 + return null;
29 + }
30 +
31 + // TODO (profiling) Parse the summary into something easier for the views to work with
32 + const profilingSummary = profilingCache.ProfilingSummary.read({
33 + rendererID: ((rendererID: any): number),
34 + rootID: ((rootID: any): number),
35 + });
36 +
37 + const numCommits = profilingSummary.commits.length / 2;
38 +
39 + if (numCommits === 0) {
40 + return null;
41 + }
42 +
43 + const viewNextCommit = () => {
44 + setCommitIndex(Math.min(commitIndex + 1, numCommits - 1));
45 + };
46 + const viewPrevCommit = () => {
47 + setCommitIndex(Math.max(commitIndex - 1, 0));
48 + };
49 +
50 + return (
51 + <Fragment>
52 + <div className={styles.VRule} />
53 + <div className={styles.SnapshotSelector}>
54 + {commitIndex + 1} / {numCommits}
55 + <Button
56 + className={styles.Button}
57 + disabled={commitIndex <= 0}
58 + onClick={viewPrevCommit}
59 + >
60 + <ButtonIcon type="previous" />
61 + </Button>
62 + <div className={styles.Commits}>
63 + [] {/* TODO (profiling) Add FixedSizeList selector */}
64 + </div>
65 + <Button
66 + className={styles.Button}
67 + disabled={commitIndex >= numCommits - 1}
68 + onClick={viewNextCommit}
69 + >
70 + <ButtonIcon type="next" />
71 + </Button>
72 </div>
21 - <Button className={styles.Button}>
22 - <ButtonIcon type="next" />
23 - </Button>
24 - </div>
73 + </Fragment>
74 );
75 }
76 +
77 +function SnapshotSelectorFallback() {
78 + // TODO (profiling) Better loading UI
79 + return <div className={styles.SnapshotSelector}>Loading...</div>;
80 +}
src/devtools/views/Profiler/types.js
+10
@@ -27,3 +27,13 @@ export type CommitDetails = {|
27 root: Node,
28 timestamp: number,
29 |};
30 +
31 +export type ProfilingSummary = {|
32 + // Tuples of commit time (relative to when profiling started) and duration
33 + commits: Array<number>,
34 +
35 + // Map of fiber id to (initial) tree base duration
36 + initialTreeBaseDurations: Map<number, number>,
37 +
38 + interactionCount: number,
39 +|};
src/devtools/views/hooks.js
+56
@@ -83,3 +83,59 @@ export function useModalDismissSignal(
83 };
84 }, [modalRef, dismissCallback]);
85 }
86 +
87 +// Copied from https://github.com/facebook/react/pull/15022
88 +export function useSubscription<Value, Source>({
89 + source,
90 + getCurrentValue,
91 + subscribe,
92 +}: {|
93 + source: Source,
94 + getCurrentValue: (source: Source) => Value,
95 + subscribe: (source: Source, callback: Function) => () => void,
96 +|}): Value {
97 + const [state, setState] = useState({
98 + source,
99 + value: getCurrentValue(source),
100 + });
101 +
102 + if (state.source !== source) {
103 + setState({
104 + source,
105 + value: getCurrentValue(source),
106 + });
107 + }
108 +
109 + useEffect(() => {
110 + let didUnsubscribe = false;
111 +
112 + const checkForUpdates = () => {
113 + if (didUnsubscribe) {
114 + return;
115 + }
116 +
117 + setState(prevState => {
118 + if (prevState.source !== source) {
119 + return prevState;
120 + }
121 +
122 + const value = getCurrentValue(source);
123 + if (prevState.value === value) {
124 + return prevState;
125 + }
126 +
127 + return { ...prevState, value };
128 + });
129 + };
130 + const unsubscribe = subscribe(source, checkForUpdates);
131 +
132 + checkForUpdates();
133 +
134 + return () => {
135 + didUnsubscribe = true;
136 + unsubscribe();
137 + };
138 + }, [getCurrentValue, source, subscribe]);
139 +
140 + return state.value;
141 +}