@samitouri / QOS-React / commits / 0653fccd65

[react-devtools-facade] 3/ implement profiler tools (#36598)

Adds the profiler building blocks to `createTools` — per-commit render timing on top of the component-tree tools from commit 2. ### Tools - **`startProfiling(traceName?)`** → `{status: 'started', trace}`. Begins a session that records timing on every commit. Errors if a session is already active. - **`stopProfiling()`** → `{status: 'stopped', traceName, commits}` (commit count). Errors if no session is active. - **`getTraceOverview(traceName)`** → one row per commit: `{commit, committedAt, renderDuration, layoutDuration, passiveDuration, componentsChanged}`. - **`getCommitReport(traceName, commitIndex)`** → one commit's detail: `{committedAt, priority, renderDuration, layoutDuration, passiveDuration, components}`, where `components` is `{label, name, type, actualDuration, selfDuration}` sorted by `actualDuration` descending. Durations are in milliseconds, or `null` when the build does not collect profiler timing. `passiveDuration` is attributed per root via the hook's post-commit pass.

Ruslan Lesiutin committed Jun 18, 2026 at 20:27 UTC 0653fccd6547df6a7442a55e01b90a98eb4478de
4 files changed +725 -6
packages/react-devtools-facade/src/DevToolsFacadeProfilerTools.js new
+327
@@ -0,0 +1,327 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import {didFiberRender} from 'react-devtools-shared/src/backend/fiber/shared/DevToolsFiberChangeDetection';
11 +
12 +import type {Fiber, FiberRoot} from 'react-reconciler/src/ReactInternalTypes';
13 +import type {RendererInternals, ProfilingState} from './DevToolsFacade';
14 +import type {ToolError} from './DevToolsFacadeTreeTools';
15 +
16 +import {getTypeTag} from './DevToolsFacadeTreeTools';
17 +
18 +// Per-component render timing within a single commit. Durations are null when
19 +// the build does not collect profiler timing.
20 +export type CommitComponent = {
21 + uid: string,
22 + name: string,
23 + type: string,
24 + actualDuration: number | null,
25 + selfDuration: number | null,
26 +};
27 +
28 +// One row of a trace overview — a per-commit timing summary.
29 +export type TraceOverviewRow = {
30 + commit: number,
31 + committedAt: number,
32 + renderDuration: number | null,
33 + layoutDuration: number | null,
34 + passiveDuration: number | null,
35 + componentsChanged: number,
36 +};
37 +
38 +// A detailed report for a single commit.
39 +export type CommitReport = {
40 + committedAt: number,
41 + priority: string,
42 + renderDuration: number | null,
43 + layoutDuration: number | null,
44 + passiveDuration: number | null,
45 + components: Array<CommitComponent>,
46 +};
47 +
48 +export type StartProfilingResult = {status: 'started', traceName: string};
49 +export type StopProfilingResult = {
50 + status: 'stopped',
51 + traceName: string,
52 + commits: number,
53 +};
54 +
55 +export type ProfilerTools = {
56 + startProfiling: (traceName?: string) => StartProfilingResult | ToolError,
57 + stopProfiling: () => StopProfilingResult | ToolError,
58 + getTraceOverview: (traceName: string) => Array<TraceOverviewRow> | ToolError,
59 + getCommitReport: (
60 + traceName: string,
61 + commitIndex: number,
62 + ) => CommitReport | ToolError,
63 +};
64 +
65 +// Internal per-commit record (durations captured at commit time).
66 +type CommitRecord = {
67 + timestamp: number,
68 + priority: string,
69 + renderDuration: number | null,
70 + layoutDuration: number | null,
71 + passiveDuration: number | null,
72 + durations: Array<CommitComponent>,
73 +};
74 +
75 +type TraceData = {
76 + startTime: number,
77 + commits: Array<CommitRecord>,
78 +};
79 +
80 +function priorityToString(
81 + internals: RendererInternals,
82 + schedulerPriority: number | void,
83 +): string {
84 + const {
85 + ImmediatePriority,
86 + UserBlockingPriority,
87 + NormalPriority,
88 + IdlePriority,
89 + } = internals.ReactPriorityLevels;
90 + switch (schedulerPriority) {
91 + case ImmediatePriority:
92 + return 'Sync';
93 + case UserBlockingPriority:
94 + return 'UserBlocking';
95 + case NormalPriority:
96 + return 'Normal';
97 + case IdlePriority:
98 + return 'Idle';
99 + default:
100 + return 'Normal';
101 + }
102 +}
103 +
104 +/**
105 + * Build the profiler tools from a renderer-internals map, the shared profiling
106 + * state, and the tree tools' getUid (so component uids are consistent with
107 + * getComponentTree/getComponentByUid). The hook installed by installFacade
108 + * invokes profilingState.onCommit/onPostCommit while a session is active.
109 + */
110 +export function createProfilerTools(
111 + rendererInternals: Map<number, RendererInternals>,
112 + profilingState: ProfilingState,
113 + getUid: (fiber: Fiber) => string,
114 +): ProfilerTools {
115 + // Walk the fiber tree collecting timing for fibers that actually rendered.
116 + // Matches the same didFiberRender check and display-name filtering as the
117 + // DevTools Profiler — only fibers with a non-null display name are recorded,
118 + // which filters out internal types (HostRoot, Fragment, Mode, HostText, etc.).
119 + function collectDurations(
120 + internals: RendererInternals,
121 + fiber: Fiber,
122 + durations: Array<CommitComponent>,
123 + ): void {
124 + const {ReactTypeOfWork, getDisplayNameForFiber} = internals;
125 + const displayName = getDisplayNameForFiber(fiber);
126 + if (displayName != null) {
127 + const prevFiber = fiber.alternate;
128 + if (
129 + prevFiber == null ||
130 + didFiberRender(ReactTypeOfWork, prevFiber, fiber)
131 + ) {
132 + const actual =
133 + fiber.actualDuration != null ? fiber.actualDuration : null;
134 + let self: number | null = actual;
135 + if (actual != null) {
136 + let selfDuration: number = actual;
137 + let child = fiber.child;
138 + while (child !== null) {
139 + selfDuration -= child.actualDuration || 0;
140 + child = child.sibling;
141 + }
142 + self = selfDuration;
143 + }
144 + durations.push({
145 + uid: getUid(fiber),
146 + name: displayName,
147 + type: getTypeTag(ReactTypeOfWork, fiber.tag),
148 + actualDuration: actual,
149 + selfDuration: self,
150 + });
151 + }
152 + }
153 + // Recurse into children regardless of whether this node rendered.
154 + let child = fiber.child;
155 + while (child !== null) {
156 + collectDurations(internals, child, durations);
157 + child = child.sibling;
158 + }
159 + }
160 +
161 + // Commits awaiting their passive-effect pass, keyed by root so that a late
162 + // onPostCommit attributes passiveDuration to the right commit even when
163 + // multiple roots commit before their passive passes run.
164 + const pendingPassive: Map<FiberRoot, CommitRecord> = new Map();
165 +
166 + /**
167 + * Start a named profiling session that captures per-commit render timing.
168 + * While active, every React commit records timing for components that
169 + * rendered. Errors if a session is already active.
170 + *
171 + * @param traceName - Optional trace name (auto-generated if omitted).
172 + */
173 + function startProfiling(
174 + traceName?: string,
175 + ): StartProfilingResult | ToolError {
176 + if (profilingState.isActive) {
177 + return {
178 + error:
179 + 'Already profiling trace "' +
180 + (profilingState.currentTraceName || '') +
181 + '"',
182 + };
183 + }
184 + const resolvedTraceName = traceName || 'trace-' + Date.now();
185 + const trace: TraceData = {startTime: Date.now(), commits: []};
186 + profilingState.traces.set(resolvedTraceName, trace);
187 + profilingState.isActive = true;
188 + profilingState.currentTraceName = resolvedTraceName;
189 +
190 + profilingState.onCommit = function onCommit(
191 + rendererID: number,
192 + root: FiberRoot,
193 + schedulerPriority: number | void,
194 + ) {
195 + const internals = rendererInternals.get(rendererID);
196 + if (internals == null) {
197 + console.error(
198 + 'react-devtools-facade: Missing internals for renderer %s, commit not recorded.',
199 + rendererID,
200 + );
201 + return;
202 + }
203 + const durations: Array<CommitComponent> = [];
204 + collectDurations(internals, root.current, durations);
205 + const rootFiber = root.current;
206 + const record: CommitRecord = {
207 + timestamp: Date.now(),
208 + priority: priorityToString(internals, schedulerPriority),
209 + renderDuration:
210 + rootFiber.actualDuration != null ? rootFiber.actualDuration : null,
211 + layoutDuration:
212 + root.effectDuration != null ? root.effectDuration : null,
213 + passiveDuration: null,
214 + durations,
215 + };
216 + trace.commits.push(record);
217 + pendingPassive.set(root, record);
218 + };
219 +
220 + profilingState.onPostCommit = function onPostCommit(root: FiberRoot) {
221 + const record = pendingPassive.get(root);
222 + if (record != null) {
223 + record.passiveDuration =
224 + root.passiveEffectDuration != null
225 + ? root.passiveEffectDuration
226 + : null;
227 + pendingPassive.delete(root);
228 + }
229 + };
230 +
231 + return {status: 'started', traceName: resolvedTraceName};
232 + }
233 +
234 + /**
235 + * Stop the active profiling session. Errors if no session is active.
236 + */
237 + function stopProfiling(): StopProfilingResult | ToolError {
238 + if (!profilingState.isActive) {
239 + return {error: 'Not currently profiling'};
240 + }
241 + const traceName = profilingState.currentTraceName;
242 + if (traceName == null) {
243 + return {error: 'No active trace'};
244 + }
245 + const trace = profilingState.traces.get(traceName);
246 + const commitCount = trace ? trace.commits.length : 0;
247 + profilingState.isActive = false;
248 + profilingState.currentTraceName = null;
249 + profilingState.onCommit = null;
250 + profilingState.onPostCommit = null;
251 + pendingPassive.clear();
252 + return {status: 'stopped', traceName, commits: commitCount};
253 + }
254 +
255 + function getTrace(traceName: string): TraceData | null {
256 + return profilingState.traces.get(traceName) || null;
257 + }
258 +
259 + /**
260 + * Return an overview of a trace — one row per commit with a timing breakdown
261 + * (render, layout effects, passive effects) and the number of components that
262 + * changed.
263 + *
264 + * @param traceName - The name of the trace to query.
265 + */
266 + function getTraceOverview(
267 + traceName: string,
268 + ): Array<TraceOverviewRow> | ToolError {
269 + const trace = getTrace(traceName);
270 + if (trace == null) {
271 + return {error: 'Unknown trace "' + traceName + '"'};
272 + }
273 + const rows: Array<TraceOverviewRow> = [];
274 + for (let i = 0; i < trace.commits.length; i++) {
275 + const commit = trace.commits[i];
276 + rows.push({
277 + commit: i,
278 + committedAt: commit.timestamp - trace.startTime,
279 + renderDuration: commit.renderDuration,
280 + layoutDuration: commit.layoutDuration,
281 + passiveDuration: commit.passiveDuration,
282 + componentsChanged: commit.durations.length,
283 + });
284 + }
285 + return rows;
286 + }
287 +
288 + /**
289 + * Return a detailed report for a single commit — timing metadata
290 + * (committedAt, priority, duration breakdown) and per-component render
291 + * durations sorted by actualDuration descending.
292 + *
293 + * @param traceName - The name of the trace.
294 + * @param commitIndex - Zero-based index of the commit within the trace.
295 + */
296 + function getCommitReport(
297 + traceName: string,
298 + commitIndex: number,
299 + ): CommitReport | ToolError {
300 + const trace = getTrace(traceName);
301 + if (trace == null) {
302 + return {error: 'Unknown trace "' + traceName + '"'};
303 + }
304 + if (commitIndex < 0 || commitIndex >= trace.commits.length) {
305 + return {error: 'Commit index out of range'};
306 + }
307 + const commit = trace.commits[commitIndex];
308 + const components = commit.durations
309 + .slice()
310 + .sort((a, b) => (b.actualDuration || 0) - (a.actualDuration || 0));
311 + return {
312 + committedAt: commit.timestamp - trace.startTime,
313 + priority: commit.priority,
314 + renderDuration: commit.renderDuration,
315 + layoutDuration: commit.layoutDuration,
316 + passiveDuration: commit.passiveDuration,
317 + components,
318 + };
319 + }
320 +
321 + return {
322 + startProfiling,
323 + stopProfiling,
324 + getTraceOverview,
325 + getCommitReport,
326 + };
327 +}
packages/react-devtools-facade/src/DevToolsFacadeTools.js
+37 -6
@@ -17,8 +17,15 @@ import type {
17 FindComponentsResult,
18 ToolError,
19 } from './DevToolsFacadeTreeTools';
20 +import type {
21 + StartProfilingResult,
22 + StopProfilingResult,
23 + TraceOverviewRow,
24 + CommitReport,
25 +} from './DevToolsFacadeProfilerTools';
26
27 import {createTreeTools} from './DevToolsFacadeTreeTools';
28 +import {createProfilerTools} from './DevToolsFacadeProfilerTools';
29
30 export type {
31 TreeNode,
@@ -31,11 +38,18 @@ export type {
38 FindComponentsResult,
39 ToolError,
40 } from './DevToolsFacadeTreeTools';
41 +export type {
42 + CommitComponent,
43 + TraceOverviewRow,
44 + CommitReport,
45 + StartProfilingResult,
46 + StopProfilingResult,
47 +} from './DevToolsFacadeProfilerTools';
48
49 // The set of tools assembled from a Facade. Each tool returns a plain
36 -// JavaScript value (see the types in ./DevToolsFacadeTreeTools); serialization is the
37 -// integrator's responsibility. Integrators decide whether to expose these on
38 -// globals or call them directly.
50 +// JavaScript value (see the types in ./DevToolsFacadeTreeTools and
51 +// ./DevToolsFacadeProfilerTools); serialization is the integrator's responsibility.
52 +// Integrators decide whether to expose these on globals or call them directly.
53 export type Tools = {
54 getComponentTree: (
55 depth?: number,
@@ -51,18 +65,31 @@ export type Tools = {
65 getComponentSource: (uid: string) => ComponentSource | ToolError,
66 getOwnersStack: (uid: string) => OwnersStack | ToolError,
67 getOwnersBranch: (uid: string) => Array<OwnerEntry> | ToolError,
68 + startProfiling: (traceName?: string) => StartProfilingResult | ToolError,
69 + stopProfiling: () => StopProfilingResult | ToolError,
70 + getTraceOverview: (traceName: string) => Array<TraceOverviewRow> | ToolError,
71 + getCommitReport: (
72 + traceName: string,
73 + commitIndex: number,
74 + ) => CommitReport | ToolError,
75 };
76
77 /**
78 * Assemble the set of tools from a Facade. The tools read the facade's tracked
58 - * runtime state (fiber roots, per-renderer internals) lazily on each call and
59 - * never touch globals, so the integrator fully owns both the facade and the
60 - * returned tools.
79 + * runtime state (fiber roots, per-renderer internals, profiling state) lazily
80 + * on each call and never touch globals, so the integrator fully owns both the
81 + * facade and the returned tools. Profiler tools share the tree tools' getUid
82 + * so component labels are consistent across all tools.
83 *
84 * @param facade - A Facade returned by installFacade().
85 */
86 export function createTools(facade: Facade): Tools {
87 const tree = createTreeTools(facade.fiberRoots, facade.rendererInternals);
88 + const profiler = createProfilerTools(
89 + facade.rendererInternals,
90 + facade.profilingState,
91 + tree.getUid,
92 + );
93
94 return {
95 getComponentTree: tree.getComponentTree,
@@ -71,5 +98,9 @@ export function createTools(facade: Facade): Tools {
98 getComponentSource: tree.getComponentSource,
99 getOwnersStack: tree.getOwnersStack,
100 getOwnersBranch: tree.getOwnersBranch,
101 + startProfiling: profiler.startProfiling,
102 + stopProfiling: profiler.stopProfiling,
103 + getTraceOverview: profiler.getTraceOverview,
104 + getCommitReport: profiler.getCommitReport,
105 };
106 }
packages/react-devtools-facade/src/DevToolsFacadeTreeTools.js
+4
@@ -91,6 +91,9 @@ export type TreeTools = {
91 getComponentSource: (uid: string) => ComponentSource | ToolError,
92 getOwnersStack: (uid: string) => OwnersStack | ToolError,
93 getOwnersBranch: (uid: string) => Array<OwnerEntry> | ToolError,
94 + // Shared with the profiler tools so component uids are consistent across all
95 + // tools. Maps a fiber to its stable uid (assigning one on first encounter).
96 + getUid: (fiber: Fiber) => string,
97 };
98
99 /**
@@ -666,5 +669,6 @@ export function createTreeTools(
669 getComponentSource,
670 getOwnersStack,
671 getOwnersBranch,
672 + getUid,
673 };
674 }
packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js
+357
@@ -15,6 +15,12 @@ let ReactDOMClient;
15 let act;
16 let container;
17
18 +// Profiler durations are timing-dependent: null when the build does not collect
19 +// them, otherwise a non-negative number.
20 +function isDuration(value) {
21 + return value === null || (typeof value === 'number' && value >= 0);
22 +}
23 +
24 describe('react-devtools-facade', () => {
25 beforeEach(() => {
26 jest.resetModules();
@@ -1534,4 +1540,355 @@ describe('react-devtools-facade', () => {
1540 expect(info.hooks).toBeUndefined();
1541 });
1542 });
1543 +
1544 + describe('profiler', () => {
1545 + let startProfiling;
1546 + let stopProfiling;
1547 + let getTraceOverview;
1548 + let getCommitReport;
1549 + let getComponentTree;
1550 + let getComponentByUid;
1551 +
1552 + beforeEach(() => {
1553 + const tools = createTools(facade);
1554 + startProfiling = tools.startProfiling;
1555 + stopProfiling = tools.stopProfiling;
1556 + getTraceOverview = tools.getTraceOverview;
1557 + getCommitReport = tools.getCommitReport;
1558 + getComponentTree = tools.getComponentTree;
1559 + getComponentByUid = tools.getComponentByUid;
1560 + });
1561 +
1562 + it('startProfiling returns the started status and trace name', () => {
1563 + expect(startProfiling('my-trace')).toEqual({
1564 + status: 'started',
1565 + traceName: 'my-trace',
1566 + });
1567 + stopProfiling();
1568 + });
1569 +
1570 + it('startProfiling auto-generates a trace name when none is provided', () => {
1571 + const result = startProfiling();
1572 + expect(result.status).toBe('started');
1573 + expect(result.traceName).toMatch(/^trace-\d+$/);
1574 + stopProfiling();
1575 + });
1576 +
1577 + it('stopProfiling reports the trace name and commit count', () => {
1578 + startProfiling('test-trace');
1579 + expect(stopProfiling()).toEqual({
1580 + status: 'stopped',
1581 + traceName: 'test-trace',
1582 + commits: 0,
1583 + });
1584 + });
1585 +
1586 + it('cannot start profiling twice', () => {
1587 + startProfiling('first');
1588 + expect(startProfiling('second')).toEqual({
1589 + error: 'Already profiling trace "first"',
1590 + });
1591 + stopProfiling();
1592 + });
1593 +
1594 + it('cannot stop when not profiling', () => {
1595 + expect(stopProfiling()).toEqual({error: 'Not currently profiling'});
1596 + });
1597 +
1598 + it('records one commit per render and reports the count on stop', () => {
1599 + function Counter({count}) {
1600 + return <div>{'Count: ' + count}</div>;
1601 + }
1602 +
1603 + const root = ReactDOMClient.createRoot(container);
1604 + act(() => {
1605 + root.render(<Counter count={0} />);
1606 + });
1607 +
1608 + startProfiling('render-trace');
1609 + act(() => {
1610 + root.render(<Counter count={1} />);
1611 + });
1612 + act(() => {
1613 + root.render(<Counter count={2} />);
1614 + });
1615 +
1616 + expect(stopProfiling()).toEqual({
1617 + status: 'stopped',
1618 + traceName: 'render-trace',
1619 + commits: 2,
1620 + });
1621 + });
1622 +
1623 + it('getTraceOverview returns one row per commit', () => {
1624 + function Child() {
1625 + return <span>child</span>;
1626 + }
1627 + function Counter({count}) {
1628 + return (
1629 + <div>
1630 + <Child />
1631 + {count}
1632 + </div>
1633 + );
1634 + }
1635 +
1636 + const root = ReactDOMClient.createRoot(container);
1637 + act(() => {
1638 + root.render(<Counter count={0} />);
1639 + });
1640 +
1641 + startProfiling('overview-trace');
1642 + act(() => {
1643 + root.render(<Counter count={1} />);
1644 + });
1645 + act(() => {
1646 + root.render(<Counter count={2} />);
1647 + });
1648 + stopProfiling();
1649 +
1650 + const overview = getTraceOverview('overview-trace');
1651 + expect(overview).toHaveLength(2);
1652 + let previousCommittedAt = 0;
1653 + overview.forEach((row, i) => {
1654 + expect(row.commit).toBe(i);
1655 + // committedAt is relative to trace start: non-negative and monotonic.
1656 + expect(row.committedAt).toBeGreaterThanOrEqual(previousCommittedAt);
1657 + previousCommittedAt = row.committedAt;
1658 + // componentsChanged matches the commit report's component count.
1659 + expect(row.componentsChanged).toBe(
1660 + getCommitReport('overview-trace', i).components.length,
1661 + );
1662 + expect(isDuration(row.renderDuration)).toBe(true);
1663 + expect(isDuration(row.layoutDuration)).toBe(true);
1664 + expect(isDuration(row.passiveDuration)).toBe(true);
1665 + });
1666 + });
1667 +
1668 + it('getTraceOverview returns an error for an unknown trace', () => {
1669 + expect(getTraceOverview('nope')).toEqual({error: 'Unknown trace "nope"'});
1670 + });
1671 +
1672 + it('getTraceOverview returns an empty array for a trace with no commits', () => {
1673 + startProfiling('empty-trace');
1674 + stopProfiling();
1675 + expect(getTraceOverview('empty-trace')).toEqual([]);
1676 + });
1677 +
1678 + it('getCommitReport returns commit metadata and the full component set', () => {
1679 + function Child() {
1680 + return <span>child</span>;
1681 + }
1682 + function Counter({count}) {
1683 + return (
1684 + <div>
1685 + <Child />
1686 + {count}
1687 + </div>
1688 + );
1689 + }
1690 +
1691 + const root = ReactDOMClient.createRoot(container);
1692 + act(() => {
1693 + root.render(<Counter count={0} />);
1694 + });
1695 +
1696 + startProfiling('detail-trace');
1697 + act(() => {
1698 + root.render(<Counter count={1} />);
1699 + });
1700 + stopProfiling();
1701 +
1702 + const report = getCommitReport('detail-trace', 0);
1703 + expect(report.priority).toBe('Normal');
1704 + expect(report.committedAt).toBeGreaterThanOrEqual(0);
1705 + expect(isDuration(report.renderDuration)).toBe(true);
1706 + expect(isDuration(report.layoutDuration)).toBe(true);
1707 + expect(isDuration(report.passiveDuration)).toBe(true);
1708 +
1709 + // The exact set of components that rendered. Order is duration-dependent
1710 + // (sorted descending), so compare sorted by name.
1711 + const byName = report.components
1712 + .map(c => ({name: c.name, type: c.type}))
1713 + .sort((a, b) => a.name.localeCompare(b.name));
1714 + expect(byName).toEqual([
1715 + {name: 'Child', type: 'function'},
1716 + {name: 'Counter', type: 'function'},
1717 + {name: 'createRoot()', type: 'root'},
1718 + {name: 'div', type: 'host'},
1719 + {name: 'span', type: 'host'},
1720 + ]);
1721 + report.components.forEach(c => {
1722 + expect(c.uid).toMatch(/^r\d+$/);
1723 + expect(isDuration(c.actualDuration)).toBe(true);
1724 + expect(isDuration(c.selfDuration)).toBe(true);
1725 + });
1726 + });
1727 +
1728 + it('getCommitReport sorts components by actualDuration descending', () => {
1729 + function Child() {
1730 + return <span>child</span>;
1731 + }
1732 + function Counter({count}) {
1733 + return (
1734 + <div>
1735 + <Child />
1736 + {count}
1737 + </div>
1738 + );
1739 + }
1740 +
1741 + const root = ReactDOMClient.createRoot(container);
1742 + act(() => {
1743 + root.render(<Counter count={0} />);
1744 + });
1745 + startProfiling('sort-trace');
1746 + act(() => {
1747 + root.render(<Counter count={1} />);
1748 + });
1749 + stopProfiling();
1750 +
1751 + const durations = getCommitReport('sort-trace', 0).components.map(
1752 + c => c.actualDuration || 0,
1753 + );
1754 + for (let i = 1; i < durations.length; i++) {
1755 + expect(durations[i]).toBeLessThanOrEqual(durations[i - 1]);
1756 + }
1757 + });
1758 +
1759 + it('getCommitReport committedAt matches getTraceOverview', () => {
1760 + function Counter({count}) {
1761 + return <div>{'Count: ' + count}</div>;
1762 + }
1763 +
1764 + const root = ReactDOMClient.createRoot(container);
1765 + act(() => {
1766 + root.render(<Counter count={0} />);
1767 + });
1768 + startProfiling('match-trace');
1769 + act(() => {
1770 + root.render(<Counter count={1} />);
1771 + });
1772 + stopProfiling();
1773 +
1774 + const overview = getTraceOverview('match-trace');
1775 + const report = getCommitReport('match-trace', 0);
1776 + expect(report.committedAt).toBe(overview[0].committedAt);
1777 + });
1778 +
1779 + it('getCommitReport returns an error for an unknown trace', () => {
1780 + expect(getCommitReport('nope', 0)).toEqual({
1781 + error: 'Unknown trace "nope"',
1782 + });
1783 + });
1784 +
1785 + it('getCommitReport returns an error for an out-of-range commit index', () => {
1786 + startProfiling('range-trace');
1787 + stopProfiling();
1788 + expect(getCommitReport('range-trace', 5)).toEqual({
1789 + error: 'Commit index out of range',
1790 + });
1791 + expect(getCommitReport('range-trace', -1)).toEqual({
1792 + error: 'Commit index out of range',
1793 + });
1794 + });
1795 +
1796 + it('does not record internal nodes like Fragment, Mode, or text', () => {
1797 + function Child() {
1798 + return <span>child</span>;
1799 + }
1800 + function App() {
1801 + return (
1802 + <React.StrictMode>
1803 + <React.Fragment>
1804 + <Child />
1805 + </React.Fragment>
1806 + </React.StrictMode>
1807 + );
1808 + }
1809 +
1810 + const root = ReactDOMClient.createRoot(container);
1811 + act(() => {
1812 + root.render(<App />);
1813 + });
1814 + startProfiling('internal-trace');
1815 + act(() => {
1816 + root.render(<App />);
1817 + });
1818 + stopProfiling();
1819 +
1820 + const names = getCommitReport('internal-trace', 0).components.map(
1821 + c => c.name,
1822 + );
1823 + expect(names).not.toContain('Fragment');
1824 + expect(names).not.toContain('StrictMode');
1825 + // Only named components are recorded; no Unknown/internal entries.
1826 + names.forEach(name => {
1827 + expect(typeof name).toBe('string');
1828 + expect(name).not.toBe('Unknown');
1829 + });
1830 + });
1831 +
1832 + it('uses uids consistent with the tree tools', () => {
1833 + function Widget() {
1834 + return <div>widget</div>;
1835 + }
1836 +
1837 + const root = ReactDOMClient.createRoot(container);
1838 + act(() => {
1839 + root.render(<Widget />);
1840 + });
1841 + const widget = getComponentTree().find(n => n.name === 'Widget');
1842 +
1843 + startProfiling('uid-trace');
1844 + act(() => {
1845 + root.render(<Widget />);
1846 + });
1847 + stopProfiling();
1848 +
1849 + const report = getCommitReport('uid-trace', 0);
1850 + const widgetEntry = report.components.find(c => c.name === 'Widget');
1851 + expect(widgetEntry).toBeDefined();
1852 + expect(widgetEntry.uid).toBe(widget.uid);
1853 + // ...and the same uid resolves back through getComponentByUid.
1854 + expect(getComponentByUid(widget.uid).name).toBe('Widget');
1855 + });
1856 +
1857 + it('records commits across multiple independent traces', () => {
1858 + function Counter({count}) {
1859 + return <div>{'Count: ' + count}</div>;
1860 + }
1861 +
1862 + const root = ReactDOMClient.createRoot(container);
1863 + act(() => {
1864 + root.render(<Counter count={0} />);
1865 + });
1866 +
1867 + startProfiling('trace-a');
1868 + act(() => {
1869 + root.render(<Counter count={1} />);
1870 + });
1871 + stopProfiling();
1872 +
1873 + startProfiling('trace-b');
1874 + act(() => {
1875 + root.render(<Counter count={2} />);
1876 + });
1877 + act(() => {
1878 + root.render(<Counter count={3} />);
1879 + });
1880 + stopProfiling();
1881 +
1882 + expect(getTraceOverview('trace-a')).toHaveLength(1);
1883 + expect(getTraceOverview('trace-b')).toHaveLength(2);
1884 + });
1885 +
1886 + it('the hook onPostCommitFiberRoot is a no-op when not profiling', () => {
1887 + const hook = facade.hook;
1888 + expect(typeof hook.onPostCommitFiberRoot).toBe('function');
1889 + expect(() => {
1890 + hook.onPostCommitFiberRoot(0, {passiveEffectDuration: 0});
1891 + }).not.toThrow();
1892 + });
1893 + });
1894 });