Merge pull request #310 from bvaughn/track-changed-props

Add profiling option to track why a component rendered
This commit is contained in:
Brian Vaughn
2019-06-08 17:45:46 -07:00
committed by GitHub
21 changed files with 2451 additions and 46 deletions
File diff suppressed because it is too large Load Diff
+1
View File
@@ -29,6 +29,7 @@ describe('ProfilerContext', () => {
bridge = global.bridge;
store = global.store;
store.collapseNodesByDefault = false;
store.recordChangeDescriptions = true;
React = require('react');
ReactDOM = require('react-dom');
+1
View File
@@ -14,6 +14,7 @@ describe('ProfilerStore', () => {
store = global.store;
store.collapseNodesByDefault = false;
store.recordChangeDescriptions = true;
React = require('react');
ReactDOM = require('react-dom');
+114
View File
@@ -5,6 +5,7 @@ import type Bridge from 'src/bridge';
import type Store from 'src/devtools/store';
describe('ProfilingCache', () => {
let PropTypes;
let React;
let ReactDOM;
let Scheduler;
@@ -21,7 +22,9 @@ describe('ProfilingCache', () => {
bridge = global.bridge;
store = global.store;
store.collapseNodesByDefault = false;
store.recordChangeDescriptions = true;
PropTypes = require('prop-types');
React = require('react');
ReactDOM = require('react-dom');
Scheduler = require('scheduler');
@@ -187,6 +190,117 @@ describe('ProfilingCache', () => {
}
});
it('should record changed props/state/context/hooks', () => {
let instance = null;
const ModernContext = React.createContext(0);
class LegacyContextProvider extends React.Component<
any,
{| count: number |}
> {
static childContextTypes = {
count: PropTypes.number,
};
state = { count: 0 };
getChildContext() {
return this.state;
}
render() {
instance = this;
return (
<ModernContext.Provider value={this.state.count}>
<React.Fragment>
<ModernContextConsumer />
<LegacyContextConsumer />
</React.Fragment>
</ModernContext.Provider>
);
}
}
const FunctionComponentWithHooks = ({ count }) => {
React.useMemo(() => count, [count]);
return null;
};
class ModernContextConsumer extends React.Component<any> {
static contextType = ModernContext;
render() {
return <FunctionComponentWithHooks count={this.context} />;
}
}
class LegacyContextConsumer extends React.Component<any> {
static contextTypes = {
count: PropTypes.number,
};
render() {
return <FunctionComponentWithHooks count={this.context.count} />;
}
}
const container = document.createElement('div');
utils.act(() => store.profilerStore.startProfiling());
utils.act(() => ReactDOM.render(<LegacyContextProvider />, container));
expect(instance).not.toBeNull();
utils.act(() => (instance: any).setState({ count: 1 }));
utils.act(() =>
ReactDOM.render(<LegacyContextProvider foo={123} />, container)
);
utils.act(() =>
ReactDOM.render(<LegacyContextProvider bar="abc" />, container)
);
utils.act(() => ReactDOM.render(<LegacyContextProvider />, container));
utils.act(() => store.profilerStore.stopProfiling());
const allCommitData = [];
function Validator({ commitIndex, previousCommitDetails, rootID }) {
const commitData = store.profilerStore.getCommitData(rootID, commitIndex);
if (previousCommitDetails != null) {
expect(commitData).toEqual(previousCommitDetails);
} else {
allCommitData.push(commitData);
expect(commitData).toMatchSnapshot(
`CommitDetails commitIndex: ${commitIndex}`
);
}
return null;
}
const rootID = store.roots[0];
for (let commitIndex = 0; commitIndex < 5; commitIndex++) {
utils.act(() => {
TestRenderer.create(
<Validator
commitIndex={commitIndex}
previousCommitDetails={null}
rootID={rootID}
/>
);
});
}
expect(allCommitData).toHaveLength(5);
utils.exportImportHelper(bridge, store);
for (let commitIndex = 0; commitIndex < 5; commitIndex++) {
utils.act(() => {
TestRenderer.create(
<Validator
commitIndex={commitIndex}
previousCommitDetails={allCommitData[commitIndex]}
rootID={rootID}
/>
);
});
}
});
it('should calculate a self duration based on actual children (not filtered children)', () => {
store.componentFilters = [utils.createDisplayNameFilter('^Parent$')];
+1
View File
@@ -18,6 +18,7 @@ describe('profiling charts', () => {
store = global.store;
store.collapseNodesByDefault = false;
store.recordChangeDescriptions = true;
React = require('react');
ReactDOM = require('react-dom');
@@ -17,6 +17,7 @@ describe('commit tree', () => {
store = global.store;
store.collapseNodesByDefault = false;
store.recordChangeDescriptions = true;
React = require('react');
ReactDOM = require('react-dom');
@@ -21,6 +21,7 @@ describe('Store component filters', () => {
store = global.store;
store.collapseNodesByDefault = false;
store.componentFilters = [];
store.recordChangeDescriptions = true;
React = require('react');
ReactDOM = require('react-dom');
+17 -4
View File
@@ -6,6 +6,7 @@ import throttle from 'lodash.throttle';
import {
SESSION_STORAGE_LAST_SELECTION_KEY,
SESSION_STORAGE_RELOAD_AND_PROFILE_KEY,
SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY,
__DEBUG__,
} from '../constants';
import {
@@ -69,6 +70,7 @@ type PersistedSelection = {|
export default class Agent extends EventEmitter {
_bridge: Bridge;
_isProfiling: boolean = false;
_recordChangeDescriptions: boolean = false;
_rendererInterfaces: { [key: RendererID]: RendererInterface } = {};
_persistedSelection: PersistedSelection | null = null;
_persistedSelectionMatch: PathMatch | null = null;
@@ -79,8 +81,13 @@ export default class Agent extends EventEmitter {
if (
sessionStorageGetItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY) === 'true'
) {
this._recordChangeDescriptions =
sessionStorageGetItem(
SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY
) === 'true';
this._isProfiling = true;
sessionStorageRemoveItem(SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY);
sessionStorageRemoveItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY);
}
@@ -250,8 +257,12 @@ export default class Agent extends EventEmitter {
}
};
reloadAndProfile = () => {
reloadAndProfile = (recordChangeDescriptions: boolean) => {
sessionStorageSetItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY, 'true');
sessionStorageSetItem(
SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY,
recordChangeDescriptions ? 'true' : 'false'
);
// This code path should only be hit if the shell has explicitly told the Store that it supports profiling.
// In that case, the shell must also listen for this specific message to know when it needs to reload the app.
@@ -358,7 +369,7 @@ export default class Agent extends EventEmitter {
this._rendererInterfaces[rendererID] = rendererInterface;
if (this._isProfiling) {
rendererInterface.startProfiling();
rendererInterface.startProfiling(this._recordChangeDescriptions);
}
// When the renderer is attached, we need to tell it whether
@@ -397,13 +408,14 @@ export default class Agent extends EventEmitter {
window.addEventListener('pointerup', this._onPointerUp, true);
};
startProfiling = () => {
startProfiling = (recordChangeDescriptions: boolean) => {
this._recordChangeDescriptions = recordChangeDescriptions;
this._isProfiling = true;
for (let rendererID in this._rendererInterfaces) {
const renderer = ((this._rendererInterfaces[
(rendererID: any)
]: any): RendererInterface);
renderer.startProfiling();
renderer.startProfiling(recordChangeDescriptions);
}
this._bridge.send('profilingStatus', this._isProfiling);
};
@@ -422,6 +434,7 @@ export default class Agent extends EventEmitter {
stopProfiling = () => {
this._isProfiling = false;
this._recordChangeDescriptions = false;
for (let rendererID in this._rendererInterfaces) {
const renderer = ((this._rendererInterfaces[
(rendererID: any)
+220 -6
View File
@@ -30,6 +30,7 @@ import { cleanForBridge, copyWithSet, setInObject } from './utils';
import {
__DEBUG__,
SESSION_STORAGE_RELOAD_AND_PROFILE_KEY,
SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY,
TREE_OPERATION_ADD,
TREE_OPERATION_REMOVE,
TREE_OPERATION_REORDER_CHILDREN,
@@ -38,6 +39,7 @@ import {
import { inspectHooksOfFiber } from './ReactDebugHooks';
import type {
ChangeDescription,
CommitDataBackend,
DevToolsHook,
Fiber,
@@ -350,7 +352,7 @@ export function attach(
// The unmount operations are already significantly smaller than mount opreations though.
// This is something to keep in mind for later.
function updateComponentFilters(componentFilters: Array<ComponentFilter>) {
if (this._isProfiling) {
if (isProfiling) {
// Re-mounting a tree while profiling is in progress might break a lot of assumptions.
// If necessary, we could support this- but it doesn't seem like a necessary use case.
throw Error('Cannot modify filter preferences while profiling');
@@ -646,8 +648,185 @@ export function attach(
return ((fiberToIDMap.get(primaryFiber): any): number);
}
function getChangeDescription(
prevFiber: Fiber | null,
nextFiber: Fiber
): ChangeDescription | null {
switch (getElementTypeForFiber(nextFiber)) {
case ElementTypeClass:
case ElementTypeFunction:
case ElementTypeMemo:
case ElementTypeForwardRef:
if (prevFiber === null) {
return {
context: null,
didHooksChange: false,
isFirstMount: true,
props: null,
state: null,
};
} else {
return {
context: getContextChangedKeys(nextFiber),
didHooksChange: didHooksChange(
prevFiber.memoizedState,
nextFiber.memoizedState
),
isFirstMount: false,
props: getChangedKeys(
prevFiber.memoizedProps,
nextFiber.memoizedProps
),
state: getChangedKeys(
prevFiber.memoizedState,
nextFiber.memoizedState
),
};
}
default:
return null;
}
}
function updateContextsForFiber(fiber: Fiber) {
switch (getElementTypeForFiber(fiber)) {
case ElementTypeClass:
if (idToContextsMap !== null) {
const id = getFiberID(getPrimaryFiber(fiber));
const contexts = getContextsForFiber(fiber);
if (contexts !== null) {
idToContextsMap.set(id, contexts);
}
}
break;
default:
break;
}
}
// Differentiates between a null context value and no context.
const NO_CONTEXT = {};
function getContextsForFiber(fiber: Fiber): [Object, any] | null {
switch (getElementTypeForFiber(fiber)) {
case ElementTypeClass:
const instance = fiber.stateNode;
let legacyContext = NO_CONTEXT;
let modernContext = NO_CONTEXT;
if (instance != null) {
if (
instance.constructor &&
instance.constructor.contextType != null
) {
modernContext = instance.context;
} else {
legacyContext = instance.context;
if (legacyContext && Object.keys(legacyContext).length === 0) {
legacyContext = NO_CONTEXT;
}
}
}
return [legacyContext, modernContext];
default:
return null;
}
}
// Record all contexts at the time profiling is started.
// Fibers only store the current context value,
// so we need to track them separatenly in order to determine changed keys.
function crawlToInitializeContextsMap(fiber: Fiber) {
updateContextsForFiber(fiber);
let current = fiber.child;
while (current !== null) {
crawlToInitializeContextsMap(current);
current = current.sibling;
}
}
function getContextChangedKeys(fiber: Fiber): null | boolean | Array<string> {
switch (getElementTypeForFiber(fiber)) {
case ElementTypeClass:
if (idToContextsMap !== null) {
const id = getFiberID(getPrimaryFiber(fiber));
const prevContexts = idToContextsMap.has(id)
? idToContextsMap.get(id)
: null;
const nextContexts = getContextsForFiber(fiber);
if (prevContexts == null || nextContexts == null) {
return null;
}
const [prevLegacyContext, prevModernContext] = prevContexts;
const [nextLegacyContext, nextModernContext] = nextContexts;
if (nextLegacyContext !== NO_CONTEXT) {
return getChangedKeys(prevLegacyContext, nextLegacyContext);
} else if (nextModernContext !== NO_CONTEXT) {
return prevModernContext !== nextModernContext;
}
}
break;
default:
break;
}
return null;
}
function didHooksChange(prev: any, next: any): boolean {
if (next == null) {
return false;
}
// We can't report anything meaningful for hooks changes.
if (
next.hasOwnProperty('baseState') &&
next.hasOwnProperty('memoizedState') &&
next.hasOwnProperty('next') &&
next.hasOwnProperty('queue')
) {
while (next !== null) {
if (next.memoizedState !== prev.memoizedState) {
return true;
} else {
next = next.next;
prev = prev.next;
}
}
}
return false;
}
function getChangedKeys(prev: any, next: any): null | Array<string> {
if (prev == null || next == null) {
return null;
}
// We can't report anything meaningful for hooks changes.
if (
next.hasOwnProperty('baseState') &&
next.hasOwnProperty('memoizedState') &&
next.hasOwnProperty('next') &&
next.hasOwnProperty('queue')
) {
return null;
}
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
const changedKeys = [];
for (let key of keys) {
if (prev[key] !== next[key]) {
changedKeys.push(key);
}
}
return changedKeys;
}
// eslint-disable-next-line no-unused-vars
function hasDataChanged(prevFiber: Fiber, nextFiber: Fiber): boolean {
function didFiberRender(prevFiber: Fiber, nextFiber: Fiber): boolean {
switch (nextFiber.tag) {
case ClassComponent:
case FunctionComponent:
@@ -1024,7 +1203,7 @@ export function attach(
pushOperation(treeBaseDuration);
}
if (alternate == null || hasDataChanged(alternate, fiber)) {
if (alternate == null || didFiberRender(alternate, fiber)) {
if (actualDuration != null) {
// The actual duration reported by React includes time spent working on children.
// This is useful information, but it's also useful to be able to exclude child durations.
@@ -1049,6 +1228,17 @@ export function attach(
metadata.maxActualDuration,
actualDuration
);
if (recordChangeDescriptions) {
const changeDescription = getChangeDescription(alternate, fiber);
if (changeDescription !== null) {
if (metadata.changeDescriptions !== null) {
metadata.changeDescriptions.set(id, changeDescription);
}
}
updateContextsForFiber(fiber);
}
}
}
}
@@ -1110,7 +1300,7 @@ export function attach(
mostRecentlyInspectedElementID !== null &&
mostRecentlyInspectedElementID ===
getFiberID(getPrimaryFiber(nextFiber)) &&
hasDataChanged(prevFiber, nextFiber)
didFiberRender(prevFiber, nextFiber)
) {
// If this Fiber has updated, clear cached inspected data.
// If it is inspected again, it may need to be re-run to obtain updated hooks values.
@@ -1300,6 +1490,7 @@ export function attach(
// If profiling is active, store commit time and duration, and the current interactions.
// The frontend may request this information after profiling has stopped.
currentCommitProfilingMetadata = {
changeDescriptions: recordChangeDescriptions ? new Map() : null,
durations: [],
commitTime: performance.now() - profilingStartTime,
interactions: Array.from(root.memoizedInteractions).map(
@@ -1343,6 +1534,7 @@ export function attach(
// If profiling is active, store commit time and duration, and the current interactions.
// The frontend may request this information after profiling has stopped.
currentCommitProfilingMetadata = {
changeDescriptions: recordChangeDescriptions ? new Map() : null,
durations: [],
commitTime: performance.now() - profilingStartTime,
interactions: Array.from(root.memoizedInteractions).map(
@@ -2058,6 +2250,7 @@ export function attach(
}
type CommitProfilingData = {|
changeDescriptions: Map<number, ChangeDescription> | null,
commitTime: number,
durations: Array<number>,
interactions: Array<Interaction>,
@@ -2070,10 +2263,12 @@ export function attach(
let currentCommitProfilingMetadata: CommitProfilingData | null = null;
let displayNamesByRootID: DisplayNamesByRootID | null = null;
let idToContextsMap: Map<number, any> | null = null;
let initialTreeBaseDurationsMap: Map<number, number> | null = null;
let initialIDToRootMap: Map<number, number> | null = null;
let isProfiling: boolean = false;
let profilingStartTime: number = 0;
let recordChangeDescriptions: boolean = false;
let rootToCommitProfilingMetadataMap: CommitProfilingMetadataMap | null = null;
function getProfilingData(): ProfilingDataBackend {
@@ -2111,6 +2306,7 @@ export function attach(
commitProfilingMetadata.forEach((commitProfilingData, commitIndex) => {
const {
changeDescriptions,
durations,
interactions,
maxActualDuration,
@@ -2144,6 +2340,10 @@ export function attach(
}
commitData.push({
changeDescriptions:
changeDescriptions !== null
? Array.from(changeDescriptions.entries())
: null,
duration: maxActualDuration,
fiberActualDurations,
fiberSelfDurations,
@@ -2170,11 +2370,13 @@ export function attach(
};
}
function startProfiling() {
function startProfiling(shouldRecordChangeDescriptions: boolean) {
if (isProfiling) {
return;
}
recordChangeDescriptions = shouldRecordChangeDescriptions;
// Capture initial values as of the time profiling starts.
// It's important we snapshot both the durations and the id-to-root map,
// since either of these may change during the profiling session
@@ -2182,6 +2384,7 @@ export function attach(
displayNamesByRootID = new Map();
initialTreeBaseDurationsMap = new Map(idToTreeBaseDurationMap);
initialIDToRootMap = new Map(idToRootMap);
idToContextsMap = new Map();
hook.getFiberRoots(rendererID).forEach(root => {
const rootID = getFiberID(getPrimaryFiber(root.current));
@@ -2189,6 +2392,13 @@ export function attach(
rootID,
getDisplayNameForRoot(root.current)
);
if (shouldRecordChangeDescriptions) {
// Record all contexts at the time profiling is started.
// Fibers only store the current context value,
// so we need to track them separatenly in order to determine changed keys.
crawlToInitializeContextsMap(root.current);
}
});
isProfiling = true;
@@ -2198,13 +2408,17 @@ export function attach(
function stopProfiling() {
isProfiling = false;
recordChangeDescriptions = false;
}
// Automatically start profiling so that we don't miss timing info from initial "mount".
if (
sessionStorageGetItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY) === 'true'
) {
startProfiling();
startProfiling(
sessionStorageGetItem(SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY) ===
'true'
);
}
// React will switch between these implementations depending on whether
+11 -1
View File
@@ -112,7 +112,17 @@ export type ReactRenderer = {
currentDispatcherRef?: {| current: null | Dispatcher |},
};
export type ChangeDescription = {|
context: Array<string> | boolean | null,
didHooksChange: boolean,
isFirstMount: boolean,
props: Array<string> | null,
state: Array<string> | null,
|};
export type CommitDataBackend = {|
// Tuple of fiber ID and change description
changeDescriptions: Array<[number, ChangeDescription]> | null,
duration: number,
// Tuple of fiber ID and actual duration
fiberActualDurations: Array<[number, number]>,
@@ -226,7 +236,7 @@ export type RendererInterface = {
setInProps: (id: number, path: Array<string | number>, value: any) => void,
setInState: (id: number, path: Array<string | number>, value: any) => void,
setTrackedPath: (path: Array<PathFrame> | null) => void,
startProfiling: () => void,
startProfiling: (recordChangeDescriptions: boolean) => void,
stopProfiling: () => void,
updateComponentFilters: (somponentFilters: Array<ComponentFilter>) => void,
};
+3
View File
@@ -14,6 +14,9 @@ export const LOCAL_STORAGE_FILTER_PREFERENCES_KEY =
export const SESSION_STORAGE_LAST_SELECTION_KEY =
'React::DevTools::lastSelection';
export const SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY =
'React::DevTools::recordChangeDescriptions';
export const SESSION_STORAGE_RELOAD_AND_PROFILE_KEY =
'React::DevTools::reloadAndProfile';
+1 -1
View File
@@ -179,7 +179,7 @@ export default class ProfilerStore extends EventEmitter {
}
startProfiling(): void {
this._bridge.send('startProfiling');
this._bridge.send('startProfiling', this._store.recordChangeDescriptions);
// Don't actually update the local profiling boolean yet!
// Wait for onProfilingStatus() to confirm the status has changed.
+22
View File
@@ -38,6 +38,8 @@ const LOCAL_STORAGE_CAPTURE_SCREENSHOTS_KEY =
'React::DevTools::captureScreenshots';
const LOCAL_STORAGE_COLLAPSE_ROOTS_BY_DEFAULT_KEY =
'React::DevTools::collapseNodesByDefault';
const LOCAL_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY =
'React::DevTools::recordChangeDescriptions';
type Config = {|
isProfiling?: boolean,
@@ -83,6 +85,8 @@ export default class Store extends EventEmitter {
_profilerStore: ProfilerStore;
_recordChangeDescriptions: boolean = false;
// Incremented each time the store is mutated.
// This enables a passive effect to detect a mutation between render and commit phase.
_revision: number = 0;
@@ -118,6 +122,10 @@ export default class Store extends EventEmitter {
localStorageGetItem(LOCAL_STORAGE_COLLAPSE_ROOTS_BY_DEFAULT_KEY) !==
'false';
this._recordChangeDescriptions =
localStorageGetItem(LOCAL_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY) ===
'true';
this._componentFilters = getSavedComponentFilters();
let isProfiling = false;
@@ -248,6 +256,20 @@ export default class Store extends EventEmitter {
return this._profilerStore;
}
get recordChangeDescriptions(): boolean {
return this._recordChangeDescriptions;
}
set recordChangeDescriptions(value: boolean): void {
this._recordChangeDescriptions = value;
localStorageSetItem(
LOCAL_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY,
value ? 'true' : 'false'
);
this.emit('recordChangeDescriptions');
}
get revision(): number {
return this._revision;
}
+2 -1
View File
@@ -14,6 +14,7 @@
display: flex;
flex-direction: column;
flex: 2 1 200px;
border-top: 1px solid var(--color-border);
}
.RightColumn {
@@ -21,6 +22,7 @@
flex-direction: column;
flex: 1 1 100px;
max-width: 300px;
overflow-x: hidden;
border-left: 1px solid var(--color-border);
border-top: 1px solid var(--color-border);
}
@@ -60,7 +62,6 @@
display: flex;
align-items: center;
border-bottom: 1px solid var(--color-border);
border-top: 1px solid var(--color-border);
}
.VRule {
@@ -7,27 +7,41 @@ import { BridgeContext, StoreContext } from '../context';
import { useSubscription } from '../hooks';
import Store from 'src/devtools/store';
type SubscriptionData = {|
recordChangeDescriptions: boolean,
supportsReloadAndProfile: boolean,
|};
export default function ReloadAndProfileButton() {
const bridge = useContext(BridgeContext);
const store = useContext(StoreContext);
const supportsReloadAndProfileSubscription = useMemo(
const subscription = useMemo(
() => ({
getCurrentValue: () => store.supportsReloadAndProfile,
getCurrentValue: () => ({
recordChangeDescriptions: store.recordChangeDescriptions,
supportsReloadAndProfile: store.supportsReloadAndProfile,
}),
subscribe: (callback: Function) => {
store.addListener('recordChangeDescriptions', callback);
store.addListener('supportsReloadAndProfile', callback);
return () => store.removeListener('supportsReloadAndProfile', callback);
return () => {
store.removeListener('recordChangeDescriptions', callback);
store.removeListener('supportsReloadAndProfile', callback);
};
},
}),
[store]
);
const supportsReloadAndProfile = useSubscription<boolean, Store>(
supportsReloadAndProfileSubscription
);
const {
recordChangeDescriptions,
supportsReloadAndProfile,
} = useSubscription<SubscriptionData, Store>(subscription);
const reloadAndProfile = useCallback(() => bridge.send('reloadAndProfile'), [
bridge,
]);
const reloadAndProfile = useCallback(
() => bridge.send('reloadAndProfile', recordChangeDescriptions),
[bridge, recordChangeDescriptions]
);
if (!supportsReloadAndProfile) {
return null;
@@ -4,12 +4,12 @@
flex: 0 0 auto;
display: flex;
align-items: center;
border-bottom: 1px solid var(--color-border);
}
.Content {
padding: 0.5rem;
user-select: none;
border-top: 1px solid var(--color-border);
overflow: auto;
}
@@ -4,18 +4,21 @@
flex: 0 0 auto;
display: flex;
align-items: center;
border-bottom: 1px solid var(--color-border);
}
.Content {
padding: 0.5rem;
user-select: none;
border-top: 1px solid var(--color-border);
overflow-y: auto;
}
.Component {
flex: 1;
color: var(--color-component-name);
white-space: nowrap;
overflow-x: hidden;
text-overflow: ellipsis;
}
.Component:before {
white-space: nowrap;
@@ -56,3 +59,22 @@
.CurrentCommit:focus {
outline: none;
}
.WhatChangedItem {
margin-top: 0.25rem;
}
.WhatChangedKey {
font-family: var(--font-family-monospace);
font-size: var(--font-size-monospace-small);
line-height: 1;
}
.WhatChangedKey:first-of-type::before {
content: ' (';
}
.WhatChangedKey::after {
content: ', ';
}
.WhatChangedKey:last-of-type::after {
content: ')';
}
@@ -1,6 +1,7 @@
// @flow
import React, { Fragment, useContext } from 'react';
import ProfilerStore from 'src/devtools/ProfilerStore';
import { ProfilerContext } from './ProfilerContext';
import { formatDuration, formatTime } from './utils';
import { StoreContext } from '../context';
@@ -29,7 +30,7 @@ export default function SidebarSelectedFiberInfo(_: Props) {
});
const listItems = [];
for (let i = 0; i < commitIndices.length; i += 2) {
for (let i = 0; i < commitIndices.length; i++) {
const commitIndex = commitIndices[i];
const { duration, timestamp } = profilerStore.getCommitData(
@@ -67,9 +68,140 @@ export default function SidebarSelectedFiberInfo(_: Props) {
<ButtonIcon type="close" />
</Button>
</div>
<WhatChanged
commitIndex={((selectedCommitIndex: any): number)}
fiberID={((selectedFiberID: any): number)}
profilerStore={profilerStore}
rootID={((rootID: any): number)}
/>
<div className={styles.Content}>
<label className={styles.Label}>Rendered at</label>: {listItems}
{listItems.length > 0 && (
<Fragment>
<label className={styles.Label}>Rendered at</label>: {listItems}
</Fragment>
)}
{listItems.length === 0 && (
<div>Did not render during this profiling session.</div>
)}
</div>
</Fragment>
);
}
type WhatChangedProps = {|
commitIndex: number,
fiberID: number,
profilerStore: ProfilerStore,
rootID: number,
|};
function WhatChanged({
commitIndex,
fiberID,
profilerStore,
rootID,
}: WhatChangedProps) {
const { changeDescriptions } = profilerStore.getCommitData(
((rootID: any): number),
commitIndex
);
if (changeDescriptions === null) {
return null;
}
const changeDescription = changeDescriptions.get(fiberID);
if (changeDescription == null) {
return null;
}
if (changeDescription.isFirstMount) {
return (
<div className={styles.Content}>
<label className={styles.Label}>Why did this render?</label>
<div className={styles.WhatChangedItem}>
This is the first time the component rendered.
</div>
</div>
);
}
const changes = [];
if (changeDescription.context === true) {
changes.push(
<div key="context" className={styles.WhatChangedItem}>
Context changed
</div>
);
} else if (
typeof changeDescription.context === 'object' &&
changeDescription.context !== null &&
changeDescription.context.length !== 0
) {
changes.push(
<div key="context" className={styles.WhatChangedItem}>
Context changed:
{changeDescription.context.map(key => (
<span key={key} className={styles.WhatChangedKey}>
{key}
</span>
))}
</div>
);
}
if (changeDescription.didHooksChange) {
changes.push(
<div key="hooks" className={styles.WhatChangedItem}>
Hooks changed
</div>
);
}
if (
changeDescription.props !== null &&
changeDescription.props.length !== 0
) {
changes.push(
<div key="props" className={styles.WhatChangedItem}>
Props changed:
{changeDescription.props.map(key => (
<span key={key} className={styles.WhatChangedKey}>
{key}
</span>
))}
</div>
);
}
if (
changeDescription.state !== null &&
changeDescription.state.length !== 0
) {
changes.push(
<div key="state" className={styles.WhatChangedItem}>
State changed:
{changeDescription.state.map(key => (
<span key={key} className={styles.WhatChangedKey}>
{key}
</span>
))}
</div>
);
}
if (changes.length === 0) {
changes.push(
<div key="nothing" className={styles.WhatChangedItem}>
The parent component rendered.
</div>
);
}
return (
<div className={styles.Content}>
<label className={styles.Label}>Why did this render?</label>
{changes}
</div>
);
}
+12
View File
@@ -31,7 +31,18 @@ export type SnapshotNode = {|
type: ElementType,
|};
export type ChangeDescription = {|
context: Array<string> | boolean | null,
didHooksChange: boolean,
isFirstMount: boolean,
props: Array<string> | null,
state: Array<string> | null,
|};
export type CommitDataFrontend = {|
// Map of Fiber (ID) to a description of what changed in this commit.
changeDescriptions: Map<number, ChangeDescription> | null,
// How long was this commit?
duration: number,
@@ -93,6 +104,7 @@ export type ProfilingDataFrontend = {|
|};
export type CommitDataExport = {|
changeDescriptions: Array<[number, ChangeDescription]> | null,
duration: number,
// Tuple of fiber ID and actual duration
fiberActualDurations: Array<[number, number]>,
+12
View File
@@ -58,6 +58,10 @@ export function prepareProfilingDataFrontendFromBackendAndStore(
dataForRoots.set(rootID, {
commitData: commitData.map((commitDataBackend, commitIndex) => ({
changeDescriptions:
commitDataBackend.changeDescriptions != null
? new Map(commitDataBackend.changeDescriptions)
: null,
duration: commitDataBackend.duration,
fiberActualDurations: new Map(
commitDataBackend.fiberActualDurations
@@ -109,6 +113,7 @@ export function prepareProfilingDataFrontendFromExport(
dataForRoots.set(rootID, {
commitData: commitData.map(
({
changeDescriptions,
duration,
fiberActualDurations,
fiberSelfDurations,
@@ -117,6 +122,8 @@ export function prepareProfilingDataFrontendFromExport(
screenshot,
timestamp,
}) => ({
changeDescriptions:
changeDescriptions != null ? new Map(changeDescriptions) : null,
duration,
fiberActualDurations: new Map(fiberActualDurations),
fiberSelfDurations: new Map(fiberSelfDurations),
@@ -159,6 +166,7 @@ export function prepareProfilingDataExport(
dataForRoots.push({
commitData: commitData.map(
({
changeDescriptions,
duration,
fiberActualDurations,
fiberSelfDurations,
@@ -167,6 +175,10 @@ export function prepareProfilingDataExport(
screenshot,
timestamp,
}) => ({
changeDescriptions:
changeDescriptions != null
? Array.from(changeDescriptions.entries())
: null,
duration,
fiberActualDurations: Array.from(fiberActualDurations.entries()),
fiberSelfDurations: Array.from(fiberSelfDurations.entries()),
+52 -20
View File
@@ -1,6 +1,6 @@
// @flow
import React, { useCallback, useContext, useMemo } from 'react';
import React, { Fragment, useCallback, useContext, useMemo } from 'react';
import { useSubscription } from '../hooks';
import { StoreContext } from '../context';
import { SettingsContext } from './SettingsContext';
@@ -43,6 +43,20 @@ function Settings(_: {||}) {
collapseNodesByDefaultSubscription
);
const recordChangeDescriptionsSubscription = useMemo(
() => ({
getCurrentValue: () => store.recordChangeDescriptions,
subscribe: (callback: Function) => {
store.addListener('recordChangeDescriptions', callback);
return () => store.removeListener('recordChangeDescriptions', callback);
},
}),
[store]
);
const recordChangeDescriptions = useSubscription<boolean, Store>(
recordChangeDescriptionsSubscription
);
const updateDisplayDensity = useCallback(
({ currentTarget }) => {
setDisplayDensity(currentTarget.value);
@@ -69,6 +83,12 @@ function Settings(_: {||}) {
},
[store]
);
const updateRecordChangeDescriptions = useCallback(
({ currentTarget }) => {
store.recordChangeDescriptions = currentTarget.checked;
},
[store]
);
return (
<div className={styles.Settings}>
@@ -145,25 +165,37 @@ function Settings(_: {||}) {
</label>
</div>
{store.supportsCaptureScreenshots && (
<div className={styles.Section}>
<div className={styles.Header}>Profiler</div>
<label className={styles.CheckboxOption}>
<input
type="checkbox"
checked={captureScreenshots}
onChange={updateCaptureScreenshotsWhileProfiling}
/>{' '}
Capture screenshots while profiling
</label>
{captureScreenshots && (
<div className={styles.ScreenshotThrottling}>
Screenshots will be throttled in order to reduce the negative
impact on performance.
</div>
)}
</div>
)}
<div className={styles.Section}>
<div className={styles.Header}>Profiler</div>
<label className={styles.CheckboxOption}>
<input
type="checkbox"
checked={recordChangeDescriptions}
onChange={updateRecordChangeDescriptions}
/>{' '}
Record why each component rendered while profiling.
</label>
{store.supportsCaptureScreenshots && (
<Fragment>
<label className={styles.CheckboxOption}>
<input
type="checkbox"
checked={captureScreenshots}
onChange={updateCaptureScreenshotsWhileProfiling}
/>{' '}
Capture screenshots while profiling
</label>
{captureScreenshots && (
<div className={styles.ScreenshotThrottling}>
Screenshots will be throttled in order to reduce the negative
impact on performance.
</div>
)}
</Fragment>
)}
</div>
</div>
);
}