Repository navigation
Expand file tree
/
Copy pathreact-profile-command.ts
More file actions
319 lines (296 loc) · 10.8 KB
/
Copy pathreact-profile-command.ts
File metadata and controls
319 lines (296 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
import type { CliOutput } from '../../commands/command-contract.ts';
import fs from 'node:fs';
import path from 'node:path';
import { AppError } from '@agent-device/kernel/errors';
import { safeSessionName } from '@agent-device/host-kit/session-paths';
import { publishFileSync } from '@agent-device/host-kit/file';
import { targetAppId, type InspectorSession } from './metro-inspector.ts';
import {
callRuntime,
parseDevtoolsArgs,
runtimeFailure,
RUNTIME_OPTIONS,
withAppRuntime,
type CommandForm,
type DevtoolsArgs,
type ReactDevtoolsCommandOptions,
type RuntimeFailure,
} from './react-devtools-connection.ts';
import {
analyzeReactProfile,
isFindingFor,
type ReactProfileAnalysis,
} from './react-profile-analysis.ts';
import {
selectPropKindRequests,
type CommitProfile,
type ComponentProfile,
type PropKindsById,
type RawReactProfile,
} from './react-profile-renders.ts';
import {
DEFAULT_FINDING_LIMIT,
findComponents,
formatSeconds,
formatCommit,
formatCommits,
formatComponentReport,
formatComponents,
formatProfileSummary,
listedCommits,
} from './react-profile-format.ts';
const REACT_PROFILE_SUBCOMMANDS = ['start', 'stop', 'report', 'commit'] as const;
type ReactProfileSubcommand = (typeof REACT_PROFILE_SUBCOMMANDS)[number];
type ProfileArgs = DevtoolsArgs & { subcommand: ReactProfileSubcommand; target?: string };
const SAVED_PROFILE_VERSION = 2;
/**
* The recording exactly as the app exported it, with what was learned about it at stop. Every view
* analyzes it again, so none of them depends on what another view chose to keep.
*/
type SavedReactProfile = {
version: typeof SAVED_PROFILE_VERSION;
savedAt: string;
appId?: string;
deviceName?: string;
profile: RawReactProfile;
propKinds: PropKindsById;
notes: string[];
};
type StartResult = { ok: true; attach: string } | RuntimeFailure;
type StopResult = ({ ok: true; failures: string[] } & RawReactProfile) | RuntimeFailure;
function isReactProfileSubcommand(value: string | undefined): value is ReactProfileSubcommand {
return REACT_PROFILE_SUBCOMMANDS.includes(value as ReactProfileSubcommand);
}
function profileForm(subcommand: ReactProfileSubcommand, target: string | undefined): CommandForm {
switch (subcommand) {
case 'start':
return { usage: 'profile start', positionals: 0, options: RUNTIME_OPTIONS };
case 'stop':
return { usage: 'profile stop', positionals: 0, options: [...RUNTIME_OPTIONS, 'limit'] };
case 'report':
return target === undefined
? { usage: 'profile report', positionals: 1, options: ['limit'] }
: { usage: 'profile report <Component>', positionals: 1, options: [] };
case 'commit':
return { usage: 'profile commit', positionals: 1, options: ['limit'] };
}
}
function parseProfileArgs(args: string[]): ProfileArgs {
const [subcommand, ...rest] = args;
if (!isReactProfileSubcommand(subcommand)) {
throw new AppError(
'INVALID_ARGS',
`Unknown react-devtools profile command: ${subcommand ?? '(none)'}.`,
{ hint: `Use one of: ${REACT_PROFILE_SUBCOMMANDS.join(', ')}.` },
);
}
const parsed = parseDevtoolsArgs(rest, (positionals) => profileForm(subcommand, positionals[0]));
return { ...parsed, subcommand, target: parsed.positionals[0] };
}
function savedProfilePath(options: ReactDevtoolsCommandOptions): string {
return path.join(options.stateDir, 'react-profiles', `${safeSessionName(options.session)}.json`);
}
function writeSavedProfile(
options: ReactDevtoolsCommandOptions,
profile: SavedReactProfile,
): string {
const filePath = savedProfilePath(options);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
publishFileSync({ destination: filePath, contents: `${JSON.stringify(profile)}\n` });
return filePath;
}
function isSavedProfile(value: SavedReactProfile | undefined): value is SavedReactProfile {
return (
value?.version === SAVED_PROFILE_VERSION &&
Array.isArray(value.profile?.commits) &&
typeof value.profile.elements === 'object' &&
Array.isArray(value.notes)
);
}
function readSavedAnalysis(options: ReactDevtoolsCommandOptions): ReactProfileAnalysis {
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 analyzeReactProfile(saved.profile, saved.propKinds, saved.notes);
}
const elapsed = (elapsedMs: number) => formatSeconds(Math.max(0, elapsedMs));
async function startProfile(
args: ProfileArgs,
options: ReactDevtoolsCommandOptions,
): Promise<{ app: string; message: string }> {
return await withAppRuntime(args, options, async (session, target) => {
const result = await callRuntime<StartResult>(session, 'start');
const app = targetAppId(target);
if (!result.ok && result.reason === 'already-profiling') {
return {
app,
alreadyProfiling: true,
message: `Already profiling ${app} for ${elapsed(result.elapsedMs ?? 0)}; run react-devtools profile stop to read it.`,
};
}
if (!result.ok) throw runtimeFailure(result);
return {
app,
attach: result.attach,
message: `Profiling React renders in ${app}. Perform the interaction, then run react-devtools profile stop.`,
};
});
}
/**
* Classifies changed prop values on mounted instances. The recording has already left the app, so
* a failure here costs only that detail and is reported as a note on the analysis.
*/
async function describeChangedProps(
session: InspectorSession,
profile: RawReactProfile,
): Promise<{ propKinds: PropKindsById; notes: string[] }> {
const request = selectPropKindRequests(profile);
if (Object.keys(request).length === 0) return { propKinds: {}, notes: [] };
try {
return {
propKinds: await callRuntime<PropKindsById>(session, 'describeProps', request),
notes: [],
};
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
return { propKinds: {}, notes: [`Changed prop values could not be inspected: ${reason}`] };
}
}
async function stopProfile(
args: ProfileArgs,
options: ReactDevtoolsCommandOptions,
): Promise<{ appId: string; analysis: ReactProfileAnalysis }> {
return await withAppRuntime(args, options, async (session, target) => {
const result = await callRuntime<StopResult>(session, 'stop');
if (!result.ok) throw runtimeFailure(result);
const { ok: _ok, failures, ...profile } = result;
const appId = targetAppId(target);
const saved: SavedReactProfile = {
version: SAVED_PROFILE_VERSION,
savedAt: new Date().toISOString(),
appId,
deviceName: target.deviceName,
profile,
propKinds: {},
notes: failures.map((failure) => `A React renderer's profile could not be read: ${failure}`),
};
writeSavedProfile(options, saved);
const described = await describeChangedProps(session, profile);
saved.propKinds = described.propKinds;
saved.notes.push(...described.notes);
writeSavedProfile(options, saved);
return { appId, analysis: analyzeReactProfile(profile, saved.propKinds, saved.notes) };
});
}
type SubcommandHandler = (
args: ProfileArgs,
options: ReactDevtoolsCommandOptions,
) => Promise<CliOutput>;
function requireComponents(analysis: ReactProfileAnalysis, name: string): ComponentProfile[] {
const components = findComponents(analysis, name);
if (components.length === 0) {
throw new AppError('COMMAND_FAILED', `No renders of ${name} in the saved profile.`, {
hint: 'Use a name that react-devtools profile report lists.',
});
}
return components;
}
function requireCommit(analysis: ReactProfileAnalysis, target: string): 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} in the saved profile.`, {
hint: `Use a commit index from 0 to ${Math.max(analysis.commits.length - 1, 0)}.`,
});
}
return commit;
}
const COMPONENT_LIMIT = 10;
const COMMIT_LIMIT = 20;
const SUBCOMMAND_HANDLERS: Record<ReactProfileSubcommand, SubcommandHandler> = {
start: async (args, options) => {
const started = await startProfile(args, options);
return { text: started.message, data: started };
},
stop: async (args, options) => {
const { appId, analysis } = await stopProfile(args, options);
return {
text: formatProfileSummary(analysis, args.limit ?? DEFAULT_FINDING_LIMIT),
data: {
appId,
wallMs: analysis.wallMs,
commitCount: analysis.commitCount,
renderMs: analysis.renderMs,
slowCommitCount: analysis.slowCommitCount,
worstCommit: analysis.worstCommit,
findings: analysis.findings.slice(0, args.limit ?? analysis.findings.length),
notes: analysis.notes,
savedTo: savedProfilePath(options),
},
};
},
report: async (args, options) => {
const analysis = readSavedAnalysis(options);
if (args.target === undefined) {
const limit = args.limit ?? COMPONENT_LIMIT;
return {
text: formatComponents(analysis, limit),
data: {
components: analysis.components.slice(0, limit),
},
};
}
const components = requireComponents(analysis, args.target);
return {
text: formatComponentReport(analysis, components),
data: {
components,
findings: analysis.findings.filter((finding) =>
components.some((component) => isFindingFor(finding, component)),
),
},
};
},
commit: async (args, options) => {
const analysis = readSavedAnalysis(options);
if (args.target === undefined) {
const limit = args.limit ?? COMMIT_LIMIT;
return {
text: formatCommits(analysis, limit),
data: { commits: listedCommits(analysis, limit) },
};
}
const commit = requireCommit(analysis, args.target);
const limit = args.limit ?? COMPONENT_LIMIT;
return {
text: formatCommit(commit, limit),
data: {
...commit,
components: commit.components.slice(0, limit),
},
};
},
};
export async function runReactProfileCommand(
args: string[],
options: ReactDevtoolsCommandOptions,
): Promise<CliOutput> {
const parsed = parseProfileArgs(args);
return await SUBCOMMAND_HANDLERS[parsed.subcommand](parsed, options);
}