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
+}