diff --git a/src/cli/react-profiler/react-profile-command.test.ts b/src/cli/react-profiler/react-profile-command.test.ts index db13448f2b..39e3cc0bf1 100644 --- a/src/cli/react-profiler/react-profile-command.test.ts +++ b/src/cli/react-profiler/react-profile-command.test.ts @@ -96,6 +96,20 @@ test('profile start, an interaction, and profile stop print a compact diagnosis '1. memo-defeated Row: memo bypassed on 36/36 updates; new onPress (function) on those updates from List -> wrap those props in useCallback/useMemo in List', ); assert.ok(fs.existsSync(path.join(stateDir, 'react-profiles', 'qa.json'))); + + const report = await run('report', 'Row'); + assert.match( + report.text, + /^Row \[memo\] self 108ms avg 3\.0ms max 3\.0ms renders 36 12 inst\ncauses: props 36\nchanged props: onPress 36 \(function\)/, + ); + assert.match( + (await run('timeline', '--sort', 'duration', '--limit', '1')).text, + /^Slowest commits \(1 of 3\):\n {2}#0 \+0\.0s 40ms 13 rendered <- List/, + ); + assert.match( + (await run('commit', '#1')).text, + /^#1 \+0\.2s 40ms 13 rendered <- List\n {2}Row x12 self 36ms props/, + ); }); test('profile stop --json returns the findings and where the profile was saved', async () => { @@ -182,6 +196,10 @@ test('runtime failures carry a typed reason and a hint', async () => { ); }); +test('drill-down views need a saved profile', async () => { + await assert.rejects(harness().run('slow'), /No React profile saved for session qa/); +}); + test('explicit dev-server flags override the session binding', async () => { const { run, requested } = harness(); await run('status', '--metro-port', '8082'); @@ -209,15 +227,29 @@ test('a remote bridge profiles through the public dev-server URL without its bun assert.deepEqual(requested, ['https://metro.example.test/json/list']); }); +test('a corrupt saved profile asks for a new recording', async () => { + const { run, stateDir } = harness(); + fs.mkdirSync(path.join(stateDir, 'react-profiles'), { recursive: true }); + const file = path.join(stateDir, 'react-profiles', 'qa.json'); + fs.writeFileSync(file, '{"analysis":'); + await assert.rejects(run('slow'), /unsupported format/); + fs.writeFileSync(file, JSON.stringify({ analysis: { version: 1, components: {} } })); + await assert.rejects(run('slow'), /unsupported format/); +}); + test('unknown profile commands and malformed options are invalid arguments', async () => { const { run } = harness(); const cases: Array<[string[], RegExp]> = [ [['export', 'out.json'], /Unknown react-devtools profile command: export/], - [['stop', '--limit', '0'], /Invalid --limit: 0/], - [['stop', '--limit', '10garbage'], /Invalid --limit: 10garbage/], - [['stop', '--limit', '1.5'], /Invalid --limit: 1\.5/], + [['slow', '--limit', '0'], /Invalid --limit: 0/], + [['slow', '--limit', '10garbage'], /Invalid --limit: 10garbage/], + [['slow', '--limit', '1.5'], /Invalid --limit: 1\.5/], + [['timeline', '--sort', 'name'], /Invalid --sort: name/], [['stop', 'extra'], /Unexpected argument for react-devtools profile stop: extra/], [['start', '--limit', '5'], /react-devtools profile start does not take --limit/], + [['report', 'Row', 'extra'], /Unexpected argument for react-devtools profile report: extra/], + [['report', 'Row', '--limit', '2'], /react-devtools profile report does not take --limit/], + [['slow', '--sort', 'duration'], /react-devtools profile slow does not take --sort/], ]; for (const [args, message] of cases) { await assert.rejects(run(...args), (error: { code: string; message: string }) => { @@ -227,3 +259,30 @@ test('unknown profile commands and malformed options are invalid arguments', asy }); } }); + +test('profile commit --json honors --limit like the text view', async () => { + const { app, run, options } = harness(); + await run('start'); + recordRowRenders(app); + await run('stop'); + options.flags = { json: true }; + const { text } = await run('commit', '1', '--limit', '1'); + const parsed = JSON.parse(text) as { data: { top: Array<{ name: string }>; rendered: number } }; + assert.deepEqual( + parsed.data.top.map((entry) => entry.name), + ['Row'], + ); + assert.equal(parsed.data.rendered, 13); +}); + +test('commit selectors must be whole indexes', async () => { + const { app, run } = harness(); + await run('start'); + recordRowRenders(app); + await run('stop'); + await assert.rejects( + run('commit', '#1junk'), + (error: { code: string }) => error.code === 'INVALID_ARGS', + ); + assert.match((await run('commit', '#1')).text, /^#1 /); +}); diff --git a/src/cli/react-profiler/react-profile-command.ts b/src/cli/react-profiler/react-profile-command.ts index 1a52ce8be6..81c3453634 100644 --- a/src/cli/react-profiler/react-profile-command.ts +++ b/src/cli/react-profiler/react-profile-command.ts @@ -22,16 +22,35 @@ import { import { analyzeReactProfile, type ReactProfileAnalysis } from './react-profile-analysis.ts'; import { selectPropKindRequests, + type CommitProfile, + type ComponentProfile, type PropKindsById, type RawReactProfile, } from './react-profile-renders.ts'; import { + componentsByRenders, DEFAULT_FINDING_LIMIT, - formatProfileSummary, + findComponent, formatSeconds, + formatCommit, + formatComponentReport, + formatProfileSummary, + formatRerenders, + formatSlowComponents, + formatTimeline, + timelineCommits, } from './react-profile-format.ts'; -const REACT_PROFILE_SUBCOMMANDS = ['start', 'stop', 'status'] as const; +const REACT_PROFILE_SUBCOMMANDS = [ + 'start', + 'stop', + 'status', + 'slow', + 'rerenders', + 'report', + 'timeline', + 'commit', +] as const; type ReactProfileSubcommand = (typeof REACT_PROFILE_SUBCOMMANDS)[number]; @@ -43,15 +62,17 @@ export type ReactProfileCommandOptions = { type ProfileArgs = { subcommand: ReactProfileSubcommand; + target?: string; limit?: number; app?: string; metroDevice?: string; metroHost?: string; metroPort?: string; bundleUrl?: string; + sort?: 'timeline' | 'duration'; }; -/** Analysis plus the runtime it came from, as `profile stop` saves it for the session. */ +/** Analysis plus the runtime it came from, as `profile stop` saves it for the drill-down views. */ type SavedReactProfile = { savedAt: string; appId?: string; @@ -64,6 +85,7 @@ type StartResult = { ok: true; attach: string } | RuntimeFailure; type StopResult = ({ ok: true } & RawReactProfile) | RuntimeFailure; type StatusResult = { profiling: boolean; elapsedMs: number | null }; +const SUBCOMMANDS_WITH_TARGET = new Set(['report', 'commit']); const POSITIVE_INTEGER = /^[1-9]\d*$/; function isReactProfileSubcommand(value: string | undefined): value is ReactProfileSubcommand { @@ -77,6 +99,7 @@ const PROFILE_OPTIONS = { 'metro-host': { type: 'string' }, 'metro-port': { type: 'string' }, 'bundle-url': { type: 'string' }, + sort: { type: 'string' }, } as const; type ProfileOption = keyof typeof PROFILE_OPTIONS; @@ -94,6 +117,11 @@ const SUBCOMMAND_OPTIONS: Record 0) { + const expected = SUBCOMMANDS_WITH_TARGET.has(subcommand) ? 1 : 0; + if (positionals.length > expected) { throw new AppError( 'INVALID_ARGS', - `Unexpected argument for react-devtools profile ${subcommand}: ${positionals[0]}.`, + `Unexpected argument for react-devtools profile ${subcommand}: ${positionals[expected]}.`, ); } if (values.limit !== undefined && !POSITIVE_INTEGER.test(values.limit)) { throw new AppError('INVALID_ARGS', `Invalid --limit: ${values.limit}. Use a positive integer.`); } const limit = values.limit === undefined ? undefined : Number(values.limit); + if (values.sort !== undefined && values.sort !== 'timeline' && values.sort !== 'duration') { + throw new AppError('INVALID_ARGS', `Invalid --sort: ${values.sort}. Use timeline or duration.`); + } return { subcommand, + target: positionals[0], limit, app: values.app, metroDevice: values['metro-device'], metroHost: values['metro-host'], metroPort: values['metro-port'], bundleUrl: values['bundle-url'], + sort: values.sort as ProfileArgs['sort'], }; } @@ -250,6 +284,39 @@ function writeSavedProfile( return filePath; } +function isSavedProfile(value: SavedReactProfile | undefined): value is SavedReactProfile { + const analysis = value?.analysis; + return ( + analysis?.version === 1 && + Array.isArray(analysis.components) && + Array.isArray(analysis.commits) && + Array.isArray(analysis.findings) + ); +} + +function readSavedProfile(options: ReactProfileCommandOptions): SavedReactProfile { + let raw: string; + try { + raw = fs.readFileSync(savedProfilePath(options), 'utf8'); + } catch { + throw new AppError('COMMAND_FAILED', `No React profile saved for session ${options.session}.`, { + hint: 'Run react-devtools profile start, perform the interaction, then react-devtools profile stop.', + }); + } + let saved: SavedReactProfile | undefined; + try { + saved = JSON.parse(raw) as SavedReactProfile; + } catch { + saved = undefined; + } + if (!isSavedProfile(saved)) { + throw new AppError('COMMAND_FAILED', 'The saved React profile has an unsupported format.', { + hint: 'Record a new profile with react-devtools profile start and stop.', + }); + } + return saved; +} + const elapsed = (elapsedMs: number) => formatSeconds(Math.max(0, elapsedMs)); async function startProfile( @@ -328,6 +395,32 @@ type SubcommandHandler = ( emit: Emit, ) => Promise; +function requireComponent( + analysis: ReactProfileAnalysis, + name: string | undefined, +): ComponentProfile { + if (!name) + throw new AppError('INVALID_ARGS', 'react-devtools profile report requires a component name.'); + const component = findComponent(analysis, name); + if (!component) { + throw new AppError('COMMAND_FAILED', `No renders of ${name} in the saved profile.`, { + hint: 'Use a name from react-devtools profile slow or profile rerenders.', + }); + } + return component; +} + +function requireCommit(analysis: ReactProfileAnalysis, target: string | undefined): CommitProfile { + const digits = (target ?? '').replace(/^#/, ''); + const commit = /^\d+$/.test(digits) ? analysis.commits[Number(digits)] : undefined; + if (!commit) { + throw new AppError('INVALID_ARGS', `No commit ${target ?? '(none)'} in the saved profile.`, { + hint: `Use a commit index from 0 to ${Math.max(analysis.commits.length - 1, 0)}.`, + }); + } + return commit; +} + const SUBCOMMAND_HANDLERS: Record = { start: async (args, options, emit) => { const started = await startProfile(args, options); @@ -354,6 +447,36 @@ const SUBCOMMAND_HANDLERS: Record = { savedTo: savedProfilePath(options), }); }, + slow: async (args, options, emit) => { + const { analysis } = readSavedProfile(options); + const limit = args.limit ?? 10; + emit(formatSlowComponents(analysis, limit), analysis.components.slice(0, limit)); + }, + rerenders: async (args, options, emit) => { + const { analysis } = readSavedProfile(options); + const limit = args.limit ?? 10; + emit(formatRerenders(analysis, limit), componentsByRenders(analysis).slice(0, limit)); + }, + report: async (args, options, emit) => { + const { analysis } = readSavedProfile(options); + const component = requireComponent(analysis, args.target); + emit(formatComponentReport(analysis, component), component); + }, + timeline: async (args, options, emit) => { + const { analysis } = readSavedProfile(options); + const limit = args.limit ?? 20; + const byDuration = args.sort === 'duration'; + emit( + formatTimeline(analysis, limit, byDuration), + timelineCommits(analysis, byDuration).slice(0, limit), + ); + }, + commit: async (args, options, emit) => { + const { analysis } = readSavedProfile(options); + const commit = requireCommit(analysis, args.target); + const limit = args.limit ?? 10; + emit(formatCommit(commit, limit), { ...commit, top: commit.top.slice(0, limit) }); + }, }; export async function runReactProfileCommand( diff --git a/src/cli/react-profiler/react-profile-format.test.ts b/src/cli/react-profiler/react-profile-format.test.ts index 9a024b1262..0cf4f40a6f 100644 --- a/src/cli/react-profiler/react-profile-format.test.ts +++ b/src/cli/react-profiler/react-profile-format.test.ts @@ -2,7 +2,14 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import type { Finding, ReactProfileAnalysis } from './react-profile-analysis.ts'; import type { ComponentProfile } from './react-profile-renders.ts'; -import { formatProfileSummary } from './react-profile-format.ts'; +import { + findComponent, + formatCommit, + formatProfileSummary, + formatRerenders, + formatSlowComponents, + formatTimeline, +} from './react-profile-format.ts'; function component(name: string, overrides: Partial = {}): ComponentProfile { return { @@ -69,19 +76,20 @@ function analysis(overrides: Partial = {}): ReactProfileAn }; } -test('the summary is a headline and one line per finding', () => { +test('the summary is a headline, one line per finding, and the drill-down commands', () => { assert.equal( formatProfileSummary(analysis()), [ 'React profile 3.2s: 4 commits, 96ms rendering, 2 over 16ms (worst #2 42ms)', '1. slow-render C1: slow -> memoize', + 'More: profile slow | profile rerenders | profile report | profile timeline | profile commit ', ].join('\n'), ); }); test('the summary caps findings and says how to see the rest', () => { const text = formatProfileSummary(analysis({ findings: [1, 2, 3, 4].map(finding) }), 2); - assert.match(text, /\n2\. slow-render C2: slow -> memoize\n\+2 smaller findings \(--limit 4\)$/); + assert.match(text, /\n2\. slow-render C2: slow -> memoize\n\+2 smaller findings \(--limit 4\)\n/); }); test('the summary reports clean profiles and empty recordings in one line', () => { @@ -101,3 +109,43 @@ test('lookup naming warns that unmounted components may be unnamed', () => { /\nNote: component names came from mounted elements/, ); }); + +test('slow and rerender tables rank by self time and render count', () => { + assert.match( + formatSlowComponents(analysis(), 1), + /^Components by self time:\n {2}Chart \[function\] self 40ms avg 10ms max 12ms renders 4\n {2}\+1 more \(--limit 2\)$/, + ); + assert.match( + formatRerenders(analysis(), 1), + /^Components by render count:\n {2}Row \[function\] 9 renders 3 inst: props 3, mount 1; changed props onPress 3\n {2}\+1 more \(--limit 2\)$/, + ); +}); + +test('the timeline lists commits with their trigger and mounts', () => { + assert.equal( + formatTimeline(analysis(), 5, false), + 'Commits:\n #0 +0.0s 10ms 3 rendered <- List\n #1 +0.9s 42ms 30 rendered (30 mounted)', + ); + assert.match(formatTimeline(analysis(), 1, true), /^Slowest commits \(1 of 2\):\n {2}#1/); +}); + +test('component lookup falls back to a case-insensitive match', () => { + assert.equal(findComponent(analysis(), 'chart')?.name, 'Chart'); + assert.equal(findComponent(analysis(), 'Missing'), undefined); +}); + +test('a commit entry without recorded changes says its cause is unrecorded', () => { + const text = formatCommit( + { + index: 3, + atMs: 1200, + durationMs: 20, + rendered: 2, + mounts: 0, + updaters: [], + top: [{ name: 'Row', instances: 2, selfMs: 12, actualMs: 18, causes: [] }], + }, + 5, + ); + assert.equal(text, '#3 +1.2s 20ms 2 rendered\n Row x2 self 12ms unrecorded'); +}); diff --git a/src/cli/react-profiler/react-profile-format.ts b/src/cli/react-profiler/react-profile-format.ts index fc18963fa4..d0852999ff 100644 --- a/src/cli/react-profiler/react-profile-format.ts +++ b/src/cli/react-profiler/react-profile-format.ts @@ -1,5 +1,11 @@ import type { ReactProfileAnalysis } from './react-profile-analysis.ts'; -import { FRAME_BUDGET_MS, formatMs } from './react-profile-renders.ts'; +import { + FRAME_BUDGET_MS, + formatMs, + type CommitProfile, + type ComponentProfile, + type RenderCauses, +} from './react-profile-renders.ts'; export const DEFAULT_FINDING_LIMIT = 5; @@ -45,5 +51,146 @@ export function formatProfileSummary( 'Note: component names came from mounted elements; components unmounted during the profile may show as Unknown.', ); } + lines.push( + 'More: profile slow | profile rerenders | profile report | profile timeline | profile commit ', + ); + return lines.join('\n'); +} + +function causeSummary(causes: RenderCauses): string { + return ( + (Object.entries(causes) as [keyof RenderCauses, number][]) + .filter(([, count]) => count > 0) + .sort((a, b) => b[1] - a[1]) + .map(([cause, count]) => `${cause} ${count}`) + .join(', ') || 'unrecorded' + ); +} + +function omittedRows(shown: number, total: number): string[] { + return shown < total ? [` +${total - shown} more (--limit ${total})`] : []; +} + +function keyCounts(record: Record, limit = 4): string { + return Object.entries(record) + .sort((a, b) => b[1] - a[1]) + .slice(0, limit) + .map(([key, count]) => `${key} ${count}`) + .join(', '); +} + +function componentLine(component: ComponentProfile): string { + const avg = component.renders > 0 ? component.selfMs / component.renders : 0; + const instances = component.instances > 1 ? ` ${component.instances} inst` : ''; + return `${component.name} [${component.kind}] self ${ms(component.selfMs)} avg ${ms(avg)} max ${ms(component.maxSelfMs)} renders ${component.renders}${instances}`; +} + +export function formatSlowComponents(analysis: ReactProfileAnalysis, limit: number): string { + if (analysis.components.length === 0) return 'No component renders recorded.'; + const shown = analysis.components.slice(0, limit); + return [ + 'Components by self time:', + ...shown.map((c) => ` ${componentLine(c)}`), + ...omittedRows(shown.length, analysis.components.length), + ].join('\n'); +} + +export function componentsByRenders(analysis: ReactProfileAnalysis): ComponentProfile[] { + return [...analysis.components].sort((a, b) => b.renders - a.renders); +} + +export function formatRerenders(analysis: ReactProfileAnalysis, limit: number): string { + if (analysis.components.length === 0) return 'No component renders recorded.'; + const sorted = componentsByRenders(analysis).slice(0, limit); + return [ + 'Components by render count:', + ...sorted.map((c) => { + const props = keyCounts(c.changedProps); + return ` ${c.name} [${c.kind}] ${c.renders} renders${c.instances > 1 ? ` ${c.instances} inst` : ''}: ${causeSummary(c.causes)}${props ? `; changed props ${props}` : ''}`; + }), + ...omittedRows(sorted.length, analysis.components.length), + ].join('\n'); +} + +export function findComponent( + analysis: ReactProfileAnalysis, + name: string, +): ComponentProfile | undefined { + return ( + analysis.components.find((c) => c.name === name) ?? + analysis.components.find((c) => c.name.toLowerCase() === name.toLowerCase()) + ); +} + +export function formatComponentReport( + analysis: ReactProfileAnalysis, + component: ComponentProfile, +): string { + const lines = [componentLine(component), `causes: ${causeSummary(component.causes)}`]; + const props = Object.entries(component.changedProps) + .sort((a, b) => b[1] - a[1]) + .map( + ([key, count]) => + `${key} ${count}${component.propKinds[key] ? ` (${component.propKinds[key]})` : ''}`, + ); + if (props.length > 0) lines.push(`changed props: ${props.join(', ')}`); + const state = keyCounts(component.changedState, 8); + if (state) lines.push(`changed state: ${state}`); + const hooks = keyCounts(component.changedHooks, 8); + if (hooks) lines.push(`changed hooks (by index): ${hooks}`); + const owners = keyCounts(component.owners, 3); + if (owners) lines.push(`rendered by: ${owners}`); + const findings = analysis.findings.filter((finding) => finding.component === component.name); + for (const finding of findings) + lines.push(`finding ${finding.kind}: ${finding.evidence} -> ${finding.fix}`); + if (component.costliestCommits.length > 0) { + const commits = component.costliestCommits.map(({ index, selfMs }) => { + const commit = analysis.commits[index]; + return `#${index} ${ms(selfMs)}${commit ? ` of ${ms(commit.durationMs)}` : ''}`; + }); + lines.push(`its costliest commits (self time of commit duration): ${commits.join(', ')}`); + } + return lines.join('\n'); +} + +function commitLine(commit: CommitProfile): string { + const trigger = commit.updaters.length > 0 ? ` <- ${commit.updaters.join(', ')}` : ''; + const mounts = commit.mounts > 0 ? ` (${commit.mounts} mounted)` : ''; + return `#${commit.index} +${seconds(commit.atMs)} ${ms(commit.durationMs)} ${commit.rendered} rendered${mounts}${trigger}`; +} + +export function timelineCommits( + analysis: ReactProfileAnalysis, + byDuration: boolean, +): CommitProfile[] { + return byDuration + ? [...analysis.commits].sort((a, b) => b.durationMs - a.durationMs) + : analysis.commits; +} + +export function formatTimeline( + analysis: ReactProfileAnalysis, + limit: number, + byDuration: boolean, +): string { + if (analysis.commits.length === 0) return 'No commits recorded.'; + const commits = timelineCommits(analysis, byDuration); + const shown = commits.slice(0, limit); + const header = byDuration ? 'Slowest commits' : 'Commits'; + const range = shown.length < commits.length ? ` (${shown.length} of ${commits.length})` : ''; + return [`${header}${range}:`, ...shown.map((commit) => ` ${commitLine(commit)}`)].join('\n'); +} + +export function formatCommit(commit: CommitProfile, limit: number): string { + const lines = [commitLine(commit)]; + const shown = commit.top.slice(0, limit); + for (const entry of shown) { + const instances = entry.instances > 1 ? ` x${entry.instances}` : ''; + lines.push( + ` ${entry.name}${instances} self ${ms(entry.selfMs)} ${entry.causes.join('+') || 'unrecorded'}`, + ); + } + const hidden = commit.rendered - shown.reduce((sum, entry) => sum + entry.instances, 0); + if (hidden > 0) lines.push(` +${hidden} more component renders`); return lines.join('\n'); } diff --git a/src/commands/schema/cli-help-topics.test.ts b/src/commands/schema/cli-help-topics.test.ts index 171c8bf6cd..004ab22a96 100644 --- a/src/commands/schema/cli-help-topics.test.ts +++ b/src/commands/schema/cli-help-topics.test.ts @@ -524,7 +524,9 @@ test('usageForCommand resolves react-devtools help topic', async () => { if (help === null) throw new Error('Expected react-devtools help text'); assert.match(help, /React Native performance profiling/); assert.match(help, /agent-device react-devtools profile start/); - assert.match(help, /agent-device react-devtools profile stop --limit 10/); + assert.match(help, /agent-device react-devtools profile report /); + assert.match(help, /agent-device react-devtools profile commit /); + assert.match(help, /agent-device react-devtools profile timeline --limit 20 --sort duration/); assert.match(help, /React Native 0\.74 or newer, with no app, Metro, or Babel changes/); assert.match(help, /verified on Android emulators and iOS simulators/); assert.match(help, /remote bridge sessions[^.]*are not verified yet/); @@ -537,12 +539,13 @@ test('usageForCommand resolves react-devtools help topic', async () => { ); assert.match(help, /compare runs instead of reading absolute numbers/); assert.match(help, /logs clear --restart before the first logs mark/); + assert.match(help, /Do not repeatedly raise table limits/); assert.match(help, /agent-device logs mark "before catalog search"/); assert.match(help, /Every profiling command is an agent-device subcommand/); assert.match(help, /agent-device network dump --include headers/); assert.doesNotMatch( help, - /agent-react-devtools|wait --connected|get tree|profile export|profile diff/, + /agent-react-devtools|wait --connected|get tree|profile export|profile diff|profile report @c/, ); }); diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index a4eb4b6a09..0a2f4ca4f1 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -300,7 +300,11 @@ Use this for React Native performance profiling: slow renders, rerenders and the Commands: agent-device react-devtools profile start agent-device react-devtools profile stop - agent-device react-devtools profile stop --limit 10 + agent-device react-devtools profile report + agent-device react-devtools profile commit + agent-device react-devtools profile slow --limit 5 + agent-device react-devtools profile rerenders --limit 5 + agent-device react-devtools profile timeline --limit 20 --sort duration agent-device react-devtools profile status Profiling attaches to the app's JavaScript runtime through its Metro dev server: a development build on React Native 0.74 or newer, with no app, Metro, or Babel changes. It is verified on Android emulators and iOS simulators; remote bridge sessions, which reach the dev server through the remote config's public Metro URL, are not verified yet. Add --app when several apps share the dev server, --metro-device when one app runs on several devices connected to it, and --metro-port/--metro-host when the dev server is neither the session's nor localhost:8081. @@ -309,7 +313,8 @@ Profiling loop: 1. Run profile start immediately before the interaction; keep the window narrow. 2. Drive the interaction with normal agent-device commands. When correlating with logs, run logs clear --restart before the first logs mark. 3. Run profile stop. It prints the commit count, render time, commits over the 16 ms frame budget, and ranked findings, each naming a component, its evidence, and a fix. - 4. After a fix, repeat the same start, interaction, and stop, and compare the findings. + 4. Drill into one finding with profile report or profile commit before reaching for the slow and rerenders tables. + 5. After a fix, repeat the same start, interaction, and stop, and compare the findings. Findings: slow-render: the component's own render is expensive. memo-defeated: a memoized component often re-renders because props change identity. parent-cascade: the component often re-renders with unchanged props, state, and context when its owner renders. context-cascade: most of the component's renders come from context changes. effect-chain: the component triggers a follow-up commit right after rendering, likely state set in an effect. render-storm: one component commits many times per second. eager-mount: hundreds of instances mount in one commit. slow-commit: a commit of three frames or more that other findings leave mostly unexplained. @@ -317,6 +322,7 @@ Findings: Rules: Every profiling command is an agent-device subcommand: agent-device react-devtools profile ... + Do not repeatedly raise table limits such as --limit 50 or --limit 200; drill into one component with profile report instead. For network evidence, use agent-device network dump --include headers; headers is not a positional argument. Example: @@ -326,6 +332,7 @@ Example: agent-device fill 'id="catalog-search"' "tart" --delay-ms 80 agent-device logs mark "after catalog search" agent-device react-devtools profile stop + agent-device react-devtools profile report CatalogScreen agent-device network dump --include headers Device busy and ownership: diff --git a/src/commands/schema/command-overrides.ts b/src/commands/schema/command-overrides.ts index cbe44aa141..b50be0c601 100644 --- a/src/commands/schema/command-overrides.ts +++ b/src/commands/schema/command-overrides.ts @@ -183,7 +183,7 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { description: 'Profile React Native renders through the Metro dev server and report slow renders, rerenders, and their causes', }, - usageOverride: 'react-devtools profile start|stop|status', + usageOverride: 'react-devtools profile start|stop|status|report|commit|slow|rerenders|timeline', listUsageOverride: 'react-devtools', positionalArgs: ['args?'], allowsExtraPositionals: true, diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 2374f34eed..fcac6e8fdc 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -830,10 +830,15 @@ For the measurement workflow, data sources, and output examples, see [Performanc agent-device react-devtools profile start agent-device react-devtools profile stop agent-device react-devtools profile stop --limit 10 +agent-device react-devtools profile report +agent-device react-devtools profile commit +agent-device react-devtools profile slow --limit 5 +agent-device react-devtools profile rerenders --limit 5 +agent-device react-devtools profile timeline --limit 20 --sort duration agent-device react-devtools profile status ``` -- `profile start` begins recording React renders in the app; `profile stop` ends it and prints the commit count, render time, commits over the 16 ms frame budget, and up to five findings (`--limit` changes that). It also saves the profile for the session. +- `profile start` begins recording React renders in the app; `profile stop` ends it and prints the commit count, render time, commits over the 16 ms frame budget, and up to five findings (`--limit` changes that). It also saves the profile for the session, so `report`, `commit`, `slow`, `rerenders`, and `timeline` read it without the app. - Profiling connects through the app's Metro dev server and needs no app or Metro setup. It works with development builds of React Native 0.74 and later; Android emulators and iOS simulators are verified, remote bridge sessions are not yet. - `--app ` picks the app when several share the dev server, and `--metro-device ` picks the device when one app runs on several. `--metro-port` and `--metro-host` pick the dev server when it is neither the session's Metro server nor `localhost:8081`. - With `--json`, `profile stop` returns the findings and the path of the saved profile. diff --git a/website/docs/docs/debugging-profiling.md b/website/docs/docs/debugging-profiling.md index bb5b6b138c..2aedd30f9c 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -43,6 +43,16 @@ React profile 8.3s: 8 commits, 1303ms rendering, 8 over 16ms (worst #6 188ms) | `eager-mount` | Hundreds of instances of one component mount in a single commit. | | `slow-commit` | A commit took three frames or more, and other findings leave most of it unexplained. | +To dig into a finding, use the saved profile: + +```bash +agent-device react-devtools profile report ContactRow +agent-device react-devtools profile commit 6 +agent-device react-devtools profile slow --limit 5 +agent-device react-devtools profile rerenders --limit 5 +agent-device react-devtools profile timeline --limit 20 --sort duration +``` + Profiling works with development builds of React Native 0.74 and later, including Expo, and is verified on Android emulators and iOS simulators. It connects to the app through the Metro dev server, so the app, Metro config, and Babel config need no changes. If several apps use the same dev server, pass `--app `. If one app runs on several devices connected to the same dev server, pass `--metro-device ` with a device name the error lists. If the dev server is not the session's Metro server or `localhost:8081`, pass `--metro-port` and `--metro-host`. Development builds render slower than release builds. Compare profiles of the same interaction before and after a change rather than reading the numbers as absolute.