/** * 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 type { Lane, Lanes, DevToolsProfilingHooks, WorkTagMap, CurrentDispatcherRef, } from 'react-devtools-shared/src/backend/types'; import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; import type {Wakeable} from 'shared/ReactTypes'; import type { BatchUID, InternalModuleSourceToRanges, LaneToLabelMap, ReactComponentMeasure, ReactLane, ReactMeasure, ReactMeasureType, ReactScheduleStateUpdateEvent, SchedulingEvent, SuspenseEvent, TimelineData, } from 'react-devtools-timeline/src/types'; import isArray from 'shared/isArray'; import { REACT_TOTAL_NUM_LANES, SCHEDULING_PROFILER_VERSION, } from 'react-devtools-timeline/src/constants'; import {describeFiber} from './fiber/DevToolsFiberComponentStack'; // Add padding to the start/stop time of the profile. // This makes the UI nicer to use. const TIME_OFFSET = 10; let performanceTarget: Performance | null = null; // If performance exists and supports the subset of the User Timing API that we require. let supportsUserTiming = typeof performance !== 'undefined' && // $FlowFixMe[method-unbinding] typeof performance.mark === 'function' && // $FlowFixMe[method-unbinding] typeof performance.clearMarks === 'function'; let supportsUserTimingV3 = false; if (supportsUserTiming) { const CHECK_V3_MARK = '__v3'; const markOptions: { detail?: mixed, startTime?: number, } = {}; Object.defineProperty(markOptions, 'startTime', { get: function () { supportsUserTimingV3 = true; return 0; }, set: function () {}, }); try { performance.mark(CHECK_V3_MARK, markOptions); } catch (error) { // Ignore } finally { performance.clearMarks(CHECK_V3_MARK); } } if (supportsUserTimingV3) { performanceTarget = performance; } // Some environments (e.g. React Native / Hermes) don't support the performance API yet. const getCurrentTime = // $FlowFixMe[method-unbinding] typeof performance === 'object' && typeof performance.now === 'function' ? () => performance.now() : () => Date.now(); // Mocking the Performance Object (and User Timing APIs) for testing is fragile. // This API allows tests to directly override the User Timing APIs. export function setPerformanceMock_ONLY_FOR_TESTING( performanceMock: Performance | null, ) { performanceTarget = performanceMock; supportsUserTiming = performanceMock !== null; supportsUserTimingV3 = performanceMock !== null; } export type GetTimelineData = () => TimelineData | null; export type ToggleProfilingStatus = ( value: boolean, recordTimeline?: boolean, ) => void; type Response = { getTimelineData: GetTimelineData, profilingHooks: DevToolsProfilingHooks, toggleProfilingStatus: ToggleProfilingStatus, }; export function createProfilingHooks({ getDisplayNameForFiber, getIsProfiling, getLaneLabelMap, workTagMap, currentDispatcherRef, reactVersion, }: { getDisplayNameForFiber: (fiber: Fiber) => string | null, getIsProfiling: () => boolean, getLaneLabelMap?: () => Map | null, currentDispatcherRef?: CurrentDispatcherRef, workTagMap: WorkTagMap, reactVersion: string, }): Response { let currentBatchUID: BatchUID = 0; let currentReactComponentMeasure: ReactComponentMeasure | null = null; let currentReactMeasuresStack: Array = []; let currentTimelineData: TimelineData | null = null; let currentFiberStacks: Map> = new Map(); let isProfiling: boolean = false; let nextRenderShouldStartNewBatch: boolean = false; function getRelativeTime() { const currentTime = getCurrentTime(); if (currentTimelineData) { if (currentTimelineData.startTime === 0) { currentTimelineData.startTime = currentTime - TIME_OFFSET; } return currentTime - currentTimelineData.startTime; } return 0; } function getInternalModuleRanges() { /* global __REACT_DEVTOOLS_GLOBAL_HOOK__ */ if ( typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ !== 'undefined' && typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.getInternalModuleRanges === 'function' ) { // Ask the DevTools hook for module ranges that may have been reported by the current renderer(s). // Don't do this eagerly like the laneToLabelMap, // because some modules might not yet have registered their boundaries when the renderer is injected. const ranges = __REACT_DEVTOOLS_GLOBAL_HOOK__.getInternalModuleRanges(); // This check would not be required, // except that it's possible for things to override __REACT_DEVTOOLS_GLOBAL_HOOK__. if (isArray(ranges)) { return ranges; } } return null; } function getTimelineData(): TimelineData | null { return currentTimelineData; } function laneToLanesArray(lanes: Lane) { const lanesArray = []; let lane = 1; for (let index = 0; index < REACT_TOTAL_NUM_LANES; index++) { if (lane & lanes) { lanesArray.push(lane); } lane *= 2; } return lanesArray; } const laneToLabelMap: LaneToLabelMap | null = typeof getLaneLabelMap === 'function' ? getLaneLabelMap() : null; function markMetadata() { markAndClear(`--react-version-${reactVersion}`); markAndClear(`--profiler-version-${SCHEDULING_PROFILER_VERSION}`); const ranges = getInternalModuleRanges(); if (ranges) { for (let i = 0; i < ranges.length; i++) { const range = ranges[i]; if (isArray(range) && range.length === 2) { const [startStackFrame, stopStackFrame] = ranges[i]; markAndClear(`--react-internal-module-start-${startStackFrame}`); markAndClear(`--react-internal-module-stop-${stopStackFrame}`); } } } if (laneToLabelMap != null) { const labels = Array.from(laneToLabelMap.values()).join(','); markAndClear(`--react-lane-labels-${labels}`); } } function markAndClear(markName: string) { // This method won't be called unless these functions are defined, so we can skip the extra typeof check. ((performanceTarget: any): Performance).mark(markName); ((performanceTarget: any): Performance).clearMarks(markName); } function recordReactMeasureStarted( type: ReactMeasureType, lanes: Lanes, ): void { // Decide what depth thi work should be rendered at, based on what's on the top of the stack. // It's okay to render over top of "idle" work but everything else should be on its own row. let depth = 0; if (currentReactMeasuresStack.length > 0) { const top = currentReactMeasuresStack[currentReactMeasuresStack.length - 1]; depth = top.type === 'render-idle' ? top.depth : top.depth + 1; } const lanesArray = laneToLanesArray(lanes); const reactMeasure: ReactMeasure = { type, batchUID: currentBatchUID, depth, lanes: lanesArray, timestamp: getRelativeTime(), duration: 0, }; currentReactMeasuresStack.push(reactMeasure); if (currentTimelineData) { const {batchUIDToMeasuresMap, laneToReactMeasureMap} = currentTimelineData; let reactMeasures = batchUIDToMeasuresMap.get(currentBatchUID); if (reactMeasures != null) { reactMeasures.push(reactMeasure); } else { batchUIDToMeasuresMap.set(currentBatchUID, [reactMeasure]); } lanesArray.forEach(lane => { reactMeasures = laneToReactMeasureMap.get(lane); if (reactMeasures) { reactMeasures.push(reactMeasure); } }); } } function recordReactMeasureCompleted(type: ReactMeasureType): void { const currentTime = getRelativeTime(); if (currentReactMeasuresStack.length === 0) { console.error( 'Unexpected type "%s" completed at %sms while currentReactMeasuresStack is empty.', type, currentTime, ); // Ignore work "completion" user timing mark that doesn't complete anything return; } const top = currentReactMeasuresStack.pop(); // $FlowFixMe[incompatible-type] // $FlowFixMe[incompatible-use] if (top.type !== type) { console.error( 'Unexpected type "%s" completed at %sms before "%s" completed.', type, currentTime, // $FlowFixMe[incompatible-use] top.type, ); } // $FlowFixMe[cannot-write] This property should not be writable outside of this function. // $FlowFixMe[incompatible-use] top.duration = currentTime - top.timestamp; if (currentTimelineData) { currentTimelineData.duration = getRelativeTime() + TIME_OFFSET; } } function markCommitStarted(lanes: Lanes): void { if (!isProfiling) { return; } recordReactMeasureStarted('commit', lanes); // TODO (timeline) Re-think this approach to "batching"; I don't think it works for Suspense or pre-rendering. // This issue applies to the User Timing data also. nextRenderShouldStartNewBatch = true; if (supportsUserTimingV3) { markAndClear(`--commit-start-${lanes}`); // Some metadata only needs to be logged once per session, // but if profiling information is being recorded via the Performance tab, // DevTools has no way of knowing when the recording starts. // Because of that, we log thie type of data periodically (once per commit). markMetadata(); } } function markCommitStopped(): void { if (!isProfiling) { return; } recordReactMeasureCompleted('commit'); recordReactMeasureCompleted('render-idle'); if (supportsUserTimingV3) { markAndClear('--commit-stop'); } } function markComponentRenderStarted(fiber: Fiber): void { if (!isProfiling) { return; } const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; // TODO (timeline) Record and cache component stack currentReactComponentMeasure = { componentName, duration: 0, timestamp: getRelativeTime(), type: 'render', warning: null, }; if (supportsUserTimingV3) { markAndClear(`--component-render-start-${componentName}`); } } function markComponentRenderStopped(): void { if (!isProfiling) { return; } if (currentReactComponentMeasure) { if (currentTimelineData) { currentTimelineData.componentMeasures.push( currentReactComponentMeasure, ); } // $FlowFixMe[incompatible-use] found when upgrading Flow currentReactComponentMeasure.duration = // $FlowFixMe[incompatible-use] found when upgrading Flow getRelativeTime() - currentReactComponentMeasure.timestamp; currentReactComponentMeasure = null; } if (supportsUserTimingV3) { markAndClear('--component-render-stop'); } } function markComponentLayoutEffectMountStarted(fiber: Fiber): void { if (!isProfiling) { return; } const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; // TODO (timeline) Record and cache component stack currentReactComponentMeasure = { componentName, duration: 0, timestamp: getRelativeTime(), type: 'layout-effect-mount', warning: null, }; if (supportsUserTimingV3) { markAndClear(`--component-layout-effect-mount-start-${componentName}`); } } function markComponentLayoutEffectMountStopped(): void { if (!isProfiling) { return; } if (currentReactComponentMeasure) { if (currentTimelineData) { currentTimelineData.componentMeasures.push( currentReactComponentMeasure, ); } // $FlowFixMe[incompatible-use] found when upgrading Flow currentReactComponentMeasure.duration = // $FlowFixMe[incompatible-use] found when upgrading Flow getRelativeTime() - currentReactComponentMeasure.timestamp; currentReactComponentMeasure = null; } if (supportsUserTimingV3) { markAndClear('--component-layout-effect-mount-stop'); } } function markComponentLayoutEffectUnmountStarted(fiber: Fiber): void { if (!isProfiling) { return; } const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; // TODO (timeline) Record and cache component stack currentReactComponentMeasure = { componentName, duration: 0, timestamp: getRelativeTime(), type: 'layout-effect-unmount', warning: null, }; if (supportsUserTimingV3) { markAndClear(`--component-layout-effect-unmount-start-${componentName}`); } } function markComponentLayoutEffectUnmountStopped(): void { if (!isProfiling) { return; } if (currentReactComponentMeasure) { if (currentTimelineData) { currentTimelineData.componentMeasures.push( currentReactComponentMeasure, ); } // $FlowFixMe[incompatible-use] found when upgrading Flow currentReactComponentMeasure.duration = // $FlowFixMe[incompatible-use] found when upgrading Flow getRelativeTime() - currentReactComponentMeasure.timestamp; currentReactComponentMeasure = null; } if (supportsUserTimingV3) { markAndClear('--component-layout-effect-unmount-stop'); } } function markComponentPassiveEffectMountStarted(fiber: Fiber): void { if (!isProfiling) { return; } const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; // TODO (timeline) Record and cache component stack currentReactComponentMeasure = { componentName, duration: 0, timestamp: getRelativeTime(), type: 'passive-effect-mount', warning: null, }; if (supportsUserTimingV3) { markAndClear(`--component-passive-effect-mount-start-${componentName}`); } } function markComponentPassiveEffectMountStopped(): void { if (!isProfiling) { return; } if (currentReactComponentMeasure) { if (currentTimelineData) { currentTimelineData.componentMeasures.push( currentReactComponentMeasure, ); } // $FlowFixMe[incompatible-use] found when upgrading Flow currentReactComponentMeasure.duration = // $FlowFixMe[incompatible-use] found when upgrading Flow getRelativeTime() - currentReactComponentMeasure.timestamp; currentReactComponentMeasure = null; } if (supportsUserTimingV3) { markAndClear('--component-passive-effect-mount-stop'); } } function markComponentPassiveEffectUnmountStarted(fiber: Fiber): void { if (!isProfiling) { return; } const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; // TODO (timeline) Record and cache component stack currentReactComponentMeasure = { componentName, duration: 0, timestamp: getRelativeTime(), type: 'passive-effect-unmount', warning: null, }; if (supportsUserTimingV3) { markAndClear(`--component-passive-effect-unmount-start-${componentName}`); } } function markComponentPassiveEffectUnmountStopped(): void { if (!isProfiling) { return; } if (currentReactComponentMeasure) { if (currentTimelineData) { currentTimelineData.componentMeasures.push( currentReactComponentMeasure, ); } // $FlowFixMe[incompatible-use] found when upgrading Flow currentReactComponentMeasure.duration = // $FlowFixMe[incompatible-use] found when upgrading Flow getRelativeTime() - currentReactComponentMeasure.timestamp; currentReactComponentMeasure = null; } if (supportsUserTimingV3) { markAndClear('--component-passive-effect-unmount-stop'); } } function markComponentErrored( fiber: Fiber, thrownValue: mixed, lanes: Lanes, ): void { if (!isProfiling) { return; } const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; const phase = fiber.alternate === null ? 'mount' : 'update'; let message = ''; if ( thrownValue !== null && typeof thrownValue === 'object' && typeof thrownValue.message === 'string' ) { message = thrownValue.message; } else if (typeof thrownValue === 'string') { message = thrownValue; } // TODO (timeline) Record and cache component stack if (currentTimelineData) { currentTimelineData.thrownErrors.push({ componentName, message, phase, timestamp: getRelativeTime(), type: 'thrown-error', }); } if (supportsUserTimingV3) { markAndClear(`--error-${componentName}-${phase}-${message}`); } } const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; // $FlowFixMe[incompatible-type]: Flow cannot handle polymorphic WeakMaps const wakeableIDs: WeakMap = new PossiblyWeakMap(); let wakeableID: number = 0; function getWakeableID(wakeable: Wakeable): number { if (!wakeableIDs.has(wakeable)) { wakeableIDs.set(wakeable, wakeableID++); } return ((wakeableIDs.get(wakeable): any): number); } function markComponentSuspended( fiber: Fiber, wakeable: Wakeable, lanes: Lanes, ): void { if (!isProfiling) { return; } const eventType = wakeableIDs.has(wakeable) ? 'resuspend' : 'suspend'; const id = getWakeableID(wakeable); const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; const phase = fiber.alternate === null ? 'mount' : 'update'; // Following the non-standard fn.displayName convention, // frameworks like Relay may also annotate Promises with a displayName, // describing what operation/data the thrown Promise is related to. // When this is available we should pass it along to the Timeline. const displayName = (wakeable: any).displayName || ''; let suspenseEvent: SuspenseEvent | null = null; // TODO (timeline) Record and cache component stack suspenseEvent = { componentName, depth: 0, duration: 0, id: `${id}`, phase, promiseName: displayName, resolution: 'unresolved', timestamp: getRelativeTime(), type: 'suspense', warning: null, }; if (currentTimelineData) { currentTimelineData.suspenseEvents.push(suspenseEvent); } if (supportsUserTimingV3) { markAndClear( `--suspense-${eventType}-${id}-${componentName}-${phase}-${lanes}-${displayName}`, ); wakeable.then( () => { if (suspenseEvent) { suspenseEvent.duration = getRelativeTime() - suspenseEvent.timestamp; suspenseEvent.resolution = 'resolved'; } if (supportsUserTimingV3) { markAndClear(`--suspense-resolved-${id}-${componentName}`); } }, () => { if (suspenseEvent) { suspenseEvent.duration = getRelativeTime() - suspenseEvent.timestamp; suspenseEvent.resolution = 'rejected'; } if (supportsUserTimingV3) { markAndClear(`--suspense-rejected-${id}-${componentName}`); } }, ); } } function markLayoutEffectsStarted(lanes: Lanes): void { if (!isProfiling) { return; } recordReactMeasureStarted('layout-effects', lanes); if (supportsUserTimingV3) { markAndClear(`--layout-effects-start-${lanes}`); } } function markLayoutEffectsStopped(): void { if (!isProfiling) { return; } recordReactMeasureCompleted('layout-effects'); if (supportsUserTimingV3) { markAndClear('--layout-effects-stop'); } } function markPassiveEffectsStarted(lanes: Lanes): void { if (!isProfiling) { return; } recordReactMeasureStarted('passive-effects', lanes); if (supportsUserTimingV3) { markAndClear(`--passive-effects-start-${lanes}`); } } function markPassiveEffectsStopped(): void { if (!isProfiling) { return; } recordReactMeasureCompleted('passive-effects'); if (supportsUserTimingV3) { markAndClear('--passive-effects-stop'); } } function markRenderStarted(lanes: Lanes): void { if (!isProfiling) { return; } if (nextRenderShouldStartNewBatch) { nextRenderShouldStartNewBatch = false; currentBatchUID++; } // If this is a new batch of work, wrap an "idle" measure around it. // Log it before the "render" measure to preserve the stack ordering. if ( currentReactMeasuresStack.length === 0 || currentReactMeasuresStack[currentReactMeasuresStack.length - 1].type !== 'render-idle' ) { recordReactMeasureStarted('render-idle', lanes); } recordReactMeasureStarted('render', lanes); if (supportsUserTimingV3) { markAndClear(`--render-start-${lanes}`); } } function markRenderYielded(): void { if (!isProfiling) { return; } recordReactMeasureCompleted('render'); if (supportsUserTimingV3) { markAndClear('--render-yield'); } } function markRenderStopped(): void { if (!isProfiling) { return; } recordReactMeasureCompleted('render'); if (supportsUserTimingV3) { markAndClear('--render-stop'); } } function markRenderScheduled(lane: Lane): void { if (!isProfiling) { return; } if (currentTimelineData) { currentTimelineData.schedulingEvents.push({ lanes: laneToLanesArray(lane), timestamp: getRelativeTime(), type: 'schedule-render', warning: null, }); } if (supportsUserTimingV3) { markAndClear(`--schedule-render-${lane}`); } } function markForceUpdateScheduled(fiber: Fiber, lane: Lane): void { if (!isProfiling) { return; } const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; // TODO (timeline) Record and cache component stack if (currentTimelineData) { currentTimelineData.schedulingEvents.push({ componentName, lanes: laneToLanesArray(lane), timestamp: getRelativeTime(), type: 'schedule-force-update', warning: null, }); } if (supportsUserTimingV3) { markAndClear(`--schedule-forced-update-${lane}-${componentName}`); } } function getParentFibers(fiber: Fiber): Array { const parents = []; let parent: null | Fiber = fiber; while (parent !== null) { parents.push(parent); parent = parent.return; } return parents; } function markStateUpdateScheduled(fiber: Fiber, lane: Lane): void { if (!isProfiling) { return; } const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; // TODO (timeline) Record and cache component stack if (currentTimelineData) { const event: ReactScheduleStateUpdateEvent = { componentName, // Store the parent fibers so we can post process // them after we finish profiling lanes: laneToLanesArray(lane), timestamp: getRelativeTime(), type: 'schedule-state-update', warning: null, }; currentFiberStacks.set(event, getParentFibers(fiber)); // $FlowFixMe[incompatible-use] found when upgrading Flow currentTimelineData.schedulingEvents.push(event); } if (supportsUserTimingV3) { markAndClear(`--schedule-state-update-${lane}-${componentName}`); } } function toggleProfilingStatus( value: boolean, recordTimeline: boolean = false, ) { if (isProfiling !== value) { isProfiling = value; if (isProfiling) { const internalModuleSourceToRanges: InternalModuleSourceToRanges = new Map(); if (supportsUserTimingV3) { const ranges = getInternalModuleRanges(); if (ranges) { for (let i = 0; i < ranges.length; i++) { const range = ranges[i]; if (isArray(range) && range.length === 2) { const [startStackFrame, stopStackFrame] = ranges[i]; markAndClear( `--react-internal-module-start-${startStackFrame}`, ); markAndClear(`--react-internal-module-stop-${stopStackFrame}`); } } } } const laneToReactMeasureMap = new Map(); let lane = 1; for (let index = 0; index < REACT_TOTAL_NUM_LANES; index++) { laneToReactMeasureMap.set(lane, []); lane *= 2; } currentBatchUID = 0; currentReactComponentMeasure = null; currentReactMeasuresStack = []; currentFiberStacks = new Map(); if (recordTimeline) { currentTimelineData = { // Session wide metadata; only collected once. internalModuleSourceToRanges, laneToLabelMap: laneToLabelMap || new Map(), reactVersion, // Data logged by React during profiling session. componentMeasures: [], schedulingEvents: [], suspenseEvents: [], thrownErrors: [], // Data inferred based on what React logs. batchUIDToMeasuresMap: new Map(), duration: 0, laneToReactMeasureMap, startTime: 0, // Data only available in Chrome profiles. flamechart: [], nativeEvents: [], networkMeasures: [], otherUserTimingMarks: [], snapshots: [], snapshotHeight: 0, }; } nextRenderShouldStartNewBatch = true; } else { // This is __EXPENSIVE__. // We could end up with hundreds of state updated, and for each one of them // would try to create a component stack with possibly hundreds of Fibers. // Creating a cache of component stacks won't help, generating a single stack is already expensive enough. // We should find a way to lazily generate component stacks on demand, when user inspects a specific event. // If we succeed with moving React DevTools Timeline Profiler to Performance panel, then Timeline Profiler would probably be removed. // Now that owner stacks are adopted, revisit this again and cache component stacks per Fiber, // but only return them when needed, sending hundreds of component stacks is beyond the Bridge's bandwidth. // Postprocess Profile data if (currentTimelineData !== null) { currentTimelineData.schedulingEvents.forEach(event => { if (event.type === 'schedule-state-update') { // TODO(luna): We can optimize this by creating a map of // fiber to component stack instead of generating the stack // for every fiber every time const fiberStack = currentFiberStacks.get(event); if (fiberStack && currentDispatcherRef != null) { event.componentStack = fiberStack.reduce((trace, fiber) => { return ( trace + describeFiber(workTagMap, fiber, currentDispatcherRef) ); }, ''); } } }); } // Clear the current fiber stacks so we don't hold onto the fibers // in memory after profiling finishes currentFiberStacks.clear(); } } } return { getTimelineData, profilingHooks: { markCommitStarted, markCommitStopped, markComponentRenderStarted, markComponentRenderStopped, markComponentPassiveEffectMountStarted, markComponentPassiveEffectMountStopped, markComponentPassiveEffectUnmountStarted, markComponentPassiveEffectUnmountStopped, markComponentLayoutEffectMountStarted, markComponentLayoutEffectMountStopped, markComponentLayoutEffectUnmountStarted, markComponentLayoutEffectUnmountStopped, markComponentErrored, markComponentSuspended, markLayoutEffectsStarted, markLayoutEffectsStopped, markPassiveEffectsStarted, markPassiveEffectsStopped, markRenderStarted, markRenderYielded, markRenderStopped, markRenderScheduled, markForceUpdateScheduled, markStateUpdateScheduled, }, toggleProfilingStatus, }; }