diff --git a/README.md b/README.md index eb005e2beb..60c0bac2c8 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ See the [Node.js API](https://oss.callstack.com/agent-device/docs/client-api), t ## What agents can do -- **Inspect app state** through accessibility snapshots, refs, selectors, and React Native component trees. +- **Inspect app state** through accessibility snapshots, refs, and selectors, and profile React Native renders to find what makes an interaction slow. - **Act on visible UI** by tapping or pressing elements, filling fields, scrolling, making gestures, waiting, asserting state, and handling alerts. - **Diagnose failures** with screenshots, video, logs, traces, network data, performance samples, crash details, and React profiles. - **Repeat workflows** by saving working steps as `.ad` scripts for local use or CI. Export strict Maestro YAML when needed. diff --git a/docs/adr/0029-daemon-policy.md b/docs/adr/0029-daemon-policy.md index 5da8b6fcf4..4251a5eacb 100644 --- a/docs/adr/0029-daemon-policy.md +++ b/docs/adr/0029-daemon-policy.md @@ -47,13 +47,13 @@ Accepted (2026-09-29). - public commands, by their own name; - internal commands with platform execution, by the public command their `catalog.servesPublicCommand` names (`install_source` as `install-from-source`), or else by - their own name (`runtime`, which `react-devtools` and Maestro flows send); + their own name (`runtime`, which Maestro flows send); - internal commands with no platform execution (leases, `human_control`, session bookkeeping) are protocol plumbing that no rule decides; - any other name is decided by the rules, so an allow list fails closed for it. Names are checked against that vocabulary at load; a local-cli name such as `react-devtools` is - refused with a pointer to the daemon command it sends. With `allow`, commands added by a later + refused, because it runs in the client. With `allow`, commands added by a later upgrade are denied by default. - `capabilities.deny` — operations denied whichever command reaches them. `device-shutdown` is the only capability today. diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index 5893defd91..120774ee72 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -150,19 +150,6 @@ export type DescriptorSessionRouteCommandName = : never : never; -/** - * The literal union of every command the daemon routes. A command that never reaches a - * daemon route cannot be the target of a request, so this is the type a caller building - * one must name — an unregistered or CLI-only target is a compile error rather than a - * string the receiver rejects at runtime. - */ -export type DescriptorDaemonRouteCommandName = - Extract<(typeof commandDescriptors)[number], { daemon: object }> extends infer Descriptor - ? Descriptor extends { name: infer Name extends string } - ? Name - : never - : never; - // --------------------------------------------------------------------------- // Daemon request-policy trait bundles — copied VERBATIM from // packages/daemon/src/core/daemon-command-registry.ts (DAEMON_COMMAND_DESCRIPTORS). @@ -1814,13 +1801,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, mcpExposed: false, - // Wave 6 residue: `react-devtools start` on a Limrun Android instance dispatches internal - // `runtime port-reverse` through the CLI-injected dispatch seam. `runtime` is itself fully - // migrated (`device-runtime`, R31), so the dispatched execution is accounted for under its - // own descriptor rather than hidden behind this one — this route owns no unmigrated platform - // execution of its own. The CLI-route dominance gate - // (`__tests__/platform-execution-cli-route.test.ts`) still rejects a `none` route whose - // CLI-injected dispatch target is still `legacy`. platformExecution: NO_PLATFORM_EXECUTION, }, { @@ -1921,8 +1901,7 @@ type Equal = [A] extends [B] ? ([B] extends [A] ? true : false) : false; * Compile-time literalness of {@link Command}. The `as const` on {@link RAW_COMMAND_DESCRIPTORS} * keeps each descriptor's literal `name` through the composition and the `.map` above; an array * that lost it would widen this union to `string`, and nothing downstream would say so: - * `COMMAND_DEFAULTS` below and `Record` in - * `src/cli/injected-daemon-dispatch.ts` stop rejecting an unknown key, and the `command: Command` + * `COMMAND_DEFAULTS` below stops rejecting an unknown key, and the `command: Command` * parameters in `src/cli/command-explain.ts` and `owner-files.ts` would stop excluding a name no * descriptor declares. That silence is why this is a type guard and not a value comparison. */ diff --git a/skills/android-emulator/SKILL.md b/skills/android-emulator/SKILL.md index 59d4b54f32..29515ee83c 100644 --- a/skills/android-emulator/SKILL.md +++ b/skills/android-emulator/SKILL.md @@ -27,6 +27,6 @@ Only when the task is specialized or a command shape is unclear, read the releva agent-device help validate # engineering validation and build freshness agent-device help debugging # screenshots, logs, traces, video, and failures agent-device help react-native # React Native and Expo runtime guidance -agent-device help react-devtools # component tree, props/state/hooks, and renders +agent-device help react-devtools # React Native performance profiling agent-device help scripting # durable replay and CI workflows ``` diff --git a/skills/ios-simulator/SKILL.md b/skills/ios-simulator/SKILL.md index 2a77984154..950f28fecc 100644 --- a/skills/ios-simulator/SKILL.md +++ b/skills/ios-simulator/SKILL.md @@ -27,6 +27,6 @@ Only when the task is specialized or a command shape is unclear, read the releva agent-device help validate # engineering validation and build freshness agent-device help debugging # screenshots, logs, traces, video, and failures agent-device help react-native # React Native and Expo runtime guidance -agent-device help react-devtools # component tree, props/state/hooks, and renders +agent-device help react-devtools # React Native performance profiling agent-device help scripting # durable replay and CI workflows ``` diff --git a/src/__tests__/cli-help.test.ts b/src/__tests__/cli-help.test.ts index 289b1539f9..6badcc7aa0 100644 --- a/src/__tests__/cli-help.test.ts +++ b/src/__tests__/cli-help.test.ts @@ -65,8 +65,8 @@ test('help react-devtools prints agent workflow topic and skips daemon dispatch' assert.equal(result.code, 0); assert.equal(result.calls.length, 0); assert.match(result.stdout, /^agent-device \S+ — react-devtools/); - assert.match(result.stdout, /React Native performance\/profiling/); - assert.match(result.stdout, /agent-device react-devtools status/); + assert.match(result.stdout, /React Native performance profiling/); + assert.match(result.stdout, /agent-device react-devtools profile start/); }); test('help workflow prints the compact workflow card with a version header and skips daemon dispatch', async () => { diff --git a/src/__tests__/cli-react-devtools-session.test.ts b/src/__tests__/cli-react-devtools-session.test.ts index c8b6242fe5..4b5d0936a4 100644 --- a/src/__tests__/cli-react-devtools-session.test.ts +++ b/src/__tests__/cli-react-devtools-session.test.ts @@ -15,42 +15,12 @@ import { hashRemoteConfigFile, writeRemoteConnectionState, } from '../remote/remote-connection-state.ts'; -import type { - DaemonRequest, - DaemonResponse, - sendToDaemon as SendToDaemon, -} from '../daemon-client/daemon-client.ts'; +import type { DaemonResponse } from '../daemon-client/daemon-client.ts'; afterEach(() => { vi.clearAllMocks(); }); -function createLimrunConnectionFixture(): { tempRoot: string; stateDir: string } { - const tempRoot = mkdtempForTestSync('agent-device-react-devtools-limrun-'); - const stateDir = path.join(tempRoot, 'state'); - const remoteConfigPath = path.join(tempRoot, 'limrun.json'); - fs.writeFileSync(remoteConfigPath, JSON.stringify({ platform: 'android' })); - writeRemoteConnectionState({ - stateDir, - state: { - version: 1, - session: 'limrun-android', - remoteConfigPath, - remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), - daemon: {}, - tenant: 'limrun', - runId: 'run-1', - leaseId: 'lease-1', - leaseBackend: 'android-instance', - leaseProvider: 'limrun', - platform: 'android', - connectedAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - }); - return { tempRoot, stateDir }; -} - test('react-devtools uses active remote connection session after defaults are merged', async () => { const tempRoot = mkdtempForTestSync('agent-device-react-devtools-session-'); const stateDir = path.join(tempRoot, 'state'); @@ -92,7 +62,9 @@ test('react-devtools uses active remote connection session after defaults are me const sendToDaemon = async (): Promise => ({ ok: true, data: {} }); try { - await runCli(['react-devtools', 'status', '--state-dir', stateDir], { sendToDaemon }); + await runCli(['react-devtools', 'profile', 'status', '--state-dir', stateDir], { + sendToDaemon, + }); } finally { restoreEnv(); process.exit = originalExit; @@ -103,66 +75,3 @@ test('react-devtools uses active remote connection session after defaults are me assert.equal(vi.mocked(runReactDevtoolsCommand).mock.calls.length, 1); assert.equal(vi.mocked(runReactDevtoolsCommand).mock.calls[0]?.[1]?.session, 'adc-android'); }); - -test('react-devtools starts Limrun port reverse through the daemon', async () => { - const { tempRoot, stateDir } = createLimrunConnectionFixture(); - const originalExit = process.exit; - let exitCode: number | undefined; - const restoreEnv = installIsolatedCliTestEnv(); - (process as any).exit = ((code?: number) => { - exitCode = code ?? 0; - }) as typeof process.exit; - const requests: Array> = []; - const transportOptions: Array[1]> = []; - const sendToDaemon = async ( - request: Omit, - options?: Parameters[1], - ): Promise => { - requests.push(request); - transportOptions.push(options); - return { ok: true, data: {} }; - }; - vi.mocked(runReactDevtoolsCommand).mockImplementationOnce(async (_args, options) => { - assert.ok(options?.configureDirectPortReverse); - await options.configureDirectPortReverse(); - return 0; - }); - - try { - await runCli( - [ - 'react-devtools', - 'start', - '--state-dir', - stateDir, - '--daemon-auth-token', - 'synthetic-direct-token', - ], - { sendToDaemon }, - ); - } finally { - restoreEnv(); - process.exit = originalExit; - fs.rmSync(tempRoot, { recursive: true, force: true }); - } - - assert.equal(exitCode, 0); - assert.equal(requests.length, 1); - const request = requests[0]; - assert.ok(request); - assert.equal(request.command, 'runtime'); - assert.deepEqual(request.positionals, ['port-reverse']); - assert.equal(request.session, 'limrun-android'); - const flags = request.flags; - assert.ok(flags); - assert.equal(flags.platform, 'android'); - assert.equal(flags.leaseId, 'lease-1'); - assert.equal(flags.leaseProvider, 'limrun'); - assert.equal(flags.devicePort, 8097); - assert.equal(flags.hostPort, 8097); - assert.equal(flags.portReverseName, 'react-devtools'); - assert.equal(Object.hasOwn(flags, 'daemonAuthToken'), false); - assert.deepEqual(transportOptions, [{ authToken: 'synthetic-direct-token' }]); - const commandOptions = vi.mocked(runReactDevtoolsCommand).mock.calls[0]?.[1]; - assert.equal(Object.hasOwn(commandOptions?.flags ?? {}, 'daemonAuthToken'), false); -}); diff --git a/src/__tests__/cli-react-devtools.test.ts b/src/__tests__/cli-react-devtools.test.ts index 5b748decda..d9271a673c 100644 --- a/src/__tests__/cli-react-devtools.test.ts +++ b/src/__tests__/cli-react-devtools.test.ts @@ -1,360 +1,24 @@ -import fs from 'node:fs'; -import { afterEach, test, vi } from 'vitest'; +import { test } from 'vitest'; import assert from 'node:assert/strict'; - -vi.mock('@agent-device/host-kit/command', () => ({ - runCmdStreaming: vi.fn(), -})); - -vi.mock('../client/client-react-devtools-companion.ts', () => ({ - ensureReactDevtoolsCompanion: vi.fn(), - stopReactDevtoolsCompanion: vi.fn(), -})); - -import { runCmdStreaming } from '@agent-device/host-kit/command'; -import { AppError } from '@agent-device/kernel/errors'; -import { withCapturedStderrAsync } from './test-utils/stream-capture.ts'; -import { - ensureReactDevtoolsCompanion, - stopReactDevtoolsCompanion, -} from '../client/client-react-devtools-companion.ts'; -import { - AGENT_REACT_DEVTOOLS_PACKAGE, - buildReactDevtoolsNpmExecArgs, - runReactDevtoolsCommand, -} from '../cli/commands/react-devtools.ts'; - -type ReactDevtoolsOptions = NonNullable[1]>; -type ReactDevtoolsFlags = NonNullable; - -const remoteBridgeScope = { - metroProxyBaseUrl: 'https://bridge.example.test', - metroBearerToken: 'token', - tenant: 'tenant-1', - runId: 'run-1', - leaseId: 'lease-1', -} as const; - -const remoteBridgeBackends = [ - { label: 'Android', leaseBackend: 'android-instance' }, - { label: 'iOS', leaseBackend: 'ios-instance' }, -] as const; - -afterEach(() => { - vi.clearAllMocks(); -}); - -test('react-devtools passthrough pins agent-react-devtools package version', () => { - assert.equal(AGENT_REACT_DEVTOOLS_PACKAGE, 'agent-react-devtools@0.5.0'); - assert.deepEqual(buildReactDevtoolsNpmExecArgs(['get', 'tree', '--depth', '3']), [ - 'exec', - '--yes', - '--package', - 'agent-react-devtools@0.5.0', - '--', - 'agent-react-devtools', - 'get', - 'tree', - '--depth', - '3', - ]); -}); - -test('react-devtools docs mention the pinned package version', () => { - const docs = ['website/docs/docs/commands.md']; - - for (const file of docs) { - assert.match(fs.readFileSync(file, 'utf8'), new RegExp(AGENT_REACT_DEVTOOLS_PACKAGE)); - } -}); - -function mockRemoteCompanionSuccess(): void { - vi.mocked(runCmdStreaming).mockResolvedValueOnce({ - exitCode: 0, - stdout: '', - stderr: '', - }); - vi.mocked(ensureReactDevtoolsCompanion).mockResolvedValueOnce({ - pid: 123, - spawned: true, - statePath: '/tmp/state.json', - logPath: '/tmp/companion.log', - }); -} - -function assertNoRemoteCompanion(): void { - assert.equal(vi.mocked(ensureReactDevtoolsCompanion).mock.calls.length, 0); - assert.equal(vi.mocked(stopReactDevtoolsCompanion).mock.calls.length, 0); -} - -async function runStatusWithoutCompanion(flags: ReactDevtoolsFlags): Promise { - vi.mocked(runCmdStreaming).mockResolvedValueOnce({ - exitCode: 0, - stdout: '', - stderr: '', - }); - - await runReactDevtoolsCommand(['status'], { - stateDir: '/tmp/agent-device-state', - session: 'default', - flags, - }); - - assertNoRemoteCompanion(); -} - -function assertRemoteCompanionStarted(env: NodeJS.ProcessEnv): void { - assert.equal(vi.mocked(ensureReactDevtoolsCompanion).mock.calls.length, 1); - assert.deepEqual(vi.mocked(ensureReactDevtoolsCompanion).mock.calls[0]?.[0], { - projectRoot: '/tmp/project', - stateDir: '/tmp/agent-device-state', - serverBaseUrl: 'https://bridge.example.test', - bearerToken: 'token', - bridgeScope: { - tenantId: 'tenant-1', - runId: 'run-1', - leaseId: 'lease-1', - }, - session: 'default', - profileKey: '/tmp/remote.json', - consumerKey: 'default', - env, - }); - assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[0], 'npm'); - assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[2]?.cwd, '/tmp/project'); - assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[2]?.env, env); - assert.equal(vi.mocked(stopReactDevtoolsCompanion).mock.calls.length, 0); -} - -for (const { label, leaseBackend } of remoteBridgeBackends) { - test(`react-devtools keeps remote ${label} companion after passthrough command`, async () => { - const env = { ...process.env }; - mockRemoteCompanionSuccess(); - - const exitCode = await runReactDevtoolsCommand(['status'], { - stateDir: '/tmp/agent-device-state', - session: 'default', - cwd: '/tmp/project', - env, - flags: { - ...remoteBridgeScope, - leaseBackend, - remoteConfig: '/tmp/remote.json', - session: 'default', - }, - }); - - assert.equal(exitCode, 0); - assertRemoteCompanionStarted(env); - }); -} - -test('react-devtools wait hints to relaunch remote iOS after startup-time connection misses', async () => { - const env = { ...process.env }; - vi.mocked(runCmdStreaming).mockResolvedValueOnce({ - exitCode: 1, - stdout: '', - stderr: '', - }); - vi.mocked(ensureReactDevtoolsCompanion).mockResolvedValueOnce({ - pid: 123, - spawned: true, - statePath: '/tmp/state.json', - logPath: '/tmp/companion.log', - }); - - const stderr = await withCapturedStderrAsync(async () => { - const exitCode = await runReactDevtoolsCommand(['wait', '--connected'], { - stateDir: '/tmp/agent-device-state', - session: 'default', - cwd: '/tmp/project', - env, - flags: { - ...remoteBridgeScope, - leaseBackend: 'ios-instance', - remoteConfig: '/tmp/remote.json', - session: 'default', - }, - }); - - assert.equal(exitCode, 1); - }); - assert.match(stderr, /Remote iOS React DevTools connects during JavaScript startup/); - assert.match(stderr, /open --platform ios --relaunch/); -}); - -test('react-devtools stop cleans up remote companion', async () => { - const env = { ...process.env }; - vi.mocked(runCmdStreaming).mockResolvedValueOnce({ - exitCode: 0, - stdout: '', - stderr: '', - }); - vi.mocked(stopReactDevtoolsCompanion).mockResolvedValueOnce({ - stopped: true, - statePath: '/tmp/state.json', - }); - - const exitCode = await runReactDevtoolsCommand(['stop'], { - stateDir: '/tmp/agent-device-state', - session: 'default', - cwd: '/tmp/project', - env, - flags: { - ...remoteBridgeScope, - leaseBackend: 'ios-instance', - remoteConfig: '/tmp/remote.json', - session: 'default', +import { runReactDevtoolsCommand } from '../cli/commands/react-devtools.ts'; + +const options = { stateDir: '/tmp/agent-device-state', session: 'default' }; + +test('react-devtools commands other than profile are invalid arguments with a profiling hint', async () => { + await assert.rejects( + runReactDevtoolsCommand(['get', 'tree'], options), + (error: { code: string; message: string; details: { hint: string } }) => { + assert.equal(error.code, 'INVALID_ARGS'); + assert.equal(error.message, 'Unknown react-devtools command: get.'); + assert.match(error.details.hint, /react-devtools profile start/); + return true; }, - }); - - assert.equal(exitCode, 0); - assert.equal(vi.mocked(ensureReactDevtoolsCompanion).mock.calls.length, 0); - assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[0], 'npm'); - assert.deepEqual(vi.mocked(runCmdStreaming).mock.calls[0]?.[1], [ - 'exec', - '--yes', - '--package', - 'agent-react-devtools@0.5.0', - '--', - 'agent-react-devtools', - 'stop', - ]); - assert.equal(vi.mocked(stopReactDevtoolsCompanion).mock.calls.length, 1); - assert.deepEqual(vi.mocked(stopReactDevtoolsCompanion).mock.calls[0]?.[0], { - projectRoot: '/tmp/project', - stateDir: '/tmp/agent-device-state', - profileKey: '/tmp/remote.json', - consumerKey: 'default', - }); -}); - -// What the companion stop answers decides the verdict of `react-devtools stop`: an unconfirmed exit -// is reported instead of a clean teardown, and the command's own fault still outranks it. -const runRemoteStop = () => - runReactDevtoolsCommand(['stop'], { - cwd: '/tmp/project', - flags: { ...remoteBridgeScope, leaseBackend: 'ios-instance', remoteConfig: '/tmp/remote.json' }, - }); - -test('react-devtools stop reports a companion whose stop could not be confirmed', async () => { - const refusal = new AppError('COMMAND_FAILED', 'companion pid 4321 is still running', { - reason: 'signal-failed', - signal: 'SIGTERM', - pid: 4321, - }); - vi.mocked(runCmdStreaming).mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); - vi.mocked(stopReactDevtoolsCompanion).mockRejectedValueOnce(refusal); - - await assert.rejects(runRemoteStop, refusal); + ); }); -test('react-devtools stop keeps its own failure ahead of an unconfirmed companion stop', async () => { - const actionFault = new Error('devtools package failed'); - const stopFault = new AppError('COMMAND_FAILED', 'companion pid 4322 is still running', { - reason: 'signal-failed', - pid: 4322, - }); - vi.mocked(runCmdStreaming).mockRejectedValueOnce(actionFault); - vi.mocked(stopReactDevtoolsCompanion).mockRejectedValueOnce(stopFault); - - const stderr = await withCapturedStderrAsync(() => assert.rejects(runRemoteStop(), actionFault)); - assert.equal(vi.mocked(stopReactDevtoolsCompanion).mock.calls.length, 1, 'cleanup still ran'); - // The action's fault is what the command reports, so the surviving companion has to be named here. - assert.match(stderr, /companion pid 4322 is still running/); -}); - -// The stop's own transport can reject with anything, and an unconfirmed exit may only ever be -// reported, never swallowed: the command fails on the rejection value itself, not on its shape. -test('react-devtools stop fails on a stop rejection that is not an Error', async () => { - vi.mocked(runCmdStreaming).mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); - vi.mocked(stopReactDevtoolsCompanion).mockRejectedValueOnce('worker exited before answering'); - - const settled = await runRemoteStop().catch((error: unknown) => error); - assert.equal(settled, 'worker exited before answering'); +test('react-devtools profile routes to the built-in profiler', async () => { + await assert.rejects( + runReactDevtoolsCommand(['profile', 'export', 'out.json'], options), + /Unknown react-devtools profile command: export/, + ); }); - -test('react-devtools start configures direct remote port reverse', async () => { - const configureDirectPortReverse = vi.fn(async () => undefined); - vi.mocked(runCmdStreaming).mockResolvedValueOnce({ - exitCode: 0, - stdout: '', - stderr: '', - }); - - const exitCode = await runReactDevtoolsCommand(['start'], { - stateDir: '/tmp/agent-device-state', - session: 'default', - flags: { - ...remoteBridgeScope, - metroProxyBaseUrl: undefined, - leaseBackend: 'android-instance', - leaseProvider: 'limrun', - remoteConfig: '/tmp/remote.json', - session: 'default', - }, - configureDirectPortReverse, - }); - - assert.equal(exitCode, 0); - assert.equal(configureDirectPortReverse.mock.calls.length, 1); - assertNoRemoteCompanion(); -}); - -test('react-devtools start keeps bridge-backed Android on companion tunnel', async () => { - const env = { ...process.env }; - const configureDirectPortReverse = vi.fn(async () => undefined); - mockRemoteCompanionSuccess(); - - const exitCode = await runReactDevtoolsCommand(['start'], { - stateDir: '/tmp/agent-device-state', - session: 'default', - cwd: '/tmp/project', - env, - flags: { - ...remoteBridgeScope, - leaseBackend: 'android-instance', - remoteConfig: '/tmp/remote.json', - session: 'default', - }, - configureDirectPortReverse, - }); - - assert.equal(exitCode, 0); - assert.equal(configureDirectPortReverse.mock.calls.length, 0); - assertRemoteCompanionStarted(env); -}); - -test('react-devtools skips companion for non-bridge remote sessions', async () => { - await runStatusWithoutCompanion({ - ...remoteBridgeScope, - leaseBackend: 'ios-simulator', - }); -}); - -test('react-devtools skips companion when remote bridge backend is missing', async () => { - await runStatusWithoutCompanion(remoteBridgeScope); -}); - -for (const { label, leaseBackend } of remoteBridgeBackends) { - test(`react-devtools fails clearly when remote ${label} bridge scope is incomplete`, async () => { - await assert.rejects( - () => - runReactDevtoolsCommand(['status'], { - stateDir: '/tmp/agent-device-state', - session: 'default', - flags: { - leaseBackend, - metroProxyBaseUrl: 'https://bridge.example.test', - tenant: 'tenant-1', - runId: 'run-1', - leaseId: 'lease-1', - }, - }), - /react-devtools remote bridge requires metroBearerToken/, - ); - - assert.equal(vi.mocked(runCmdStreaming).mock.calls.length, 0); - assertNoRemoteCompanion(); - }); -} diff --git a/src/__tests__/client-companion-tunnel-worker.test.ts b/src/__tests__/client-companion-tunnel-worker.test.ts index 281839dc05..9d54597bd4 100644 --- a/src/__tests__/client-companion-tunnel-worker.test.ts +++ b/src/__tests__/client-companion-tunnel-worker.test.ts @@ -243,29 +243,26 @@ afterEach(async () => { } }); -test('companion payload includes React DevTools session and device port', () => { +test('companion payload carries the bridge scope, local base URL, and launch URL', () => { assert.deepEqual( buildCompanionPayload({ serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', - localBaseUrl: 'http://127.0.0.1:8097/', + localBaseUrl: 'http://127.0.0.1:8081/', bridgeScope: { tenantId: 'tenant-1', runId: 'run-1', leaseId: 'lease-1', }, - session: 'default', - devicePort: 8097, - registerPath: '/api/react-devtools/companion/register', - unregisterPath: '/api/react-devtools/companion/unregister', + launchUrl: 'myapp://open', + registerPath: '/api/metro/companion/register', }), { tenantId: 'tenant-1', runId: 'run-1', leaseId: 'lease-1', - session: 'default', - local_base_url: 'http://127.0.0.1:8097', - device_port: 8097, + local_base_url: 'http://127.0.0.1:8081', + launch_url: 'myapp://open', }, ); }); @@ -677,42 +674,6 @@ test('companion tunnel entrypoint runs only for its own run args and refuses inc } }); -test('companion tunnel entrypoint validates the device port before serving', async () => { - for (const devicePort of ['0', 'abc', '70000']) { - await assert.rejects( - () => - runCompanionTunnelProcessFromEnv( - [METRO_COMPANION_RUN_ARG], - companionEnv({ AGENT_DEVICE_COMPANION_TUNNEL_DEVICE_PORT: devicePort }), - ), - /invalid device port/i, - `expected device port ${devicePort} to be rejected`, - ); - } - - // Both accepted shapes of the optional port: absent, and a valid number. Once its lease file is - // gone the worker returns without registering, so the entrypoint answers true without a bridge. - const tempRoot = mkdtempForTestSync('agent-device-companion-entrypoint-'); - cleanupTasks.push(async () => { - fs.rmSync(tempRoot, { recursive: true, force: true }); - }); - const withoutPort = await runCompanionTunnelProcessFromEnv( - [METRO_COMPANION_RUN_ARG], - companionEnv({ AGENT_DEVICE_COMPANION_TUNNEL_STATE_PATH: path.join(tempRoot, 'a.json') }), - ); - const withPort = await runCompanionTunnelProcessFromEnv( - ['--agent-device-run-react-devtools-companion'], - companionEnv({ - AGENT_DEVICE_COMPANION_TUNNEL_DEVICE_PORT: '8097', - AGENT_DEVICE_COMPANION_TUNNEL_SESSION: 'default', - AGENT_DEVICE_COMPANION_TUNNEL_LAUNCH_URL: 'myapp://open', - AGENT_DEVICE_COMPANION_TUNNEL_UNREGISTER_PATH: '/api/react-devtools/companion/unregister', - AGENT_DEVICE_COMPANION_TUNNEL_STATE_PATH: path.join(tempRoot, 'b.json'), - }), - ); - assert.deepEqual([withoutPort, withPort], [true, true]); -}); - test('metro companion worker proxies bridge http requests to the local server', async () => { const requests = [ { requestId: 'req-text', method: 'GET', path: '/text' }, diff --git a/src/__tests__/client-metro-companion.test.ts b/src/__tests__/client-metro-companion.test.ts index d32d8cc209..6a9e490500 100644 --- a/src/__tests__/client-metro-companion.test.ts +++ b/src/__tests__/client-metro-companion.test.ts @@ -29,7 +29,6 @@ import { } from '@agent-device/host-kit/process'; import { ensureMetroCompanion, stopMetroCompanion } from '../metro/client-metro-companion.ts'; -import { ensureReactDevtoolsCompanion } from '../client/client-react-devtools-companion.ts'; import { METRO_COMPANION_RUN_ARG } from '../client/client-companion-tunnel-contract.ts'; const TEST_BRIDGE_SCOPE = { @@ -54,12 +53,6 @@ function assertCompanionSpawnTarget(): void { assert.equal(firstCall[1].at(-1), '--agent-device-run-metro-companion'); } -function assertCompanionRunArg(callIndex: number, runArg: string): void { - const call = vi.mocked(runCmdDetached).mock.calls[callIndex]; - assert.ok(call, `expected runCmdDetached call ${callIndex}`); - assert.equal(call[1].at(-1), runArg); -} - /** The stops the code asked host-kit for: the identity recorded beside the pid, and the budget. */ const COMPANION_STOP_BUDGET = { termTimeoutMs: 1_000, killTimeoutMs: 1_000 }; const COMPANION_TUNNEL_COMMAND = `${process.execPath} src/client/companion-tunnel.ts ${METRO_COMPANION_RUN_ARG}`; @@ -232,54 +225,6 @@ test('launchUrl changes force a companion respawn for the same profile', async ( } }); -test('metro and React DevTools companions use distinct profile state paths', async () => { - const projectRoot = mkdtempForTestSync('agent-device-companion-paths-'); - try { - vi.mocked(runCmdDetached).mockReturnValueOnce(777).mockReturnValueOnce(888); - vi.mocked(readProcessStartTime).mockImplementation((pid) => - pid === 777 ? 'start-777' : 'start-888', - ); - vi.mocked(readProcessCommand).mockImplementation((pid) => - pid === 888 - ? `${process.execPath} src/client/companion-tunnel.ts --agent-device-run-react-devtools-companion` - : `${process.execPath} src/client/companion-tunnel.ts --agent-device-run-metro-companion`, - ); - - const profileKey = '/tmp/shared-remote.json'; - const metro = await ensureMetroCompanion({ - projectRoot, - serverBaseUrl: 'https://bridge.example.test', - bearerToken: 'token', - localBaseUrl: 'http://127.0.0.1:8081', - bridgeScope: TEST_BRIDGE_SCOPE, - launchUrl: 'myapp://open', - profileKey, - consumerKey: 'session-a', - }); - const reactDevtools = await ensureReactDevtoolsCompanion({ - projectRoot, - serverBaseUrl: 'https://bridge.example.test', - bearerToken: 'token', - bridgeScope: TEST_BRIDGE_SCOPE, - session: 'session-a', - profileKey, - consumerKey: 'session-a', - }); - - assert.notEqual(metro.statePath, reactDevtools.statePath); - assert.notEqual(metro.logPath, reactDevtools.logPath); - assert.match(metro.statePath, /metro-companion[/\\]metro-companion-[a-f0-9]+\.json$/); - assert.match( - reactDevtools.statePath, - /react-devtools-companion[/\\]react-devtools-companion-[a-f0-9]+\.json$/, - ); - assertCompanionRunArg(0, '--agent-device-run-metro-companion'); - assertCompanionRunArg(1, '--agent-device-run-react-devtools-companion'); - } finally { - fs.rmSync(projectRoot, { recursive: true, force: true }); - } -}); - test('spawned companion uses neutral env names', async () => { const projectRoot = mkdtempForTestSync('agent-device-companion-env-'); try { diff --git a/src/__tests__/command-descriptor-platform-execution-cli-route.test.ts b/src/__tests__/command-descriptor-platform-execution-cli-route.test.ts index a0904460aa..416a21c30f 100644 --- a/src/__tests__/command-descriptor-platform-execution-cli-route.test.ts +++ b/src/__tests__/command-descriptor-platform-execution-cli-route.test.ts @@ -3,65 +3,20 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseSync } from 'oxc-parser'; import { describe, expect, test } from 'vitest'; -import { - CLI_INJECTED_DAEMON_DISPATCHES, - type CliInjectedRoute, -} from '../cli/injected-daemon-dispatch.ts'; -import { commandDescriptors } from '@agent-device/command-registry/registry'; /** - * ADR 0019 §6 coherence gate for delegated platform execution. - * - * The registry entry gate inspects one descriptor at a time, so it cannot see a command - * whose own module holds no platform code while its CLI route injects a *different* - * command that does. The dispatched command's own descriptor remains the execution authority; - * this gate proves every injected route and target resolves to that registry. - * - * The dispatched pairs are read from the typed table at the construction seam rather - * than recovered from syntax. The second test keeps that seam singular, which is what - * makes the table total: the transport cannot be called anywhere else, so there is no - * second way to dispatch. + * The CLI reaches the daemon only through its client transport factories, so every daemon + * request it sends carries its own command name and the command's descriptor stays the execution + * authority. A CLI route that called the transport directly could dispatch a different command + * than the one the user ran, hiding platform execution behind a platform-free descriptor. */ const CLI_ROOT = fileURLToPath(new URL('../cli.ts', import.meta.url)); const CLI_DIR = fileURLToPath(new URL('../cli', import.meta.url)); -const SEAM_MODULE = path.join(CLI_DIR, 'injected-daemon-dispatch.ts'); const TRANSPORT = 'sendToDaemon'; type AstNode = { type: string } & Record; -type PlatformExecutionKindOf = (command: string) => string | undefined; - -const registryKind: PlatformExecutionKindOf = (command) => - commandDescriptors.find(({ name }) => name === command)?.platformExecution.kind; - -type DispatchTable = Readonly>; - -function declaredPairs(table: DispatchTable): { route: string; dispatched: string }[] { - return Object.entries(table).flatMap(([route, commands]) => - commands.map((dispatched) => ({ route, dispatched })), - ); -} - -/** - * The table's types reject an unregistered target, but a cast would slip past them and - * then read as "no dominance failure" because an unknown command has no mode at all. - * An unresolvable end of a declared pair is a gate error, not an absence. - */ -function unresolvedPairs( - kindOf: PlatformExecutionKindOf, - table: DispatchTable = CLI_INJECTED_DAEMON_DISPATCHES, -): string[] { - return declaredPairs(table) - .flatMap(({ route, dispatched }) => [ - ...(kindOf(route) === undefined ? [`route ${route} is not a registered command`] : []), - ...(kindOf(dispatched) === undefined - ? [`${route} dispatches ${dispatched}, which is not a registered command`] - : []), - ]) - .sort(); -} - // --------------------------------------------------------------------------- // Seam singularity. The rule is positional, not value-resolving: the transport // identifier may appear only in the positions listed below. Anything else — a call, @@ -105,10 +60,7 @@ function referencesTransport(node: AstNode): boolean { * record), never merely because their key happens to be named `transport`. */ const TRANSPORT_FACTORY_CALLEES = new Set(['createClientDaemonTransport']); -const TRANSPORT_PROPERTY_CALLEES = new Set([ - 'createCliDaemonTransport', - 'sendInjectedDaemonRequest', -]); +const TRANSPORT_PROPERTY_CALLEES = new Set(['createCliDaemonTransport']); const CLI_MODULE = 'src/cli.ts'; function propertyKeyName(node: AstNode): string | undefined { @@ -207,11 +159,11 @@ function transportDefect( ancestors: readonly AstNode[], ): string | undefined { if (node.type === 'CallExpression' && referencesTransportNode(node['callee'])) { - return `calls ${TRANSPORT} outside the injected-dispatch seam`; + return `calls ${TRANSPORT} outside a client transport factory`; } if (node.type === 'VariableDeclarator') { return referencesTransportNode(node['init']) - ? `aliases ${TRANSPORT} outside the injected-dispatch seam` + ? `aliases ${TRANSPORT} outside a client transport factory` : undefined; } if (node.type !== 'Identifier' && node.type !== 'MemberExpression') return undefined; @@ -237,34 +189,11 @@ function cliModulePaths(): string[] { if (entry.isDirectory()) return walkDir(full); return entry.isFile() && full.endsWith('.ts') && !full.endsWith('.test.ts') ? [full] : []; }); - return [CLI_ROOT, ...walkDir(CLI_DIR)].filter((file) => file !== SEAM_MODULE); + return [CLI_ROOT, ...walkDir(CLI_DIR)]; } -describe('platform-execution coherence across CLI route delegation', () => { - test('a none route delegates execution to a registered runtime command', () => { - expect(registryKind('runtime')).toBe('device-runtime'); - expect(registryKind('react-devtools')).toBe('none'); - expect(unresolvedPairs(registryKind)).toEqual([]); - }); - - test('the declared table still carries the react-devtools runtime delegation', () => { - const route: CliInjectedRoute = 'react-devtools'; - expect(CLI_INJECTED_DAEMON_DISPATCHES[route]).toEqual(['runtime']); - }); - - test('every declared pair names registered commands on both ends', () => { - expect(unresolvedPairs(registryKind)).toEqual([]); - }); - - test('planted red: a cast-around table target is reported, not read as no failure', () => { - const planted = { 'react-devtools': ['not-a-command'] } as const; - - expect(unresolvedPairs(registryKind, planted)).toEqual([ - 'react-devtools dispatches not-a-command, which is not a registered command', - ]); - }); - - test('the CLI reaches the daemon transport only through the injected-dispatch seam', () => { +describe('the CLI reaches the daemon transport only through client transport factories', () => { + test('no CLI module calls or hands off the transport elsewhere', () => { const misuse = cliModulePaths().flatMap((file) => transportMisuse(path.relative(process.cwd(), file), fs.readFileSync(file, 'utf8')), ); @@ -277,14 +206,14 @@ describe('platform-execution coherence across CLI route delegation', () => { ['a computed-member call', "await deps['sendToDaemon'](request);"], ['a variable envelope', 'const request = { command: x }; await deps.sendToDaemon(request);'], ['an alias', 'const send = deps.sendToDaemon; await send(request);'], - ['a handoff to an arbitrary helper', 'await runInjected(deps.sendToDaemon, request);'], + ['a handoff to an arbitrary helper', 'await runHelper(deps.sendToDaemon, request);'], [ 'a handoff under the globally reviewed transport key', - 'await runInjected({ transport: deps.sendToDaemon });', + 'await runHelper({ transport: deps.sendToDaemon });', ], - ['a handoff under an unreviewed key', 'await runInjected({ send: deps.sendToDaemon });'], + ['a handoff under an unreviewed key', 'await runHelper({ send: deps.sendToDaemon });'], ['a bare re-export', 'export { sendToDaemon };'], - ])('planted red: %s outside the seam is rejected', (_label, body) => { + ])('planted red: %s outside a transport factory is rejected', (_label, body) => { expect( transportMisuse('src/cli/planted.ts', `async function run(deps) { ${body} }`), ).not.toEqual([]); @@ -301,9 +230,6 @@ describe('platform-execution coherence across CLI route delegation', () => { return createClient({ transport: createClientDaemonTransport(deps.sendToDaemon) }); } const DEFAULT_CLI_DEPS: CliDeps = { sendToDaemon }; - function inject(deps: CliDeps) { - return sendInjectedDaemonRequest({ transport: deps.sendToDaemon }); - } `; expect(transportMisuse(CLI_MODULE, wiring)).toEqual([]); }); @@ -320,7 +246,7 @@ describe('platform-execution coherence across CLI route delegation', () => { test('the CLI module cannot hand off the transport through a generically named property', () => { const planted = ` async function run(deps) { - return runInjected({ transport: deps.sendToDaemon }); + return runHelper({ transport: deps.sendToDaemon }); } `; expect(transportMisuse(CLI_MODULE, planted)).not.toEqual([]); diff --git a/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts b/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts index 5727e7192d..ca8651490b 100644 --- a/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts +++ b/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts @@ -17,10 +17,6 @@ vi.mock('../metro/client-metro-companion.ts', () => ({ stopMetroCompanion: vi.fn(), })); -vi.mock('../client/client-react-devtools-companion.ts', () => ({ - stopReactDevtoolsCompanion: vi.fn(), -})); - import { materializeRemoteConnectionForCommand } from '../cli/commands/connection-runtime.ts'; import { readRemoteConnectionState } from '../remote/remote-connection-state.ts'; diff --git a/src/__tests__/remote-connection-proxy-lease-id.test.ts b/src/__tests__/remote-connection-proxy-lease-id.test.ts index a1e8dfb2a7..07053678f0 100644 --- a/src/__tests__/remote-connection-proxy-lease-id.test.ts +++ b/src/__tests__/remote-connection-proxy-lease-id.test.ts @@ -13,10 +13,6 @@ vi.mock('../metro/client-metro-companion.ts', () => ({ stopMetroCompanion: vi.fn(), })); -vi.mock('../client/client-react-devtools-companion.ts', () => ({ - stopReactDevtoolsCompanion: vi.fn(), -})); - import { materializeRemoteConnectionForCommand } from '../cli/commands/connection-runtime.ts'; import { readRemoteConnectionState } from '../remote/remote-connection-state.ts'; diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 5002473cf0..313e20afcd 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -20,10 +20,6 @@ vi.mock('../metro/client-metro-companion.ts', () => ({ stopMetroCompanion: vi.fn(), })); -vi.mock('../client/client-react-devtools-companion.ts', () => ({ - stopReactDevtoolsCompanion: vi.fn(), -})); - import { connectCommand, connectionCommand, diff --git a/src/cli.ts b/src/cli.ts index 88d9f4abff..2c9601bc54 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,7 +4,6 @@ import { asAppError, AppError, normalizeError, - throwDaemonError, type NormalizedError, } from '@agent-device/kernel/errors'; import { resolveRemoteRequestDiagnosticsPath } from '@agent-device/daemon/session-artifact-paths'; @@ -48,8 +47,7 @@ import { resolveRemoteAuthForCli } from './cli/auth-session.ts'; import type { FlagKey } from '@agent-device/command-registry/flag-types'; import type { CliFlags } from '@agent-device/contracts/command'; import type { SessionRuntimeHints } from '@agent-device/kernel/contracts'; -import { INTERNAL_COMMANDS, isKnownCliCommandName } from '@agent-device/command-registry/catalog'; -import { sendInjectedDaemonRequest } from './cli/injected-daemon-dispatch.ts'; +import { isKnownCliCommandName } from '@agent-device/command-registry/catalog'; type CliDeps = { sendToDaemon: typeof sendToDaemon; @@ -140,7 +138,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): let logTailStopper: (() => void) | null = null; try { if (command === 'react-devtools') { - await exitAfterFlush(await runReactDevtoolsCli(ctx, deps)); + await exitAfterFlush(await runReactDevtoolsCli(ctx)); return; } if (command === 'web') { @@ -350,38 +348,12 @@ async function resolveRunContextOrExit( } } -async function runReactDevtoolsCli(ctx: CliRunContext, deps: CliDeps): Promise { +async function runReactDevtoolsCli(ctx: CliRunContext): Promise { const { runReactDevtoolsCommand } = await import('./cli/commands/react-devtools.ts'); - const { daemonAuthToken, ...directRequestFlags } = ctx.effectiveFlags; return await runReactDevtoolsCommand(ctx.positionals, { - flags: { - ...directRequestFlags, - leaseProvider: ctx.connectionDefaults?.connection?.leaseProvider, - }, + flags: ctx.effectiveFlags, stateDir: ctx.daemonPaths.baseDir, session: ctx.effectiveFlags.session ?? ctx.sessionName, - cwd: process.cwd(), - env: process.env, - configureDirectPortReverse: async () => { - const response = await sendInjectedDaemonRequest({ - route: 'react-devtools', - command: INTERNAL_COMMANDS.runtime, - request: { - positionals: ['port-reverse'], - flags: { - ...directRequestFlags, - leaseProvider: ctx.connectionDefaults?.connection?.leaseProvider, - devicePort: 8097, - hostPort: 8097, - portReverseName: 'react-devtools', - }, - session: ctx.effectiveFlags.session ?? ctx.sessionName, - }, - transport: deps.sendToDaemon, - transportOptions: { authToken: daemonAuthToken }, - }); - if (!response.ok) throwDaemonError(response.error); - }, }); } diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 8474e2c4b0..21793ef2cd 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -1,7 +1,6 @@ import crypto from 'node:crypto'; import type { MetroBridgeScope } from '@agent-device/contracts/remote'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { stopReactDevtoolsCompanion } from '../../client/client-react-devtools-companion.ts'; import { stopMetroTunnel } from '../../metro/metro.ts'; import { resolveRemoteConfigProfile } from '../../remote/remote-config.ts'; // Provenance-preserving file-only read (no ambient env defaults merged in) — @@ -566,22 +565,6 @@ export async function stopMetroCleanup( } } -export async function stopReactDevtoolsCleanup(options: { - stateDir: string; - state: Pick; -}): Promise { - try { - await stopReactDevtoolsCompanion({ - projectRoot: process.cwd(), - stateDir: options.stateDir, - profileKey: options.state.remoteConfigPath, - consumerKey: options.state.session, - }); - } catch { - // Connection lifecycle cleanup must stay best-effort. - } -} - /** The host allocated a `macos-app` lease and ends it itself; a tenant never releases it. */ export function isHostAllocatedLease(state: RemoteConnectionState): boolean { return state.leaseBackend === 'macos-app'; diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 1ce2c5e61e..3b8dc44d56 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -35,7 +35,6 @@ import { releasePreviousLease, resolveRequestedLeaseBackend, stopMetroCleanup, - stopReactDevtoolsCleanup, } from './connection-runtime.ts'; import { writeCommandOutput } from './shared.ts'; import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; @@ -86,7 +85,6 @@ export const connectCommand: ClientCommandHandler = async ({ positionals, flags, writeRemoteConnectionState({ stateDir, state }); const previousLeaseNotice = await cleanupForcedPreviousConnection( client, - stateDir, connectFlags, context.previous, state, @@ -267,14 +265,12 @@ function shouldReusePreviousConnectionState( async function cleanupForcedPreviousConnection( client: Parameters[0]['client'], - stateDir: string, flags: CliFlags, previous: RemoteConnectionState | null, next: RemoteConnectionState, ): Promise { if (!previous || !flags.force) return undefined; await stopMetroCleanup(previous.metro); - await stopReactDevtoolsCleanup({ stateDir, state: previous }); if (previous.leaseId && previous.leaseId === next.leaseId) return undefined; return await releasePreviousLease(client, previous, { nextDaemonBaseUrl: next.daemon?.baseUrl, @@ -314,7 +310,6 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) } } await stopMetroCleanup(state.metro); - await stopReactDevtoolsCleanup({ stateDir, state }); let released = false; if (state.leaseId) { try { diff --git a/src/cli/commands/react-devtools.ts b/src/cli/commands/react-devtools.ts index 2eb8842cc4..50dfbb2f76 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -1,217 +1,17 @@ -import { runCmdStreaming } from '@agent-device/host-kit/command'; +import { AppError } from '@agent-device/kernel/errors'; import { - ensureReactDevtoolsCompanion, - stopReactDevtoolsCompanion, -} from '../../client/client-react-devtools-companion.ts'; -import { AppError, errorMessage } from '@agent-device/kernel/errors'; -import { isRemoteBridgeBackend } from './remote-bridge.ts'; -import type { CliFlags } from '@agent-device/contracts/command'; -import { connectionProviderCapabilities } from '../connection/provider-policy.ts'; - -const AGENT_REACT_DEVTOOLS_VERSION = '0.5.0'; -export const AGENT_REACT_DEVTOOLS_PACKAGE = `agent-react-devtools@${AGENT_REACT_DEVTOOLS_VERSION}`; -const AGENT_REACT_DEVTOOLS_BIN = 'agent-react-devtools'; - -type ReactDevtoolsFlags = Pick< - CliFlags, - | 'leaseBackend' - | 'metroProxyBaseUrl' - | 'metroBearerToken' - | 'tenant' - | 'runId' - | 'leaseId' - | 'remoteConfig' - | 'session' -> & { - leaseProvider?: string; -}; - -type ReactDevtoolsCommandOptions = { - flags?: ReactDevtoolsFlags; - stateDir?: string; - session?: string; - cwd?: string; - env?: NodeJS.ProcessEnv; - configureDirectPortReverse?: () => Promise; -}; - -type RemoteBridgeConfig = { - serverBaseUrl: string; - bearerToken: string; - tenantId: string; - runId: string; - leaseId: string; -}; - -export function buildReactDevtoolsNpmExecArgs(args: string[]): string[] { - return [ - 'exec', - '--yes', - '--package', - AGENT_REACT_DEVTOOLS_PACKAGE, - '--', - AGENT_REACT_DEVTOOLS_BIN, - ...args, - ]; -} - -function isRemoteIosBridgeBackend(leaseBackend: CliFlags['leaseBackend']): boolean { - return leaseBackend === 'ios-instance'; -} - -function isWaitConnectedCommand(args: string[]): boolean { - return args[0] === 'wait' && args.includes('--connected'); -} - -function maybePrintRemoteIosWaitHint( - args: string[], - flags: ReactDevtoolsCommandOptions['flags'], - exitCode: number, -): void { - if ( - exitCode === 0 || - !isWaitConnectedCommand(args) || - !isRemoteIosBridgeBackend(flags?.leaseBackend) - ) { - return; - } - process.stderr.write( - [ - 'Hint: Remote iOS React DevTools connects during JavaScript startup.', - 'If the app was already open before `agent-device react-devtools start`, relaunch it with `agent-device open --platform ios --relaunch`, then retry `agent-device react-devtools wait --connected`.', - '', - ].join('\n'), - ); -} - -function readRemoteBridgeField( - missing: string[], - field: string, - value: string | undefined, -): string { - if (value) return value; - missing.push(field); - return ''; -} - -function resolveRemoteBridgeConfig( - flags: ReactDevtoolsCommandOptions['flags'], -): RemoteBridgeConfig | null { - if (!flags?.metroProxyBaseUrl || !isRemoteBridgeBackend(flags.leaseBackend)) return null; - const missing: string[] = []; - const config = { - serverBaseUrl: readRemoteBridgeField(missing, 'metroProxyBaseUrl', flags.metroProxyBaseUrl), - bearerToken: readRemoteBridgeField(missing, 'metroBearerToken', flags.metroBearerToken), - tenantId: readRemoteBridgeField(missing, 'tenant', flags.tenant), - runId: readRemoteBridgeField(missing, 'runId', flags.runId), - leaseId: readRemoteBridgeField(missing, 'leaseId', flags.leaseId), - }; - if (missing.length > 0) { - throw new AppError( - 'INVALID_ARGS', - `react-devtools remote bridge requires ${missing.join(', ')}.`, - { missing }, - ); - } - return config; -} - -async function withRemoteDevtoolsCompanion( - args: string[], - options: ReactDevtoolsCommandOptions, - action: () => Promise, -): Promise { - const { flags } = options; - const bridgeConfig = resolveRemoteBridgeConfig(flags); - if (!bridgeConfig) return action(); - - const stateDir = options.stateDir ?? process.cwd(); - const session = options.session ?? flags?.session ?? 'default'; - const profileKey = - flags?.remoteConfig ?? `${bridgeConfig.tenantId}:${bridgeConfig.runId}:${bridgeConfig.leaseId}`; - - if (args[0] === 'stop') { - // An unconfirmed companion stop fails this command, except when the action failed too: then the - // action's fault is the verdict and the surviving companion is named on stderr beside it. - const acted = await action().then( - (value) => ({ value }) as const, - (error: unknown) => ({ error }) as const, - ); - const stopped = await stopReactDevtoolsCompanion({ - projectRoot: options.cwd ?? process.cwd(), - stateDir, - profileKey, - consumerKey: session, - }).then( - () => ({}) as const, - (error: unknown) => ({ error }) as const, - ); - if ('error' in acted) { - if ('error' in stopped) { - process.stderr.write(`Warning: ${errorMessage(stopped.error)}\n`); - } - throw acted.error; - } - if ('error' in stopped) throw stopped.error; - return acted.value; - } - - await ensureReactDevtoolsCompanion({ - projectRoot: options.cwd ?? process.cwd(), - stateDir, - serverBaseUrl: bridgeConfig.serverBaseUrl, - bearerToken: bridgeConfig.bearerToken, - bridgeScope: { - tenantId: bridgeConfig.tenantId, - runId: bridgeConfig.runId, - leaseId: bridgeConfig.leaseId, - }, - session, - profileKey, - consumerKey: session, - env: options.env ?? process.env, - }); - return await action(); -} - -function shouldConfigureDirectReverse( - args: string[], - options: ReactDevtoolsCommandOptions, -): boolean { - if (args[0] !== 'start') return false; - const { flags } = options; - if (!flags) return false; - return ( - connectionProviderCapabilities(flags.leaseProvider, options.env).supportsDirectPortReverse && - flags.leaseBackend === 'android-instance' && - flags.metroProxyBaseUrl === undefined && - options.configureDirectPortReverse !== undefined - ); -} + runReactProfileCommand, + type ReactProfileCommandOptions, +} from '../react-profiler/react-profile-command.ts'; export async function runReactDevtoolsCommand( args: string[], - options: ReactDevtoolsCommandOptions = {}, + options: ReactProfileCommandOptions, ): Promise { - const cwd = options.cwd ?? process.cwd(); - const env = options.env ?? process.env; - const exitCode = await withRemoteDevtoolsCompanion(args, options, async () => { - if (shouldConfigureDirectReverse(args, options)) { - await options.configureDirectPortReverse?.(); - } - const result = await runCmdStreaming('npm', buildReactDevtoolsNpmExecArgs(args), { - cwd, - env, - allowFailure: true, - onStdoutChunk: (chunk) => { - process.stdout.write(chunk); - }, - onStderrChunk: (chunk) => { - process.stderr.write(chunk); - }, + if (args[0] !== 'profile') { + throw new AppError('INVALID_ARGS', `Unknown react-devtools command: ${args[0] ?? '(none)'}.`, { + hint: 'Run react-devtools profile start before the interaction and react-devtools profile stop after it.', }); - return result.exitCode; - }); - maybePrintRemoteIosWaitHint(args, options.flags, exitCode); - return exitCode; + } + return await runReactProfileCommand(args.slice(1), options); } diff --git a/src/cli/injected-daemon-dispatch.ts b/src/cli/injected-daemon-dispatch.ts deleted file mode 100644 index bfe9da4352..0000000000 --- a/src/cli/injected-daemon-dispatch.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { DaemonResponse, sendToDaemon } from '../daemon-client/daemon-client.ts'; -import { INTERNAL_COMMANDS } from '@agent-device/command-registry/catalog'; -import type { - DescriptorCliCommandName, - DescriptorDaemonRouteCommandName, -} from '@agent-device/command-registry/registry'; - -type CliDaemonTransport = typeof sendToDaemon; -type CliDaemonRequest = Parameters[0]; -type CliDaemonTransportOptions = Parameters[1]; - -/** - * The CLI's injected daemon dispatches (ADR 0019 §6). - * - * Ordinary commands reach the daemon through the client transport, carrying their own - * name. A few CLI routes additionally inject a *different* command into their flow — - * `react-devtools start` on a Limrun Android instance sets up its port reverse by - * dispatching internal `runtime`. That injection is the case where a descriptor's own - * module is platform-free while its route reaches platform behavior, so the pairs must - * be enumerable to keep the migration denominator honest. - * - * This table is that enumeration, and {@link sendInjectedDaemonRequest} is the only way - * to perform one: the route and command are typed parameters checked against this table, - * so an undeclared pair is a compile error rather than something a scanner must find. - */ -export const CLI_INJECTED_DAEMON_DISPATCHES = { - 'react-devtools': [INTERNAL_COMMANDS.runtime], -} as const satisfies Partial< - Record ->; - -export type CliInjectedRoute = keyof typeof CLI_INJECTED_DAEMON_DISPATCHES; - -type InjectedCommandFor = - (typeof CLI_INJECTED_DAEMON_DISPATCHES)[Route][number]; - -/** - * The single construction point for a CLI-injected daemon request. `command` comes from - * the declared pair rather than from the caller's object literal, so the dispatched - * command is known by type at every call site. - */ -export async function sendInjectedDaemonRequest(params: { - route: Route; - command: InjectedCommandFor; - request: Omit; - transport: CliDaemonTransport; - transportOptions?: CliDaemonTransportOptions; -}): Promise { - const { command, request, transport, transportOptions } = params; - return await transport({ ...request, command }, transportOptions); -} diff --git a/src/cli/parser/__tests__/args-parse-session.test.ts b/src/cli/parser/__tests__/args-parse-session.test.ts index f1313bc8e4..7ece0e63ca 100644 --- a/src/cli/parser/__tests__/args-parse-session.test.ts +++ b/src/cli/parser/__tests__/args-parse-session.test.ts @@ -275,14 +275,14 @@ test('parseArgs recognizes network include flag', () => { assert.equal(parsed.flags.networkInclude, 'headers'); }); -test('parseArgs preserves react-devtools arguments as passthrough positionals', () => { +test('parseArgs preserves react-devtools profile options as passthrough positionals', () => { const parsed = parseArgs( [ 'react-devtools', 'profile', - 'diff', - '--threshold', - '10', + 'stop', + '--metro-device', + 'Pixel 9', '--limit=5', '--json', '--session', @@ -293,14 +293,13 @@ test('parseArgs preserves react-devtools arguments as passthrough positionals', assert.equal(parsed.command, 'react-devtools'); assert.equal(parsed.flags.json, true); assert.equal(parsed.flags.session, 'rn'); - assert.deepEqual(parsed.positionals, ['profile', 'diff', '--threshold', '10', '--limit=5']); -}); - -test('parseArgs supports explicit passthrough boundary for react-devtools global flag names', () => { - const parsed = parseArgs(['react-devtools', '--', 'status', '--json'], { strictFlags: true }); - assert.equal(parsed.command, 'react-devtools'); - assert.equal(parsed.flags.json, false); - assert.deepEqual(parsed.positionals, ['status', '--json']); + assert.deepEqual(parsed.positionals, [ + 'profile', + 'stop', + '--metro-device', + 'Pixel 9', + '--limit=5', + ]); }); test('parseArgs preserves cdp arguments as passthrough positionals', () => { diff --git a/src/cli/react-profiler/metro-inspector.test.ts b/src/cli/react-profiler/metro-inspector.test.ts new file mode 100644 index 0000000000..0badbe150a --- /dev/null +++ b/src/cli/react-profiler/metro-inspector.test.ts @@ -0,0 +1,264 @@ +import { afterEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { createServer, type IncomingMessage } from 'node:http'; +import { once } from 'node:events'; +import type { Duplex } from 'node:stream'; +import { + InspectorSession, + listMetroInspectorTargets, + selectReactNativeTarget, + type MetroInspectorTarget, +} from './metro-inspector.ts'; + +function target(id: string, extra: Partial = {}): MetroInspectorTarget { + return { + id, + title: 'React Native', + webSocketDebuggerUrl: `ws://127.0.0.1:8081/inspector/debug?page=${id}`, + reactNative: { logicalDeviceId: id.slice(0, id.lastIndexOf('-')) }, + ...extra, + }; +} + +test('the main runtime wins over worklet runtimes and later pages on the same device', () => { + const selected = selectReactNativeTarget([ + target('pixel-2', { appId: 'com.app', description: 'React Native Bridge' }), + target('pixel-1', { appId: 'com.app', description: 'React Native Bridge' }), + target('pixel-3', { appId: 'com.app', description: 'Reanimated UI runtime [C++ connection]' }), + ]); + assert.equal(selected.id, 'pixel-1'); +}); + +test('auxiliary pages without React Native metadata are never profiled', () => { + assert.throws( + () => + selectReactNativeTarget([ + { id: 'legacy-1', title: 'Hermes', webSocketDebuggerUrl: 'ws://x' }, + ]), + /No React Native runtime is connected/, + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test('one app on several devices fails closed until --metro-device names one', () => { + const targets = [ + target('ipad-1', { appId: 'com.app', deviceName: 'iPad Air' }), + target('pixel-1', { appId: 'com.app', deviceName: 'Pixel 9' }), + target('pixel-2', { appId: 'com.app', deviceName: 'Pixel 9' }), + ]; + assert.throws( + () => selectReactNativeTarget(targets), + (error: { code: string; details: { hint: string } }) => { + assert.equal(error.code, 'INVALID_ARGS'); + assert.equal(error.details.hint, 'Pass --metro-device with one of: iPad Air, Pixel 9.'); + return true; + }, + ); + assert.equal(selectReactNativeTarget(targets, { device: 'Pixel 9' }).id, 'pixel-1'); + assert.equal(selectReactNativeTarget(targets, { device: 'ipad' }).id, 'ipad-1'); +}); + +test('devices that share a name are listed by a key --metro-device can select', () => { + const targets = [ + target('sim-a-1', { appId: 'com.app', deviceName: 'iPhone 17' }), + target('sim-b-1', { appId: 'com.app', deviceName: 'iPhone 17' }), + target('pixel-1', { appId: 'com.app', deviceName: 'Pixel 9' }), + ]; + const hint = (filter?: { device: string }) => { + try { + selectReactNativeTarget(targets, filter); + } catch (error) { + return (error as { details: { hint: string } }).details.hint; + } + return undefined; + }; + assert.equal(hint(), 'Pass --metro-device with one of: sim-a, sim-b, Pixel 9.'); + assert.equal(hint({ device: 'iPhone 17' }), 'Pass --metro-device with one of: sim-a, sim-b.'); + assert.equal(selectReactNativeTarget(targets, { device: 'sim-b' }).id, 'sim-b-1'); +}); + +test('devices without a logical id are told apart by their target ids, not their names', () => { + const targets = [ + target('sim-a-1', { appId: 'com.app', deviceName: 'iPhone 17', reactNative: {} }), + target('sim-b-1', { appId: 'com.app', deviceName: 'iPhone 17', reactNative: {} }), + ]; + + assert.throws( + () => selectReactNativeTarget(targets), + (error: { details: { hint: string } }) => + error.details.hint === 'Pass --metro-device with one of: sim-a, sim-b.', + ); + assert.equal(selectReactNativeTarget(targets, { device: 'sim-a' }).id, 'sim-a-1'); +}); + +test('distinct apps need --app; older dev servers report the app id as the description', () => { + const targets = [ + target('a-1', { appId: 'com.one' }), + target('b-1', { description: 'com.two', title: 'React Native Bridgeless [C++ connection]' }), + ]; + assert.throws( + () => selectReactNativeTarget(targets), + (error: { code: string; details: { hint: string } }) => { + assert.equal(error.code, 'INVALID_ARGS'); + assert.equal(error.details.hint, 'Pass --app with one of: com.one, com.two.'); + return true; + }, + ); + assert.equal(selectReactNativeTarget(targets, { appId: 'com.two' }).id, 'b-1'); + assert.throws( + () => selectReactNativeTarget(targets, { appId: 'com.three' }), + /No matching React Native runtime/, + ); +}); + +test('an unreachable dev server names the address it tried', async () => { + vi.stubGlobal('fetch', async () => { + throw new TypeError('fetch failed'); + }); + await assert.rejects( + listMetroInspectorTargets(new URL('http://localhost:8081')), + /Could not reach the React Native dev server at http:\/\/localhost:8081/, + ); +}); + +test('a server that is not a dev server is named instead of yielding no targets', async () => { + vi.stubGlobal('fetch', async () => new Response('', { status: 200 })); + await assert.rejects( + listMetroInspectorTargets(new URL('http://localhost:8081')), + /is not a React Native dev server/, + ); +}); + +type Frame = { opcode: number; payload: Buffer }; + +function readFrame(buffer: Buffer): { frame: Frame; rest: Buffer } | null { + if (buffer.length < 2) return null; + let length = buffer[1]! & 0x7f; + let offset = 2; + if (length === 126) { + if (buffer.length < 4) return null; + length = buffer.readUInt16BE(2); + offset = 4; + } + if (buffer.length < offset + 4 + length) return null; + const mask = buffer.subarray(offset, offset + 4); + const payload = Buffer.from(buffer.subarray(offset + 4, offset + 4 + length)); + for (let i = 0; i < payload.length; i += 1) payload[i] = payload[i]! ^ mask[i % 4]!; + return { + frame: { opcode: buffer[0]! & 0x0f, payload }, + rest: buffer.subarray(offset + 4 + length), + }; +} + +function textFrame(text: string): Buffer { + const payload = Buffer.from(text); + const header = + payload.length < 126 + ? Buffer.from([0x81, payload.length]) + : Buffer.from([0x81, 126, payload.length >> 8, payload.length & 0xff]); + return Buffer.concat([header, payload]); +} + +/** Loopback inspector endpoint answering `Runtime.evaluate` through `respond`. */ +async function inspectorServer( + respond: (message: { id: number; params: { expression: string } }) => unknown, +) { + const sockets = new Set(); + const origins: Array = []; + const server = createServer((_req, res) => res.end()); + server.on('upgrade', (req: IncomingMessage, socket: Duplex) => { + sockets.add(socket); + origins.push(req.headers.origin); + const accept = createHash('sha1') + .update(`${req.headers['sec-websocket-key']}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest('base64'); + socket.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + let pending: Buffer = Buffer.alloc(0); + socket.on('data', (chunk: Buffer) => { + pending = Buffer.concat([pending, chunk]); + for (let read = readFrame(pending); read; read = readFrame(pending)) { + pending = read.rest; + if (read.frame.opcode === 0x8) { + socket.end(); + return; + } + const message = JSON.parse(read.frame.payload.toString('utf8')); + const reply = respond(message); + if (reply !== undefined) + socket.write(textFrame(JSON.stringify({ id: message.id, ...(reply as object) }))); + } + }); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + return { + url: `ws://127.0.0.1:${address.port}/inspector/debug?page=1`, + port: address.port, + origins, + close: async () => { + for (const socket of sockets) socket.destroy(); + server.close(); + await once(server, 'close'); + }, + }; +} + +test('the session sends a loopback Origin and returns evaluated values', async () => { + const server = await inspectorServer((message) => ({ + result: { result: { value: `echo:${message.params.expression}` } }, + })); + try { + const session = await InspectorSession.connect(server.url); + assert.equal(await session.evaluate('1 + 1'), 'echo:1 + 1'); + session.close(); + assert.deepEqual(server.origins, [`http://127.0.0.1:${server.port}`]); + } finally { + await server.close(); + } +}); + +test('an exception inside the app becomes a command failure with the thrown description', async () => { + const server = await inspectorServer(() => ({ + result: { + result: {}, + exceptionDetails: { text: 'Uncaught', exception: { description: 'TypeError: boom' } }, + }, + })); + try { + const session = await InspectorSession.connect(server.url); + await assert.rejects( + session.evaluate('boom()'), + (error: { message: string; cause: unknown }) => { + assert.equal(error.message, 'The profiler runtime threw inside the app.'); + assert.equal(error.cause, 'TypeError: boom'); + return true; + }, + ); + session.close(); + } finally { + await server.close(); + } +}); + +test('a dropped connection rejects the pending evaluation', async () => { + let drop: (() => void) | undefined; + const server = await inspectorServer(() => { + drop?.(); + return undefined; + }); + try { + const session = await InspectorSession.connect(server.url); + drop = () => void server.close(); + await assert.rejects(session.evaluate('wait()'), /closed the debugger connection/); + } finally { + await server.close().catch(() => {}); + } +}); diff --git a/src/cli/react-profiler/metro-inspector.ts b/src/cli/react-profiler/metro-inspector.ts new file mode 100644 index 0000000000..ef0c0243ae --- /dev/null +++ b/src/cli/react-profiler/metro-inspector.ts @@ -0,0 +1,322 @@ +import { AppError } from '@agent-device/kernel/errors'; + +const TARGET_LIST_TIMEOUT_MS = 3_000; +const CONNECT_TIMEOUT_MS = 5_000; +const EVALUATE_TIMEOUT_MS = 30_000; + +/** One debuggable JavaScript runtime the dev server's inspector proxy lists at `/json/list`. */ +export type MetroInspectorTarget = { + id: string; + title: string; + description?: string; + appId?: string; + deviceName?: string; + webSocketDebuggerUrl: string; + /** Present on React Native runtimes; absent on auxiliary targets such as legacy Hermes pages. */ + reactNative?: { logicalDeviceId?: string }; +}; + +/** + * App id the dev server reports for a target. Newer inspector proxies set `appId`; older ones only + * put the bundle id or package name in `description`. + */ +export function targetAppId(target: MetroInspectorTarget): string { + return target.appId ?? target.description ?? target.title; +} + +export async function listMetroInspectorTargets(serverUrl: URL): Promise { + const listUrl = new URL('json/list', withTrailingSlash(serverUrl)); + let response: Response; + try { + response = await fetch(listUrl, { signal: AbortSignal.timeout(TARGET_LIST_TIMEOUT_MS) }); + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + `Could not reach the React Native dev server at ${serverUrl.origin}.`, + { + hint: 'Start Metro (or Expo/Re.Pack) for the app, or pass --metro-port/--metro-host when it does not run on localhost:8081.', + }, + error, + ); + } + if (!response.ok) { + throw new AppError( + 'COMMAND_FAILED', + `The dev server at ${serverUrl.origin} answered ${response.status} for /json/list.`, + ); + } + const body: unknown = await response.json().catch(() => undefined); + if (!Array.isArray(body)) { + throw new AppError( + 'COMMAND_FAILED', + `The server at ${serverUrl.origin} is not a React Native dev server: /json/list returned no target list.`, + { hint: "Pass --metro-port/--metro-host for the app's Metro, Expo, or Re.Pack dev server." }, + ); + } + return body.filter(isInspectorTarget); +} + +function isInspectorTarget(value: unknown): value is MetroInspectorTarget { + if (!value || typeof value !== 'object') return false; + const record = value as Record; + return typeof record.id === 'string' && typeof record.webSocketDebuggerUrl === 'string'; +} + +/** Secondary JavaScript runtimes, such as Reanimated's UI runtime, that render no React tree. */ +const WORKLET_RUNTIME_PATTERN = /reanimated|worklet/i; + +function pageNumber(target: MetroInspectorTarget): number { + const suffix = Number.parseInt(target.id.slice(target.id.lastIndexOf('-') + 1), 10); + return Number.isInteger(suffix) ? suffix : Number.MAX_SAFE_INTEGER; +} + +/** Identifies the device behind a target; display names can repeat, so they are the last resort. */ +function deviceKey(target: MetroInspectorTarget): string { + const separator = target.id.lastIndexOf('-'); + return ( + target.reactNative?.logicalDeviceId ?? + (separator > 0 ? target.id.slice(0, separator) : undefined) ?? + target.deviceName ?? + target.id + ); +} + +export type RuntimeTargetFilter = { + /** Bundle id or package name, as the dev server reports it. */ + appId?: string; + /** Device name or logical device id, as the dev server reports it. */ + device?: string; +}; + +function isAppRuntime(target: MetroInspectorTarget): boolean { + return ( + target.reactNative !== undefined && !WORKLET_RUNTIME_PATTERN.test(target.description ?? '') + ); +} + +function matchesFilter(target: MetroInspectorTarget, filter: RuntimeTargetFilter): boolean { + const app = filter.appId === undefined || targetAppId(target) === filter.appId; + const device = + filter.device === undefined || + target.deviceName === filter.device || + deviceKey(target) === filter.device; + return app && device; +} + +function noMatchingRuntime( + runtimes: MetroInspectorTarget[], + filter: RuntimeTargetFilter, +): AppError { + const seen = runtimes.map( + (target) => `${targetAppId(target)} on ${target.deviceName ?? deviceKey(target)}`, + ); + return new AppError( + 'COMMAND_FAILED', + filter.appId || filter.device + ? 'No matching React Native runtime is connected to the dev server.' + : 'No React Native runtime is connected to the dev server.', + { + hint: + seen.length > 0 + ? `Connected runtimes: ${[...new Set(seen)].join(', ')}.` + : 'Open the app from a development build so it loads its bundle from this dev server.', + }, + ); +} + +/** The main runtime of each device: a device registers it before any secondary runtime. */ +function mainRuntimePerDevice(candidates: MetroInspectorTarget[]): MetroInspectorTarget[] { + const devices = new Map(); + for (const target of candidates) { + const key = deviceKey(target); + const current = devices.get(key); + devices.set(key, current && pageNumber(current) <= pageNumber(target) ? current : target); + } + return [...devices.values()]; +} + +/** A value `--metro-device` matches uniquely: the device name, or its key when names collide. */ +function deviceSelectors(devices: MetroInspectorTarget[]): string[] { + const nameCounts = new Map(); + for (const { deviceName } of devices) { + if (deviceName) nameCounts.set(deviceName, (nameCounts.get(deviceName) ?? 0) + 1); + } + return devices.map((target) => + target.deviceName && nameCounts.get(target.deviceName) === 1 + ? target.deviceName + : deviceKey(target), + ); +} + +/** + * Picks the one React Native runtime to profile. Pages without the `reactNative` field are not app + * runtimes, and worklet runtimes render no React tree. Several apps or several devices fail + * closed: the caller must narrow to exactly one runtime. + */ +export function selectReactNativeTarget( + targets: MetroInspectorTarget[], + filter: RuntimeTargetFilter = {}, +): MetroInspectorTarget { + const runtimes = targets.filter(isAppRuntime); + const candidates = runtimes.filter((target) => matchesFilter(target, filter)); + if (candidates.length === 0) throw noMatchingRuntime(runtimes, filter); + const apps = new Set(candidates.map(targetAppId)); + if (apps.size > 1) { + throw new AppError( + 'INVALID_ARGS', + 'Several React Native apps are connected to the dev server.', + { + hint: `Pass --app with one of: ${[...apps].join(', ')}.`, + }, + ); + } + const devices = mainRuntimePerDevice(candidates); + if (devices.length > 1) { + throw new AppError( + 'INVALID_ARGS', + 'The app is connected to the dev server from several devices.', + { + hint: `Pass --metro-device with one of: ${deviceSelectors(devices).join(', ')}.`, + }, + ); + } + return devices[0]!; +} + +/** + * Node's WebSocket (undici) accepts an init object with request headers; the DOM typing this + * repo compiles against only declares subprotocols. + */ +const WebSocketWithHeaders = WebSocket as unknown as new ( + url: URL, + init: { headers: Record }, +) => WebSocket; + +type PendingCall = { resolve: (value: unknown) => void; reject: (error: Error) => void }; + +/** Minimal Chrome DevTools Protocol client over the dev server's inspector proxy. */ +export class InspectorSession { + #nextId = 1; + #pending = new Map(); + readonly #socket: WebSocket; + + private constructor(socket: WebSocket) { + this.#socket = socket; + socket.addEventListener('message', (event) => this.#onMessage(String(event.data))); + socket.addEventListener('close', () => { + for (const { reject } of this.#pending.values()) { + reject(new AppError('COMMAND_FAILED', 'The app runtime closed the debugger connection.')); + } + this.#pending.clear(); + }); + } + + static async connect(webSocketDebuggerUrl: string): Promise { + const url = new URL(webSocketDebuggerUrl); + // Metro refuses debugger upgrades without a loopback Origin, and Expo's dev server terminates + // any whose Origin host differs from its own 127.0.0.1: base URL. + const origin = `http://127.0.0.1:${url.port || (url.protocol === 'wss:' ? '443' : '80')}`; + const socket = new WebSocketWithHeaders(url, { headers: { origin } }); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + socket.close(); + reject(new AppError('COMMAND_FAILED', 'Timed out connecting to the app runtime debugger.')); + }, CONNECT_TIMEOUT_MS); + socket.addEventListener( + 'open', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + socket.addEventListener( + 'error', + () => { + clearTimeout(timer); + reject( + new AppError( + 'COMMAND_FAILED', + 'The dev server refused the debugger connection to the app runtime.', + ), + ); + }, + { once: true }, + ); + }); + return new InspectorSession(socket); + } + + async evaluate(expression: string): Promise { + const result = (await this.#send('Runtime.evaluate', { expression, returnByValue: true })) as { + result?: { value?: unknown }; + exceptionDetails?: { text?: string; exception?: { description?: string } }; + }; + if (result.exceptionDetails) { + const { text, exception } = result.exceptionDetails; + throw new AppError( + 'COMMAND_FAILED', + 'The profiler runtime threw inside the app.', + undefined, + exception?.description ?? text ?? 'unknown exception', + ); + } + return result.result?.value; + } + + close(): void { + this.#socket.close(); + } + + #send(method: string, params: Record): Promise { + const id = this.#nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.#pending.delete(id); + reject(new AppError('COMMAND_FAILED', `The app runtime did not answer ${method} in time.`)); + }, EVALUATE_TIMEOUT_MS); + this.#pending.set(id, { + resolve: (value) => { + clearTimeout(timer); + resolve(value); + }, + reject: (error) => { + clearTimeout(timer); + reject(error); + }, + }); + this.#socket.send(JSON.stringify({ id, method, params })); + }); + } + + #onMessage(raw: string): void { + let message: { id?: number; result?: unknown; error?: { message?: string } }; + try { + message = JSON.parse(raw); + } catch { + return; + } + if (typeof message.id !== 'number') return; + const pending = this.#pending.get(message.id); + if (!pending) return; + this.#pending.delete(message.id); + if (message.error) { + pending.reject( + new AppError( + 'COMMAND_FAILED', + `Debugger protocol error: ${message.error.message ?? 'unknown'}`, + ), + ); + } else { + pending.resolve(message.result); + } + } +} + +function withTrailingSlash(url: URL): URL { + const copy = new URL(url); + if (!copy.pathname.endsWith('/')) copy.pathname = `${copy.pathname}/`; + copy.search = ''; + copy.hash = ''; + return copy; +} diff --git a/src/cli/react-profiler/react-profile-analysis.test.ts b/src/cli/react-profiler/react-profile-analysis.test.ts new file mode 100644 index 0000000000..3025b5ff7d --- /dev/null +++ b/src/cli/react-profiler/react-profile-analysis.test.ts @@ -0,0 +1,473 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { analyzeReactProfile } from './react-profile-analysis.ts'; +import { selectPropKindRequests, type RawReactProfile } from './react-profile-renders.ts'; +import { + FUNCTION, + MEMO, + rawProfile, + repeat, + type Render, +} from './react-profile-renders.fixtures.ts'; + +const kinds = (raw: RawReactProfile) => + analyzeReactProfile(raw).findings.map((f) => `${f.kind} ${f.component}`); + +test('a component whose own render is slow is reported with its per-render cost', () => { + const raw = rawProfile( + { 1: ['Screen'], 2: ['Chart', FUNCTION, 1] }, + repeat(4, (i) => [ + i * 200, + 60, + [1], + [ + [1, 1, [0, null, [0], null]], + [2, 55], + ], + ]), + ); + const [finding] = analyzeReactProfile(raw).findings; + assert.equal(finding?.kind, 'slow-render'); + assert.equal(finding?.component, 'Chart'); + assert.match(finding?.evidence ?? '', /^55ms per render/); +}); + +test('memo bypassed by props that change on every render names the props, their kinds, and the owner', () => { + const rows = repeat(20, (i) => 10 + i); + const elements: Record = { 1: ['List'] }; + for (const id of rows) elements[id] = ['Memo(Row)', MEMO, 1]; + const raw = rawProfile( + elements, + repeat(3, (i) => [ + i * 200, + 30, + [1], + [[1, 1, [0, null, [0], null]], ...rows.map((id): Render => [id, 1, [0, [0, 1], null, null]])], + ]), + ['onPress', 'style'], + ); + + const request = selectPropKindRequests(raw); + assert.deepEqual( + new Set(Object.values(request).map((keys) => keys.join())), + new Set(['onPress,style']), + ); + const [id] = Object.keys(request); + const [finding] = analyzeReactProfile(raw, { + [id!]: { onPress: 'function', style: 'object' }, + }).findings; + assert.equal(finding?.kind, 'memo-defeated'); + assert.equal(finding?.component, 'Row'); + assert.match( + finding?.evidence ?? '', + /new onPress \(function\) 60x, style \(object\) 60x from List/, + ); +}); + +test('a memoized component re-rendering on real value changes is not memo-defeated', () => { + const rows = repeat(12, (i) => 10 + i); + const elements: Record = { 1: ['List'] }; + for (const id of rows) elements[id] = ['Row', MEMO, 1]; + const raw = rawProfile( + elements, + repeat(3, (i) => [ + i * 200, + 30, + [1], + [[1, 1, [0, null, [0], null]], ...rows.map((id): Render => [id, 2, [0, [0], null, null]])], + ]), + ['selected'], + ); + const [id] = Object.keys(selectPropKindRequests(raw)); + assert.deepEqual(analyzeReactProfile(raw, { [id!]: { selected: 'boolean' } }).findings, []); +}); + +test('renders forced only by a parent re-render point at the owner to memoize below', () => { + const rows = repeat(15, (i) => 10 + i); + const elements: Record = { 1: ['Search'] }; + for (const id of rows) elements[id] = ['ResultRow', FUNCTION, 1]; + const raw = rawProfile( + elements, + repeat(4, (i) => [ + i * 100, + 20, + [1], + [[1, 1, [0, null, [0], null]], ...rows.map((id): Render => [id, 1, 'parent'])], + ]), + ); + + assert.deepEqual(kinds(raw), ['parent-cascade ResultRow']); + assert.match( + analyzeReactProfile(raw).findings[0]!.evidence, + /when Search rendered across 15 instances/, + ); +}); + +test('a cascade of moderately slow rows is reported as the cascade, not as slow renders', () => { + const rows = repeat(30, (i) => 10 + i); + const elements: Record = { 1: ['Search'] }; + for (const id of rows) elements[id] = ['ResultRow', FUNCTION, 1]; + const raw = rawProfile( + elements, + repeat(4, (i) => [ + i * 300, + 200, + [1], + [[1, 1, [0, null, [0], null]], ...rows.map((id): Render => [id, 5, 'parent'])], + ]), + ); + assert.deepEqual(kinds(raw), ['parent-cascade ResultRow']); +}); + +test('context-driven renders are traced to the component whose update changed the context', () => { + const badges = repeat(12, (i) => 10 + i); + const elements: Record = { 1: ['SessionProvider'] }; + for (const id of badges) elements[id] = ['Badge', FUNCTION, 1]; + const raw = rawProfile( + elements, + repeat(3, (i) => [ + i * 100, + 25, + [1], + [[1, 1, [0, null, [0], null]], ...badges.map((id): Render => [id, 2, [1, null, null, null]])], + ]), + ); + + const [finding] = analyzeReactProfile(raw).findings; + assert.equal(finding?.kind, 'context-cascade'); + assert.match( + finding?.evidence ?? '', + /36 context-driven renders across 12 instances after updates in SessionProvider/, + ); +}); + +test('a state update set right after the previous commit is reported as an effect chain', () => { + const raw = rawProfile( + { 1: ['Screen'], 2: ['Filtered', FUNCTION, 1] }, + repeat(4, (i) => i).flatMap((i): Array<[number, number, number[], Render[]]> => [ + [ + i * 200, + 10, + [1], + [ + [1, 1, [0, null, [0], null]], + [2, 5], + ], + ], + [i * 200 + 12, 10, [2], [[2, 9, [0, null, [0], null]]]], + ]), + ); + assert.ok(kinds(raw).includes('effect-chain Filtered')); +}); + +test('a follow-up commit counts once per component however many of its instances triggered it', () => { + const raw = rawProfile( + { + 1: ['Screen'], + 2: ['Filtered', FUNCTION, 1], + 3: ['Filtered', FUNCTION, 1], + 4: ['Filtered', FUNCTION, 1], + }, + repeat(3, (i) => i).flatMap((i): Array<[number, number, number[], Render[]]> => [ + [ + i * 300, + 10, + [1], + [ + [1, 1, [0, null, [0], null]], + [2, 2], + [3, 2], + [4, 2], + ], + ], + [ + i * 300 + 12, + 10, + [2, 3, 4], + [ + [2, 3, [0, null, [0], null]], + [3, 3, [0, null, [0], null]], + [4, 3, [0, null, [0], null]], + ], + ], + ]), + ); + const chain = analyzeReactProfile(raw).findings.find( + (finding) => finding.kind === 'effect-chain', + ); + assert.match( + chain?.evidence ?? '', + /^3 follow-up commits it triggered right after rendering, likely state set in an effect \(30ms\)$/, + ); +}); + +test('without inspected value kinds a memo bypass names the changed props without claiming new identities', () => { + const rows = repeat(12, (i) => 10 + i); + const elements: Record = { 1: ['List'] }; + for (const id of rows) elements[id] = ['Row', MEMO, 1]; + const raw = rawProfile( + elements, + repeat(3, (i) => [ + i * 200, + 30, + [1], + [[1, 1, [0, null, [0], null]], ...rows.map((id): Render => [id, 2, [0, [0], null, null]])], + ]), + ['onPress'], + ); + const [finding] = analyzeReactProfile(raw).findings; + assert.equal( + finding?.evidence, + 'memo bypassed on 36/36 updates; changed onPress 36x from List (value kinds not inspected)', + ); +}); + +test('every large mount group in a commit is its own eager-mount finding', () => { + const tiles = repeat(220, (i) => 10 + i); + const rows = repeat(210, (i) => 1000 + i); + const elements: Record = { 1: ['Catalog'] }; + for (const id of tiles) elements[id] = ['Tile', FUNCTION, 1]; + for (const id of rows) elements[id] = ['Row', FUNCTION, 1]; + const raw = rawProfile(elements, [ + [ + 0, + 400, + [1], + [ + [1, 5, [0, null, [0], null]], + ...tiles.map((id): Render => [id, 1, 1]), + ...rows.map((id): Render => [id, 1, 1]), + ], + ], + ]); + assert.deepEqual(kinds(raw).sort(), ['eager-mount Row', 'eager-mount Tile']); + const costs = Object.fromEntries( + analyzeReactProfile(raw).findings.map((finding) => [finding.component, finding.costMs]), + ); + assert.deepEqual(costs, { Tile: 220, Row: 210 }); +}); + +test('many commits from one updater in a short span are a render storm', () => { + const raw = rawProfile( + { 1: ['Clock'] }, + repeat(40, (i) => [i * 16, 0.5, [1], [[1, 0.4, [0, null, [0], null]]]]), + ); + const [finding] = analyzeReactProfile(raw).findings; + assert.equal(finding?.kind, 'render-storm'); + assert.match(finding?.evidence ?? '', /^40 commits in 0\.6s \(63\/s, 20ms rendering\)$/); +}); + +test('mounting hundreds of one component in a commit is an eager list, reported once per component', () => { + const tiles = repeat(250, (i) => 10 + i); + const elements: Record = { 1: ['Catalog'] }; + for (const id of tiles) elements[id] = ['Tile', FUNCTION, 1]; + const raw = rawProfile(elements, [ + [0, 300, [1], [[1, 5, [0, null, [0], null]], ...tiles.map((id): Render => [id, 1, 1, 1.1])]], + ]); + + assert.deepEqual(kinds(raw), ['eager-mount Tile']); +}); + +test('a component mounted eagerly in several commits is one finding costed across them', () => { + const tiles = repeat(250, (i) => 10 + i); + const elements: Record = { 1: ['Catalog'] }; + for (const id of tiles) elements[id] = ['Tile', FUNCTION, 1]; + const mount = (at: number, duration: number): [number, number, number[], Render[]] => [ + at, + duration, + [1], + [[1, 5, [0, null, [0], null]], ...tiles.map((id): Render => [id, 1, 1, duration / 300])], + ]; + const [finding, ...rest] = analyzeReactProfile( + rawProfile(elements, [mount(0, 300), mount(1_000, 600)]), + ).findings; + + assert.equal(finding?.kind, 'eager-mount'); + assert.equal(rest.length, 0); + assert.equal(Math.round(finding!.costMs), 750); + assert.match( + finding!.evidence, + /^250 instances mounted at once in commit #1, 500ms of its 600ms/, + ); + assert.match(finding!.evidence, /; 2 such commits, 750ms in all$/); +}); + +test('an Animated wrapper stays a framework component instead of merging into the one it wraps', () => { + const rows = repeat(30, (i) => 10 + i); + const elements: Record = { 1: ['Screen'] }; + for (const id of rows) elements[id] = ['Animated(Row)', FUNCTION, 1]; + const raw = rawProfile( + elements, + repeat(5, (i) => [ + i * 100, + 20, + [1], + [[1, 1, [0, null, [0], null]], ...rows.map((id): Render => [id, 1, 'parent'])], + ]), + ); + + assert.deepEqual(kinds(raw), []); + assert.deepEqual( + analyzeReactProfile(raw).components.map((c) => c.name), + ['Animated(Row)', 'Screen'], + ); +}); + +test('renders of elements the profile could not name stay out of components and findings', () => { + const unnamed = repeat(30, (i) => 10 + i); + const raw = rawProfile( + { 1: ['Screen'] }, + repeat(5, (i) => [ + i * 100, + 20, + [1], + [[1, 1, [0, null, [0], null]], ...unnamed.map((id): Render => [id, 1, 'parent'])], + ]), + ); + + assert.deepEqual(kinds(raw), []); + assert.deepEqual( + analyzeReactProfile(raw).components.map((c) => c.name), + ['Screen'], + ); +}); + +test('memo-wrapped and plain components of one name are profiled separately', () => { + const raw = rawProfile({ 1: ['List'], 2: ['Memo(Row)', MEMO, 1], 3: ['Row', FUNCTION, 1] }, [ + [ + 0, + 20, + [1], + [ + [1, 1, [0, null, [0], null]], + [2, 4, 'parent'], + [3, 9, 'parent'], + ], + ], + ]); + + assert.deepEqual( + analyzeReactProfile(raw).components.map((c) => `${c.name} ${c.kind} ${c.selfMs}`), + ['Row function 9', 'Row memo 4', 'List function 1'], + ); +}); + +test('a prop whose inspected instances disagree on its kind claims no new identity', () => { + const rows = repeat(12, (i) => 10 + i); + const elements: Record = { 1: ['List'] }; + for (const id of rows) elements[id] = ['Memo(Row)', MEMO, 1]; + const raw = rawProfile( + elements, + repeat(3, (i) => [ + i * 200, + 30, + [1], + [[1, 1, [0, null, [0], null]], ...rows.map((id): Render => [id, 1, [0, [0], null, null]])], + ]), + ['label'], + ); + const [finding] = analyzeReactProfile(raw, { + 10: { label: 'object' }, + 11: { label: 'string' }, + }).findings; + + assert.equal(finding?.kind, 'memo-defeated'); + assert.match(finding!.evidence, /changed label 36x from List \(value kinds not inspected\)$/); +}); + +test('framework primitives never head a finding even when they re-render the most', () => { + const texts = repeat(30, (i) => 10 + i); + const elements: Record = { 1: ['Screen'] }; + for (const id of texts) elements[id] = ['Text', FUNCTION, 1]; + const raw = rawProfile( + elements, + repeat(5, (i) => [ + i * 100, + 20, + [1], + [[1, 1, [0, null, [0], null]], ...texts.map((id): Render => [id, 1, 'parent'])], + ]), + ); + + assert.deepEqual(kinds(raw), []); + assert.equal(analyzeReactProfile(raw).components.find((c) => c.name === 'Text')?.renders, 150); +}); + +test('a slow commit nobody explains is reported with its heaviest components', () => { + const raw = rawProfile({ 1: ['Login'], 2: ['Form', FUNCTION, 1], 3: ['Text', FUNCTION, 2] }, [ + [ + 0, + 90, + [1], + [ + [1, 12, 1, 90], + [2, 8, 1, 70], + [3, 30, 1], + ], + ], + ]); + const [finding] = analyzeReactProfile(raw).findings; + assert.equal(finding?.kind, 'slow-commit'); + assert.equal(finding?.component, 'Login'); + assert.equal( + finding?.evidence, + 'commit #0 took 90ms rendering 3 components (3 mounted) after Login updated; most time in Text 30ms, Login 12ms, Form 8.0ms', + ); +}); + +test('a slow commit names the app component whose subtree holds the time when it is not the updater', () => { + const raw = rawProfile( + { 1: ['Screen'], 2: ['Feed', FUNCTION, 1], 3: ['View', FUNCTION, 2], 4: ['Store'] }, + [ + [ + 0, + 100, + [4], + [ + [1, 2, [1, null, null, null], 100], + [2, 3, 'parent', 95], + [3, 90, 'parent'], + ], + ], + ], + ); + const [finding] = analyzeReactProfile(raw).findings; + assert.equal(finding?.component, 'Feed'); + assert.match( + finding?.evidence ?? '', + /rendering 3 components inside Feed after Store updated; most time in View 90ms/, + ); + assert.equal(finding?.fix, 'render less under Feed per update (memoize, virtualize, or defer)'); +}); + +test('cheap patterns stay below the noise floor', () => { + const rows = repeat(12, (i) => 10 + i); + const elements: Record = { + 1: ['List'], + 99: ['Heavy', FUNCTION, 1], + }; + for (const id of rows) elements[id] = ['Row', FUNCTION, 1]; + const raw = rawProfile( + elements, + repeat(2, (i) => [ + i * 300, + 200, + [1], + [[1, 1, [0, null, [0], null]], [99, 190], ...rows.map((id): Render => [id, 0.01, 'parent'])], + ]), + ); + assert.deepEqual(kinds(raw), ['slow-render Heavy']); +}); + +test('the summary counts commits over the frame budget and names the worst', () => { + const raw = rawProfile({ 1: ['A'] }, [ + [0, 10, [1], [[1, 1]]], + [100, 40, [1], [[1, 1]]], + [200, 20, [1], [[1, 1]]], + ]); + const analysis = analyzeReactProfile(raw); + assert.equal(analysis.slowCommitCount, 2); + assert.deepEqual(analysis.worstCommit, { index: 1, durationMs: 40 }); + assert.equal(analysis.renderMs, 70); + assert.equal(analysis.wallMs, 5_000); +}); diff --git a/src/cli/react-profiler/react-profile-analysis.ts b/src/cli/react-profiler/react-profile-analysis.ts new file mode 100644 index 0000000000..9efb7159aa --- /dev/null +++ b/src/cli/react-profiler/react-profile-analysis.ts @@ -0,0 +1,425 @@ +import { + bump, + chronological, + commitRenders, + componentNames, + formatMs, + FRAME_BUDGET_MS, + isFrameworkPrimitive, + summarizeRenders, + type CommitComponent, + type CommitProfile, + type ComponentProfile, + type PropKindsById, + type RawProfileCommit, + type RawReactProfile, +} from './react-profile-renders.ts'; + +export type FindingKind = + | 'slow-commit' + | 'slow-render' + | 'eager-mount' + | 'memo-defeated' + | 'context-cascade' + | 'parent-cascade' + | 'effect-chain' + | 'render-storm'; + +export type Finding = { + kind: FindingKind; + component: string; + /** Estimated render time the pattern cost during the profile. */ + costMs: number; + evidence: string; + fix: string; +}; + +export type ReactProfileAnalysis = { + version: 1; + wallMs: number; + commitCount: number; + renderMs: number; + slowCommitCount: number; + worstCommit: { index: number; durationMs: number } | null; + naming: RawReactProfile['naming']; + /** Gaps in the evidence behind the findings, such as uninspected prop values. */ + notes: string[]; + components: ComponentProfile[]; + commits: CommitProfile[]; + findings: Finding[]; +}; + +/** Findings cheaper than this, or than this share of all render time, are noise. */ +const MIN_FINDING_COST_MS = 2; +const MIN_FINDING_COST_SHARE = 0.03; + +function topKeys(record: Record, minimum: number): string[] { + return Object.entries(record) + .filter(([, count]) => count >= minimum) + .sort((a, b) => b[1] - a[1]) + .map(([key]) => key); +} + +function topOwner(entry: ComponentProfile): string | undefined { + return topKeys(entry.owners, 1)[0]; +} + +function costShare(entry: ComponentProfile, renders: number): number { + return entry.renders === 0 ? 0 : (entry.selfMs * renders) / entry.renders; +} + +const ms = formatMs; + +function plural(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? '' : 's'}`; +} + +function instancesNote(entry: ComponentProfile): string { + return entry.instances > 1 ? ` across ${entry.instances} instances` : ''; +} + +function slowRenderFinding(entry: ComponentProfile): Finding | null { + const avg = entry.selfMs / entry.renders; + if (entry.selfMs < FRAME_BUDGET_MS * 2 || (avg < 4 && entry.maxSelfMs < FRAME_BUDGET_MS)) + return null; + if (entry.instances > 20 && avg < 4) return null; + return { + kind: 'slow-render', + component: entry.name, + costMs: entry.selfMs, + evidence: `${ms(avg)} per render (max ${ms(entry.maxSelfMs)}), ${plural(entry.renders, 'render')}${instancesNote(entry)}`, + fix: 'memoize or move the expensive work out of render', + }; +} + +/** Prop value kinds whose change is a real value change rather than a new identity per render. */ +const VALUE_KINDS = new Set([ + 'boolean', + 'number', + 'string', + 'null', + 'undefined', + 'bigint', + 'symbol', +]); + +function memoDefeatedFinding(entry: ComponentProfile): Finding | null { + const updates = entry.renders - entry.causes.mount; + if (entry.kind !== 'memo' || updates < 10 || entry.causes.props < updates * 0.5) return null; + const frequent = topKeys(entry.changedProps, Math.ceil(entry.causes.props * 0.8)); + const candidates = frequent.length > 0 ? frequent : topKeys(entry.changedProps, 1).slice(0, 3); + const unstable = candidates.filter((key) => !VALUE_KINDS.has(entry.propKinds[key] ?? '')); + if (unstable.length === 0) return null; + const owner = topOwner(entry); + const from = owner ? ` from ${owner}` : ''; + const inOwner = owner ? ` in ${owner}` : ''; + const identities = unstable.filter((key) => entry.propKinds[key] !== undefined); + const changes = (key: string) => `${entry.changedProps[key]}x`; + if (identities.length === 0) { + return { + kind: 'memo-defeated', + component: entry.name, + costMs: costShare(entry, entry.causes.props), + evidence: `memo bypassed on ${entry.causes.props}/${updates} updates; changed ${unstable.map((key) => `${key} ${changes(key)}`).join(', ')}${from} (value kinds not inspected)`, + fix: `check whether those props are recreated on each render${inOwner}; stabilize them with useCallback/useMemo`, + }; + } + return { + kind: 'memo-defeated', + component: entry.name, + costMs: costShare(entry, entry.causes.props), + evidence: `memo bypassed on ${entry.causes.props}/${updates} updates; new ${identities.map((key) => `${key} (${entry.propKinds[key]}) ${changes(key)}`).join(', ')}${from}`, + fix: `wrap those props in useCallback/useMemo${inOwner}`, + }; +} + +function contextCascadeFinding(entry: ComponentProfile, triggers: string[]): Finding | null { + const updates = entry.renders - entry.causes.mount; + if (updates < 10 || entry.causes.context < updates * 0.5) return null; + return { + kind: 'context-cascade', + component: entry.name, + costMs: costShare(entry, entry.causes.context), + evidence: `${entry.causes.context} context-driven renders${instancesNote(entry)}${triggers.length ? ` after updates in ${triggers.join(', ')}` : ''}`, + fix: 'split the context so consumers subscribe only to what they read, or memoize the provider value', + }; +} + +function parentCascadeFinding(entry: ComponentProfile): Finding | null { + const updates = entry.renders - entry.causes.mount; + if (entry.kind === 'memo' || updates < 10 || entry.causes.parent < updates * 0.5) return null; + const owner = topOwner(entry); + return { + kind: 'parent-cascade', + component: entry.name, + costMs: costShare(entry, entry.causes.parent), + evidence: `${entry.causes.parent}/${updates} re-renders with unchanged props, state, and context${owner ? ` when ${owner} rendered` : ''}${instancesNote(entry)}`, + fix: `wrap ${entry.name} in React.memo or move state below ${owner ?? 'its parent'}`, + }; +} + +/** Commit that renders more than this many instances of one component at mount is eager. */ +const EAGER_MOUNT_INSTANCES = 200; + +type EagerMount = { + index: number; + count: number; + costMs: number; + durationMs: number; + mounted: number; +}; + +function eagerMountFindings(profile: RawReactProfile, commits: CommitProfile[]): Finding[] { + const mountsByName = new Map(); + profile.commits.forEach((commit, index) => { + const groups = new Map(); + let mounted = 0; + for (const render of commitRenders(profile, commit)) { + if (!render.causes.includes('mount')) continue; + mounted += 1; + if (isFrameworkPrimitive(render.name)) continue; + const group = groups.get(render.name) ?? { count: 0, actualMs: 0 }; + group.count += 1; + group.actualMs += render.actualMs; + groups.set(render.name, group); + } + const durationMs = commits[index]!.durationMs; + for (const [name, group] of groups) { + if (group.count < EAGER_MOUNT_INSTANCES) continue; + const costMs = Math.min(group.actualMs, durationMs); + const mounts = mountsByName.get(name) ?? []; + mounts.push({ index, count: group.count, costMs, durationMs, mounted }); + mountsByName.set(name, mounts); + } + }); + return [...mountsByName.entries()].map(([name, mounts]) => { + const worst = mounts.reduce((a, b) => (b.costMs > a.costMs ? b : a)); + const costMs = mounts.reduce((sum, mount) => sum + mount.costMs, 0); + const repeated = + mounts.length > 1 ? `; ${mounts.length} such commits, ${ms(costMs)} in all` : ''; + return { + kind: 'eager-mount' as const, + component: name, + costMs, + evidence: `${worst.count} instances mounted at once in commit #${worst.index}, ${ms(worst.costMs)} of its ${ms(worst.durationMs)} (${worst.mounted} components mounted)${repeated}`, + fix: 'virtualize the list (FlatList/FlashList)', + }; + }); +} + +/** + * A follow-up commit that starts within a frame of the previous one, triggered by a component that + * rendered in it but did not trigger it, most likely came from that component's own effect. The + * profile records no effect provenance, so the finding is worded as a likely cause. + */ +const CHAINED_COMMIT_GAP_MS = FRAME_BUDGET_MS; + +/** App components that rendered in `previous` without triggering it, then triggered `commit`. */ +function chainedUpdaterNames( + profile: RawReactProfile, + previous: RawProfileCommit, + commit: RawProfileCommit, +): string[] { + const renderedBefore = new Set(previous.fibers.filter((_, offset) => offset % 3 === 0)); + const triggeredBefore = new Set(previous.updaters); + const chained = commit.updaters.filter( + (updater) => renderedBefore.has(updater) && !triggeredBefore.has(updater), + ); + return componentNames(profile, chained).filter((name) => !isFrameworkPrimitive(name)); +} + +function effectChainFindings(profile: RawReactProfile): Finding[] { + const chains = new Map(); + for (let index = 1; index < profile.commits.length; index += 1) { + const previous = profile.commits[index - 1]!; + const commit = profile.commits[index]!; + const gap = commit.timestamp - commit.duration - previous.timestamp; + if (gap > CHAINED_COMMIT_GAP_MS) continue; + for (const name of chainedUpdaterNames(profile, previous, commit)) { + const chain = chains.get(name) ?? { count: 0, costMs: 0 }; + chain.count += 1; + chain.costMs += commit.duration; + chains.set(name, chain); + } + } + return [...chains.entries()] + .filter(([, chain]) => chain.count >= 3) + .map(([name, chain]) => ({ + kind: 'effect-chain' as const, + component: name, + costMs: chain.costMs, + evidence: `${plural(chain.count, 'follow-up commit')} it triggered right after rendering, likely state set in an effect (${ms(chain.costMs)})`, + fix: 'if an effect copies derived state, compute it during render (useMemo) instead', + })); +} + +const STORM_MIN_COMMITS = 20; +const STORM_MIN_RATE_PER_SECOND = 20; + +function renderStormFindings(profile: RawReactProfile): Finding[] { + const byUpdater = new Map(); + profile.commits.forEach((commit, index) => { + for (const name of componentNames(profile, commit.updaters)) { + const indexes = byUpdater.get(name) ?? []; + indexes.push(index); + byUpdater.set(name, indexes); + } + }); + const findings: Finding[] = []; + for (const [name, indexes] of byUpdater) { + if (indexes.length < STORM_MIN_COMMITS || isFrameworkPrimitive(name)) continue; + const first = profile.commits[indexes[0]!]!.timestamp; + const last = profile.commits[indexes.at(-1)!]!.timestamp; + const spanSeconds = Math.max((last - first) / 1000, 0.001); + const rate = (indexes.length - 1) / spanSeconds; + if (rate < STORM_MIN_RATE_PER_SECOND) continue; + const costMs = indexes.reduce((sum, index) => sum + profile.commits[index]!.duration, 0); + findings.push({ + kind: 'render-storm', + component: name, + costMs, + evidence: `${indexes.length} commits in ${spanSeconds.toFixed(1)}s (${Math.round(rate)}/s, ${ms(costMs)} rendering)`, + fix: 'throttle the updates or animate outside React state (Animated/Reanimated)', + }); + } + return findings; +} + +/** A commit at least this slow without a component finding behind it is reported on its own. */ +const SLOW_COMMIT_MS = FRAME_BUDGET_MS * 3; +const SLOW_COMMIT_FINDING_LIMIT = 2; +const EXPLAINED_SHARE = 0.5; +/** The app component whose subtree holds this share of a slow commit is where to cut work. */ +const SUBTREE_SHARE = 0.8; + +/** Findings whose cost lives in the component's own render, versus in the subtree it re-renders. */ +const SELF_COST_FINDINGS = new Set(['slow-render', 'effect-chain', 'render-storm']); + +/** Render time in a commit that earlier findings already account for. */ +function explainedMs(commit: CommitProfile, earlier: Finding[]): number { + const bySelf = new Set( + earlier.filter((f) => SELF_COST_FINDINGS.has(f.kind)).map((f) => f.component), + ); + const bySubtree = new Set( + earlier.filter((f) => !SELF_COST_FINDINGS.has(f.kind)).map((f) => f.component), + ); + let total = 0; + for (const entry of commit.top) { + if (bySubtree.has(entry.name)) total += entry.actualMs; + else if (bySelf.has(entry.name)) total += entry.selfMs; + } + return total; +} + +/** The app component to cut work under: the updater when it holds the commit, else the deepest holder. */ +function subtreeOwner(commit: CommitProfile): CommitComponent | undefined { + const covering = commit.top.filter( + (entry) => + !isFrameworkPrimitive(entry.name) && entry.actualMs >= commit.durationMs * SUBTREE_SHARE, + ); + return ( + covering.find((entry) => commit.updaters.includes(entry.name)) ?? + covering.sort((a, b) => a.actualMs - b.actualMs)[0] + ); +} + +function slowCommitFinding(commit: CommitProfile): Finding { + const owner = subtreeOwner(commit); + const heaviest = [...commit.top] + .sort((a, b) => b.selfMs - a.selfMs) + .slice(0, 3) + .map( + (entry) => + `${entry.name}${entry.instances > 1 ? ` x${entry.instances}` : ''} ${ms(entry.selfMs)}`, + ); + const mounted = commit.mounts > 0 ? ` (${commit.mounts} mounted)` : ''; + const inside = owner && !commit.updaters.includes(owner.name) ? ` inside ${owner.name}` : ''; + const trigger = commit.updaters.length > 0 ? ` after ${commit.updaters.join(', ')} updated` : ''; + return { + kind: 'slow-commit', + component: owner?.name ?? commit.updaters[0] ?? `#${commit.index}`, + costMs: commit.durationMs, + evidence: `commit #${commit.index} took ${ms(commit.durationMs)} rendering ${commit.rendered} components${mounted}${inside}${trigger}; most time in ${heaviest.join(', ')}`, + fix: `render less ${owner ? `under ${owner.name} ` : ''}per update (memoize, virtualize, or defer)`, + }; +} + +function slowCommitFindings(commits: CommitProfile[], earlier: Finding[]): Finding[] { + return commits + .filter((commit) => commit.durationMs >= SLOW_COMMIT_MS) + .filter((commit) => explainedMs(commit, earlier) < commit.durationMs * EXPLAINED_SHARE) + .sort((a, b) => b.durationMs - a.durationMs) + .slice(0, SLOW_COMMIT_FINDING_LIMIT) + .map(slowCommitFinding); +} + +function contextTriggers(profile: RawReactProfile, name: string): string[] { + const triggers: Record = {}; + for (const commit of profile.commits) { + let contextRender = false; + for (const render of commitRenders(profile, commit)) { + if (render.name === name && render.causes.includes('context')) { + contextRender = true; + break; + } + } + if (!contextRender) continue; + for (const name of componentNames(profile, commit.updaters)) bump(triggers, name); + } + return topKeys(triggers, 1).slice(0, 2); +} + +export function analyzeReactProfile( + recorded: RawReactProfile, + propKindsById: PropKindsById = {}, + notes: string[] = [], +): ReactProfileAnalysis { + const profile = chronological(recorded); + const { components, commits } = summarizeRenders(profile, propKindsById); + const slowCommits = commits.filter((commit) => commit.durationMs > FRAME_BUDGET_MS); + const worst = commits.reduce( + (current, commit) => (current && current.durationMs >= commit.durationMs ? current : commit), + null, + ); + + const findings: Finding[] = []; + for (const entry of components) { + if (isFrameworkPrimitive(entry.name)) continue; + const cascades = [ + memoDefeatedFinding(entry), + contextCascadeFinding( + entry, + entry.causes.context > 0 ? contextTriggers(profile, entry.name) : [], + ), + parentCascadeFinding(entry), + ].filter((finding): finding is Finding => finding !== null); + // A cascade explains many cheap renders; only a render that alone overruns a frame is also slow. + const slow = + cascades.length === 0 || entry.maxSelfMs >= FRAME_BUDGET_MS ? slowRenderFinding(entry) : null; + const perComponent = slow ? [slow, ...cascades] : cascades; + findings.push(...perComponent); + } + findings.push(...eagerMountFindings(profile, commits)); + findings.push(...effectChainFindings(profile)); + findings.push(...renderStormFindings(profile)); + findings.push(...slowCommitFindings(commits, findings)); + const renderMs = commits.reduce((sum, commit) => sum + commit.durationMs, 0); + const costFloorMs = Math.max(MIN_FINDING_COST_MS, renderMs * MIN_FINDING_COST_SHARE); + const significant = findings + .filter((finding) => finding.costMs >= costFloorMs) + .sort((a, b) => b.costMs - a.costMs); + + return { + version: 1, + wallMs: profile.stoppedAt - profile.startedAt, + commitCount: commits.length, + renderMs, + slowCommitCount: slowCommits.length, + worstCommit: worst ? { index: worst.index, durationMs: worst.durationMs } : null, + naming: profile.naming, + notes, + components, + commits, + findings: significant, + }; +} diff --git a/src/cli/react-profiler/react-profile-command.test.ts b/src/cli/react-profiler/react-profile-command.test.ts new file mode 100644 index 0000000000..1b35fbf0ed --- /dev/null +++ b/src/cli/react-profiler/react-profile-command.test.ts @@ -0,0 +1,288 @@ +import { afterEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { + runReactProfileCommand, + type ReactProfileCommandOptions, +} from './react-profile-command.ts'; +import { InspectorSession } from './metro-inspector.ts'; +import { FakeAppRuntime, MEMO_TYPE, encodeOperations } from './react-profiler-runtime.fixtures.ts'; + +const TARGET = { + id: 'device-1', + title: 'com.example.app (Pixel)', + appId: 'com.example.app', + webSocketDebuggerUrl: 'ws://127.0.0.1:8081/inspector/debug?device=1&page=1', + reactNative: { logicalDeviceId: 'device' }, +}; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +/** Serves TARGET from `/json/list` and evaluates debugger calls in `app`'s realm. */ +function harness(app = new FakeAppRuntime(), targets: unknown[] = [TARGET]) { + const stateDir = mkdtempForTestSync('react-profile-'); + const output: string[] = []; + const requested: string[] = []; + const options: ReactProfileCommandOptions = { stateDir, session: 'qa' }; + vi.stubGlobal('fetch', async (url: URL) => { + requested.push(String(url)); + return new Response(JSON.stringify(targets), { status: 200 }); + }); + vi.spyOn(InspectorSession, 'connect').mockImplementation( + async () => + ({ + evaluate: async (expression: string) => vm.runInContext(expression, app.context), + close: () => {}, + }) as unknown as InspectorSession, + ); + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + const run = async (...args: string[]) => { + output.length = 0; + const code = await runReactProfileCommand(args, options); + return { code, text: output.join('') }; + }; + return { app, run, options, stateDir, requested }; +} + +function recordRowRenders(app: FakeAppRuntime) { + const rows = Array.from({ length: 12 }, (_, index) => 10 + index); + app.renderer.commit( + encodeOperations({ + add: [ + { id: 2, name: 'List' }, + ...rows.map((id) => ({ id, name: 'Memo(Row)', type: MEMO_TYPE, owner: 2 })), + ], + }), + ); + for (let index = 0; index < 3; index += 1) { + app.renderer.commit(encodeOperations({}), { + timestamp: index * 200, + duration: 40, + updaters: [{ id: 2, displayName: 'List' }], + fibers: [[2, 40, 1], ...rows.map((id): [number, number, number] => [id, 3, 3])], + changes: [ + [2, { state: ['0'] }], + ...rows.map((id): [number, Record] => [id, { props: ['onPress'] }]), + ], + }); + } + app.renderer.props.set(rows.at(-1)!, { + data: { onPress: { type: 'function' } }, + cleaned: [['onPress']], + }); +} + +test('profile start, an interaction, and profile stop print a compact diagnosis and save it', async () => { + const { app, run, stateDir, requested } = harness(); + const started = await run('start'); + assert.equal(started.code, 0); + assert.match(started.text, /^Profiling React renders in com\.example\.app\./); + assert.deepEqual(requested, ['http://localhost:8081/json/list']); + + recordRowRenders(app); + const stopped = await run('stop'); + assert.equal(stopped.code, 0); + assert.equal( + stopped.text.split('\n')[1], + '1. memo-defeated Row: memo bypassed on 36/36 updates; new onPress (function) 36x 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 () => { + const { app, run, options, stateDir } = harness(); + await run('start'); + recordRowRenders(app); + options.flags = { json: true }; + const { text } = await run('stop', '--limit', '1'); + const parsed = JSON.parse(text) as { + success: boolean; + data: { findings: Array<{ kind: string }>; savedTo: string }; + }; + assert.equal(parsed.success, true); + assert.deepEqual( + parsed.data.findings.map((f) => f.kind), + ['memo-defeated'], + ); + assert.equal(parsed.data.savedTo, path.join(stateDir, 'react-profiles', 'qa.json')); +}); + +test('profile start twice keeps the running recording and what it captured', async () => { + const { app, run } = harness(); + await run('start'); + recordRowRenders(app); + assert.match( + (await run('start')).text, + /^Already profiling com\.example\.app for \d+\.\ds; run react-devtools profile stop/, + ); + assert.match((await run('status')).text, /^Profiling com\.example\.app for \d+\.\ds\.$/m); + assert.match((await run('stop')).text, /^React profile \d+\.\ds: 3 commits/); +}); + +test('one app on two devices fails closed until --metro-device picks one', async () => { + const second = { + ...TARGET, + id: 'tablet-1', + deviceName: 'Tablet', + webSocketDebuggerUrl: 'ws://127.0.0.1:8081/inspector/debug?device=tablet&page=1', + reactNative: { logicalDeviceId: 'tablet' }, + }; + const { run } = harness(new FakeAppRuntime(), [{ ...TARGET, deviceName: 'Pixel' }, second]); + await assert.rejects(run('start'), (error: { code: string }) => error.code === 'INVALID_ARGS'); + assert.equal(vi.mocked(InspectorSession.connect).mock.calls.length, 0); + assert.match((await run('start', '--metro-device', 'Tablet')).text, /^Profiling React renders/); + assert.deepEqual( + vi.mocked(InspectorSession.connect).mock.calls.map(([url]) => url), + [second.webSocketDebuggerUrl], + ); +}); + +test('a failed prop inspection becomes a note instead of failing the stop', async () => { + const { app, run } = harness(); + await run('start'); + recordRowRenders(app); + vi.spyOn(InspectorSession, 'connect').mockImplementation( + async () => + ({ + evaluate: async (expression: string) => { + if (expression.includes('.describeProps(')) throw new Error('inspector gone'); + return vm.runInContext(expression, app.context); + }, + close: () => {}, + }) as unknown as InspectorSession, + ); + const { text } = await run('stop'); + assert.match(text, /changed onPress 36x from List \(value kinds not inspected\)/); + assert.match(text, /\nNote: Changed prop values could not be inspected: inspector gone\n/); +}); + +test('runtime failures carry a typed reason and a hint', async () => { + const { run } = harness(new FakeAppRuntime({ hook: false })); + await assert.rejects( + run('start'), + (error: { code: string; details: { reason: string; hint: string } }) => { + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.details.reason, 'no-devtools-hook'); + assert.match(error.details.hint, /development build/); + return true; + }, + ); + await assert.rejects( + harness().run('stop'), + (error: { details: { reason: string } }) => error.details.reason === 'not-profiling', + ); +}); + +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'); + assert.deepEqual(requested, ['http://localhost:8082/json/list']); +}); + +test('the session Metro binding picks the dev server', async () => { + const { run, requested, stateDir } = harness(); + fs.mkdirSync(path.join(stateDir, 'metro-sessions'), { recursive: true }); + fs.writeFileSync( + path.join(stateDir, 'metro-sessions', 'qa.json'), + JSON.stringify({ controlBaseUrl: 'http://127.0.0.1:19000/' }), + ); + await run('status'); + assert.deepEqual(requested, ['http://127.0.0.1:19000/json/list']); +}); + +test('a remote bridge profiles through the public dev-server URL without its bundle path', async () => { + const { run, requested, options } = harness(); + options.flags = { + leaseBackend: 'android-instance', + metroPublicBaseUrl: 'https://metro.example.test/index.bundle?platform=android', + }; + await run('status'); + 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/], + [['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 }) => { + assert.equal(error.code, 'INVALID_ARGS', args.join(' ')); + assert.match(error.message, message); + return true; + }); + } +}); + +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 new file mode 100644 index 0000000000..add4719cf0 --- /dev/null +++ b/src/cli/react-profiler/react-profile-command.ts @@ -0,0 +1,497 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { parseArgs } from 'node:util'; +import { AppError } from '@agent-device/kernel/errors'; +import { safeSessionName } from '@agent-device/host-kit/session-paths'; +import type { CliFlags } from '@agent-device/contracts/command'; +import { resolveMetroServerUrl } from '../../metro/metro-reload-endpoints.ts'; +import { readMetroSessionHints } from '../../metro/metro-session-hints.ts'; +import { isRemoteBridgeBackend } from '../commands/remote-bridge.ts'; +import { + InspectorSession, + listMetroInspectorTargets, + selectReactNativeTarget, + targetAppId, + type MetroInspectorTarget, +} from './metro-inspector.ts'; +import { + REACT_PROFILER_RUNTIME_GLOBAL, + REACT_PROFILER_RUNTIME_SOURCE, + REACT_PROFILER_RUNTIME_VERSION, +} from './react-profiler-runtime.ts'; +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, + findComponents, + formatSeconds, + formatCommit, + formatComponentReport, + formatProfileSummary, + formatRerenders, + formatSlowComponents, + formatTimeline, + timelineCommits, +} from './react-profile-format.ts'; + +const REACT_PROFILE_SUBCOMMANDS = [ + 'start', + 'stop', + 'status', + 'slow', + 'rerenders', + 'report', + 'timeline', + 'commit', +] as const; + +type ReactProfileSubcommand = (typeof REACT_PROFILE_SUBCOMMANDS)[number]; + +export type ReactProfileCommandOptions = { + flags?: Partial>; + stateDir: string; + session: string; +}; + +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 drill-down views. */ +type SavedReactProfile = { + savedAt: string; + appId?: string; + deviceName?: string; + analysis: ReactProfileAnalysis; +}; + +type RuntimeFailure = { ok: false; reason: string; elapsedMs?: number }; +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 { + return REACT_PROFILE_SUBCOMMANDS.includes(value as ReactProfileSubcommand); +} + +const PROFILE_OPTIONS = { + limit: { type: 'string' }, + app: { type: 'string' }, + 'metro-device': { type: 'string' }, + 'metro-host': { type: 'string' }, + 'metro-port': { type: 'string' }, + 'bundle-url': { type: 'string' }, + sort: { type: 'string' }, +} as const; + +type ProfileOption = keyof typeof PROFILE_OPTIONS; + +const RUNTIME_OPTIONS: readonly ProfileOption[] = [ + 'app', + 'metro-device', + 'metro-host', + 'metro-port', + 'bundle-url', +]; + +/** The options each subcommand reads; any other option is refused rather than ignored. */ +const SUBCOMMAND_OPTIONS: Record = { + start: RUNTIME_OPTIONS, + status: RUNTIME_OPTIONS, + stop: [...RUNTIME_OPTIONS, 'limit'], + slow: ['limit'], + rerenders: ['limit'], + report: [], + timeline: ['limit', 'sort'], + commit: ['limit'], +}; + +function parseProfileOptions(args: string[]) { + try { + return parseArgs({ args, allowPositionals: true, strict: true, options: PROFILE_OPTIONS }); + } catch (error) { + throw new AppError('INVALID_ARGS', error instanceof Error ? error.message : String(error)); + } +} + +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 { values, positionals } = parseProfileOptions(rest); + const unread = (Object.keys(values) as ProfileOption[]).find( + (option) => !SUBCOMMAND_OPTIONS[subcommand].includes(option), + ); + if (unread) { + throw new AppError( + 'INVALID_ARGS', + `react-devtools profile ${subcommand} does not take --${unread}.`, + ); + } + 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[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'], + }; +} + +/** Explicit flags, then a remote bridge's public dev-server URL, then the session's Metro binding. */ +function resolveDevServerUrl(args: ProfileArgs, options: ReactProfileCommandOptions): URL { + const publicBaseUrl = options.flags?.metroPublicBaseUrl?.trim(); + const explicit = + args.metroHost !== undefined || args.metroPort !== undefined || args.bundleUrl !== undefined; + if (!explicit && publicBaseUrl && isRemoteBridgeBackend(options.flags?.leaseBackend)) { + return resolveMetroServerUrl({ bundleUrl: publicBaseUrl }); + } + const binding = explicit + ? undefined + : readMetroSessionHints({ stateDir: options.stateDir, session: options.session }); + return resolveMetroServerUrl({ + metroHost: args.metroHost, + metroPort: args.metroPort, + bundleUrl: args.bundleUrl, + runtime: binding ? { bundleUrl: binding.controlBaseUrl } : undefined, + }); +} + +async function withAppRuntime( + args: ProfileArgs, + options: ReactProfileCommandOptions, + action: (session: InspectorSession, target: MetroInspectorTarget) => Promise, +): Promise { + const serverUrl = resolveDevServerUrl(args, options); + const target = selectReactNativeTarget(await listMetroInspectorTargets(serverUrl), { + appId: args.app, + device: args.metroDevice, + }); + const session: InspectorSession = await InspectorSession.connect(target.webSocketDebuggerUrl); + try { + await session.evaluate( + `(${REACT_PROFILER_RUNTIME_SOURCE})(${JSON.stringify(REACT_PROFILER_RUNTIME_VERSION)})`, + ); + return await action(session, target); + } finally { + session.close(); + } +} + +async function callRuntime( + session: InspectorSession, + method: string, + argument?: unknown, +): Promise { + const call = `${REACT_PROFILER_RUNTIME_GLOBAL}.${method}(${argument === undefined ? '' : JSON.stringify(argument)})`; + const value = await session.evaluate(call); + if (typeof value !== 'string') { + throw new AppError('COMMAND_FAILED', `The profiler runtime returned no result for ${method}.`); + } + return JSON.parse(value) as T; +} + +const RUNTIME_FAILURES: Record = { + 'no-devtools-hook': { + message: 'The app runtime has no React DevTools hook.', + hint: 'Profile a development build; release builds do not include React DevTools.', + }, + 'no-react-renderer': { + message: 'React has not rendered in this app runtime yet.', + hint: 'Wait for the app to finish loading its bundle, then retry.', + }, + 'no-devtools-backend': { + message: 'This React Native version exposes no React DevTools backend to attach to.', + hint: 'React Native 0.74 or newer is required.', + }, + 'backend-attach-failed': { + message: 'The React DevTools backend did not attach to the app renderer.', + hint: 'Reload the app and retry.', + }, + 'start-failed': { + message: 'The React renderer refused to start profiling.', + hint: 'Profile a development build; production renderers do not record render timings.', + }, + 'not-profiling': { + message: 'No React profile is recording in this app runtime.', + hint: 'Run react-devtools profile start first. A reload or app restart discards a recording.', + }, +}; + +function runtimeFailure(result: RuntimeFailure): AppError { + const known = RUNTIME_FAILURES[result.reason]; + return new AppError( + 'COMMAND_FAILED', + known?.message ?? `React profiling failed: ${result.reason}.`, + { + reason: result.reason, + ...(known ? { hint: known.hint } : {}), + }, + ); +} + +function savedProfilePath(options: ReactProfileCommandOptions): string { + return path.join(options.stateDir, 'react-profiles', `${safeSessionName(options.session)}.json`); +} + +function writeSavedProfile( + options: ReactProfileCommandOptions, + profile: SavedReactProfile, +): string { + const filePath = savedProfilePath(options); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(profile)}\n`, 'utf8'); + 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( + args: ProfileArgs, + options: ReactProfileCommandOptions, +): Promise<{ app: string; message: string }> { + return await withAppRuntime(args, options, async (session, target) => { + const result = await callRuntime(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(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: ReactProfileCommandOptions, +): Promise { + return await withAppRuntime(args, options, async (session, target) => { + const result = await callRuntime(session, 'stop'); + if (!result.ok) throw runtimeFailure(result); + const { propKinds, notes } = await describeChangedProps(session, result); + const saved: SavedReactProfile = { + savedAt: new Date().toISOString(), + appId: targetAppId(target), + deviceName: target.deviceName, + analysis: analyzeReactProfile(result, propKinds, notes), + }; + writeSavedProfile(options, saved); + return saved; + }); +} + +function formatStatus(status: StatusResult, app: string): string { + return status.profiling && status.elapsedMs !== null + ? `Profiling ${app} for ${elapsed(status.elapsedMs)}.` + : `Not profiling ${app}.`; +} + +type Emit = (text: string, data: unknown) => void; +type SubcommandHandler = ( + args: ProfileArgs, + options: ReactProfileCommandOptions, + emit: Emit, +) => Promise; + +function requireComponents( + analysis: ReactProfileAnalysis, + name: string | undefined, +): ComponentProfile[] { + if (!name) + throw new AppError('INVALID_ARGS', 'react-devtools profile report requires a component name.'); + 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 from react-devtools profile slow or profile rerenders.', + }); + } + return components; +} + +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); + emit(started.message, started); + }, + status: async (args, options, emit) => { + const status = await withAppRuntime(args, options, async (session, target) => ({ + app: targetAppId(target), + ...(await callRuntime(session, 'status')), + })); + emit(formatStatus(status, status.app), status); + }, + stop: async (args, options, emit) => { + const saved = await stopProfile(args, options); + const { analysis } = saved; + emit(formatProfileSummary(analysis, args.limit ?? DEFAULT_FINDING_LIMIT), { + appId: saved.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), + 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 components = requireComponents(analysis, args.target); + emit(formatComponentReport(analysis, components), { + components, + findings: analysis.findings.filter((finding) => finding.component === components[0]!.name), + }); + }, + 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( + args: string[], + options: ReactProfileCommandOptions, +): Promise { + const json = options.flags?.json === true; + const parsed = parseProfileArgs(args); + await SUBCOMMAND_HANDLERS[parsed.subcommand](parsed, options, (text, data) => { + process.stdout.write( + json ? `${JSON.stringify({ success: true, data }, null, 2)}\n` : `${text}\n`, + ); + }); + return 0; +} diff --git a/src/cli/react-profiler/react-profile-format.test.ts b/src/cli/react-profiler/react-profile-format.test.ts new file mode 100644 index 0000000000..f2278cf17d --- /dev/null +++ b/src/cli/react-profiler/react-profile-format.test.ts @@ -0,0 +1,182 @@ +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 { + findComponents, + formatCommit, + formatComponentReport, + formatProfileSummary, + formatRerenders, + formatSlowComponents, + formatTimeline, +} from './react-profile-format.ts'; + +function component(name: string, overrides: Partial = {}): ComponentProfile { + return { + name, + kind: 'function', + instances: 1, + renders: 4, + selfMs: 40, + maxSelfMs: 12, + causes: { mount: 1, props: 3, state: 0, hooks: 0, context: 0, parent: 0 }, + changedProps: { onPress: 3 }, + changedState: {}, + changedHooks: {}, + owners: {}, + propKinds: {}, + costliestCommits: [], + ...overrides, + }; +} + +function finding(index: number): Finding { + return { + kind: 'slow-render', + component: `C${index}`, + costMs: 100 - index, + evidence: 'slow', + fix: 'memoize', + }; +} + +function analysis(overrides: Partial = {}): ReactProfileAnalysis { + return { + version: 1, + wallMs: 3_200, + commitCount: 4, + renderMs: 96, + slowCommitCount: 2, + worstCommit: { index: 2, durationMs: 41.5 }, + naming: 'operations', + notes: [], + components: [component('Chart'), component('Row', { renders: 9, instances: 3, selfMs: 9 })], + commits: [ + { + index: 0, + atMs: 0, + durationMs: 10, + rendered: 3, + mounts: 0, + updaters: ['List'], + top: [], + }, + { + index: 1, + atMs: 900, + durationMs: 41.5, + rendered: 30, + mounts: 30, + updaters: [], + top: [], + }, + ], + findings: [finding(1)], + ...overrides, + }; +} + +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\)\n/); +}); + +test('the summary reports clean profiles and empty recordings in one line', () => { + assert.match( + formatProfileSummary(analysis({ findings: [], slowCommitCount: 0 })), + /^React profile 3\.2s: 4 commits, 96ms rendering\nNo render problems above thresholds\./, + ); + assert.match( + formatProfileSummary(analysis({ commitCount: 0 })), + /^React profile: no commits recorded\./, + ); +}); + +test('lookup naming warns that unmounted components may be unnamed', () => { + assert.match( + formatProfileSummary(analysis({ naming: 'lookup' })), + /\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.deepEqual( + findComponents(analysis(), 'chart').map((c) => c.name), + ['Chart'], + ); + assert.deepEqual(findComponents(analysis(), 'Missing'), []); +}); + +test('a report covers every component of the name, then its findings once', () => { + const base = analysis(); + const row = base.components.find((c) => c.name === 'Row')!; + const memoRow = { ...row, kind: 'memo' as const, renders: 2, selfMs: 4, maxSelfMs: 2 }; + const withVariants = { + ...base, + components: [...base.components, memoRow], + findings: [ + { + kind: 'parent-cascade' as const, + component: 'Row', + costMs: 9, + evidence: 'often re-renders', + fix: 'memoize', + }, + ], + }; + const variants = findComponents(withVariants, 'Row'); + assert.deepEqual( + variants.map((c) => c.kind), + ['function', 'memo'], + ); + const text = formatComponentReport(withVariants, variants); + assert.match(text, /^Row \[function\][^\n]*\n[\s\S]*\nRow \[memo\] self 4\.0ms/); + assert.equal(text.match(/finding parent-cascade/g)?.length, 1); +}); + +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 new file mode 100644 index 0000000000..3e70196271 --- /dev/null +++ b/src/cli/react-profiler/react-profile-format.ts @@ -0,0 +1,199 @@ +import type { ReactProfileAnalysis } from './react-profile-analysis.ts'; +import { + FRAME_BUDGET_MS, + formatMs, + type CommitProfile, + type ComponentProfile, + type RenderCauses, +} from './react-profile-renders.ts'; + +export const DEFAULT_FINDING_LIMIT = 5; + +const ms = formatMs; +export const formatSeconds = (value: number) => `${(value / 1000).toFixed(1)}s`; +const seconds = formatSeconds; + +function headline(analysis: ReactProfileAnalysis): string { + const parts = [ + `React profile ${seconds(analysis.wallMs)}: ${analysis.commitCount} commits, ${ms(analysis.renderMs)} rendering`, + ]; + if (analysis.worstCommit && analysis.slowCommitCount > 0) { + parts.push( + `${analysis.slowCommitCount} over ${FRAME_BUDGET_MS}ms (worst #${analysis.worstCommit.index} ${ms(analysis.worstCommit.durationMs)})`, + ); + } + return parts.join(', '); +} + +export function formatProfileSummary( + analysis: ReactProfileAnalysis, + limit = DEFAULT_FINDING_LIMIT, +): string { + if (analysis.commitCount === 0) { + return 'React profile: no commits recorded. Start profiling, then perform the interaction before stopping.'; + } + const lines = [headline(analysis)]; + const findings = analysis.findings.slice(0, limit); + if (findings.length === 0) { + lines.push('No render problems above thresholds.'); + } else { + findings.forEach((finding, index) => { + lines.push( + `${index + 1}. ${finding.kind} ${finding.component}: ${finding.evidence} -> ${finding.fix}`, + ); + }); + const hidden = analysis.findings.length - findings.length; + if (hidden > 0) lines.push(`+${hidden} smaller findings (--limit ${analysis.findings.length})`); + } + for (const note of analysis.notes) lines.push(`Note: ${note}`); + if (analysis.naming === 'lookup') { + lines.push( + 'Note: component names came from mounted elements; components unmounted during the profile are left out.', + ); + } + 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'); +} + +/** Every component of that name, such as `Row [memo]` and `Row [function]`; case-insensitive when no exact match. */ +export function findComponents(analysis: ReactProfileAnalysis, name: string): ComponentProfile[] { + const exact = analysis.components.filter((c) => c.name === name); + if (exact.length > 0) return exact; + return analysis.components.filter((c) => c.name.toLowerCase() === name.toLowerCase()); +} + +function componentDetail(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}`); + 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; +} + +/** Reports each component of one name, then the findings that name it. */ +export function formatComponentReport( + analysis: ReactProfileAnalysis, + components: ComponentProfile[], +): string { + const lines = components.flatMap((component) => componentDetail(analysis, component)); + const name = components[0]?.name; + for (const finding of analysis.findings.filter((f) => f.component === name)) + lines.push(`finding ${finding.kind}: ${finding.evidence} -> ${finding.fix}`); + 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/cli/react-profiler/react-profile-renders.fixtures.ts b/src/cli/react-profiler/react-profile-renders.fixtures.ts new file mode 100644 index 0000000000..ca64f513d8 --- /dev/null +++ b/src/cli/react-profiler/react-profile-renders.fixtures.ts @@ -0,0 +1,46 @@ +import type { RawProfileChange, RawReactProfile } from './react-profile-renders.ts'; + +export const FUNCTION = 5; +export const MEMO = 8; + +export type Render = [ + id: number, + selfMs: number, + change?: RawProfileChange | 'parent', + actualMs?: number, +]; + +/** Builds a raw profile: `elements` by id, then commits as `[timestamp, duration, updaterIds, renders]`. */ +export function rawProfile( + elements: Record, + commits: Array<[timestamp: number, duration: number, updaters: number[], renders: Render[]]>, + keys: string[] = [], +): RawReactProfile { + return { + startedAt: 0, + stoppedAt: 5_000, + naming: 'operations', + keys, + elements: Object.fromEntries( + Object.entries(elements).map(([id, [name, type = FUNCTION, owner = 0]]) => [ + id, + [name, type, owner, 0], + ]), + ), + commits: commits.map(([timestamp, duration, updaters, renders]) => ({ + timestamp, + duration, + updaters, + fibers: renders.flatMap(([id, self, , actual]) => [id, actual ?? self, self]), + changes: Object.fromEntries( + renders + .filter(([, , change]) => change !== undefined && change !== 'parent') + .map(([id, , change]) => [String(id), change as RawProfileChange]), + ), + })), + }; +} + +export function repeat(count: number, make: (index: number) => T): T[] { + return Array.from({ length: count }, (_, index) => make(index)); +} diff --git a/src/cli/react-profiler/react-profile-renders.test.ts b/src/cli/react-profiler/react-profile-renders.test.ts new file mode 100644 index 0000000000..61c70839c0 --- /dev/null +++ b/src/cli/react-profiler/react-profile-renders.test.ts @@ -0,0 +1,75 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { + chronological, + selectPropKindRequests, + splitDisplayName, + summarizeRenders, +} from './react-profile-renders.ts'; +import { MEMO, rawProfile, repeat, type Render } from './react-profile-renders.fixtures.ts'; + +test('splitDisplayName separates DevTools wrapper names from the component', () => { + assert.deepEqual(splitDisplayName('Memo(ForwardRef(Row))'), { + name: 'Row', + wrappers: ['Memo', 'ForwardRef'], + }); + assert.deepEqual(splitDisplayName('Row'), { name: 'Row', wrappers: [] }); +}); + +test('commits from several roots are read as one timeline', () => { + const raw = rawProfile({ 1: ['A'], 2: ['B'] }, [ + [300, 20, [1], [[1, 1]]], + [100, 30, [2], [[2, 1]]], + ]); + const { commits } = summarizeRenders(chronological(raw)); + assert.deepEqual( + commits.map((commit) => [commit.atMs, commit.updaters[0]]), + [ + [0, 'B'], + [200, 'A'], + ], + ); +}); + +test('unrecorded change descriptions leave render causes empty rather than blaming the parent', () => { + const raw = rawProfile({ 1: ['A'] }, [[0, 1, [], [[1, 1]]]]); + raw.commits[0]!.changes = null; + assert.deepEqual(summarizeRenders(raw).components[0]?.causes, { + mount: 0, + props: 0, + state: 0, + hooks: 0, + context: 0, + parent: 0, + }); +}); + +test('prop kinds are requested for the most recent instances of each props-driven component', () => { + const rows = repeat(3, (i) => 10 + i); + const elements: Record = { 1: ['List'], 2: ['Header'] }; + for (const id of rows) elements[id] = ['Row', MEMO, 1]; + const raw = rawProfile( + elements, + [ + [ + 0, + 10, + [1], + [ + [1, 1, [0, null, [0], null]], + [2, 1, 'parent'], + ...rows.map((id): Render => [id, 1, [0, [0], null, null]]), + ], + ], + ], + ['onPress'], + ); + assert.deepEqual(selectPropKindRequests(raw), { + '10': ['onPress'], + '11': ['onPress'], + '12': ['onPress'], + }); + const { components } = summarizeRenders(raw, { '12': { onPress: 'function' } }); + assert.deepEqual(components.find((c) => c.name === 'Row')?.propKinds, { onPress: 'function' }); + assert.equal(components.find((c) => c.name === 'Header')?.causes.parent, 1); +}); diff --git a/src/cli/react-profiler/react-profile-renders.ts b/src/cli/react-profiler/react-profile-renders.ts new file mode 100644 index 0000000000..da83196ff5 --- /dev/null +++ b/src/cli/react-profiler/react-profile-renders.ts @@ -0,0 +1,418 @@ +/** Frame budget at 60 Hz; a commit slower than this drops at least one frame. */ +export const FRAME_BUDGET_MS = 16; + +const ELEMENT_CLASS = 1; +const ELEMENT_FUNCTION = 5; +const ELEMENT_FORWARD_REF = 6; +const ELEMENT_MEMO = 8; +const ELEMENT_VIRTUAL = 15; +/** Name resolved by lookup, so the backend element type is unknown. */ +const ELEMENT_UNKNOWN = 0; +const COMPONENT_TYPES = new Set([ + ELEMENT_CLASS, + ELEMENT_FUNCTION, + ELEMENT_FORWARD_REF, + ELEMENT_MEMO, + ELEMENT_VIRTUAL, + ELEMENT_UNKNOWN, +]); + +/** + * React Native building blocks whose renders follow from the app component that renders them. + * They stay in the tables but never head a finding: the fix belongs in their owner. + */ +const FRAMEWORK_PRIMITIVES = new Set([ + 'ActivityIndicator', + 'CellRenderer', + 'FlatList', + 'Image', + 'ImageBackground', + 'InternalTextInput', + 'KeyboardAvoidingView', + 'Modal', + 'Pressable', + 'PressabilityDebugView', + 'RefreshControl', + 'SafeAreaView', + 'ScrollView', + 'SectionList', + 'Switch', + 'Text', + 'TextInput', + 'TouchableHighlight', + 'TouchableOpacity', + 'TouchableWithoutFeedback', + 'View', + 'VirtualizedList', + 'VirtualizedListCellContextProvider', + 'VirtualizedSectionList', +]); + +const HOC_PATTERN = /^([A-Za-z0-9_$]+)\((.+)\)$/; + +/** Splits `Memo(ForwardRef(Row))` into the component name and its wrappers, as DevTools shows it. */ +export function splitDisplayName(displayName: string): { name: string; wrappers: string[] } { + const wrappers: string[] = []; + let name = displayName; + for (let match = HOC_PATTERN.exec(name); match; match = HOC_PATTERN.exec(name)) { + wrappers.push(match[1]!); + name = match[2]!; + } + return { name, wrappers }; +} + +/** + * The name findings and tables use. An `Animated(...)` wrapper keeps its full name: it is a separate + * framework component that renders the inner one, and `isFrameworkPrimitive` recognizes it by it. + */ +function renderName(displayName: string): { name: string; wrappers: string[] } { + const split = splitDisplayName(displayName); + return split.wrappers.includes('Animated') ? { ...split, name: displayName } : split; +} + +export function isFrameworkPrimitive(name: string): boolean { + const { name: inner, wrappers } = splitDisplayName(name); + return FRAMEWORK_PRIMITIVES.has(inner) || wrappers.includes('Animated'); +} + +const CHANGE_CONTEXT = 1; +const CHANGE_HOOKS = 2; + +/** `[name, elementType, ownerId, parentId]` as recorded from the backend's tree operations. */ +type RawProfileElement = [string, number, number, number]; +/** `1` marks a first mount; otherwise `[bits, propKeyIndexes, stateKeyIndexes, hookIndexes]`. */ +export type RawProfileChange = 1 | [number, number[] | null, number[] | null, number[] | null]; + +export type RawProfileCommit = { + timestamp: number; + duration: number; + updaters: number[]; + /** Flat `[id, actualMs, selfMs, ...]` triples for every element that rendered. */ + fibers: number[]; + /** Absent entries rendered without a prop, state, hook, or context change; null when unrecorded. */ + changes: Record | null; +}; + +/** Profile exactly as the in-app runtime exports it from `stop()`. */ +export type RawReactProfile = { + startedAt: number; + stoppedAt: number; + naming: 'operations' | 'lookup'; + elements: Record; + keys: string[]; + commits: RawProfileCommit[]; +}; + +export type ComponentKind = 'class' | 'function' | 'forwardRef' | 'memo' | 'unknown'; + +export type RenderCauses = { + mount: number; + props: number; + state: number; + hooks: number; + context: number; + /** Rendered with the same props, state, hooks, and context: only its parent rendering forced it. */ + parent: number; +}; + +export type ComponentProfile = { + name: string; + kind: ComponentKind; + instances: number; + renders: number; + selfMs: number; + maxSelfMs: number; + causes: RenderCauses; + changedProps: Record; + changedState: Record; + changedHooks: Record; + /** Component whose render created this one's elements, by occurrence. */ + owners: Record; + /** Value kind of changed props on a mounted instance at stop: function, object, array, ... */ + propKinds: Record; + /** Commits where this component spent the most self time, costliest first, capped. */ + costliestCommits: Array<{ index: number; selfMs: number }>; +}; + +export type CommitComponent = { + name: string; + instances: number; + selfMs: number; + /** Time including descendants, summed over instances. */ + actualMs: number; + causes: string[]; +}; + +export type CommitProfile = { + index: number; + /** Milliseconds since the first commit of the profile. */ + atMs: number; + durationMs: number; + rendered: number; + mounts: number; + updaters: string[]; + /** Components with the largest self time in this commit, capped. */ + top: CommitComponent[]; +}; + +/** Mounted instance to inspect, per component name, for the value kinds of its changed props. */ +type PropKindRequest = Record; +export type PropKindsById = Record>; + +const COMMIT_TOP_LIMIT = 15; +const COMPONENT_COMMIT_LIMIT = 5; +const RECENT_INSTANCE_LIMIT = 3; +const PROP_KIND_CANDIDATES = 8; + +type ComponentAccumulator = ComponentProfile & { + ids: Set; + /** Most recently rendered instances, newest last, so at least one is likely still mounted. */ + recentIds: number[]; + commitCosts: Map; +}; + +type ParsedRender = { + id: number; + name: string; + type: number; + actualMs: number; + selfMs: number; + causes: string[]; +}; + +function componentKind(type: number): ComponentKind { + switch (type) { + case ELEMENT_CLASS: + return 'class'; + case ELEMENT_FUNCTION: + case ELEMENT_VIRTUAL: + return 'function'; + case ELEMENT_FORWARD_REF: + return 'forwardRef'; + case ELEMENT_MEMO: + return 'memo'; + default: + return 'unknown'; + } +} + +/** Undefined when the profile could not name the element; such renders stay out of the analysis. */ +function elementOf(profile: RawReactProfile, id: number): RawProfileElement | undefined { + return profile.elements[String(id)]; +} + +function componentName(profile: RawReactProfile, id: number): string | undefined { + const element = elementOf(profile, id); + return element ? renderName(element[0]).name : undefined; +} + +export function componentNames(profile: RawReactProfile, ids: number[]): string[] { + const names = new Set(); + for (const id of ids) { + const name = componentName(profile, id); + if (name) names.add(name); + } + return [...names]; +} + +function keysAt(profile: RawReactProfile, indexes: number[] | null): string[] { + return (indexes ?? []).map((index) => profile.keys[index] ?? `#${index}`); +} + +export function bump(record: Record, key: string, by = 1): void { + record[key] = (record[key] ?? 0) + by; +} + +export function* commitRenders( + profile: RawReactProfile, + commit: RawProfileCommit, +): Generator { + for (let offset = 0; offset + 2 < commit.fibers.length; offset += 3) { + const id = commit.fibers[offset]!; + const element = elementOf(profile, id); + if (!element || !COMPONENT_TYPES.has(element[1])) continue; + const [displayName, type] = element; + const { name, wrappers } = renderName(displayName); + yield { + id, + name, + type: wrappers.includes('Memo') ? ELEMENT_MEMO : type, + actualMs: commit.fibers[offset + 1]!, + selfMs: commit.fibers[offset + 2]!, + causes: renderCauses(commit, id), + }; + } +} + +function renderCauses(commit: RawProfileCommit, id: number): string[] { + if (commit.changes === null) return []; + const change = commit.changes[String(id)]; + if (change === undefined) return ['parent']; + if (change === 1) return ['mount']; + const [bits, props, state, hooks] = change; + const causes: string[] = []; + if (props?.length) causes.push('props'); + if (state?.length) causes.push('state'); + if (hooks?.length || (bits & CHANGE_HOOKS) !== 0) causes.push('hooks'); + if ((bits & CHANGE_CONTEXT) !== 0) causes.push('context'); + return causes.length > 0 ? causes : ['parent']; +} + +function createAccumulator(render: ParsedRender): ComponentAccumulator { + return { + name: render.name, + kind: componentKind(render.type), + instances: 0, + renders: 0, + selfMs: 0, + maxSelfMs: 0, + causes: { mount: 0, props: 0, state: 0, hooks: 0, context: 0, parent: 0 }, + changedProps: {}, + changedState: {}, + changedHooks: {}, + owners: {}, + propKinds: {}, + costliestCommits: [], + ids: new Set(), + recentIds: [], + commitCosts: new Map(), + }; +} + +function recordRender( + entry: ComponentAccumulator, + render: ParsedRender, + profile: RawReactProfile, + commit: RawProfileCommit, + commitIndex: number, +): void { + entry.ids.add(render.id); + entry.recentIds = [...entry.recentIds.filter((id) => id !== render.id), render.id].slice( + -RECENT_INSTANCE_LIMIT, + ); + entry.renders += 1; + entry.selfMs += render.selfMs; + entry.maxSelfMs = Math.max(entry.maxSelfMs, render.selfMs); + entry.commitCosts.set(commitIndex, (entry.commitCosts.get(commitIndex) ?? 0) + render.selfMs); + for (const cause of render.causes) entry.causes[cause as keyof RenderCauses] += 1; + const change = commit.changes?.[String(render.id)]; + if (Array.isArray(change)) { + for (const key of keysAt(profile, change[1])) bump(entry.changedProps, key); + for (const key of keysAt(profile, change[2])) bump(entry.changedState, key); + for (const hook of change[3] ?? []) bump(entry.changedHooks, String(hook)); + } + const ownerId = elementOf(profile, render.id)?.[2]; + const owner = ownerId ? componentName(profile, ownerId) : undefined; + if (owner) bump(entry.owners, owner); +} + +/** A memo-wrapped and a plain component of one name are separate components with separate costs. */ +function accumulateComponents(profile: RawReactProfile): Map { + const components = new Map(); + profile.commits.forEach((commit, commitIndex) => { + for (const render of commitRenders(profile, commit)) { + const key = `${render.name}\0${componentKind(render.type)}`; + let entry = components.get(key); + if (!entry) { + entry = createAccumulator(render); + components.set(key, entry); + } + recordRender(entry, render, profile, commit, commitIndex); + } + }); + for (const entry of components.values()) { + entry.instances = entry.ids.size; + entry.costliestCommits = [...entry.commitCosts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, COMPONENT_COMMIT_LIMIT) + .map(([index, selfMs]) => ({ index, selfMs })); + } + return components; +} + +/** + * Picks the components whose changed props are worth classifying by value kind: those that + * re-rendered mostly because props changed. The host asks the app to inspect their most recent + * instances, any of which may still be mounted, before analysis. + */ +export function selectPropKindRequests(profile: RawReactProfile): PropKindRequest { + const candidates = [...accumulateComponents(chronological(profile)).values()] + .filter((entry) => entry.causes.props > 0 && Object.keys(entry.changedProps).length > 0) + .sort((a, b) => b.selfMs - a.selfMs) + .slice(0, PROP_KIND_CANDIDATES); + const request: PropKindRequest = {}; + for (const entry of candidates) { + for (const id of entry.recentIds) request[String(id)] = Object.keys(entry.changedProps); + } + return request; +} + +function summarizeCommits(profile: RawReactProfile): CommitProfile[] { + const origin = profile.commits[0]?.timestamp ?? 0; + return profile.commits.map((commit, index) => { + const byName = new Map(); + let rendered = 0; + let mounts = 0; + for (const render of commitRenders(profile, commit)) { + rendered += 1; + if (render.causes.includes('mount')) mounts += 1; + const entry = byName.get(render.name) ?? { + name: render.name, + instances: 0, + selfMs: 0, + actualMs: 0, + causes: [], + }; + entry.instances += 1; + entry.selfMs += render.selfMs; + entry.actualMs += render.actualMs; + for (const cause of render.causes) + if (!entry.causes.includes(cause)) entry.causes.push(cause); + byName.set(render.name, entry); + } + return { + index, + atMs: Math.round(commit.timestamp - origin), + durationMs: commit.duration, + rendered, + mounts, + updaters: componentNames(profile, commit.updaters), + top: [...byName.values()].sort((a, b) => b.selfMs - a.selfMs).slice(0, COMMIT_TOP_LIMIT), + }; + }); +} + +export const formatMs = (value: number) => `${value.toFixed(value >= 10 ? 0 : 1)}ms`; + +/** Each root records its own commit list; the analysis reads them as one timeline. */ +export function chronological(profile: RawReactProfile): RawReactProfile { + return { ...profile, commits: [...profile.commits].sort((a, b) => a.timestamp - b.timestamp) }; +} + +/** Each prop's kind where every inspected instance agrees; a prop whose instances disagree has none. */ +function commonPropKinds(ids: Set, propKindsById: PropKindsById): Record { + const kinds: Record = {}; + const conflicting = new Set(); + for (const id of ids) { + for (const [key, kind] of Object.entries(propKindsById[String(id)] ?? {})) { + if (kinds[key] !== undefined && kinds[key] !== kind) conflicting.add(key); + else kinds[key] = kind; + } + } + for (const key of conflicting) delete kinds[key]; + return kinds; +} + +/** Per-component render totals and per-commit summaries of a chronological profile. */ +export function summarizeRenders( + profile: RawReactProfile, + propKindsById: PropKindsById = {}, +): { components: ComponentProfile[]; commits: CommitProfile[] } { + const accumulators = accumulateComponents(profile); + for (const entry of accumulators.values()) + entry.propKinds = commonPropKinds(entry.ids, propKindsById); + const components: ComponentProfile[] = [...accumulators.values()] + .map(({ ids: _ids, recentIds: _recentIds, commitCosts: _commitCosts, ...entry }) => entry) + .sort((a, b) => b.selfMs - a.selfMs); + return { components, commits: summarizeCommits(profile) }; +} diff --git a/src/cli/react-profiler/react-profiler-runtime.fixtures.ts b/src/cli/react-profiler/react-profiler-runtime.fixtures.ts new file mode 100644 index 0000000000..6200500c11 --- /dev/null +++ b/src/cli/react-profiler/react-profiler-runtime.fixtures.ts @@ -0,0 +1,240 @@ +import vm from 'node:vm'; +import { + REACT_PROFILER_RUNTIME_GLOBAL, + REACT_PROFILER_RUNTIME_SOURCE, + REACT_PROFILER_RUNTIME_VERSION, +} from './react-profiler-runtime.ts'; + +const OP_ADD = 1; +const OP_REMOVE = 2; +const OP_UPDATE_TREE_BASE_DURATION = 4; +const ROOT_TYPE = 11; +export const FUNCTION_TYPE = 5; +export const MEMO_TYPE = 8; + +export type FakeElement = { + id: number; + name: string; + type?: number; + owner?: number; + parent?: number; +}; + +export type FakeCommit = { + timestamp: number; + duration: number; + fibers: Array<[id: number, actualMs: number, selfMs: number]>; + changes?: Array<[id: number, change: Record]>; + updaters?: Array<{ id: number; displayName: string }>; +}; + +/** + * Encodes one backend `operations` batch: renderer id, root id, the string table, then ops. Only + * the op shapes the runtime reads are produced. + */ +export function encodeOperations(options: { + root?: boolean; + add?: FakeElement[]; + remove?: number[]; + baseDurations?: number[]; + extra?: number[]; +}): number[] { + const strings: string[] = []; + const stringId = (value: string) => { + let index = strings.indexOf(value); + if (index === -1) index = strings.push(value) - 1; + return index + 1; + }; + const ops: number[] = options.root ? [OP_ADD, 1, ROOT_TYPE, 0, 1, 1, 1] : []; + for (const element of options.add ?? []) { + ops.push( + OP_ADD, + element.id, + element.type ?? FUNCTION_TYPE, + element.parent ?? 0, + element.owner ?? 0, + stringId(element.name), + 0, + ); + } + if (options.remove?.length) ops.push(OP_REMOVE, options.remove.length, ...options.remove); + for (const id of options.baseDurations ?? []) ops.push(OP_UPDATE_TREE_BASE_DURATION, id, 1); + ops.push(...(options.extra ?? [])); + const table: number[] = []; + for (const value of strings) { + const codePoints = [...value].map((char) => char.codePointAt(0)!); + table.push(codePoints.length, ...codePoints); + } + return [1, 1, table.length, ...table, ...ops]; +} + +type Listener = (payload: number[]) => void; + +/** One renderer interface with an operations queue that flushes like the real backend's. */ +export class FakeRendererInterface { + queue: number[][] | null = []; + names = new Map(); + profiling = false; + flushes = 0; + commits: FakeCommit[] = []; + props = new Map; cleaned: Array> }>(); + readonly #emit: (event: string, payload: number[]) => void; + + constructor(emit: (event: string, payload: number[]) => void) { + this.#emit = emit; + } + + /** Records a commit the way React and the backend would: ops first, then profiling data. */ + commit(operations: number[], profiled?: FakeCommit): void { + if (this.queue) this.queue.push(operations); + else this.#emit('operations', operations); + if (profiled && this.profiling) this.commits.push(profiled); + } + + flushInitialOperations(): void { + this.flushes += 1; + const queued = this.queue ?? []; + this.queue = null; + for (const operations of queued) this.#emit('operations', operations); + } + + startProfiling(): void { + this.profiling = true; + this.commits = []; + } + + stopProfiling(): void { + this.profiling = false; + } + + getDisplayNameForElementID(id: number): string | null { + return this.names.get(id) ?? null; + } + + getProfilingData(): unknown { + return { + dataForRoots: [ + { + displayName: 'App', + commitData: this.commits.map((commit) => ({ + timestamp: commit.timestamp, + duration: commit.duration, + effectDuration: 0, + passiveEffectDuration: 0, + priorityLevel: 'Normal', + fiberActualDurations: commit.fibers.map(([id, actual]) => [id, actual]), + fiberSelfDurations: commit.fibers.map(([id, , self]) => [id, self]), + changeDescriptions: commit.changes ?? [], + updaters: (commit.updaters ?? []).map((updater) => ({ + ...updater, + type: FUNCTION_TYPE, + })), + })), + }, + ], + }; + } + + inspectElement(_requestID: number, id: number): unknown { + const props = this.props.get(id); + return props ? { type: 'full-data', value: { props } } : { type: 'not-found' }; + } +} + +export type FakeRuntimeOptions = { + /** React DevTools 6 attaches renderer interfaces when React injects; older backends need a frontend. */ + attachAtInject?: boolean; + /** React Native 0.76+ registers the Fusebox dispatcher; defaults to present. */ + fusebox?: boolean; + devtoolsCoreModule?: boolean; + hook?: boolean; +}; + +/** A JavaScript realm shaped like a React Native dev runtime, with the profiler runtime evaluable. */ +export class FakeAppRuntime { + readonly context: vm.Context; + readonly renderer: FakeRendererInterface; + readonly hook: { + renderers: Map; + rendererInterfaces: Map; + listeners: Record; + sub: (event: string, fn: Listener) => () => void; + emit: (event: string, payload: number[]) => void; + }; + domainInitializations = 0; + customConnections = 0; + + constructor(options: FakeRuntimeOptions = {}) { + const listeners: Record = {}; + const emit = (event: string, payload: number[]) => { + for (const listener of listeners[event] ?? []) listener(payload); + }; + this.renderer = new FakeRendererInterface(emit); + this.hook = { + renderers: new Map([[1, {}]]), + rendererInterfaces: new Map(), + listeners, + sub: (event, fn) => { + (listeners[event] ??= []).push(fn); + return () => { + listeners[event] = (listeners[event] ?? []).filter((listener) => listener !== fn); + }; + }, + emit, + }; + const attach = () => this.hook.rendererInterfaces.set(1, this.renderer); + if (options.attachAtInject !== false) attach(); + const globals: Record = {}; + if (options.hook !== false) globals.__REACT_DEVTOOLS_GLOBAL_HOOK__ = this.hook; + if (options.fusebox !== false) { + globals.__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__ = { + initializeDomain: () => { + this.domainInitializations += 1; + attach(); + }, + }; + } + if (options.devtoolsCoreModule) { + const modules = new Map([ + [ + 7, + { + isInitialized: true, + publicModule: { + exports: { + connectWithCustomMessagingProtocol: () => { + this.customConnections += 1; + attach(); + }, + }, + }, + }, + ], + ]); + globals.__r = { getModules: () => modules }; + } + this.context = vm.createContext(globals); + vm.runInContext('globalThis.globalThis = globalThis;', this.context); + } + + install(version = REACT_PROFILER_RUNTIME_VERSION): unknown { + return vm.runInContext( + `(${REACT_PROFILER_RUNTIME_SOURCE})(${JSON.stringify(version)})`, + this.context, + ); + } + + call(method: string, argument?: unknown): T { + const value = vm.runInContext( + `${REACT_PROFILER_RUNTIME_GLOBAL}.${method}(${argument === undefined ? '' : JSON.stringify(argument)})`, + this.context, + ); + return JSON.parse(value as string) as T; + } + + /** Mounts elements before profiling, as the app's initial render would. */ + mount(elements: FakeElement[]): void { + this.renderer.commit(encodeOperations({ root: true, add: elements })); + for (const element of elements) this.renderer.names.set(element.id, element.name); + } +} diff --git a/src/cli/react-profiler/react-profiler-runtime.test.ts b/src/cli/react-profiler/react-profiler-runtime.test.ts new file mode 100644 index 0000000000..3e16a54196 --- /dev/null +++ b/src/cli/react-profiler/react-profiler-runtime.test.ts @@ -0,0 +1,242 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { FakeAppRuntime, MEMO_TYPE, encodeOperations } from './react-profiler-runtime.fixtures.ts'; +import type { RawReactProfile } from './react-profile-renders.ts'; + +type Started = { + ok: boolean; + reason?: string; + attach?: string; + naming?: string; + elapsedMs?: number; +}; +type Stopped = { ok: true } & RawReactProfile; + +test('start on a hook-attached backend flushes the queued tree so mounted components are named', () => { + const app = new FakeAppRuntime(); + app.mount([ + { id: 2, name: 'App' }, + { id: 3, name: 'Row', parent: 2, owner: 2 }, + ]); + app.renderer.names.clear(); + app.install(); + + const started = app.call('start'); + assert.deepEqual( + { ok: started.ok, attach: started.attach, naming: app.call('status').naming }, + { ok: true, attach: 'attached', naming: 'operations' }, + ); + assert.equal(app.renderer.flushes, 1); + + app.renderer.commit(encodeOperations({ baseDurations: [3] }), { + timestamp: 100, + duration: 4, + fibers: [[3, 4, 3]], + changes: [ + [3, { props: ['onPress'], state: null, hooks: null, context: null, didHooksChange: false }], + ], + }); + const stopped = app.call('stop'); + + assert.equal(stopped.ok, true); + assert.deepEqual(stopped.elements['3'], ['Row', 5, 2, 2]); + assert.deepEqual(stopped.elements['2'], ['App', 5, 0, 0], 'owners ride along for attribution'); + assert.deepEqual(stopped.keys, ['onPress']); + assert.deepEqual(stopped.commits[0]!.fibers, [3, 4, 3]); + assert.deepEqual(stopped.commits[0]!.changes, { '3': [0, [0], null, null] }); +}); + +test('components that mount and unmount during the profile keep their names', () => { + const app = new FakeAppRuntime(); + app.install(); + app.call('start'); + app.renderer.commit(encodeOperations({ add: [{ id: 9, name: 'Toast', type: MEMO_TYPE }] }), { + timestamp: 10, + duration: 2, + fibers: [[9, 2, 2]], + changes: [[9, { isFirstMount: true }]], + }); + app.renderer.commit(encodeOperations({ remove: [9] })); + + const stopped = app.call('stop'); + assert.deepEqual(stopped.elements['9'], ['Toast', MEMO_TYPE, 0, 0]); + assert.deepEqual(stopped.commits[0]!.changes, { '9': 1 }); +}); + +test('a render with no recorded change is omitted, so absence reads as a parent-driven render', () => { + const app = new FakeAppRuntime(); + app.mount([{ id: 4, name: 'Cell' }]); + app.install(); + app.call('start'); + app.renderer.commit(encodeOperations({}), { + timestamp: 1, + duration: 1, + fibers: [[4, 1, 1]], + changes: [[4, { props: [], state: null, hooks: null, context: false, didHooksChange: false }]], + }); + + assert.deepEqual(app.call('stop').commits[0]!.changes, {}); +}); + +test('an operation the runtime cannot decode switches naming to lookups instead of guessing', () => { + const app = new FakeAppRuntime(); + app.install(); + app.call('start'); + app.renderer.names.set(5, 'Looked up'); + app.renderer.commit( + encodeOperations({ add: [{ id: 5, name: 'Parsed' }], extra: [42, 1, 2, 3] }), + { + timestamp: 1, + duration: 1, + fibers: [ + [5, 1, 1], + [6, 1, 1], + ], + }, + ); + + const stopped = app.call('stop'); + assert.equal(stopped.naming, 'lookup'); + assert.equal(stopped.elements['5']![0], 'Parsed'); + assert.equal(stopped.elements['6'], undefined); +}); + +test('a backend that needs a frontend attaches through the Fusebox domain with a stand-in binding', () => { + const app = new FakeAppRuntime({ attachAtInject: false }); + app.install(); + + const started = app.call('start'); + assert.equal(started.attach, 'fusebox-domain'); + assert.equal(app.domainInitializations, 1); + assert.equal(app.renderer.flushes, 1); + assert.equal( + typeof (app.context as Record).__CHROME_DEVTOOLS_FRONTEND_BINDING__, + 'function', + ); +}); + +test('a connected DevTools frontend keeps its operations stream: no second flush', () => { + const app = new FakeAppRuntime(); + (app.context as Record).__CHROME_DEVTOOLS_FRONTEND_BINDING__ = () => {}; + app.renderer.queue = null; + app.install(); + + const started = app.call('start'); + assert.equal(started.ok, true); + assert.equal(app.renderer.flushes, 0); +}); + +test('an attached backend without Fusebox belongs to an external frontend: names come from lookups', () => { + const app = new FakeAppRuntime({ fusebox: false }); + app.install(); + app.call('start'); + assert.equal(app.call('status').naming, 'lookup'); + assert.equal(app.renderer.flushes, 0); +}); + +test('another DevTools agent keeps its queue; new mounts are parsed and the rest looked up', () => { + const app = new FakeAppRuntime(); + app.mount([{ id: 2, name: 'Existing' }]); + app.renderer.queue = null; + app.hook.sub('operations', () => {}); + app.install(); + + const started = app.call('start'); + assert.equal(started.ok, true); + assert.equal(app.call('status').naming, 'lookup'); + assert.equal(app.renderer.flushes, 0); + app.renderer.commit(encodeOperations({ add: [{ id: 7, name: 'Fresh' }] }), { + timestamp: 1, + duration: 2, + fibers: [ + [2, 1, 1], + [7, 1, 1], + ], + }); + const stopped = app.call('stop'); + assert.equal(stopped.elements['7']![0], 'Fresh'); + assert.equal(stopped.elements['2']![0], 'Existing'); +}); + +test('lookups use the React DevTools 5 name accessor when the backend predates element ids', () => { + const app = new FakeAppRuntime({ fusebox: false }); + const renderer = app.renderer as unknown as Record; + renderer.getDisplayNameForFiberID = (id: number) => (id === 3 ? 'Chart' : null); + renderer.getDisplayNameForElementID = undefined; + app.install(); + app.call('start'); + app.renderer.commit(encodeOperations({}), { timestamp: 1, duration: 1, fibers: [[3, 1, 1]] }); + assert.equal(app.call('stop').elements['3']![0], 'Chart'); +}); + +test('without Fusebox the runtime connects through react-devtools-core in the module registry', () => { + const app = new FakeAppRuntime({ + attachAtInject: false, + fusebox: false, + devtoolsCoreModule: true, + }); + app.install(); + + assert.equal(app.call('start').attach, 'devtools-core'); + assert.equal(app.customConnections, 1); +}); + +test('start reports a typed reason when the app has nothing to attach to', () => { + assert.equal(new FakeAppRuntime({ hook: false }).install(), 'installed'); + const noHook = new FakeAppRuntime({ hook: false }); + noHook.install(); + assert.equal(noHook.call('start').reason, 'no-devtools-hook'); + + const noBackend = new FakeAppRuntime({ attachAtInject: false, fusebox: false }); + noBackend.install(); + assert.equal(noBackend.call('start').reason, 'no-devtools-backend'); +}); + +test('start while recording reports the elapsed time measured on the device clock', () => { + const app = new FakeAppRuntime(); + app.install(); + app.call('start'); + const again = app.call('start'); + assert.equal(again.reason, 'already-profiling'); + assert.equal(typeof again.elapsedMs, 'number'); + assert.equal(app.call<{ ok: boolean }>('stop').ok, true); + assert.equal(app.call<{ reason: string }>('stop').reason, 'not-profiling'); +}); + +test('installing a newer runtime adopts the names the previous one collected', () => { + const app = new FakeAppRuntime(); + app.install('one'); + app.call('start'); + app.renderer.commit(encodeOperations({ add: [{ id: 8, name: 'Header' }] })); + app.call('stop'); + + assert.equal(app.install('one'), 'ready'); + assert.equal(app.install('two'), 'installed'); + app.call('start'); + app.renderer.commit(encodeOperations({}), { timestamp: 1, duration: 1, fibers: [[8, 1, 1]] }); + assert.equal(app.call('stop').elements['8']![0], 'Header'); +}); + +test('describeProps classifies changed prop values on a mounted instance', () => { + const app = new FakeAppRuntime(); + app.install(); + app.renderer.props.set(3, { + data: { + onPress: { type: 'function', name: 'onPress' }, + style: { opacity: 1 }, + items: [1], + label: 'x', + }, + cleaned: [['onPress']], + }); + + assert.deepEqual( + app.call('describeProps', { + '3': ['onPress', 'style', 'items', 'label', 'missing'], + '4': ['x'], + }), + { + '3': { onPress: 'function', style: 'object', items: 'array', label: 'string' }, + }, + ); +}); diff --git a/src/cli/react-profiler/react-profiler-runtime.ts b/src/cli/react-profiler/react-profiler-runtime.ts new file mode 100644 index 0000000000..46ebc3fbbf --- /dev/null +++ b/src/cli/react-profiler/react-profiler-runtime.ts @@ -0,0 +1,339 @@ +import { createHash } from 'node:crypto'; + +/** Global the runtime installs itself on inside the app's JavaScript runtime. */ +export const REACT_PROFILER_RUNTIME_GLOBAL = '__AGENT_DEVICE_REACT_PROFILER__'; + +/** + * Source evaluated inside the app through CDP `Runtime.evaluate`. It installs + * `globalThis.__AGENT_DEVICE_REACT_PROFILER__` once per JavaScript runtime; every host command is + * then a one-line call returning a JSON string. Hermes evaluates it unbundled, so it stays ES5. + * + * It drives the React DevTools backend that React Native dev builds already ship: + * - React Native 0.79+ attaches renderer interfaces when React registers with the global hook. + * - React Native 0.76-0.78 attaches them when the `react-devtools` Fusebox domain initializes; a + * discarding frontend binding stands in until a real frontend replaces it. + * - Older versions attach them through `react-devtools-core`'s custom messaging connection. + * + * Component names come from the backend's `operations` stream. Without a frontend the backend + * queues those batches forever, so the runtime flushes the queue once, which replays the tree + * from app start and switches the backend to emitting each commit's batch. When another DevTools + * agent already consumes the stream, its frontend owns the queue: the runtime only reads the + * batches that follow and looks up the names it missed. + */ +export const REACT_PROFILER_RUNTIME_SOURCE = String.raw` +(function (VERSION) { + var g = globalThis; + var previous = g.__AGENT_DEVICE_REACT_PROFILER__; + if (previous && previous.version === VERSION) return 'ready'; + var inherited = previous && previous.elements instanceof Map ? previous.elements : null; + var inheritedHistory = !!(previous && previous.history); + if (previous && typeof previous.dispose === 'function') previous.dispose(); + + var BINDING = '__CHROME_DEVTOOLS_FRONTEND_BINDING__'; + var ROOT_TYPE = 11; + var KNOWN_TYPES = { 1: 1, 2: 1, 5: 1, 6: 1, 7: 1, 8: 1, 9: 1, 10: 1, 11: 1, 12: 1, 13: 1, 14: 1, 15: 1, 16: 1, 17: 1 }; + var elements = inherited || new Map(); + var history = inheritedHistory; + var decodable = true; + var unsubscribe = null; + var session = null; + var standInBinding = null; + + function hook() { return g.__REACT_DEVTOOLS_GLOBAL_HOOK__; } + function size(collection) { var n = 0; if (collection && collection.forEach) collection.forEach(function () { n++; }); return n; } + function eachRenderer(fn) { var h = hook(); if (h && h.rendererInterfaces) h.rendererInterfaces.forEach(fn); } + + function readOperations(ops) { + var strings = [null]; + var i = 3; + var tableEnd = 3 + ops[2]; + while (i < tableEnd) { + var length = ops[i++]; + var text = ''; + for (var k = 0; k < length; k++) text += String.fromCodePoint(ops[i + k]); + i += length; + strings.push(text); + } + while (i < ops.length) { + var op = ops[i]; + if (op === 1) { + var id = ops[i + 1]; + var type = ops[i + 2]; + if (KNOWN_TYPES[type] !== 1) return false; + if (type !== ROOT_TYPE) { + var nameIndex = ops[i + 5]; + if (nameIndex >= strings.length) return false; + elements.set(id, [strings[nameIndex] || 'Anonymous', type, ops[i + 4], ops[i + 3]]); + } + i += 7; + } else if (op === 2) { + for (var r = 0; r < ops[i + 1]; r++) { + if (session) session.removed.push(ops[i + 2 + r]); + else elements.delete(ops[i + 2 + r]); + } + i += 2 + ops[i + 1]; + } else if (op === 3) i += 3 + ops[i + 2]; + else if (op === 4) i += 3; + else if (op === 5) i += 4; + else if (op === 6) i += 1; + else if (op === 7) i += 3; + else return false; + } + return true; + } + + function naming() { return history && decodable ? 'operations' : 'lookup'; } + + function operationListenerCount(h) { + var listeners = h.listeners && h.listeners.operations; + return listeners ? listeners.length : 0; + } + + function onOperations(ops) { + if (!decodable) return; + try { if (!readOperations(ops)) decodable = false; } catch (e) { decodable = false; } + } + + function findDevtoolsCore() { + var registry = g.__r && typeof g.__r.getModules === 'function' ? g.__r.getModules() : null; + var found = null; + function inspect(module) { + if (found || !module || !module.isInitialized || !module.publicModule) return; + var exports = module.publicModule.exports; + if (exports && typeof exports.connectWithCustomMessagingProtocol === 'function') found = exports; + } + if (registry && typeof registry.forEach === 'function') registry.forEach(inspect); + else if (registry) for (var key in registry) inspect(registry[key]); + return found; + } + + function connectBackend() { + var dispatcher = g.__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__; + if (dispatcher) { + if (g[BINDING] == null) { + standInBinding = function () {}; + Object.defineProperty(g, BINDING, { value: standInBinding, configurable: true, writable: true }); + } + dispatcher.initializeDomain('react-devtools'); + return 'fusebox-domain'; + } + var core = findDevtoolsCore(); + if (!core) return null; + core.connectWithCustomMessagingProtocol({ + onSubscribe: function () {}, + onUnsubscribe: function () {}, + onMessage: function () {} + }); + return 'devtools-core'; + } + + function attach() { + var h = hook(); + if (!h) return { ok: false, reason: 'no-devtools-hook' }; + if (size(h.renderers) === 0) return { ok: false, reason: 'no-react-renderer' }; + var foreignAgent = !unsubscribe && operationListenerCount(h) > 0; + var connectedBy = null; + if (size(h.rendererInterfaces) === 0) { + connectedBy = connectBackend(); + if (!connectedBy) return { ok: false, reason: 'no-devtools-backend' }; + if (size(h.rendererInterfaces) === 0) return { ok: false, reason: 'backend-attach-failed' }; + } + if (!unsubscribe) { + unsubscribe = h.sub('operations', onOperations); + var frontendBinding = g[BINDING] != null && g[BINDING] !== standInBinding; + var ownsQueue = connectedBy !== null || (!foreignAgent && !frontendBinding && !!g.__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__); + if (!h.__agentDeviceOperationsFlushed && ownsQueue) { + h.__agentDeviceOperationsFlushed = true; + history = true; + eachRenderer(function (ri) { ri.flushInitialOperations(); }); + } + } + return { ok: true, attach: connectedBy || 'attached', naming: naming() }; + } + + function lookupName(ri, id) { + var lookup = ri.getDisplayNameForElementID || ri.getDisplayNameForFiberID; + try { return lookup ? lookup.call(ri, id) : null; } catch (e) { return null; } + } + + function remember(ri, id, out) { + if (out[id]) return; + var known = elements.get(id); + if (known) { out[id] = known; return; } + var name = lookupName(ri, id); + if (!name) return; + out[id] = [name, 0, 0, 0]; + elements.set(id, out[id]); + } + + function internAll(list, table) { + if (!list || !list.length) return null; + var out = []; + for (var i = 0; i < list.length; i++) { + var key = String(list[i]); + var index = table.index[key]; + if (index === undefined) { index = table.index[key] = table.keys.length; table.keys.push(key); } + out.push(index); + } + return out; + } + + function compactChange(change, table) { + if (!change) return 0; + if (change.isFirstMount) return 1; + var bits = (change.context === true || (change.context && change.context.length) ? 1 : 0) | (change.didHooksChange ? 2 : 0); + var props = internAll(change.props, table); + var state = internAll(change.state, table); + var hooks = change.hooks && change.hooks.length ? change.hooks : null; + if (!bits && !props && !state && !hooks) return 0; + return [bits, props, state, hooks]; + } + + function exportProfile() { + var commits = []; + var refs = Object.create(null); + var keyTable = { keys: [], index: Object.create(null) }; + eachRenderer(function (ri) { + var data; + try { ri.stopProfiling(); data = ri.getProfilingData(); } catch (e) { return; } + data.dataForRoots.forEach(function (root) { + root.commitData.forEach(function (commit) { + var fibers = []; + var self = Object.create(null); + var i; + for (i = 0; i < commit.fiberSelfDurations.length; i++) self[commit.fiberSelfDurations[i][0]] = commit.fiberSelfDurations[i][1]; + for (i = 0; i < commit.fiberActualDurations.length; i++) { + var entry = commit.fiberActualDurations[i]; + remember(ri, entry[0], refs); + fibers.push(entry[0], entry[1], self[entry[0]] || 0); + } + var changes = null; + if (commit.changeDescriptions) { + changes = Object.create(null); + for (i = 0; i < commit.changeDescriptions.length; i++) { + var compact = compactChange(commit.changeDescriptions[i][1], keyTable); + if (compact !== 0) changes[commit.changeDescriptions[i][0]] = compact; + } + } + var updaters = []; + (commit.updaters || []).forEach(function (updater) { + if (!refs[updater.id]) refs[updater.id] = elements.get(updater.id) || [updater.displayName || 'Anonymous', updater.type || 0, 0, 0]; + updaters.push(updater.id); + }); + commits.push({ + timestamp: commit.timestamp, + duration: commit.duration, + updaters: updaters, + fibers: fibers, + changes: changes + }); + }); + }); + }); + var owners = Object.create(null); + Object.keys(refs).forEach(function (id) { + var owner = refs[id][2]; + if (owner && !refs[owner] && !owners[owner] && elements.get(owner)) owners[owner] = elements.get(owner); + }); + Object.keys(owners).forEach(function (id) { refs[id] = owners[id]; }); + return { elements: refs, keys: keyTable.keys, commits: commits }; + } + + function propKind(value, path, cleaned) { + for (var i = 0; cleaned && i < cleaned.length; i++) { + if (cleaned[i].length === 1 && cleaned[i][0] === path) return value && value.type ? String(value.type) : 'object'; + } + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + return typeof value; + } + + function describeProps(request) { + var out = Object.create(null); + var requestID = 1; + Object.keys(request).forEach(function (id) { + var keys = request[id]; + eachRenderer(function (ri) { + if (out[id]) return; + var inspected; + try { inspected = ri.inspectElement(requestID++, Number(id), null, true); } catch (e) { return; } + if (!inspected || inspected.type !== 'full-data' || !inspected.value || !inspected.value.props) return; + var props = inspected.value.props; + var data = props.data !== undefined ? props.data : props; + var kinds = Object.create(null); + keys.forEach(function (key) { if (data && key in data) kinds[key] = propKind(data[key], key, props.cleaned); }); + out[id] = kinds; + }); + }); + return out; + } + + function start() { + var attached = attach(); + if (!attached.ok) return attached; + if (session) return { ok: false, reason: 'already-profiling', elapsedMs: Date.now() - session.startedAt }; + var started = 0; + eachRenderer(function (ri) { + try { ri.startProfiling(true, false); started++; } catch (e) {} + }); + if (started === 0) return { ok: false, reason: 'start-failed' }; + session = { startedAt: Date.now(), removed: [] }; + return { ok: true, attach: attached.attach }; + } + + function stop() { + if (!session) return { ok: false, reason: 'not-profiling' }; + var startedAt = session.startedAt; + var profile = exportProfile(); + session.removed.forEach(function (id) { elements.delete(id); }); + session = null; + return { + ok: true, + startedAt: startedAt, + stoppedAt: Date.now(), + naming: naming(), + elements: profile.elements, + keys: profile.keys, + commits: profile.commits + }; + } + + function dispose() { + if (unsubscribe) unsubscribe(); + unsubscribe = null; + if (session) eachRenderer(function (ri) { try { ri.stopProfiling(); } catch (e) {} }); + session = null; + } + + g.__AGENT_DEVICE_REACT_PROFILER__ = { + version: VERSION, + elements: elements, + get history() { return history; }, + status: function () { + var h = hook(); + return JSON.stringify({ + hook: !!h, + renderers: h ? size(h.renderers) : 0, + rendererInterfaces: h ? size(h.rendererInterfaces) : 0, + profiling: !!session, + elapsedMs: session ? Date.now() - session.startedAt : null, + naming: naming() + }); + }, + start: function () { return JSON.stringify(start()); }, + stop: function () { return JSON.stringify(stop()); }, + describeProps: function (request) { return JSON.stringify(describeProps(request)); }, + dispose: dispose + }; + return 'installed'; +}) +`; + +/** + * Identity of this runtime source. An app that holds a runtime with another identity replaces it + * instead of calling into an incompatible one. + */ +export const REACT_PROFILER_RUNTIME_VERSION = createHash('sha256') + .update(REACT_PROFILER_RUNTIME_SOURCE) + .digest('hex') + .slice(0, 16); diff --git a/src/client/client-companion-tunnel-contract.ts b/src/client/client-companion-tunnel-contract.ts index 34a9b9126e..ccb90754e5 100644 --- a/src/client/client-companion-tunnel-contract.ts +++ b/src/client/client-companion-tunnel-contract.ts @@ -1,5 +1,4 @@ export const METRO_COMPANION_RUN_ARG = '--agent-device-run-metro-companion'; -export const REACT_DEVTOOLS_COMPANION_RUN_ARG = '--agent-device-run-react-devtools-companion'; export const COMPANION_TUNNEL_RECONNECT_DELAY_MS = 1_000; export const COMPANION_TUNNEL_LEASE_CHECK_INTERVAL_MS = 250; export const WS_READY_STATE_OPEN = 1; @@ -14,8 +13,6 @@ export const ENV_COMPANION_TUNNEL_SCOPE_RUN_ID = 'AGENT_DEVICE_COMPANION_TUNNEL_ export const ENV_COMPANION_TUNNEL_SCOPE_LEASE_ID = 'AGENT_DEVICE_COMPANION_TUNNEL_SCOPE_LEASE_ID'; export const ENV_COMPANION_TUNNEL_REGISTER_PATH = 'AGENT_DEVICE_COMPANION_TUNNEL_REGISTER_PATH'; export const ENV_COMPANION_TUNNEL_UNREGISTER_PATH = 'AGENT_DEVICE_COMPANION_TUNNEL_UNREGISTER_PATH'; -export const ENV_COMPANION_TUNNEL_DEVICE_PORT = 'AGENT_DEVICE_COMPANION_TUNNEL_DEVICE_PORT'; -export const ENV_COMPANION_TUNNEL_SESSION = 'AGENT_DEVICE_COMPANION_TUNNEL_SESSION'; // The scope SHAPE is declared in contracts/ so zones that only need the shape do not have to // declare themselves in terms of client/. Re-exported here for this module's existing consumers. @@ -34,6 +31,4 @@ export type CompanionTunnelWorkerOptions = { launchUrl?: string; statePath?: string; unregisterPath?: string; - devicePort?: number; - session?: string; }; diff --git a/src/client/client-companion-tunnel-worker.ts b/src/client/client-companion-tunnel-worker.ts index f072de5f81..49468826a3 100644 --- a/src/client/client-companion-tunnel-worker.ts +++ b/src/client/client-companion-tunnel-worker.ts @@ -4,7 +4,6 @@ import { COMPANION_TUNNEL_LEASE_CHECK_INTERVAL_MS, COMPANION_TUNNEL_RECONNECT_DELAY_MS, ENV_COMPANION_TUNNEL_BEARER_TOKEN, - ENV_COMPANION_TUNNEL_DEVICE_PORT, ENV_COMPANION_TUNNEL_LAUNCH_URL, ENV_COMPANION_TUNNEL_LOCAL_BASE_URL, ENV_COMPANION_TUNNEL_REGISTER_PATH, @@ -12,11 +11,9 @@ import { ENV_COMPANION_TUNNEL_SCOPE_RUN_ID, ENV_COMPANION_TUNNEL_SCOPE_TENANT_ID, ENV_COMPANION_TUNNEL_SERVER_BASE_URL, - ENV_COMPANION_TUNNEL_SESSION, ENV_COMPANION_TUNNEL_STATE_PATH, ENV_COMPANION_TUNNEL_UNREGISTER_PATH, METRO_COMPANION_RUN_ARG, - REACT_DEVTOOLS_COMPANION_RUN_ARG, WS_READY_STATE_OPEN, MissingCompanionEnvError, type CompanionTunnelWorkerOptions, @@ -75,9 +72,7 @@ export function buildCompanionPayload( ): Record { return { ...options.bridgeScope, - ...(options.session ? { session: options.session } : {}), local_base_url: normalizeBaseUrl(options.localBaseUrl), - ...(options.devicePort ? { device_port: options.devicePort } : {}), ...(options.launchUrl ? { launch_url: options.launchUrl } : {}), }; } @@ -555,23 +550,12 @@ export async function runCompanionTunnelWorker( } } -function parseDevicePort(value: string | undefined): number | undefined { - if (!value?.trim()) return undefined; - const parsed = Number.parseInt(value, 10); - if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { - throw new Error('Companion worker received invalid device port configuration.'); - } - return parsed; -} - function readWorkerOptions( argv: string[], env: NodeJS.ProcessEnv, ): CompanionTunnelWorkerOptions | null { const commandArg = argv[0]; - if (commandArg !== METRO_COMPANION_RUN_ARG && commandArg !== REACT_DEVTOOLS_COMPANION_RUN_ARG) { - return null; - } + if (commandArg !== METRO_COMPANION_RUN_ARG) return null; const serverBaseUrl = env[ENV_COMPANION_TUNNEL_SERVER_BASE_URL]?.trim(); const bearerToken = env[ENV_COMPANION_TUNNEL_BEARER_TOKEN]?.trim(); const localBaseUrl = env[ENV_COMPANION_TUNNEL_LOCAL_BASE_URL]?.trim(); @@ -607,8 +591,6 @@ function readWorkerOptions( launchUrl: env[ENV_COMPANION_TUNNEL_LAUNCH_URL]?.trim(), statePath: env[ENV_COMPANION_TUNNEL_STATE_PATH]?.trim(), unregisterPath: env[ENV_COMPANION_TUNNEL_UNREGISTER_PATH]?.trim(), - devicePort: parseDevicePort(env[ENV_COMPANION_TUNNEL_DEVICE_PORT]?.trim()), - session: env[ENV_COMPANION_TUNNEL_SESSION]?.trim(), }; } diff --git a/src/client/client-companion-tunnel.ts b/src/client/client-companion-tunnel.ts index ca3a83ec23..92a7986fca 100644 --- a/src/client/client-companion-tunnel.ts +++ b/src/client/client-companion-tunnel.ts @@ -5,7 +5,6 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { ENV_COMPANION_TUNNEL_BEARER_TOKEN, - ENV_COMPANION_TUNNEL_DEVICE_PORT, ENV_COMPANION_TUNNEL_LAUNCH_URL, ENV_COMPANION_TUNNEL_LOCAL_BASE_URL, ENV_COMPANION_TUNNEL_REGISTER_PATH, @@ -13,7 +12,6 @@ import { ENV_COMPANION_TUNNEL_SCOPE_RUN_ID, ENV_COMPANION_TUNNEL_SCOPE_TENANT_ID, ENV_COMPANION_TUNNEL_SERVER_BASE_URL, - ENV_COMPANION_TUNNEL_SESSION, ENV_COMPANION_TUNNEL_STATE_PATH, ENV_COMPANION_TUNNEL_UNREGISTER_PATH, } from './client-companion-tunnel-contract.ts'; @@ -49,8 +47,6 @@ type CompanionTunnelState = { launchUrl?: string; registerPath?: string; unregisterPath?: string; - devicePort?: number; - session?: string; bridgeScope?: CompanionTunnelScope; tokenHash: string; consumers: string[]; @@ -66,8 +62,6 @@ export type EnsureCompanionTunnelOptions = { launchUrl?: string; registerPath?: string; unregisterPath?: string; - devicePort?: number; - session?: string; profileKey?: string; consumerKey?: string; stateDir?: string; @@ -161,8 +155,6 @@ function readCompanionState(statePath: string): CompanionTunnelState | null { launchUrl: normalizeOptionalString(parsed.launchUrl), registerPath: normalizeOptionalString(parsed.registerPath), unregisterPath: normalizeOptionalString(parsed.unregisterPath), - devicePort: Number.isInteger(parsed.devicePort) ? Number(parsed.devicePort) : undefined, - session: normalizeOptionalString(parsed.session), bridgeScope: readCompanionScope(parsed.bridgeScope), tokenHash: parsed.tokenHash, consumers, @@ -240,8 +232,6 @@ function shouldReuseCompanion( state.launchUrl === normalizeOptionalString(options.launchUrl) && state.registerPath === normalizeOptionalString(options.registerPath) && state.unregisterPath === normalizeOptionalString(options.unregisterPath) && - state.devicePort === options.devicePort && - state.session === normalizeOptionalString(options.session) && areCompanionScopesEqual(state.bridgeScope, options.bridgeScope) && state.tokenHash === hashString(options.bearerToken) ); @@ -377,12 +367,6 @@ function buildCompanionEnv( ENV_COMPANION_TUNNEL_UNREGISTER_PATH, normalizeOptionalString(options.unregisterPath), ); - setOrDelete( - nextEnv, - ENV_COMPANION_TUNNEL_DEVICE_PORT, - options.devicePort === undefined ? undefined : String(options.devicePort), - ); - setOrDelete(nextEnv, ENV_COMPANION_TUNNEL_SESSION, normalizeOptionalString(options.session)); return nextEnv; } @@ -435,8 +419,6 @@ function spawnCompanionProcess( launchUrl: normalizeOptionalString(options.launchUrl), registerPath: normalizeOptionalString(options.registerPath), unregisterPath: normalizeOptionalString(options.unregisterPath), - devicePort: options.devicePort, - session: normalizeOptionalString(options.session), bridgeScope: options.bridgeScope, tokenHash: hashString(options.bearerToken), consumers: [], diff --git a/src/client/client-react-devtools-companion.ts b/src/client/client-react-devtools-companion.ts deleted file mode 100644 index b7e461415c..0000000000 --- a/src/client/client-react-devtools-companion.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { CompanionTunnelScope } from '@agent-device/contracts/remote'; -import { - ensureCompanionTunnel, - stopCompanionTunnel, - type CompanionTunnelDefinition, - type EnsureCompanionTunnelResult, -} from './client-companion-tunnel.ts'; -import { REACT_DEVTOOLS_COMPANION_RUN_ARG } from './client-companion-tunnel-contract.ts'; - -const REACT_DEVTOOLS_LOCAL_BASE_URL = 'http://127.0.0.1:8097'; -const REACT_DEVTOOLS_DEVICE_PORT = 8097; -const REACT_DEVTOOLS_REGISTER_PATH = '/api/react-devtools/companion/register'; -const REACT_DEVTOOLS_UNREGISTER_PATH = '/api/react-devtools/companion/unregister'; - -const REACT_DEVTOOLS_COMPANION_TUNNEL: CompanionTunnelDefinition = { - slug: 'react-devtools-companion', - runArg: REACT_DEVTOOLS_COMPANION_RUN_ARG, - displayName: 'React DevTools companion', -}; - -export type EnsureReactDevtoolsCompanionOptions = { - projectRoot: string; - stateDir?: string; - serverBaseUrl: string; - bearerToken: string; - bridgeScope: CompanionTunnelScope; - session: string; - profileKey?: string; - consumerKey?: string; - localBaseUrl?: string; - devicePort?: number; - env?: NodeJS.ProcessEnv; -}; - -export type StopReactDevtoolsCompanionOptions = { - projectRoot: string; - stateDir?: string; - profileKey?: string; - consumerKey?: string; -}; - -export async function ensureReactDevtoolsCompanion( - options: EnsureReactDevtoolsCompanionOptions, -): Promise { - return await ensureCompanionTunnel({ - ...options, - definition: REACT_DEVTOOLS_COMPANION_TUNNEL, - localBaseUrl: options.localBaseUrl ?? REACT_DEVTOOLS_LOCAL_BASE_URL, - registerPath: REACT_DEVTOOLS_REGISTER_PATH, - unregisterPath: REACT_DEVTOOLS_UNREGISTER_PATH, - devicePort: options.devicePort ?? REACT_DEVTOOLS_DEVICE_PORT, - }); -} - -export async function stopReactDevtoolsCompanion( - options: StopReactDevtoolsCompanionOptions, -): Promise<{ stopped: boolean; statePath: string }> { - return await stopCompanionTunnel({ - ...options, - definition: REACT_DEVTOOLS_COMPANION_TUNNEL, - }); -} diff --git a/src/commands/perf/index.ts b/src/commands/perf/index.ts index 481f1b2ee2..fe46fdceb8 100644 --- a/src/commands/perf/index.ts +++ b/src/commands/perf/index.ts @@ -74,7 +74,7 @@ export const perfCommandFacet = defineCommandFacet({ text: { summary: 'Check frames, memory, or native profiles', cliDetail: - 'Use perf frames for bounded frame-health evidence and perf memory sample for a compact process-memory reading. On iOS simulators and macOS, process sampling and captures target the resolved app executable and exclude other copies with the same name. Apple xctrace and Android Simpleperf/Perfetto captures keep raw artifacts on disk; report produces bounded agent-readable evidence. For React render internals, use agent-device react-devtools.', + 'Use perf frames for bounded frame-health evidence and perf memory sample for a compact process-memory reading. On iOS simulators and macOS, process sampling and captures target the resolved app executable and exclude other copies with the same name. Apple xctrace and Android Simpleperf/Perfetto captures keep raw artifacts on disk; report produces bounded agent-readable evidence. For React render performance, run agent-device react-devtools profile start before the interaction and react-devtools profile stop after it.', mcpDetail: 'For CPU profiles, start and stop write the raw artifact while report writes a compact summary; request the report when the task needs readable native CPU evidence. Profiling output is evidence only: compact state, artifact path, and size.', }, diff --git a/src/commands/schema/cli-help-topics.test.ts b/src/commands/schema/cli-help-topics.test.ts index 5a06ef0d50..004ab22a96 100644 --- a/src/commands/schema/cli-help-topics.test.ts +++ b/src/commands/schema/cli-help-topics.test.ts @@ -522,37 +522,31 @@ test('usageForCommand resolves macos help topic', async () => { test('usageForCommand resolves react-devtools help topic', async () => { const help = await usageForCommand('react-devtools'); if (help === null) throw new Error('Expected react-devtools help text'); - assert.match(help, /agent-device react-devtools start/); - assert.match(help, /agent-device react-devtools wait --component /); - assert.match(help, /agent-device react-devtools find --exact/); - assert.match(help, /agent-device react-devtools errors/); - assert.match(help, /agent-device react-devtools profile report @c5/); - assert.match(help, /agent-device react-devtools profile timeline --limit 20/); - assert.match(help, /agent-device react-devtools profile export profile\.json/); + assert.match(help, /React Native performance profiling/); + assert.match(help, /agent-device react-devtools profile start/); + 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/); + assert.match(help, /Add --app when several apps share the dev server/); + assert.match(help, /--metro-device when one app runs on several devices/); + assert.match(help, /ranked findings, each naming a component, its evidence, and a fix/); assert.match( help, - /agent-device react-devtools profile diff before\.json after\.json --limit 10/, + /memo-defeated: a memoized component often re-renders because props change identity/, ); - assert.match(help, /render causes and changed props\/state\/hooks/); - assert.match(help, /Run agent-device react-devtools status first/); - assert.match(help, /start is not a connection check/); - assert.match(help, /Always run agent-device react-devtools wait --connected after status/); + assert.match(help, /compare runs instead of reading absolute numbers/); assert.match(help, /logs clear --restart before the first logs mark/); - assert.match(help, /one bounded first-pass survey/); - assert.match(help, /profile slow --limit 5 once/); - assert.match(help, /profile rerenders --limit 5 once/); - assert.match(help, /profile timeline --limit 20 only when commit timing matters/); - assert.match(help, /Do not repeatedly raise broad profile slow limits/); - assert.match(help, /profile report unless you have a specific target/); + assert.match(help, /Do not repeatedly raise table limits/); assert.match(help, /agent-device logs mark "before catalog search"/); - assert.match(help, /agent-device react-devtools profile timeline --limit 20/); - assert.match(help, /Do not write agent-devtools/); - assert.match(help, /Every profiling and survey line must begin with agent-device react-devtools/); + assert.match(help, /Every profiling command is an agent-device subcommand/); assert.match(help, /agent-device network dump --include headers/); - assert.match(help, /@c refs reset after reload\/remount/); - assert.match(help, /use separate sessions\/devices/); - assert.match(help, /local service tunnel/); - assert.match(help, /Remote iOS apps attempt the legacy React DevTools websocket/); + assert.doesNotMatch( + help, + /agent-react-devtools|wait --connected|get tree|profile export|profile diff|profile report @c/, + ); }); test('usageForCommand resolves cdp help topic', async () => { @@ -586,8 +580,8 @@ test('usageForCommand resolves react-native help topic', async () => { assert.match(help, /Multiple local worktrees can reuse one native iOS simulator build/); assert.match(help, /--metro-host 127\.0\.0\.1 --metro-port 8081/); assert.match(help, /One simulator cannot run two copies of the same bundle id/); - assert.match(help, /Keep the agent-device react-devtools prefix/); - assert.match(help, /Use help react-devtools for status\/wait/); + assert.match(help, /Keep the agent-device react-devtools prefix on every profiling command/); + assert.match(help, /Use help react-devtools for render profiles/); assert.match(help, /Keep the agent-device cdp prefix/); assert.match(help, /Use help cdp for JS heap usage samples/); assert.match(help, /logs clear --restart/); @@ -595,15 +589,13 @@ test('usageForCommand resolves react-native help topic', async () => { assert.match(help, /agent-device open "Agent Device Tester" --platform android/); assert.match(help, /Start React Native slow-flow plans with this ordered scaffold/); assert.match(help, /include the open command even when it also describes the current screen/); - assert.match(help, /agent-device react-devtools status/); - assert.match(help, /Profiling plans need both status and wait --connected before profile start/); - assert.match(help, /Do not substitute react-devtools start for status/); + assert.match(help, /react-devtools profile start right before the interaction/); + assert.doesNotMatch(help, /react-devtools status|wait --connected|react-devtools errors/); assert.match(help, /If snapshot reports a React Native warning\/error overlay/); assert.match(help, /agent-device react-native dismiss-overlay/); assert.match(help, /verifies the overlay is gone with a fresh post-dismiss snapshot -i/); assert.match(help, /Do not use a plain snapshot after dismiss-overlay/); - assert.match(help, /When overlay evidence and React diagnostics are required/); - assert.match(help, /agent-device react-devtools errors/); + assert.match(help, /When overlay evidence is required before continuing/); assert.match(help, /overlay is still visible/); assert.match(help, /Do not manually press warning\/error text bodies/); assert.match(help, /dismiss-overlay command owns the narrow LogBox\/RedBox targeting policy/); @@ -631,7 +623,7 @@ test('commands topic renders concise commands inline with descriptions', async ( assert.match(help, / {2}metro\s{2,}Prepare the dev server or reload apps/); assert.match(help, / {2}perf\s{2,}Check frames, memory, or native profiles/); assert.match(help, / {2}cdp\s{2,}Inspect CDP targets, JS heap, and leaks/); - assert.match(help, / {2}react-devtools\s{2,}Inspect components, hooks, and render profiles/); + assert.match(help, / {2}react-devtools\s{2,}React Native performance profiling/); assert.match(help, / {2}proxy\s{2,}Expose a local daemon through an HTTP tunnel/); assert.match(help, / {2}batch --steps \| --steps-file \s{2,}Run multiple commands/); assert.match(help, / {2}test \.\.\.\s{2,}Run replay test suites/); diff --git a/src/commands/schema/cli-help-workflows.ts b/src/commands/schema/cli-help-workflows.ts index e064da5d1f..c176d7ae39 100644 --- a/src/commands/schema/cli-help-workflows.ts +++ b/src/commands/schema/cli-help-workflows.ts @@ -187,7 +187,7 @@ Text-entry quirks: iOS accessibility-driven fill types the replacement once and never clears and retypes a read-back mismatch. Exact settled text confirms the entry. A value containing only characters in order from the post-clear value plus the request fails with TEXT_ENTRY_MISMATCH, including plain dropped characters and unchanged values; legitimate trimming or masks can also hit this limit ("5" into "$0.00" displaying "$0.05"). Other settled values return verification: "unconfirmed" with requested/before/after/target evidence, including grouping ("00 062 91 77" for "000629177"), currency ("$10.00" for "1000"), and OTP summaries. Missing, reordered, duplicated or stale text can also be unconfirmed, so assert the expected value with a value selector, get/is, or the resulting screen before continuing. For single-line fills ending in a submit key, any non-matching value observed on the same target succeeds unverified, including partial or cleared text; this exception skips dropped-character checks because the submit may change the field. Assert the resulting screen. Multiline fields compare newlines as content. Unreadable or removed fields remain unverified. The coordinate-driven fill route still requires an exact match. Only retry fill with --delay-ms 80 after TEXT_ENTRY_MISMATCH and inspection shows the field needs correction. React Native internals: - If the question is about React Native performance, profiling, props, state, hooks, render causes, slow components, or rerenders, use help react-devtools instead of inferring from screenshots or logs.`, + If the question is about React Native performance, render causes, slow components, or rerenders, use help react-devtools instead of inferring from screenshots or logs.`, }, } as const; diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index b95c8a5d35..97ed726eaa 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -146,7 +146,7 @@ Escalate: help scripting recording, save-script, batch, replay repair help gestures multi-touch gesture shapes/quirks help tv Android TV, tvOS, Vega VVD remote - help react-devtools RN perf/profiling, hooks, renders + help react-devtools RN performance profiling help react-native RN hazards, Metro/Re.Pack, routing help remote remote/cloud config, lease, tunnels help macos desktop, frontmost-app, menu bar @@ -292,69 +292,47 @@ Focus and visual truth: }, ...debuggingHelpTopics, 'react-devtools': { - summary: 'React Native performance, profiling, and component internals', + summary: 'React Native performance profiling', body: `agent-device help react-devtools -Use this for React Native performance/profiling and internals that the accessibility tree cannot expose: components, props, state, hooks, ownership, slow renders, and rerenders. - -Core commands: - agent-device react-devtools status - agent-device react-devtools start - agent-device react-devtools stop - agent-device react-devtools wait --connected - agent-device react-devtools wait --component - agent-device react-devtools count - agent-device react-devtools get tree --depth 3 - agent-device react-devtools find - agent-device react-devtools find --exact - agent-device react-devtools get component @c5 - agent-device react-devtools errors +Use this for React Native performance profiling: slow renders, rerenders and their causes, and commits that take longer than a 16 ms frame. + +Commands: agent-device react-devtools profile start agent-device react-devtools profile stop + 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 report @c5 - agent-device react-devtools profile timeline --limit 20 - agent-device react-devtools profile export profile.json - agent-device react-devtools profile diff before.json after.json --limit 10 + 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 (or --bundle-url) when the dev server is neither the session's nor localhost:8081. Profiling loop: - 1. Run agent-device react-devtools status first. Use start only if status reports the React DevTools helper is not running; start is not a connection check. - 2. Always run agent-device react-devtools wait --connected after status and before profiling so the app, not just the helper, is attached. - 3. If correlating with logs or network, run logs clear --restart before the first logs mark. - 4. Start profiling immediately before the interaction. - 5. Drive the interaction with normal agent-device commands and mark before/after the repro when timing matters. - 6. Stop profiling. - 7. Make one bounded first-pass survey: profile stop for the summary, profile slow --limit 5 once, profile rerenders --limit 5 once, and profile timeline --limit 20 only when commit timing matters. - 8. Use profile report @cN for targeted render causes and changed props/state/hooks; use get component @cN for current props/state/hooks. + 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. 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. + Timings come from development builds and run slower than release builds; compare runs instead of reading absolute numbers. Rules: - Every React DevTools command is an agent-device subcommand: agent-device react-devtools ... - Do not write agent-devtools, agent-react-devtools, or bare react-devtools commands in final command plans. Every profiling and survey line must begin with agent-device react-devtools. - Start with get tree --depth 3 or find ; use find --exact when fuzzy results are noisy. - @c refs reset after reload/remount. After reload, wait --connected and inspect again. - Keep the profile window narrow; unrelated navigation makes render data noisy. - Do not repeatedly raise broad profile slow limits such as --limit 50, --limit 200, or --limit 500. Drill into a specific @c ref with profile report unless you have a specific target that needs more rows. + 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. - For cross-platform validation with explicit device selectors, use separate sessions/devices and restart react-devtools between platforms. - Remote Android and iOS bridge runs normally through agent-device react-devtools; the CLI keeps the needed local service tunnel alive until agent-device react-devtools stop or disconnect. Expo support depends on the SDK's bundled React Native runtime. - Remote iOS apps attempt the legacy React DevTools websocket during JavaScript startup. If the app was already open before react-devtools start, run open --platform ios --relaunch, then wait --connected. - React Native 0.87+ needs agent-react-devtools installed as a dev dependency of the app project, then a one-time npx agent-react-devtools init there and a rebundle, before wait --connected can succeed. The npm exec package this wrapper runs is temporary and does not install it into the app. Run npx agent-react-devtools uninit when the task is done unless the user wants to keep the setup. - Verify an attached app with status or wait --connected before trusting any result. With 0 connected apps, count, errors, and get tree return empty results that look like a clean pass. Example: - agent-device react-devtools status - agent-device react-devtools wait --connected agent-device logs clear --restart agent-device logs mark "before catalog search" agent-device react-devtools profile start 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 slow --limit 5 - agent-device react-devtools profile rerenders --limit 5 - agent-device react-devtools profile timeline --limit 20 - agent-device react-devtools profile report @c5 + agent-device react-devtools profile report CatalogScreen agent-device network dump --include headers Device busy and ownership: @@ -362,7 +340,7 @@ Device busy and ownership: Inspect ownership without any daemon: agent-device device status (add --stale for proven-dead owners; settle and release those with agent-device device release --stale). devices marks rows that are claimed, so pick an unclaimed device instead of contending. A live foreign owner is released by closing its session from its own workspace or stopping its daemon: agent-device daemon stop --state-dir (the error names the state dir). The device itself can settle a claim: when it rebooted after that claim was taken, its app, runner, and accessibility session were destroyed, so open reconciles the owner's resources, takes the claim, and reports the release in warnings. Never recover by hunting PIDs with ps/kill. boot/install/shutdown take the same claims as open and refuse foreign-claimed devices identically; they do not ask the device about its boot. -Use snapshot, screenshot, logs, network, perf frames, and perf memory for device/app runtime evidence. Use react-devtools when component internals or React rendering behavior matters.`, +Use snapshot, screenshot, logs, network, perf frames, and perf memory for device/app runtime evidence. Use react-devtools when React rendering performance matters.`, }, cdp: { summary: 'React Native CDP targets, JS heap snapshots, and leak triage', @@ -414,7 +392,7 @@ Output contract: Until cdp has a compact leak report command, synthesize one from memory usage diff, snapshot diff, leak-triplet, and retainers. Report heap deltas, top retained classes/shapes, leak-triplet rows that stayed high after cleanup, and the shortest useful retaining paths. Do not paste raw heap snapshots or large allocation profiles into the response; use exported artifacts only when the user asks for raw data. Target caveats: - React Native/Hermes implements a subset of browser CDP. If a command reports an unsupported method, keep the target selected and switch to heap usage samples plus heap snapshots. Prefer react-devtools for component tree/render causes; prefer perf memory sample or perf memory snapshot for native/process memory.`, + React Native/Hermes implements a subset of browser CDP. If a command reports an unsupported method, keep the target selected and switch to heap usage samples plus heap snapshots. Prefer react-devtools profile for render causes; prefer perf memory sample or perf memory snapshot for native/process memory.`, }, 'react-native': { summary: 'React Native app automation hazards and routing', @@ -428,7 +406,7 @@ Choose the next help topic: Routine QA/dogfood/manual-test flow (open, snapshot -i, press/fill --settle, verify, close): help manual-qa; it has the concrete command shapes, so you should not need generic navigation help for a normal pass. Deep exploration of navigation/selector/ref edge cases, or a serial-command question manual-qa does not answer: help workflow (full reference, larger read). Logs, network, diagnostics, traces, permission dialogs, or runtime failures: help debugging. - Component tree, props/state/hooks, slow renders, rerenders, or render causes: help react-devtools. + Slow renders, rerenders, render causes, or dropped frames in React: help react-devtools. JS heap growth, heap snapshots, allocation hotspots, or retained-object leaks: help cdp. Remote/cloud config, leases, and local service tunnels: help remote. @@ -459,10 +437,9 @@ Overlays and busy RN UIs: agent-device snapshot -i agent-device press 'id="submit-order"' Do not use a plain snapshot after dismiss-overlay when the next step needs current refs; use snapshot -i. - When overlay evidence and React diagnostics are required before continuing, keep the sequence explicit: + When overlay evidence is required before continuing, keep the sequence explicit: agent-device snapshot -i agent-device screenshot --overlay-refs - agent-device react-devtools errors agent-device react-native dismiss-overlay agent-device snapshot -i agent-device press 'id="submit-order"' @@ -473,10 +450,10 @@ Overlays and busy RN UIs: agent-device snapshot -i Android runtime permission dialogs and native alerts are handled by alert wait/accept/dismiss. If alert reports no alert, treat the visible surface as app-owned UI and use snapshot -i plus press by label/ref. -React DevTools routing: - Keep the agent-device react-devtools prefix on every React DevTools command. - Use help react-devtools for status/wait, component trees, props/state/hooks, profile windows, slow renders, rerenders, and remote bridge rules. - If React DevTools cannot connect, report status and continue with logs, network, perf frames, perf memory, screenshot, and trace evidence instead of blocking the whole flow. +Performance profiling routing: + Keep the agent-device react-devtools prefix on every profiling command. + Use help react-devtools for render profiles, slow renders, rerenders, and their causes. + If profiling cannot attach, report the error and continue with logs, network, perf frames, perf memory, screenshot, and trace evidence instead of blocking the whole flow. CDP memory routing: Keep the agent-device cdp prefix on every CDP command. @@ -488,11 +465,8 @@ Slow-flow investigation: Start React Native slow-flow plans with this ordered scaffold: agent-device open "Agent Device Tester" --platform android agent-device snapshot -i - agent-device react-devtools status - agent-device react-devtools wait --connected If the task says to open the app, include the open command even when it also describes the current screen. - Use help react-devtools for the narrow React profile window. Profiling plans need both status and wait --connected before profile start. - Check status before wait/profile. Do not substitute react-devtools start for status; start launches the helper, while status reports connection state. + Use help react-devtools for the narrow React profile window: react-devtools profile start right before the interaction and react-devtools profile stop after it. Use help debugging for logs clear --restart, logs mark, network dump --include headers, perf frames, perf memory, native profiles, traces, and runtime failure evidence. For 15-20s async work, use wait with the exact expected text or selector instead of repeated snapshots. Report React render offenders separately from network/backend waits and device frame/CPU/memory findings.`, @@ -695,7 +669,7 @@ Rules: connect without --session always creates a fresh remote session and prints that session in its next-step commands. Concurrent callers must pass the returned --session on every command; the ambient active connection is only a single-workflow convenience. To replace an existing connection, pass its returned session explicitly with --session --force. --force without --session creates another fresh session and does not release or overwrite an unrelated active connection. Prefer short-lived AWS role credentials in CI. Generated connection profiles store app/device selectors and ARNs, not Limrun API keys or instance tokens, BrowserStack or TestMu AI access keys, or AWS credentials. - Limrun Android supports direct ADB port reverse for local Metro. Limrun iOS requires a public Metro/React DevTools URL because it cannot reach local host ports directly. + Limrun Android supports direct ADB port reverse for local Metro. Limrun iOS requires a public Metro URL because it cannot reach local host ports directly. After closing a device cloud session, run agent-device artifacts --json to retrieve provider video/log/dashboard URLs when the provider has made them available. connect proxy stores the connection profile and client identity. Proxy device leases are acquired on open and expire after five minutes without commands; when a lease expires or is released, the daemon ends the session opened under it and frees its device; devices may inspect proxy inventory without allocating. Multiple agents can share one proxy when each uses connect proxy, open, commands, close, and disconnect. @@ -703,14 +677,13 @@ Rules: A busy direct-proxy device error means another agent owns the device until it closes or its inactivity lease expires. Keep the proxy token secret. Anyone with the token can control the proxied daemon. A daemon with AGENT_DEVICE_HTTP_AUTH_HOOK configured treats HTTP requests as remote: host-path install sources are rejected, uploaded artifacts remain supported, and Maestro runScript HTTP helpers allow only public network destinations. No-hook local HTTP and socket flows retain their local behavior. - A daemon started with AGENT_DEVICE_DAEMON_POLICY= enforces that policy for every request, including batch steps and replay actions: it can limit which devices are visible and bindable, allow or deny commands (react-devtools and Maestro flows reach the daemon as the runtime command), and deny device-shutdown. A denial is UNAUTHORIZED with details.reason DAEMON_POLICY_DENIED; do not retry it. Use an allowed command or device, or ask the daemon operator to change the policy; a denied capability such as device-shutdown has no allowed alternative. + A daemon started with AGENT_DEVICE_DAEMON_POLICY= enforces that policy for every request, including batch steps and replay actions: it can limit which devices are visible and bindable, allow or deny commands (Maestro flows reach the daemon as the runtime command), and deny device-shutdown. A denial is UNAUTHORIZED with details.reason DAEMON_POLICY_DENIED; do not retry it. Use an allowed command or device, or ask the daemon operator to change the policy; a denied capability such as device-shutdown has no allowed alternative. If local/proxy iOS reports that the runner is already owned by another agent-device daemon after lease admission, retry after the owning session closes or after lease expiry. If the conflict repeats, clean stale daemon state on the machine with simulator access. Do not use --config as a remote profile flag. --config loads CLI defaults; --remote-config selects remote daemon/profile settings. For self-contained scripts, pass the same --remote-config to every operational command, including disconnect; a preceding connect is optional but not required. For remote artifact installs, use install-from-source or install-from-source --github-actions-artifact org/repo:artifact; do not download CI artifacts locally first. After connect, let the active remote connection supply runtime hints. For connected phone/tablet setup and iOS signing prerequisites, read agent-device help physical-device. - For remote Android and iOS bridge React DevTools, run agent-device react-devtools normally. The CLI opens the needed local service tunnel for the DevTools daemon and keeps it alive until agent-device react-devtools stop or disconnect. Use --debug when remote connection or transport errors need diagnostic ids and remote log hints.`, }, macos: { diff --git a/src/commands/schema/command-overrides.ts b/src/commands/schema/command-overrides.ts index 5c50607014..0f2172e759 100644 --- a/src/commands/schema/command-overrides.ts +++ b/src/commands/schema/command-overrides.ts @@ -179,11 +179,12 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { }, 'react-devtools': { text: { - summary: 'Inspect components, hooks, and render profiles', + summary: 'React Native performance profiling', description: - 'Run pinned agent-react-devtools commands for React Native performance profiling, component trees, props/state/hooks, and render analysis', + 'Profile React Native renders through the Metro dev server and report slow renders, rerenders, and their causes', }, - usageOverride: 'react-devtools [...args]', + 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/agent-setup.md b/website/docs/docs/agent-setup.md index bda5a25cfe..a98d04f607 100644 --- a/website/docs/docs/agent-setup.md +++ b/website/docs/docs/agent-setup.md @@ -37,9 +37,9 @@ For a normal app-driving task, start immediately. Do not probe first with `--hel For TV, Fire TV, or Vega OS tasks, read `agent-device help tv`. For exploratory QA, read `agent-device help dogfood`. For logs, network, audio, traces, or runtime failures, read `agent-device help debugging`. -For React Native component trees, props/state/hooks, slow renders, or rerenders, read `agent-device help react-devtools`. +For React Native performance profiling (slow renders, rerenders, and their causes), read `agent-device help react-devtools`. For React Native JavaScript heap growth, heap snapshots, allocation hotspots, or retained-object leaks, read `agent-device help cdp`. -For React Native apps, overlays, Metro/Fast Refresh blockers, and routing to React DevTools or debugging evidence, read `agent-device help react-native`. +For React Native apps, overlays, Metro/Fast Refresh blockers, and routing to profiling or debugging evidence, read `agent-device help react-native`. Use MCP tools or the CLI in the integrated terminal. If `agent-device` is not on PATH but the user installed it globally in another shell, resolve the command the same way the user would from a normal terminal session and run that absolute path instead. This may require inspecting shell startup behavior or package-manager/global bin locations; do not assume the agent process `PATH` is the user's `PATH`. diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index bb34ad5114..306cbec91c 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -824,30 +824,27 @@ agent-device perf trace stop --kind perfetto --out app.perfetto-trace For the measurement workflow, data sources, and output examples, see [Performance snapshots](/docs/debugging-profiling#performance-snapshots). -## React Native component internals +## React Native performance profiling ```bash -agent-device react-devtools status -agent-device react-devtools wait --connected -agent-device react-devtools get tree --depth 3 -agent-device react-devtools get component @c5 -agent-device react-devtools find Button 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 -agent-device react-devtools profile report @c5 +agent-device react-devtools profile timeline --limit 20 --sort duration +agent-device react-devtools profile status ``` -- `react-devtools` runs the pinned `agent-react-devtools@0.5.0` package through npm and passes arguments through unchanged. The first run may download it; later runs can reuse the npm cache. -- React Native 0.87+ needs a one-time app setup before `react-devtools wait --connected` can succeed; see [Debugging & Profiling](/docs/debugging-profiling#react-native-component-internals). -- Use it for component hierarchy, props, state, hooks, render causes, slow components, and re-render counts. For the profiling workflow, see [Debugging & Profiling](/docs/debugging-profiling#react-native-component-internals) and `agent-device help react-devtools`. -- `agent-device` global flags work before or after `react-devtools`. Put `--` before downstream flags only when they share an `agent-device` global flag name. -- Development builds connect to the DevTools daemon on port 8097. If an Android emulator or device cannot reach the host, run `adb reverse tcp:8097 tcp:8097`. Android `open` of a local Metro URL with a port sets up host reachability; for app/package launches, run `adb reverse tcp:8081 tcp:8081` if the app cannot reach Metro. -- Remote bridge sessions (Android and iOS) work through the same command; the CLI keeps a tunnel to the remote DevTools daemon alive until `react-devtools stop` or `disconnect`. See [Remote bridge sessions](/docs/debugging-profiling#remote-bridge-sessions) for the iOS relaunch order and supported React Native versions. -- `react-devtools stop` reports the tunnel's pid and keeps tracking it when it cannot confirm the tunnel exited — it belongs to another user, for example — instead of starting a second one on the next run. Stop that process as its owner and run `react-devtools stop` again. -- For cross-platform validation, use separate sessions/devices and restart `react-devtools` between iOS and Android runs. +- `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`, or `--bundle-url` for a dev server behind a URL path, 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. +- See [Debugging & Profiling](/docs/debugging-profiling#react-native-performance-profiling) for the findings and the workflow. + +## React Native overlays ```bash agent-device react-native dismiss-overlay diff --git a/website/docs/docs/debugging-profiling.md b/website/docs/docs/debugging-profiling.md index 88c7867b34..914bbd8f30 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -15,49 +15,55 @@ When UI automation is not enough, use these commands to collect runtime evidence - Apple crash symbolication with `debug symbols` - Screenshots, recordings, and replayable repro flows -## React Native component internals +## React Native performance profiling -To inspect the React Native component tree, props, state, hooks, or render profiling, use `react-devtools`: +To find out why an interaction renders slowly, record a React profile around it: ```bash -agent-device react-devtools status -agent-device react-devtools wait --connected -agent-device react-devtools get tree --depth 3 -agent-device react-devtools get component @c5 agent-device react-devtools profile start +agent-device fill 'id="catalog-search"' "tart" --delay-ms 80 agent-device react-devtools profile stop -agent-device react-devtools profile slow --limit 5 -agent-device react-devtools profile rerenders --limit 5 -agent-device react-devtools profile timeline --limit 20 -agent-device react-devtools profile report @c5 ``` -`react-devtools` runs a pinned version of `agent-react-devtools` and passes your arguments to it. +`profile stop` prints a short diagnosis: how many commits React made, how long they took, how many went over the 16 ms frame budget (when any did), and the problems it found, worst first. Each finding names a component, shows the evidence, and suggests a fix: -On React Native 0.87 and later, set up the app once before `react-devtools wait --connected` can succeed, locally or through a remote bridge: install `agent-react-devtools` as a dev dependency of the app project, run `npx agent-react-devtools init` there, and rebundle. The package `react-devtools` runs is temporary and does not install it into the app. Run `npx agent-react-devtools uninit` when you no longer need the setup. +```text +React profile 8.3s: 8 commits, 1303ms rendering, 8 over 16ms (worst #6 188ms) +1. memo-defeated ContactRow: memo bypassed on 480/480 updates; new highlight (object) 480x, onSelect (function) 480x from ContactList -> wrap those props in useCallback/useMemo in ContactList +``` -For React Native apps, overlays, Metro/Fast Refresh blockers, and routing to React DevTools or debugging evidence, start with `agent-device help react-native`. For slow-flow investigations, combine `help react-devtools` for the narrow React profile window with `help debugging` for log markers, network/audio evidence, traces, and perf samples. Start with one bounded survey: the `profile stop` summary, the `slow` and `rerenders` tables with a `--limit`, and `timeline` only when commit timing matters. Then drill into a specific `@c` ref with `profile report` instead of raising the `profile slow` limit again and again. +| Finding | Meaning | +| --- | --- | +| `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 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. | -React Native warning and error overlays come from the app. Treat them as findings or blockers: capture them, check `react-devtools errors` when connected, run `agent-device react-native dismiss-overlay` when the overlay is unrelated, then re-snapshot and report the overlay. +To dig into a finding, use the saved profile: -For permission prompts and other alerts already on screen, see [Wait and alerts](/docs/commands#wait-and-alerts). +```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 +``` -### Remote bridge sessions +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`. -On Android and iOS sessions connected through a remote bridge profile, `react-devtools` opens a tunnel to the DevTools daemon at `127.0.0.1:8097` inside the remote sandbox. Android uses the bridge's `adb reverse` mapping; iOS uses the bridge's wildcard Metro host tunnel. The tunnel stays up until you run `agent-device react-devtools stop` or `agent-device disconnect`. +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. -React Native tries to reach DevTools once, during JavaScript startup. On remote iOS, start DevTools before that launch: +Render profiling has not been verified on remote bridge sessions yet. -```bash -agent-device open com.example.myapp --platform ios -agent-device react-devtools start -agent-device open com.example.myapp --platform ios --relaunch -agent-device react-devtools wait --connected -``` +For React Native apps, overlays, Metro/Fast Refresh blockers, and routing to profiling or debugging evidence, start with `agent-device help react-native`. For slow-flow investigations, combine `help react-devtools` for the React profile with `help debugging` for log markers, network/audio evidence, traces, and perf samples. -The first `open` creates the bridge session; the relaunch lets the app find the running DevTools daemon. +React Native warning and error overlays come from the app. Treat them as findings or blockers: capture them, run `agent-device react-native dismiss-overlay` when the overlay is unrelated, then re-snapshot and report the overlay. -This path assumes the DevTools behavior bundled with React Native 0.83 and later. Browser or Chromium DevTools workflows from older versions are not available inside remote sandboxes. For Expo projects, check the React Native version your SDK bundles; no Expo SDK has been verified separately. +For permission prompts and other alerts already on screen, see [Wait and alerts](/docs/commands#wait-and-alerts). ## React Native JS memory through CDP @@ -135,7 +141,7 @@ agent-device debug symbols --artifact crash.ips --search-path ./build --out cras The command supports Apple `.ips`, `.crash`, and log-style crash artifacts that contain Binary Images or IPS `usedImages`. It matches UUIDs from the crash artifact against `dwarfdump --uuid` output from `.dSYM` bundles, runs `atos`, writes a symbolicated artifact, and prints only the output path plus a compact crash report: app/thread, exception or termination, top symbolicated frames, and the first actionable frame finding. The full symbolicated artifact stays on disk, so agent context stays small. -`debug` covers symbolication only. Use `logs` for app logs, `network` for HTTP evidence, `perf` for performance samples, `record`/`trace` for media and traces, and `react-devtools` for React Native internals. `debug symbols` does not handle Android Java/R8 `mapping.txt` or native `ndk-stack`/`addr2line` symbolication; capture Android crash evidence with `logs` and symbolicate it with other tools. +`debug` covers symbolication only. Use `logs` for app logs, `network` for HTTP evidence, `perf` for performance samples, `record`/`trace` for media and traces, and `react-devtools` for React Native performance profiling. `debug symbols` does not handle Android Java/R8 `mapping.txt` or native `ndk-stack`/`addr2line` symbolication; capture Android crash evidence with `logs` and symbolicate it with other tools. ## Core commands diff --git a/website/docs/docs/introduction.md b/website/docs/docs/introduction.md index 0c84cb8443..cf541de0ea 100644 --- a/website/docs/docs/introduction.md +++ b/website/docs/docs/introduction.md @@ -17,7 +17,7 @@ Use it when an agent needs to inspect and operate a running app, not only reason - **Token-efficient UI context**: accessibility snapshots give agents structured UI state instead of screenshots alone. - **Runtime evidence**: capture screenshots, recordings, logs, network traffic, audio-level probes for browser and host-rendered simulator/emulator audio, traces, CPU/memory/perf snapshots, and crash-related logs when the happy path breaks. - **Replayable checks**: turn stable exploratory sessions into `.ad` replay scripts that run again without AI. -- **React Native and Expo workflows**: pair device automation with optional React DevTools profiling for component trees, props/state/hooks, slow renders, and rerenders. +- **React Native and Expo workflows**: pair device automation with React Native performance profiling that names slow renders, rerenders, and their causes. - **Local devices and app surfaces**: drive simulators, emulators, physical devices, TV targets, desktop apps, and browser sessions through one CLI. If you know `agent-browser`, `agent-device` brings the same workflow to mobile, TV, desktop, and a limited managed web browser. diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index b4cf15c391..f76db6c7f3 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -71,7 +71,7 @@ agent-device disconnect On Android, agent-device connects to Limrun over an ADB tunnel. Snapshots use the Android snapshot helper, installs upload the app through Limrun asset storage, and port reverse runs over ADB, including the usual reverse setup for a local Metro server. -On iOS, agent-device supports app lifecycle commands, snapshots, screenshots, taps, text input, scrolling, the home button, clipboard read and write, and app installation. `open --launch-args` relaunches the app with those arguments through simctl. iOS cannot reverse a remote device port to a local host port, so for Metro or React DevTools use a publicly reachable HTTPS endpoint or bridge URL instead of a local-only address. +On iOS, agent-device supports app lifecycle commands, snapshots, screenshots, taps, text input, scrolling, the home button, clipboard read and write, and app installation. `open --launch-args` relaunches the app with those arguments through simctl. iOS cannot reverse a remote device port to a local host port, so for Metro use a publicly reachable HTTPS endpoint or bridge URL instead of a local-only address. On iOS, `settings` supports `appearance`, `permission`, `location`, and `clear-app-state`, with the same arguments as a local simulator. `clear-app-state` resets the app's data container through Limrun's soft reset. That reset relaunches the app once before agent-device stops it, so the app may run once after its data is cleared. `reset-keychain`, `wifi`, `airplane`, `faceid`, `touchid`, and `text-size` are unsupported, and you cannot read back any iOS setting. diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index a8122a47ad..1924e9090b 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -223,7 +223,7 @@ AGENT_DEVICE_DAEMON_POLICY=./policy.json agent-device proxy devices and `serial` for Android. - `commands` takes either `allow` or `deny`, not both. With `allow`, commands added in later releases stay denied. Some client-side tools reach the daemon through internal commands: allow `runtime` for - `react-devtools` and Maestro flows, and `install-from-source` for remote installs. + Maestro flows, and `install-from-source` for remote installs. - `capabilities.deny: ["device-shutdown"]` blocks `shutdown`, `close --shutdown`, and any other path that would shut the device down. - `leases.require: "macos-app"` (the only accepted value) refuses every request that is not made diff --git a/website/docs/index.md b/website/docs/index.md index 3a0a2487f2..04253bbb16 100644 --- a/website/docs/index.md +++ b/website/docs/index.md @@ -24,5 +24,5 @@ features: - title: Session and replay details: Keep app state across commands in a session, and replay recorded `.ad` scripts to reproduce flows without an AI agent. - title: React Native internals - details: Use `agent-device react-devtools` for component trees and render profiles, and `agent-device cdp` for JS heap snapshots, diffs, and leak retainers. + details: Use `agent-device react-devtools profile` for React Native performance profiling, and `agent-device cdp` for JS heap snapshots, diffs, and leak retainers. ---