mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
- substr is Annex B - substring silently flips its arguments if they're in the "wrong order", which is confusing - slice is better than sliced bread (no pun intended) and also it works the same way on Arrays so there's less to remember --- > I'd be down to just lint and enforce a single form just for the potential compression savings by using a repeated string. _Originally posted by @sebmarkbage in https://github.com/facebook/react/pull/26663#discussion_r1170455401_
1173 lines
38 KiB
JavaScript
1173 lines
38 KiB
JavaScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @flow
|
|
*/
|
|
|
|
import {
|
|
importFromChromeTimeline,
|
|
Flamechart as SpeedscopeFlamechart,
|
|
} from '@elg/speedscope';
|
|
import type {TimelineEvent} from '@elg/speedscope';
|
|
import type {
|
|
ErrorStackFrame,
|
|
BatchUID,
|
|
Flamechart,
|
|
Milliseconds,
|
|
NativeEvent,
|
|
NetworkMeasure,
|
|
Phase,
|
|
ReactLane,
|
|
ReactComponentMeasure,
|
|
ReactComponentMeasureType,
|
|
ReactMeasure,
|
|
ReactMeasureType,
|
|
TimelineData,
|
|
SchedulingEvent,
|
|
SuspenseEvent,
|
|
} from '../types';
|
|
import {
|
|
REACT_TOTAL_NUM_LANES,
|
|
SCHEDULING_PROFILER_VERSION,
|
|
SNAPSHOT_MAX_HEIGHT,
|
|
} from '../constants';
|
|
import InvalidProfileError from './InvalidProfileError';
|
|
import {getBatchRange} from '../utils/getBatchRange';
|
|
import ErrorStackParser from 'error-stack-parser';
|
|
|
|
type MeasureStackElement = {
|
|
type: ReactMeasureType,
|
|
depth: number,
|
|
measure: ReactMeasure,
|
|
startTime: Milliseconds,
|
|
stopTime?: Milliseconds,
|
|
};
|
|
|
|
type ProcessorState = {
|
|
asyncProcessingPromises: Promise<any>[],
|
|
batchUID: BatchUID,
|
|
currentReactComponentMeasure: ReactComponentMeasure | null,
|
|
internalModuleCurrentStackFrame: ErrorStackFrame | null,
|
|
internalModuleStackStringSet: Set<string>,
|
|
measureStack: MeasureStackElement[],
|
|
nativeEventStack: NativeEvent[],
|
|
nextRenderShouldGenerateNewBatchID: boolean,
|
|
potentialLongEvents: Array<[NativeEvent, BatchUID]>,
|
|
potentialLongNestedUpdate: SchedulingEvent | null,
|
|
potentialLongNestedUpdates: Array<[SchedulingEvent, BatchUID]>,
|
|
potentialSuspenseEventsOutsideOfTransition: Array<
|
|
[SuspenseEvent, ReactLane[]],
|
|
>,
|
|
requestIdToNetworkMeasureMap: Map<string, NetworkMeasure>,
|
|
uidCounter: BatchUID,
|
|
unresolvedSuspenseEvents: Map<string, SuspenseEvent>,
|
|
};
|
|
|
|
const NATIVE_EVENT_DURATION_THRESHOLD = 20;
|
|
const NESTED_UPDATE_DURATION_THRESHOLD = 20;
|
|
|
|
const WARNING_STRINGS = {
|
|
LONG_EVENT_HANDLER:
|
|
'An event handler scheduled a big update with React. Consider using the Transition API to defer some of this work.',
|
|
NESTED_UPDATE:
|
|
'A big nested update was scheduled during layout. ' +
|
|
'Nested updates require React to re-render synchronously before the browser can paint. ' +
|
|
'Consider delaying this update by moving it to a passive effect (useEffect).',
|
|
SUSPEND_DURING_UPDATE:
|
|
'A component suspended during an update which caused a fallback to be shown. ' +
|
|
"Consider using the Transition API to avoid hiding components after they've been mounted.",
|
|
};
|
|
|
|
// Exported for tests
|
|
export function getLanesFromTransportDecimalBitmask(
|
|
laneBitmaskString: string,
|
|
): ReactLane[] {
|
|
const laneBitmask = parseInt(laneBitmaskString, 10);
|
|
|
|
// As negative numbers are stored in two's complement format, our bitmask
|
|
// checks will be thrown off by them.
|
|
if (laneBitmask < 0) {
|
|
return [];
|
|
}
|
|
|
|
const lanes = [];
|
|
let powersOfTwo = 0;
|
|
while (powersOfTwo <= REACT_TOTAL_NUM_LANES) {
|
|
if ((1 << powersOfTwo) & laneBitmask) {
|
|
lanes.push(powersOfTwo);
|
|
}
|
|
powersOfTwo++;
|
|
}
|
|
return lanes;
|
|
}
|
|
|
|
function updateLaneToLabelMap(
|
|
profilerData: TimelineData,
|
|
laneLabelTuplesString: string,
|
|
): void {
|
|
// These marks appear multiple times in the data;
|
|
// We only need to extact them once.
|
|
if (profilerData.laneToLabelMap.size === 0) {
|
|
const laneLabelTuples = laneLabelTuplesString.split(',');
|
|
for (let laneIndex = 0; laneIndex < laneLabelTuples.length; laneIndex++) {
|
|
// The numeric lane value (e.g. 64) isn't important.
|
|
// The profiler parses and stores the lane's position within the bitmap,
|
|
// (e.g. lane 1 is index 0, lane 16 is index 4).
|
|
profilerData.laneToLabelMap.set(laneIndex, laneLabelTuples[laneIndex]);
|
|
}
|
|
}
|
|
}
|
|
|
|
let profilerVersion = null;
|
|
|
|
function getLastType(stack: $PropertyType<ProcessorState, 'measureStack'>) {
|
|
if (stack.length > 0) {
|
|
const {type} = stack[stack.length - 1];
|
|
return type;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getDepth(stack: $PropertyType<ProcessorState, 'measureStack'>) {
|
|
if (stack.length > 0) {
|
|
const {depth, type} = stack[stack.length - 1];
|
|
return type === 'render-idle' ? depth : depth + 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function markWorkStarted(
|
|
type: ReactMeasureType,
|
|
startTime: Milliseconds,
|
|
lanes: ReactLane[],
|
|
currentProfilerData: TimelineData,
|
|
state: ProcessorState,
|
|
) {
|
|
const {batchUID, measureStack} = state;
|
|
const depth = getDepth(measureStack);
|
|
|
|
const measure: ReactMeasure = {
|
|
type,
|
|
batchUID,
|
|
depth,
|
|
lanes,
|
|
timestamp: startTime,
|
|
duration: 0,
|
|
};
|
|
|
|
state.measureStack.push({depth, measure, startTime, type});
|
|
|
|
// This array is pre-initialized when the batchUID is generated.
|
|
const measures = currentProfilerData.batchUIDToMeasuresMap.get(batchUID);
|
|
if (measures != null) {
|
|
measures.push(measure);
|
|
} else {
|
|
currentProfilerData.batchUIDToMeasuresMap.set(state.batchUID, [measure]);
|
|
}
|
|
|
|
// This array is pre-initialized before processing starts.
|
|
lanes.forEach(lane => {
|
|
((currentProfilerData.laneToReactMeasureMap.get(
|
|
lane,
|
|
): any): ReactMeasure[]).push(measure);
|
|
});
|
|
}
|
|
|
|
function markWorkCompleted(
|
|
type: ReactMeasureType,
|
|
stopTime: Milliseconds,
|
|
currentProfilerData: TimelineData,
|
|
stack: $PropertyType<ProcessorState, 'measureStack'>,
|
|
) {
|
|
if (stack.length === 0) {
|
|
console.error(
|
|
'Unexpected type "%s" completed at %sms while stack is empty.',
|
|
type,
|
|
stopTime,
|
|
);
|
|
// Ignore work "completion" user timing mark that doesn't complete anything
|
|
return;
|
|
}
|
|
|
|
const last = stack[stack.length - 1];
|
|
if (last.type !== type) {
|
|
console.error(
|
|
'Unexpected type "%s" completed at %sms before "%s" completed.',
|
|
type,
|
|
stopTime,
|
|
last.type,
|
|
);
|
|
}
|
|
|
|
const {measure, startTime} = stack.pop();
|
|
if (!measure) {
|
|
console.error('Could not find matching measure for type "%s".', type);
|
|
}
|
|
|
|
// $FlowFixMe[cannot-write] This property should not be writable outside of this function.
|
|
measure.duration = stopTime - startTime;
|
|
}
|
|
|
|
function throwIfIncomplete(
|
|
type: ReactMeasureType,
|
|
stack: $PropertyType<ProcessorState, 'measureStack'>,
|
|
) {
|
|
const lastIndex = stack.length - 1;
|
|
if (lastIndex >= 0) {
|
|
const last = stack[lastIndex];
|
|
if (last.stopTime === undefined && last.type === type) {
|
|
throw new InvalidProfileError(
|
|
`Unexpected type "${type}" started before "${last.type}" completed.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function processEventDispatch(
|
|
event: TimelineEvent,
|
|
timestamp: Milliseconds,
|
|
profilerData: TimelineData,
|
|
state: ProcessorState,
|
|
) {
|
|
const data = event.args.data;
|
|
const type = data.type;
|
|
|
|
if (type.startsWith('react-')) {
|
|
const stackTrace = data.stackTrace;
|
|
if (stackTrace) {
|
|
const topFrame = stackTrace[stackTrace.length - 1];
|
|
if (topFrame.url.includes('/react-dom.')) {
|
|
// Filter out fake React events dispatched by invokeGuardedCallbackDev.
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reduce noise from events like DOMActivate, load/unload, etc. which are usually not relevant
|
|
if (
|
|
type === 'blur' ||
|
|
type === 'click' ||
|
|
type === 'input' ||
|
|
type.startsWith('focus') ||
|
|
type.startsWith('key') ||
|
|
type.startsWith('mouse') ||
|
|
type.startsWith('pointer')
|
|
) {
|
|
const duration = event.dur / 1000;
|
|
|
|
let depth = 0;
|
|
|
|
while (state.nativeEventStack.length > 0) {
|
|
const prevNativeEvent =
|
|
state.nativeEventStack[state.nativeEventStack.length - 1];
|
|
const prevStopTime = prevNativeEvent.timestamp + prevNativeEvent.duration;
|
|
|
|
if (timestamp < prevStopTime) {
|
|
depth = prevNativeEvent.depth + 1;
|
|
break;
|
|
} else {
|
|
state.nativeEventStack.pop();
|
|
}
|
|
}
|
|
|
|
const nativeEvent = {
|
|
depth,
|
|
duration,
|
|
timestamp,
|
|
type,
|
|
warning: null,
|
|
};
|
|
|
|
profilerData.nativeEvents.push(nativeEvent);
|
|
|
|
// Keep track of curent event in case future ones overlap.
|
|
// We separate them into different vertical lanes in this case.
|
|
state.nativeEventStack.push(nativeEvent);
|
|
}
|
|
}
|
|
|
|
function processResourceFinish(
|
|
event: TimelineEvent,
|
|
timestamp: Milliseconds,
|
|
profilerData: TimelineData,
|
|
state: ProcessorState,
|
|
) {
|
|
const requestId = event.args.data.requestId;
|
|
const networkMeasure = state.requestIdToNetworkMeasureMap.get(requestId);
|
|
if (networkMeasure != null) {
|
|
networkMeasure.finishTimestamp = timestamp;
|
|
if (networkMeasure.firstReceivedDataTimestamp === 0) {
|
|
networkMeasure.firstReceivedDataTimestamp = timestamp;
|
|
}
|
|
if (networkMeasure.lastReceivedDataTimestamp === 0) {
|
|
networkMeasure.lastReceivedDataTimestamp = timestamp;
|
|
}
|
|
|
|
// Clean up now that the resource is done.
|
|
state.requestIdToNetworkMeasureMap.delete(event.args.data.requestId);
|
|
}
|
|
}
|
|
|
|
function processResourceReceivedData(
|
|
event: TimelineEvent,
|
|
timestamp: Milliseconds,
|
|
profilerData: TimelineData,
|
|
state: ProcessorState,
|
|
) {
|
|
const requestId = event.args.data.requestId;
|
|
const networkMeasure = state.requestIdToNetworkMeasureMap.get(requestId);
|
|
if (networkMeasure != null) {
|
|
if (networkMeasure.firstReceivedDataTimestamp === 0) {
|
|
networkMeasure.firstReceivedDataTimestamp = timestamp;
|
|
}
|
|
networkMeasure.lastReceivedDataTimestamp = timestamp;
|
|
networkMeasure.finishTimestamp = timestamp;
|
|
}
|
|
}
|
|
|
|
function processResourceReceiveResponse(
|
|
event: TimelineEvent,
|
|
timestamp: Milliseconds,
|
|
profilerData: TimelineData,
|
|
state: ProcessorState,
|
|
) {
|
|
const requestId = event.args.data.requestId;
|
|
const networkMeasure = state.requestIdToNetworkMeasureMap.get(requestId);
|
|
if (networkMeasure != null) {
|
|
networkMeasure.receiveResponseTimestamp = timestamp;
|
|
}
|
|
}
|
|
|
|
function processScreenshot(
|
|
event: TimelineEvent,
|
|
timestamp: Milliseconds,
|
|
profilerData: TimelineData,
|
|
state: ProcessorState,
|
|
) {
|
|
const encodedSnapshot = event.args.snapshot; // Base 64 encoded
|
|
|
|
const snapshot = {
|
|
height: 0,
|
|
image: null,
|
|
imageSource: `data:image/png;base64,${encodedSnapshot}`,
|
|
timestamp,
|
|
width: 0,
|
|
};
|
|
|
|
// Delay processing until we've extracted snapshot dimensions.
|
|
let resolveFn = ((null: any): Function);
|
|
state.asyncProcessingPromises.push(
|
|
new Promise(resolve => {
|
|
resolveFn = resolve;
|
|
}),
|
|
);
|
|
|
|
// Parse the Base64 image data to determine native size.
|
|
// This will be used later to scale for display within the thumbnail strip.
|
|
fetch(snapshot.imageSource)
|
|
.then(response => response.blob())
|
|
.then(blob => {
|
|
// $FlowFixMe[cannot-resolve-name] createImageBitmap
|
|
createImageBitmap(blob).then(bitmap => {
|
|
snapshot.height = bitmap.height;
|
|
snapshot.width = bitmap.width;
|
|
|
|
resolveFn();
|
|
});
|
|
});
|
|
|
|
profilerData.snapshots.push(snapshot);
|
|
}
|
|
|
|
function processResourceSendRequest(
|
|
event: TimelineEvent,
|
|
timestamp: Milliseconds,
|
|
profilerData: TimelineData,
|
|
state: ProcessorState,
|
|
) {
|
|
const data = event.args.data;
|
|
const requestId = data.requestId;
|
|
|
|
const availableDepths = new Array<boolean>(
|
|
state.requestIdToNetworkMeasureMap.size + 1,
|
|
).fill(true);
|
|
state.requestIdToNetworkMeasureMap.forEach(({depth}) => {
|
|
availableDepths[depth] = false;
|
|
});
|
|
|
|
let depth = 0;
|
|
for (let i = 0; i < availableDepths.length; i++) {
|
|
if (availableDepths[i]) {
|
|
depth = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const networkMeasure: NetworkMeasure = {
|
|
depth,
|
|
finishTimestamp: 0,
|
|
firstReceivedDataTimestamp: 0,
|
|
lastReceivedDataTimestamp: 0,
|
|
requestId,
|
|
requestMethod: data.requestMethod,
|
|
priority: data.priority,
|
|
sendRequestTimestamp: timestamp,
|
|
receiveResponseTimestamp: 0,
|
|
url: data.url,
|
|
};
|
|
|
|
state.requestIdToNetworkMeasureMap.set(requestId, networkMeasure);
|
|
|
|
profilerData.networkMeasures.push(networkMeasure);
|
|
networkMeasure.sendRequestTimestamp = timestamp;
|
|
}
|
|
|
|
function processTimelineEvent(
|
|
event: TimelineEvent,
|
|
/** Finalized profiler data up to `event`. May be mutated. */
|
|
currentProfilerData: TimelineData,
|
|
/** Intermediate processor state. May be mutated. */
|
|
state: ProcessorState,
|
|
) {
|
|
const {cat, name, ts, ph} = event;
|
|
|
|
const startTime = (ts - currentProfilerData.startTime) / 1000;
|
|
|
|
switch (cat) {
|
|
case 'disabled-by-default-devtools.screenshot':
|
|
processScreenshot(event, startTime, currentProfilerData, state);
|
|
break;
|
|
case 'devtools.timeline':
|
|
switch (name) {
|
|
case 'EventDispatch':
|
|
processEventDispatch(event, startTime, currentProfilerData, state);
|
|
break;
|
|
case 'ResourceFinish':
|
|
processResourceFinish(event, startTime, currentProfilerData, state);
|
|
break;
|
|
case 'ResourceReceivedData':
|
|
processResourceReceivedData(
|
|
event,
|
|
startTime,
|
|
currentProfilerData,
|
|
state,
|
|
);
|
|
break;
|
|
case 'ResourceReceiveResponse':
|
|
processResourceReceiveResponse(
|
|
event,
|
|
startTime,
|
|
currentProfilerData,
|
|
state,
|
|
);
|
|
break;
|
|
case 'ResourceSendRequest':
|
|
processResourceSendRequest(
|
|
event,
|
|
startTime,
|
|
currentProfilerData,
|
|
state,
|
|
);
|
|
break;
|
|
}
|
|
break;
|
|
case 'blink.user_timing':
|
|
if (name.startsWith('--react-version-')) {
|
|
const [reactVersion] = name.slice(16).split('-');
|
|
currentProfilerData.reactVersion = reactVersion;
|
|
} else if (name.startsWith('--profiler-version-')) {
|
|
const [versionString] = name.slice(19).split('-');
|
|
profilerVersion = parseInt(versionString, 10);
|
|
if (profilerVersion !== SCHEDULING_PROFILER_VERSION) {
|
|
throw new InvalidProfileError(
|
|
`This version of profiling data (${versionString}) is not supported by the current profiler.`,
|
|
);
|
|
}
|
|
} else if (name.startsWith('--react-lane-labels-')) {
|
|
const [laneLabelTuplesString] = name.slice(20).split('-');
|
|
updateLaneToLabelMap(currentProfilerData, laneLabelTuplesString);
|
|
} else if (name.startsWith('--component-')) {
|
|
processReactComponentMeasure(
|
|
name,
|
|
startTime,
|
|
currentProfilerData,
|
|
state,
|
|
);
|
|
} else if (name.startsWith('--schedule-render-')) {
|
|
const [laneBitmaskString] = name.slice(18).split('-');
|
|
|
|
currentProfilerData.schedulingEvents.push({
|
|
type: 'schedule-render',
|
|
lanes: getLanesFromTransportDecimalBitmask(laneBitmaskString),
|
|
timestamp: startTime,
|
|
warning: null,
|
|
});
|
|
} else if (name.startsWith('--schedule-forced-update-')) {
|
|
const [laneBitmaskString, componentName] = name.slice(25).split('-');
|
|
|
|
const forceUpdateEvent = {
|
|
type: 'schedule-force-update',
|
|
lanes: getLanesFromTransportDecimalBitmask(laneBitmaskString),
|
|
componentName,
|
|
timestamp: startTime,
|
|
warning: null,
|
|
};
|
|
|
|
// If this is a nested update, make a note of it.
|
|
// Once we're done processing events, we'll check to see if it was a long update and warn about it.
|
|
if (state.measureStack.find(({type}) => type === 'commit')) {
|
|
state.potentialLongNestedUpdate = forceUpdateEvent;
|
|
}
|
|
|
|
currentProfilerData.schedulingEvents.push(forceUpdateEvent);
|
|
} else if (name.startsWith('--schedule-state-update-')) {
|
|
const [laneBitmaskString, componentName] = name.slice(24).split('-');
|
|
|
|
const stateUpdateEvent = {
|
|
type: 'schedule-state-update',
|
|
lanes: getLanesFromTransportDecimalBitmask(laneBitmaskString),
|
|
componentName,
|
|
timestamp: startTime,
|
|
warning: null,
|
|
};
|
|
|
|
// If this is a nested update, make a note of it.
|
|
// Once we're done processing events, we'll check to see if it was a long update and warn about it.
|
|
if (state.measureStack.find(({type}) => type === 'commit')) {
|
|
state.potentialLongNestedUpdate = stateUpdateEvent;
|
|
}
|
|
|
|
currentProfilerData.schedulingEvents.push(stateUpdateEvent);
|
|
} else if (name.startsWith('--error-')) {
|
|
const [componentName, phase, message] = name.slice(8).split('-');
|
|
|
|
currentProfilerData.thrownErrors.push({
|
|
componentName,
|
|
message,
|
|
phase: ((phase: any): Phase),
|
|
timestamp: startTime,
|
|
type: 'thrown-error',
|
|
});
|
|
} else if (name.startsWith('--suspense-suspend-')) {
|
|
const [id, componentName, phase, laneBitmaskString, promiseName] = name
|
|
.slice(19)
|
|
.split('-');
|
|
const lanes = getLanesFromTransportDecimalBitmask(laneBitmaskString);
|
|
|
|
const availableDepths = new Array<boolean>(
|
|
state.unresolvedSuspenseEvents.size + 1,
|
|
).fill(true);
|
|
state.unresolvedSuspenseEvents.forEach(({depth}) => {
|
|
availableDepths[depth] = false;
|
|
});
|
|
|
|
let depth = 0;
|
|
for (let i = 0; i < availableDepths.length; i++) {
|
|
if (availableDepths[i]) {
|
|
depth = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// TODO (timeline) Maybe we should calculate depth in post,
|
|
// so unresolved Suspense requests don't take up space.
|
|
// We can't know if they'll be resolved or not at this point.
|
|
// We'll just give them a default (fake) duration width.
|
|
|
|
const suspenseEvent = {
|
|
componentName,
|
|
depth,
|
|
duration: null,
|
|
id,
|
|
phase: ((phase: any): Phase),
|
|
promiseName: promiseName || null,
|
|
resolution: 'unresolved',
|
|
timestamp: startTime,
|
|
type: 'suspense',
|
|
warning: null,
|
|
};
|
|
|
|
if (phase === 'update') {
|
|
// If a component suspended during an update, we should verify that it was during a transition.
|
|
// We need the lane metadata to verify this though.
|
|
// Since that data is only logged during commit, we may not have it yet.
|
|
// Store these events for post-processing then.
|
|
state.potentialSuspenseEventsOutsideOfTransition.push([
|
|
suspenseEvent,
|
|
lanes,
|
|
]);
|
|
}
|
|
|
|
currentProfilerData.suspenseEvents.push(suspenseEvent);
|
|
state.unresolvedSuspenseEvents.set(id, suspenseEvent);
|
|
} else if (name.startsWith('--suspense-resolved-')) {
|
|
const [id] = name.slice(20).split('-');
|
|
const suspenseEvent = state.unresolvedSuspenseEvents.get(id);
|
|
if (suspenseEvent != null) {
|
|
state.unresolvedSuspenseEvents.delete(id);
|
|
|
|
suspenseEvent.duration = startTime - suspenseEvent.timestamp;
|
|
suspenseEvent.resolution = 'resolved';
|
|
}
|
|
} else if (name.startsWith('--suspense-rejected-')) {
|
|
const [id] = name.slice(20).split('-');
|
|
const suspenseEvent = state.unresolvedSuspenseEvents.get(id);
|
|
if (suspenseEvent != null) {
|
|
state.unresolvedSuspenseEvents.delete(id);
|
|
|
|
suspenseEvent.duration = startTime - suspenseEvent.timestamp;
|
|
suspenseEvent.resolution = 'rejected';
|
|
}
|
|
} else if (name.startsWith('--render-start-')) {
|
|
if (state.nextRenderShouldGenerateNewBatchID) {
|
|
state.nextRenderShouldGenerateNewBatchID = false;
|
|
state.batchUID = ((state.uidCounter++: any): BatchUID);
|
|
}
|
|
|
|
// If this render is the result of a nested update, make a note of it.
|
|
// Once we're done processing events, we'll check to see if it was a long update and warn about it.
|
|
if (state.potentialLongNestedUpdate !== null) {
|
|
state.potentialLongNestedUpdates.push([
|
|
state.potentialLongNestedUpdate,
|
|
state.batchUID,
|
|
]);
|
|
state.potentialLongNestedUpdate = null;
|
|
}
|
|
|
|
const [laneBitmaskString] = name.slice(15).split('-');
|
|
|
|
throwIfIncomplete('render', state.measureStack);
|
|
if (getLastType(state.measureStack) !== 'render-idle') {
|
|
markWorkStarted(
|
|
'render-idle',
|
|
startTime,
|
|
getLanesFromTransportDecimalBitmask(laneBitmaskString),
|
|
currentProfilerData,
|
|
state,
|
|
);
|
|
}
|
|
markWorkStarted(
|
|
'render',
|
|
startTime,
|
|
getLanesFromTransportDecimalBitmask(laneBitmaskString),
|
|
currentProfilerData,
|
|
state,
|
|
);
|
|
|
|
for (let i = 0; i < state.nativeEventStack.length; i++) {
|
|
const nativeEvent = state.nativeEventStack[i];
|
|
const stopTime = nativeEvent.timestamp + nativeEvent.duration;
|
|
|
|
// If React work was scheduled during an event handler, and the event had a long duration,
|
|
// it might be because the React render was long and stretched the event.
|
|
// It might also be that the React work was short and that something else stretched the event.
|
|
// Make a note of this event for now and we'll examine the batch of React render work later.
|
|
// (We can't know until we're done processing the React update anyway.)
|
|
if (stopTime > startTime) {
|
|
state.potentialLongEvents.push([nativeEvent, state.batchUID]);
|
|
}
|
|
}
|
|
} else if (
|
|
name.startsWith('--render-stop') ||
|
|
name.startsWith('--render-yield')
|
|
) {
|
|
markWorkCompleted(
|
|
'render',
|
|
startTime,
|
|
currentProfilerData,
|
|
state.measureStack,
|
|
);
|
|
} else if (name.startsWith('--commit-start-')) {
|
|
state.nextRenderShouldGenerateNewBatchID = true;
|
|
const [laneBitmaskString] = name.slice(15).split('-');
|
|
|
|
markWorkStarted(
|
|
'commit',
|
|
startTime,
|
|
getLanesFromTransportDecimalBitmask(laneBitmaskString),
|
|
currentProfilerData,
|
|
state,
|
|
);
|
|
} else if (name.startsWith('--commit-stop')) {
|
|
markWorkCompleted(
|
|
'commit',
|
|
startTime,
|
|
currentProfilerData,
|
|
state.measureStack,
|
|
);
|
|
markWorkCompleted(
|
|
'render-idle',
|
|
startTime,
|
|
currentProfilerData,
|
|
state.measureStack,
|
|
);
|
|
} else if (name.startsWith('--layout-effects-start-')) {
|
|
const [laneBitmaskString] = name.slice(23).split('-');
|
|
|
|
markWorkStarted(
|
|
'layout-effects',
|
|
startTime,
|
|
getLanesFromTransportDecimalBitmask(laneBitmaskString),
|
|
currentProfilerData,
|
|
state,
|
|
);
|
|
} else if (name.startsWith('--layout-effects-stop')) {
|
|
markWorkCompleted(
|
|
'layout-effects',
|
|
startTime,
|
|
currentProfilerData,
|
|
state.measureStack,
|
|
);
|
|
} else if (name.startsWith('--passive-effects-start-')) {
|
|
const [laneBitmaskString] = name.slice(24).split('-');
|
|
|
|
markWorkStarted(
|
|
'passive-effects',
|
|
startTime,
|
|
getLanesFromTransportDecimalBitmask(laneBitmaskString),
|
|
currentProfilerData,
|
|
state,
|
|
);
|
|
} else if (name.startsWith('--passive-effects-stop')) {
|
|
markWorkCompleted(
|
|
'passive-effects',
|
|
startTime,
|
|
currentProfilerData,
|
|
state.measureStack,
|
|
);
|
|
} else if (name.startsWith('--react-internal-module-start-')) {
|
|
const stackFrameStart = name.slice(30);
|
|
|
|
if (!state.internalModuleStackStringSet.has(stackFrameStart)) {
|
|
state.internalModuleStackStringSet.add(stackFrameStart);
|
|
|
|
const parsedStackFrameStart = parseStackFrame(stackFrameStart);
|
|
|
|
state.internalModuleCurrentStackFrame = parsedStackFrameStart;
|
|
}
|
|
} else if (name.startsWith('--react-internal-module-stop-')) {
|
|
const stackFrameStop = name.slice(29);
|
|
|
|
if (!state.internalModuleStackStringSet.has(stackFrameStop)) {
|
|
state.internalModuleStackStringSet.add(stackFrameStop);
|
|
|
|
const parsedStackFrameStop = parseStackFrame(stackFrameStop);
|
|
|
|
if (
|
|
parsedStackFrameStop !== null &&
|
|
state.internalModuleCurrentStackFrame !== null
|
|
) {
|
|
const parsedStackFrameStart = state.internalModuleCurrentStackFrame;
|
|
|
|
state.internalModuleCurrentStackFrame = null;
|
|
|
|
const range = [parsedStackFrameStart, parsedStackFrameStop];
|
|
const ranges = currentProfilerData.internalModuleSourceToRanges.get(
|
|
parsedStackFrameStart.fileName,
|
|
);
|
|
if (ranges == null) {
|
|
currentProfilerData.internalModuleSourceToRanges.set(
|
|
parsedStackFrameStart.fileName,
|
|
[range],
|
|
);
|
|
} else {
|
|
ranges.push(range);
|
|
}
|
|
}
|
|
}
|
|
} else if (ph === 'R' || ph === 'n') {
|
|
// User Timing mark
|
|
currentProfilerData.otherUserTimingMarks.push({
|
|
name,
|
|
timestamp: startTime,
|
|
});
|
|
} else if (ph === 'b') {
|
|
// TODO: Begin user timing measure
|
|
} else if (ph === 'e') {
|
|
// TODO: End user timing measure
|
|
} else if (ph === 'i' || ph === 'I') {
|
|
// Instant events.
|
|
// Note that the capital "I" is a deprecated value that exists in Chrome Canary traces.
|
|
} else {
|
|
throw new InvalidProfileError(
|
|
`Unrecognized event ${JSON.stringify(
|
|
event,
|
|
)}! This is likely a bug in this profiler tool.`,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
function assertNoOverlappingComponentMeasure(state: ProcessorState) {
|
|
if (state.currentReactComponentMeasure !== null) {
|
|
console.error(
|
|
'Component measure started while another measure in progress:',
|
|
state.currentReactComponentMeasure,
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertCurrentComponentMeasureType(
|
|
state: ProcessorState,
|
|
type: ReactComponentMeasureType,
|
|
): void {
|
|
if (state.currentReactComponentMeasure === null) {
|
|
console.error(
|
|
`Component measure type "${type}" stopped while no measure was in progress`,
|
|
);
|
|
} else if (state.currentReactComponentMeasure.type !== type) {
|
|
console.error(
|
|
`Component measure type "${type}" stopped while type ${state.currentReactComponentMeasure.type} in progress`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function processReactComponentMeasure(
|
|
name: string,
|
|
startTime: Milliseconds,
|
|
currentProfilerData: TimelineData,
|
|
state: ProcessorState,
|
|
): void {
|
|
if (name.startsWith('--component-render-start-')) {
|
|
const [componentName] = name.slice(25).split('-');
|
|
|
|
assertNoOverlappingComponentMeasure(state);
|
|
|
|
state.currentReactComponentMeasure = {
|
|
componentName,
|
|
timestamp: startTime,
|
|
duration: 0,
|
|
type: 'render',
|
|
warning: null,
|
|
};
|
|
} else if (name === '--component-render-stop') {
|
|
assertCurrentComponentMeasureType(state, 'render');
|
|
|
|
if (state.currentReactComponentMeasure !== null) {
|
|
const componentMeasure = state.currentReactComponentMeasure;
|
|
componentMeasure.duration = startTime - componentMeasure.timestamp;
|
|
|
|
state.currentReactComponentMeasure = null;
|
|
|
|
currentProfilerData.componentMeasures.push(componentMeasure);
|
|
}
|
|
} else if (name.startsWith('--component-layout-effect-mount-start-')) {
|
|
const [componentName] = name.slice(38).split('-');
|
|
|
|
assertNoOverlappingComponentMeasure(state);
|
|
|
|
state.currentReactComponentMeasure = {
|
|
componentName,
|
|
timestamp: startTime,
|
|
duration: 0,
|
|
type: 'layout-effect-mount',
|
|
warning: null,
|
|
};
|
|
} else if (name === '--component-layout-effect-mount-stop') {
|
|
assertCurrentComponentMeasureType(state, 'layout-effect-mount');
|
|
|
|
if (state.currentReactComponentMeasure !== null) {
|
|
const componentMeasure = state.currentReactComponentMeasure;
|
|
componentMeasure.duration = startTime - componentMeasure.timestamp;
|
|
|
|
state.currentReactComponentMeasure = null;
|
|
|
|
currentProfilerData.componentMeasures.push(componentMeasure);
|
|
}
|
|
} else if (name.startsWith('--component-layout-effect-unmount-start-')) {
|
|
const [componentName] = name.slice(40).split('-');
|
|
|
|
assertNoOverlappingComponentMeasure(state);
|
|
|
|
state.currentReactComponentMeasure = {
|
|
componentName,
|
|
timestamp: startTime,
|
|
duration: 0,
|
|
type: 'layout-effect-unmount',
|
|
warning: null,
|
|
};
|
|
} else if (name === '--component-layout-effect-unmount-stop') {
|
|
assertCurrentComponentMeasureType(state, 'layout-effect-unmount');
|
|
|
|
if (state.currentReactComponentMeasure !== null) {
|
|
const componentMeasure = state.currentReactComponentMeasure;
|
|
componentMeasure.duration = startTime - componentMeasure.timestamp;
|
|
|
|
state.currentReactComponentMeasure = null;
|
|
|
|
currentProfilerData.componentMeasures.push(componentMeasure);
|
|
}
|
|
} else if (name.startsWith('--component-passive-effect-mount-start-')) {
|
|
const [componentName] = name.slice(39).split('-');
|
|
|
|
assertNoOverlappingComponentMeasure(state);
|
|
|
|
state.currentReactComponentMeasure = {
|
|
componentName,
|
|
timestamp: startTime,
|
|
duration: 0,
|
|
type: 'passive-effect-mount',
|
|
warning: null,
|
|
};
|
|
} else if (name === '--component-passive-effect-mount-stop') {
|
|
assertCurrentComponentMeasureType(state, 'passive-effect-mount');
|
|
|
|
if (state.currentReactComponentMeasure !== null) {
|
|
const componentMeasure = state.currentReactComponentMeasure;
|
|
componentMeasure.duration = startTime - componentMeasure.timestamp;
|
|
|
|
state.currentReactComponentMeasure = null;
|
|
|
|
currentProfilerData.componentMeasures.push(componentMeasure);
|
|
}
|
|
} else if (name.startsWith('--component-passive-effect-unmount-start-')) {
|
|
const [componentName] = name.slice(41).split('-');
|
|
|
|
assertNoOverlappingComponentMeasure(state);
|
|
|
|
state.currentReactComponentMeasure = {
|
|
componentName,
|
|
timestamp: startTime,
|
|
duration: 0,
|
|
type: 'passive-effect-unmount',
|
|
warning: null,
|
|
};
|
|
} else if (name === '--component-passive-effect-unmount-stop') {
|
|
assertCurrentComponentMeasureType(state, 'passive-effect-unmount');
|
|
|
|
if (state.currentReactComponentMeasure !== null) {
|
|
const componentMeasure = state.currentReactComponentMeasure;
|
|
componentMeasure.duration = startTime - componentMeasure.timestamp;
|
|
|
|
state.currentReactComponentMeasure = null;
|
|
|
|
currentProfilerData.componentMeasures.push(componentMeasure);
|
|
}
|
|
}
|
|
}
|
|
|
|
function preprocessFlamechart(rawData: TimelineEvent[]): Flamechart {
|
|
let parsedData;
|
|
try {
|
|
parsedData = importFromChromeTimeline(rawData, 'react-devtools');
|
|
} catch (error) {
|
|
// Assume any Speedscope errors are caused by bad profiles
|
|
const errorToRethrow = new InvalidProfileError(error.message);
|
|
errorToRethrow.stack = error.stack;
|
|
throw errorToRethrow;
|
|
}
|
|
|
|
const profile = parsedData.profiles[0]; // TODO: Choose the main CPU thread only
|
|
|
|
const speedscopeFlamechart = new SpeedscopeFlamechart({
|
|
// $FlowFixMe[method-unbinding]
|
|
getTotalWeight: profile.getTotalWeight.bind(profile),
|
|
// $FlowFixMe[method-unbinding]
|
|
forEachCall: profile.forEachCall.bind(profile),
|
|
// $FlowFixMe[method-unbinding]
|
|
formatValue: profile.formatValue.bind(profile),
|
|
getColorBucketForFrame: () => 0,
|
|
});
|
|
|
|
const flamechart: Flamechart = speedscopeFlamechart.getLayers().map(layer =>
|
|
layer.map(
|
|
({
|
|
start,
|
|
end,
|
|
node: {
|
|
frame: {name, file, line, col},
|
|
},
|
|
}) => ({
|
|
name,
|
|
timestamp: start / 1000,
|
|
duration: (end - start) / 1000,
|
|
scriptUrl: file,
|
|
locationLine: line,
|
|
locationColumn: col,
|
|
}),
|
|
),
|
|
);
|
|
|
|
return flamechart;
|
|
}
|
|
|
|
function parseStackFrame(stackFrame: string): ErrorStackFrame | null {
|
|
const error = new Error();
|
|
error.stack = stackFrame;
|
|
|
|
const frames = ErrorStackParser.parse(error);
|
|
|
|
return frames.length === 1 ? frames[0] : null;
|
|
}
|
|
|
|
export default async function preprocessData(
|
|
timeline: TimelineEvent[],
|
|
): Promise<TimelineData> {
|
|
const flamechart = preprocessFlamechart(timeline);
|
|
|
|
const laneToReactMeasureMap: Map<ReactLane, Array<ReactMeasure>> = new Map();
|
|
for (let lane: ReactLane = 0; lane < REACT_TOTAL_NUM_LANES; lane++) {
|
|
laneToReactMeasureMap.set(lane, []);
|
|
}
|
|
|
|
const profilerData: TimelineData = {
|
|
batchUIDToMeasuresMap: new Map(),
|
|
componentMeasures: [],
|
|
duration: 0,
|
|
flamechart,
|
|
internalModuleSourceToRanges: new Map(),
|
|
laneToLabelMap: new Map(),
|
|
laneToReactMeasureMap,
|
|
nativeEvents: [],
|
|
networkMeasures: [],
|
|
otherUserTimingMarks: [],
|
|
reactVersion: null,
|
|
schedulingEvents: [],
|
|
snapshots: [],
|
|
snapshotHeight: 0,
|
|
startTime: 0,
|
|
suspenseEvents: [],
|
|
thrownErrors: [],
|
|
};
|
|
|
|
// Sort `timeline`. JSON Array Format trace events need not be ordered. See:
|
|
// https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview#heading=h.f2f0yd51wi15
|
|
timeline = timeline.filter(Boolean).sort((a, b) => (a.ts > b.ts ? 1 : -1));
|
|
|
|
// Events displayed in flamechart have timestamps relative to the profile
|
|
// event's startTime. Source: https://github.com/v8/v8/blob/44bd8fd7/src/inspector/js_protocol.json#L1486
|
|
//
|
|
// We'll thus expect there to be a 'Profile' event; if there is not one, we
|
|
// can deduce that there are no flame chart events. As we expect React
|
|
// scheduling profiling user timing marks to be recorded together with browser
|
|
// flame chart events, we can futher deduce that the data is invalid and we
|
|
// don't bother finding React events.
|
|
const indexOfProfileEvent = timeline.findIndex(
|
|
event => event.name === 'Profile',
|
|
);
|
|
if (indexOfProfileEvent === -1) {
|
|
return profilerData;
|
|
}
|
|
|
|
// Use Profile event's `startTime` as the start time to align with flame chart.
|
|
// TODO: Remove assumption that there'll only be 1 'Profile' event. If this
|
|
// assumption does not hold, the chart may start at the wrong time.
|
|
profilerData.startTime = timeline[indexOfProfileEvent].args.data.startTime;
|
|
profilerData.duration =
|
|
(timeline[timeline.length - 1].ts - profilerData.startTime) / 1000;
|
|
|
|
const state: ProcessorState = {
|
|
asyncProcessingPromises: [],
|
|
batchUID: 0,
|
|
currentReactComponentMeasure: null,
|
|
internalModuleCurrentStackFrame: null,
|
|
internalModuleStackStringSet: new Set(),
|
|
measureStack: [],
|
|
nativeEventStack: [],
|
|
nextRenderShouldGenerateNewBatchID: true,
|
|
potentialLongEvents: [],
|
|
potentialLongNestedUpdate: null,
|
|
potentialLongNestedUpdates: [],
|
|
potentialSuspenseEventsOutsideOfTransition: [],
|
|
requestIdToNetworkMeasureMap: new Map(),
|
|
uidCounter: 0,
|
|
unresolvedSuspenseEvents: new Map(),
|
|
};
|
|
|
|
timeline.forEach(event => processTimelineEvent(event, profilerData, state));
|
|
|
|
if (profilerVersion === null) {
|
|
if (
|
|
profilerData.schedulingEvents.length === 0 &&
|
|
profilerData.batchUIDToMeasuresMap.size === 0
|
|
) {
|
|
// No profiler version could indicate data was logged using an older build of React,
|
|
// before an explicitly profiler version was included in the logging data.
|
|
// But it could also indicate that the website was either not using React, or using a production build.
|
|
// The easiest way to check for this case is to see if the data contains any scheduled updates or render work.
|
|
throw new InvalidProfileError(
|
|
'No React marks were found in the provided profile.' +
|
|
' Please provide profiling data from an React application running in development or profiling mode.',
|
|
);
|
|
}
|
|
|
|
throw new InvalidProfileError(
|
|
`This version of profiling data is not supported by the current profiler.`,
|
|
);
|
|
}
|
|
|
|
// Validate that all events and measures are complete
|
|
const {measureStack} = state;
|
|
if (measureStack.length > 0) {
|
|
console.error('Incomplete events or measures', measureStack);
|
|
}
|
|
|
|
// Check for warnings.
|
|
state.potentialLongEvents.forEach(([nativeEvent, batchUID]) => {
|
|
// See how long the subsequent batch of React work was.
|
|
// Ignore any work that was already started.
|
|
const [startTime, stopTime] = getBatchRange(
|
|
batchUID,
|
|
profilerData,
|
|
nativeEvent.timestamp,
|
|
);
|
|
if (stopTime - startTime > NATIVE_EVENT_DURATION_THRESHOLD) {
|
|
nativeEvent.warning = WARNING_STRINGS.LONG_EVENT_HANDLER;
|
|
}
|
|
});
|
|
state.potentialLongNestedUpdates.forEach(([schedulingEvent, batchUID]) => {
|
|
// See how long the subsequent batch of React work was.
|
|
const [startTime, stopTime] = getBatchRange(batchUID, profilerData);
|
|
if (stopTime - startTime > NESTED_UPDATE_DURATION_THRESHOLD) {
|
|
// Don't warn about transition updates scheduled during the commit phase.
|
|
// e.g. useTransition, useDeferredValue
|
|
// These are allowed to be long-running.
|
|
if (
|
|
!schedulingEvent.lanes.some(
|
|
lane => profilerData.laneToLabelMap.get(lane) === 'Transition',
|
|
)
|
|
) {
|
|
// FIXME: This warning doesn't account for "nested updates" that are
|
|
// spawned by useDeferredValue. Disabling temporarily until we figure
|
|
// out the right way to handle this.
|
|
// schedulingEvent.warning = WARNING_STRINGS.NESTED_UPDATE;
|
|
}
|
|
}
|
|
});
|
|
state.potentialSuspenseEventsOutsideOfTransition.forEach(
|
|
([suspenseEvent, lanes]) => {
|
|
// HACK This is a bit gross but the numeric lane value might change between render versions.
|
|
if (
|
|
!lanes.some(
|
|
lane => profilerData.laneToLabelMap.get(lane) === 'Transition',
|
|
)
|
|
) {
|
|
suspenseEvent.warning = WARNING_STRINGS.SUSPEND_DURING_UPDATE;
|
|
}
|
|
},
|
|
);
|
|
|
|
// Wait for any async processing to complete before returning.
|
|
// Since processing is done in a worker, async work must complete before data is serialized and returned.
|
|
await Promise.all(state.asyncProcessingPromises);
|
|
|
|
// Now that all images have been loaded, let's figure out the display size we're going to use for our thumbnails:
|
|
// both the ones rendered to the canvas and the ones shown on hover.
|
|
if (profilerData.snapshots.length > 0) {
|
|
// NOTE We assume a static window size here, which is not necessarily true but should be for most cases.
|
|
// Regardless, Chrome also sets a single size/ratio and stick with it- so we'll do the same.
|
|
const snapshot = profilerData.snapshots[0];
|
|
|
|
profilerData.snapshotHeight = Math.min(
|
|
snapshot.height,
|
|
SNAPSHOT_MAX_HEIGHT,
|
|
);
|
|
}
|
|
|
|
return profilerData;
|
|
}
|