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 0d594e8939..5b13d2b498 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..51c65ba5d8 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 components, props/state/hooks, render 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..a08d50e27a 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 components, props/state/hooks, render 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..22526b2b61 100644 --- a/src/__tests__/cli-help.test.ts +++ b/src/__tests__/cli-help.test.ts @@ -65,8 +65,9 @@ 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 internals the accessibility tree cannot show/); + assert.match(result.stdout, /agent-device react-devtools inspect <@ref\|Name>/); + 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 28458d1ee6..db50422820 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', 'start', '--state-dir', stateDir], { + sendToDaemon, + }); } finally { restoreEnv(); process.exit = originalExit; @@ -101,68 +73,6 @@ test('react-devtools uses active remote connection session after defaults are me assert.equal(exitCode, 0); assert.equal(vi.mocked(runReactDevtoolsCommand).mock.calls.length, 1); + assert.deepEqual(vi.mocked(runReactDevtoolsCommand).mock.calls[0]?.[0], ['profile', 'start']); 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..d93c6cb7c1 100644 --- a/src/__tests__/cli-react-devtools.test.ts +++ b/src/__tests__/cli-react-devtools.test.ts @@ -1,360 +1,31 @@ -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', - }, - }); - - 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 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', +import { runReactDevtoolsCommand } from '../cli/commands/react-devtools.ts'; + +const options = { stateDir: '/tmp/agent-device-state', session: 'default' }; + +test('unknown react-devtools commands are invalid arguments that list the commands', 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 tree, find, inspect, errors, or profile/); + return true; }, - 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 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 skips companion when remote bridge backend is missing', async () => { - await runStatusWithoutCompanion(remoteBridgeScope); +test('react-devtools inspection commands route to the component inspector', async () => { + await assert.rejects( + runReactDevtoolsCommand(['inspect'], options), + /react-devtools inspect <@ref\|Name> needs an argument/, + ); }); - -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__/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 fe09faa0a7..50fd691b14 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 b7bce9f4f9..033fdfb6c7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,6 @@ import { asAppError, AppError, normalizeError, - throwDaemonError, type NormalizedError, } from '@agent-device/kernel/errors'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; @@ -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..3e0c83a8c3 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -1,217 +1,19 @@ -import { runCmdStreaming } from '@agent-device/host-kit/command'; +import { AppError } from '@agent-device/kernel/errors'; +import type { ReactDevtoolsCommandOptions } from '../react-devtools/react-devtools-connection.ts'; 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 - ); -} + isInspectCommand, + runReactInspectCommand, +} from '../react-devtools/react-devtools-inspect-command.ts'; +import { runReactProfileCommand } from '../react-devtools/react-profile-command.ts'; export async function runReactDevtoolsCommand( args: string[], - options: ReactDevtoolsCommandOptions = {}, + options: ReactDevtoolsCommandOptions, ): 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); - }, - }); - return result.exitCode; + const [command, ...rest] = args; + if (command === 'profile') return await runReactProfileCommand(rest, options); + if (isInspectCommand(command)) return await runReactInspectCommand(command, rest, options); + throw new AppError('INVALID_ARGS', `Unknown react-devtools command: ${command ?? '(none)'}.`, { + hint: 'Use react-devtools tree, find, inspect, errors, or profile.', }); - maybePrintRemoteIosWaitHint(args, options.flags, exitCode); - return exitCode; } 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..adf7c068ed 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,25 @@ 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']); + assert.deepEqual(parsed.positionals, [ + 'profile', + 'stop', + '--metro-device', + 'Pixel 9', + '--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']); +test('parseArgs preserves react-devtools inspection options as passthrough positionals', () => { + assert.deepEqual( + parseArgs(['react-devtools', 'tree', '@c3', '--depth', '3'], { strictFlags: true }).positionals, + ['tree', '@c3', '--depth', '3'], + ); + assert.deepEqual( + parseArgs(['react-devtools', 'find', 'Row', '--exact', '--limit', '2'], { strictFlags: true }) + .positionals, + ['find', 'Row', '--exact', '--limit', '2'], + ); }); test('parseArgs preserves cdp arguments as passthrough positionals', () => { diff --git a/src/cli/react-devtools/metro-inspector.test.ts b/src/cli/react-devtools/metro-inspector.test.ts new file mode 100644 index 0000000000..fff5e3f89a --- /dev/null +++ b/src/cli/react-devtools/metro-inspector.test.ts @@ -0,0 +1,281 @@ +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 { + debuggerUrlOn, + 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(() => {}); + } +}); + +test('the debugger socket is opened on the server that listed the target', () => { + const target = { + id: 'device-1', + title: 'app', + webSocketDebuggerUrl: 'ws://127.0.0.1:8081/inspector/debug?device=d1&page=1', + }; + assert.equal( + debuggerUrlOn(new URL('http://10.0.2.2:8081'), target), + 'ws://10.0.2.2:8081/inspector/debug?device=d1&page=1', + ); + assert.equal( + debuggerUrlOn(new URL('https://bridge.example.test/api/metro/runtimes/r1/'), target), + 'wss://bridge.example.test/api/metro/runtimes/r1/inspector/debug?device=d1&page=1', + ); +}); diff --git a/src/cli/react-devtools/metro-inspector.ts b/src/cli/react-devtools/metro-inspector.ts new file mode 100644 index 0000000000..d8c1376788 --- /dev/null +++ b/src/cli/react-devtools/metro-inspector.ts @@ -0,0 +1,336 @@ +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); + } + } +} + +/** + * The target's debugger socket on the server that listed it. Metro builds `webSocketDebuggerUrl` + * from the Host header it received, and a bridge or tunnel in front of Metro shows it Metro's own + * loopback address, which a caller on another machine cannot reach. + */ +export function debuggerUrlOn(serverUrl: URL, target: MetroInspectorTarget): string { + const advertised = new URL(target.webSocketDebuggerUrl); + const url = withTrailingSlash(serverUrl); + url.protocol = serverUrl.protocol === 'https:' ? 'wss:' : 'ws:'; + url.pathname = `${url.pathname.replace(/\/+$/, '')}${advertised.pathname}`; + url.search = advertised.search; + return url.toString(); +} + +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-devtools/react-component-inspect.test.ts b/src/cli/react-devtools/react-component-inspect.test.ts new file mode 100644 index 0000000000..952b4c55c5 --- /dev/null +++ b/src/cli/react-devtools/react-component-inspect.test.ts @@ -0,0 +1,126 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { formatInspectedComponent, type InspectedComponent } from './react-component-inspect.ts'; + +function component(overrides: Partial = {}): InspectedComponent { + return { + name: 'ContactRow', + type: 8, + key: '3', + props: null, + state: null, + hooks: null, + context: null, + owners: null, + errors: [], + warnings: [], + isErrored: false, + ...overrides, + }; +} + +test('props print as short previews, with placeholders where the backend left values out', () => { + const text = formatInspectedComponent( + 42, + component({ + props: { + data: { + contact: { id: 3, name: 'Ada', tags: ['a', 'b', 'c', 'd', 'e'] }, + onSelect: { type: 'function', name: 'onSelect', preview_short: 'ƒ onSelect() {}' }, + style: { inspectable: true, type: 'object', preview_short: '{…}', size: 4 }, + selected: false, + }, + cleaned: [['onSelect'], ['style']], + unserializable: [], + }, + owners: [ + { id: 40, displayName: 'ContactList' }, + { id: 12, displayName: 'PerfLabScreen' }, + ], + }), + ); + assert.equal( + text, + [ + '@c42 ContactRow [memo] key=3', + 'rendered by: ContactList @c40 < PerfLabScreen @c12', + 'props:', + ' contact: {id: 3, name: "Ada", tags: ["a", "b", "c", "d", …1]}', + ' onSelect: ƒ onSelect() {}', + ' style: {…}', + ' selected: false', + ].join('\n'), + ); +}); + +test('custom hooks list the built-in hooks they call', () => { + const text = formatInspectedComponent( + 7, + component({ + hooks: { + data: [ + { id: 0, name: 'State', value: 2, subHooks: [] }, + { + id: null, + name: 'Contacts', + value: undefined, + subHooks: [{ id: 1, name: 'Memo', value: [1, 2], subHooks: [] }], + }, + ], + cleaned: [], + unserializable: [], + }, + }), + ); + assert.match(text, /\nhooks:\n {2}State: 2\n {2}Contacts:\n {4}Memo: \[1, 2\]$/); +}); + +test('values and messages the app controls cannot start a line or reach the terminal', () => { + const text = formatInspectedComponent( + 7, + component({ + props: { + data: { title: { type: 'object', preview_short: 'x\n@c1 Forged' } }, + cleaned: [['title']], + unserializable: [], + }, + errors: [['bad\u001b[2Jvalue\nstack', 1]], + }), + ); + assert.equal(text.split('\n').length, 5); + assert.match(text, /\n {2}title: x @c1 Forged\n/); + assert.match(text, /\n {2}error: bad \[2Jvalue$/); +}); + +test('logged errors and warnings print one line each with their repeat count', () => { + const text = formatInspectedComponent( + 7, + component({ errors: [['Each child needs a key.\n at Row', 3]], warnings: [['slow', 1]] }), + ); + assert.match( + text, + /\nlogged while rendering:\n {2}error: Each child needs a key\. \(x3\)\n {2}warning: slow$/, + ); +}); + +test('hooks DevTools could not name print as the built-in hooks they wrap', () => { + const text = formatInspectedComponent( + 7, + component({ + name: 'Memo(Screen)', + hooks: { + data: [ + { + id: null, + name: 'anonymous', + value: undefined, + subHooks: [{ id: 0, name: 'State', value: 'idle', subHooks: [] }], + }, + ], + cleaned: [], + unserializable: [], + }, + }), + ); + assert.match(text, /^@c7 Screen \[memo\] key=3\nhooks:\n {2}State: "idle"$/); +}); diff --git a/src/cli/react-devtools/react-component-inspect.ts b/src/cli/react-devtools/react-component-inspect.ts new file mode 100644 index 0000000000..71ac907ec2 --- /dev/null +++ b/src/cli/react-devtools/react-component-inspect.ts @@ -0,0 +1,166 @@ +import { componentName, componentRef, kindLabel } from './react-component-tree.ts'; +import { printable, printableLines } from './react-devtools-text.ts'; + +type ValuePath = Array; + +/** + * A value as the DevTools backend sends it: plain data to a fixed depth, where each path in + * `cleaned` holds a placeholder describing the value it stands for instead of the value itself. + */ +export type DehydratedValue = { + data: unknown; + cleaned: ValuePath[]; + unserializable: ValuePath[]; +}; + +type Placeholder = { type?: string; preview_short?: string; name?: string }; + +type HookNode = { + id: number | null; + name: string; + value: unknown; + subHooks: HookNode[]; +}; + +export type InspectedOwner = { id: number; displayName: string | null; key?: string | null }; + +/** What the in-app runtime returns for one mounted component. */ +export type InspectedComponent = { + name: string | null; + type: number; + key: string | null; + props: DehydratedValue | null; + state: DehydratedValue | null; + hooks: DehydratedValue | null; + context: DehydratedValue | null; + owners: InspectedOwner[] | null; + /** `[message, count]` for each distinct error the component logged while rendering. */ + errors: Array<[string, number]>; + warnings: Array<[string, number]>; + isErrored: boolean; +}; + +const STRING_PREVIEW = 60; +const ARRAY_ITEMS = 4; +const OBJECT_KEYS = 5; +const VALUE_WIDTH = 160; +const OWNERS_SHOWN = 4; + +function clip(text: string, width: number): string { + return text.length > width ? `${text.slice(0, width - 1)}…` : text; +} + +class ValueFormatter { + readonly #placeholders: Set; + + constructor(value: DehydratedValue) { + this.#placeholders = new Set( + [...value.cleaned, ...value.unserializable].map((path) => JSON.stringify(path)), + ); + } + + format(value: unknown, path: ValuePath): string { + if (this.#placeholders.has(JSON.stringify(path))) { + const placeholder = value as Placeholder; + return placeholder.preview_short ?? placeholder.name ?? placeholder.type ?? '…'; + } + if (value === undefined) return 'undefined'; + if (value === null || typeof value !== 'object') { + return typeof value === 'string' + ? JSON.stringify(clip(value, STRING_PREVIEW)) + : String(value); + } + if (Array.isArray(value)) { + const items = value + .slice(0, ARRAY_ITEMS) + .map((item, index) => this.format(item, [...path, index])); + if (value.length > ARRAY_ITEMS) items.push(`…${value.length - ARRAY_ITEMS}`); + return `[${items.join(', ')}]`; + } + const entries = Object.entries(value); + const shown = entries + .slice(0, OBJECT_KEYS) + .map(([key, item]) => `${key}: ${this.format(item, [...path, key])}`); + if (entries.length > OBJECT_KEYS) shown.push(`…${entries.length - OBJECT_KEYS}`); + return `{${shown.join(', ')}}`; + } +} + +function recordLines(title: string, value: DehydratedValue | null): string[] { + if (!value || value.data === null || typeof value.data !== 'object') return []; + const entries = Object.entries(value.data); + if (entries.length === 0) return []; + const formatter = new ValueFormatter(value); + return [ + `${title}:`, + ...entries.map( + ([key, item]) => ` ${key}: ${clip(formatter.format(item, [key]), VALUE_WIDTH)}`, + ), + ]; +} + +/** Hermes stacks name no custom hook, so DevTools wraps each built-in hook in an `anonymous` one. */ +function isUnnamedHook(hook: HookNode): boolean { + return !hook.name || hook.name === 'anonymous'; +} + +function hookLines(value: DehydratedValue | null): string[] { + if (!value || !Array.isArray(value.data) || value.data.length === 0) return []; + const formatter = new ValueFormatter(value); + const lines = ['hooks:']; + const visit = (hooks: HookNode[], path: ValuePath, depth: number): void => { + hooks.forEach((hook, index) => { + const at = [...path, index]; + const indent = ' '.repeat(depth + 1); + if (hook.subHooks?.length > 0 && isUnnamedHook(hook)) { + visit(hook.subHooks, [...at, 'subHooks'], depth); + return; + } + if (hook.subHooks?.length > 0) { + lines.push(`${indent}${hook.name}:`); + visit(hook.subHooks, [...at, 'subHooks'], depth + 1); + return; + } + lines.push( + `${indent}${hook.name}: ${clip(formatter.format(hook.value, [...at, 'value']), VALUE_WIDTH)}`, + ); + }); + }; + visit(value.data as HookNode[], [], 0); + return lines; +} + +function logLines(label: string, entries: Array<[string, number]>): string[] { + return entries.map( + ([message, count]) => + ` ${label}: ${clip(printable(message.split('\n')[0] ?? ''), VALUE_WIDTH)}${count > 1 ? ` (x${count})` : ''}`, + ); +} + +/** The errors and warnings one component logged while rendering, one line each. */ +export function formatComponentLogs(component: InspectedComponent): string[] { + return [...logLines('error', component.errors), ...logLines('warning', component.warnings)]; +} + +export function formatInspectedComponent(id: number, component: InspectedComponent): string { + const key = component.key === null ? '' : ` key=${component.key}`; + const name = componentName(component.name ?? 'Anonymous'); + const lines = [`${componentRef({ id })} ${name}${kindLabel(component.type)}${key}`]; + const owners = (component.owners ?? []).slice(0, OWNERS_SHOWN); + if (owners.length > 0) { + const more = (component.owners?.length ?? 0) > owners.length ? ' < …' : ''; + lines.push( + `rendered by: ${owners.map((owner) => `${owner.displayName ?? 'Anonymous'} ${componentRef(owner)}`).join(' < ')}${more}`, + ); + } + if (component.isErrored) lines.push('error boundary: showing its error state'); + lines.push( + ...recordLines('props', component.props), + ...recordLines('state', component.state), + ...hookLines(component.hooks), + ...recordLines('context', component.context), + ); + const logs = formatComponentLogs(component); + if (logs.length > 0) lines.push('logged while rendering:', ...logs); + return printableLines(lines); +} diff --git a/src/cli/react-devtools/react-component-tree.test.ts b/src/cli/react-devtools/react-component-tree.test.ts new file mode 100644 index 0000000000..64c0b86639 --- /dev/null +++ b/src/cli/react-devtools/react-component-tree.test.ts @@ -0,0 +1,151 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { + buildComponentTree, + findNodes, + renderTree, + resolveNode, + type RawComponentTree, +} from './react-component-tree.ts'; + +const FUNCTION = 5; +const MEMO = 8; + +/** Providers wrap the app, which renders a header and a list of rows. */ +function appTree(rows = 3): RawComponentTree { + const nodes: RawComponentTree['nodes'] = [ + [1, 'ThemeProvider', FUNCTION, 0, 0, null], + [2, 'StoreProvider', FUNCTION, 1, 0, null], + [3, 'NavigationContainer', FUNCTION, 2, 0, null], + [4, 'Stack', FUNCTION, 3, 0, null], + [5, 'Screen', FUNCTION, 4, 0, null], + [6, 'App', FUNCTION, 5, 0, null], + [7, 'Header', FUNCTION, 6, 6, null], + [8, 'List', FUNCTION, 6, 6, null], + ]; + for (let index = 0; index < rows; index += 1) { + nodes.push([20 + index, 'Memo(Row)', MEMO, 8, 8, String(index)]); + nodes.push([40 + index, 'Text', FUNCTION, 20 + index, 20 + index, null]); + } + return { nodes, order: {}, logs: { '21': [1, 0] }, logsTracked: true }; +} + +test('wrapper chains and runs of same-shape sibling rows each read as one line', () => { + const raw = appTree(); + raw.logs = {}; + const view = renderTree(buildComponentTree(raw).roots, 8, 60); + assert.deepEqual(view.lines, [ + '@c1 ThemeProvider > @c2 StoreProvider > (2 wrappers) > @c5 Screen > @c6 App', + ' @c7 Header', + ' @c8 List', + ' @c20 Row [memo] key=0 x3 (+3 below)', + ]); + assert.equal(view.total, 14); + assert.equal(view.shown, 11, 'the rows under a collapsed run are counted as not shown'); +}); + +test('a row that logged a problem its neighbours did not keeps its own line', () => { + const tree = buildComponentTree(appTree()); + assert.deepEqual(renderTree([tree.byId.get(8)!], 8, 60).lines, [ + '@c8 List', + ' @c20 Row [memo] key=0 > @c40 Text', + ' @c21 Row [memo] key=1 (1 error) > @c41 Text', + ' @c22 Row [memo] key=2 > @c42 Text', + ]); +}); + +test('siblings with equal subtree sizes but different children stay open', () => { + const raw = appTree(0); + raw.nodes.push( + [30, 'Scene', FUNCTION, 8, 8, 'a'], + [31, 'Header', FUNCTION, 30, 30, null], + [32, 'Scene', FUNCTION, 8, 8, 'b'], + [33, 'Form', FUNCTION, 32, 32, null], + [34, 'Scene', FUNCTION, 8, 8, 'c'], + [35, 'Header', FUNCTION, 34, 34, null], + ); + assert.deepEqual(renderTree([buildComponentTree(raw).byId.get(8)!], 8, 60).lines, [ + '@c8 List', + ' @c30 Scene key=a > @c31 Header', + ' @c32 Scene key=b > @c33 Form', + ' @c34 Scene key=c > @c35 Header', + ]); +}); + +test('names and keys the app controls cannot start a line or reach the terminal', () => { + const raw = appTree(0); + raw.nodes.push([30, 'Evil\n@c1 Forged', FUNCTION, 8, 8, 'k\u001b[2J']); + assert.deepEqual(renderTree([buildComponentTree(raw).byId.get(30)!], 8, 60).lines, [ + '@c30 Evil @c1 Forged key=k [2J', + ]); +}); + +test('depth and line limits cut the tree and count what they leave out', () => { + const tree = buildComponentTree(appTree()); + assert.deepEqual(renderTree(tree.roots, 1, 60).lines, [ + '@c1 ThemeProvider > @c2 StoreProvider > (2 wrappers) > @c5 Screen > @c6 App (+8 below)', + ]); + const limited = renderTree(tree.roots, 8, 2); + assert.equal(limited.lines.length, 2); + assert.equal(limited.shown, 7); +}); + +test('a recorded reorder puts children in their rendered order', () => { + const raw = appTree(2); + raw.order = { '8': [21, 20] }; + const list = buildComponentTree(raw).byId.get(8)!; + assert.deepEqual( + list.children.map((child) => child.id), + [21, 20], + ); +}); + +test('same-name siblings of different shapes, such as navigator screens, stay open', () => { + const raw = appTree(0); + raw.nodes.push( + [30, 'Scene', FUNCTION, 8, 8, 'home'], + [31, 'Scene', FUNCTION, 8, 8, 'settings'], + [32, 'Scene', FUNCTION, 8, 8, 'profile'], + [33, 'Form', FUNCTION, 31, 31, null], + ); + const tree = buildComponentTree(raw); + assert.deepEqual(renderTree([tree.byId.get(8)!], 8, 60).lines, [ + '@c8 List', + ' @c30 Scene key=home', + ' @c31 Scene key=settings > @c33 Form', + ' @c32 Scene key=profile', + ]); +}); + +test('a branch renders with its logged problems on the line', () => { + const tree = buildComponentTree(appTree(2)); + assert.deepEqual(renderTree([tree.byId.get(21)!], 8, 60).lines, [ + '@c21 Row [memo] key=1 (1 error) > @c41 Text', + ]); +}); + +test('find matches part of a name unless asked for the exact name', () => { + const tree = buildComponentTree(appTree()); + assert.deepEqual( + findNodes(tree, 'provider', false).map((node) => node.name), + ['ThemeProvider', 'StoreProvider'], + ); + assert.deepEqual(findNodes(tree, 'provider', true), []); +}); + +test('a ref or a unique name selects one component; a shared name fails closed', () => { + const tree = buildComponentTree(appTree()); + assert.equal(resolveNode(tree, '@c8').name, 'List'); + assert.equal(resolveNode(tree, 'header').id, 7); + assert.throws( + () => resolveNode(tree, 'Row'), + (error: { code: string; message: string }) => + error.code === 'INVALID_ARGS' && + /3 mounted components are named Row: @c20, @c21, @c22/.test(error.message), + ); + assert.throws( + () => resolveNode(tree, '@c999'), + (error: { details: { reason: string } }) => error.details.reason === 'element-not-found', + ); + assert.throws(() => resolveNode(tree, 'Missing'), /No mounted component is named Missing/); +}); diff --git a/src/cli/react-devtools/react-component-tree.ts b/src/cli/react-devtools/react-component-tree.ts new file mode 100644 index 0000000000..6cf1c100c6 --- /dev/null +++ b/src/cli/react-devtools/react-component-tree.ts @@ -0,0 +1,302 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { printable } from './react-devtools-text.ts'; + +/** `[id, name, elementType, parentId, ownerId, key]` for every mounted component. */ +type RawTreeNode = [number, string, number, number, number, string | null]; + +/** The component tree as the in-app runtime exports it from `tree()`. */ +export type RawComponentTree = { + nodes: RawTreeNode[]; + /** Child order a reorder recorded, by parent id; other children keep their mount order. */ + order: Record; + /** `[errors, warnings]` the component logged while rendering, by id. */ + logs: Record; + /** + * Whether the backend attributes console errors and warnings to components from app start. + * Older backends attribute them partly or not at all, so missing counts prove nothing. + */ + logsTracked: boolean; +}; + +export type TreeNode = { + id: number; + name: string; + elementType: number; + key: string | null; + ownerId: number; + parent?: TreeNode; + children: TreeNode[]; + errors: number; + warnings: number; +}; + +export type ComponentTree = { + roots: TreeNode[]; + byId: Map; + logsTracked: boolean; +}; + +const ELEMENT_CLASS = 1; +const ELEMENT_FORWARD_REF = 6; +const ELEMENT_MEMO = 8; + +const TYPE_WRAPPER = /^(?:Memo|ForwardRef)\((.+)\)$/; + +/** `Memo(Row)` reads as `Row`: the element type already says it is memoized. */ +export function componentName(displayName: string): string { + let name = displayName; + for (let match = TYPE_WRAPPER.exec(name); match; match = TYPE_WRAPPER.exec(name)) { + name = match[1]!; + } + return name; +} + +export function buildComponentTree(raw: RawComponentTree): ComponentTree { + const byId = new Map(); + for (const [id, displayName, elementType, , ownerId, key] of raw.nodes) { + const [errors, warnings] = raw.logs[String(id)] ?? [0, 0]; + const name = componentName(printable(displayName)); + byId.set(id, { + id, + name, + elementType, + key: key === null ? null : printable(key), + ownerId, + children: [], + errors, + warnings, + }); + } + const roots: TreeNode[] = []; + for (const [id, , , parentId] of raw.nodes) { + const node = byId.get(id)!; + const parent = byId.get(parentId); + if (!parent) { + roots.push(node); + continue; + } + node.parent = parent; + parent.children.push(node); + } + for (const [parentId, childIds] of Object.entries(raw.order)) { + const parent = byId.get(Number(parentId)); + if (!parent) continue; + const position = new Map(childIds.map((id, index) => [id, index])); + parent.children.sort( + (a, b) => (position.get(a.id) ?? Infinity) - (position.get(b.id) ?? Infinity), + ); + } + return { roots, byId, logsTracked: raw.logsTracked }; +} + +export function componentRef(node: { id: number }): string { + return `@c${node.id}`; +} + +export function kindLabel(elementType: number): string { + if (elementType === ELEMENT_MEMO) return ' [memo]'; + if (elementType === ELEMENT_CLASS) return ' [class]'; + if (elementType === ELEMENT_FORWARD_REF) return ' [forwardRef]'; + return ''; +} + +function plural(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? '' : 's'}`; +} + +export function logSummary(node: TreeNode): string { + const parts = []; + if (node.errors > 0) parts.push(plural(node.errors, 'error')); + if (node.warnings > 0) parts.push(plural(node.warnings, 'warning')); + return parts.join(', '); +} + +function nodeLabel(node: TreeNode): string { + const key = node.key === null ? '' : ` key=${node.key}`; + const logs = logSummary(node); + return `${componentRef(node)} ${node.name}${kindLabel(node.elementType)}${key}${logs ? ` (${logs})` : ''}`; +} + +function descendantCount(node: TreeNode): number { + return node.children.reduce((sum, child) => sum + 1 + descendantCount(child), 0); +} + +/** A run of single-child wrappers, such as providers, reads as one line. */ +const CHAIN_HEAD = 2; +const CHAIN_TAIL = 2; + +function chainOf(node: TreeNode): TreeNode[] { + const chain = [node]; + for (let last = node; last.children.length === 1; last = last.children[0]!) { + chain.push(last.children[0]!); + } + return chain; +} + +function chainLabel(chain: TreeNode[]): string { + if (chain.length <= CHAIN_HEAD + CHAIN_TAIL + 1) return chain.map(nodeLabel).join(' > '); + const hidden = chain.length - CHAIN_HEAD - CHAIN_TAIL; + return [ + ...chain.slice(0, CHAIN_HEAD).map(nodeLabel), + `(${hidden} wrappers)`, + ...chain.slice(-CHAIN_TAIL).map(nodeLabel), + ].join(' > '); +} + +/** Siblings that collapse into one line: this many or more, all of one shape. */ +const MIN_COLLAPSED_RUN = 3; + +/** + * Numbers each subtree by its shape: names, element types, and logged problems, all the way down. + * Two subtrees with one number print the same, apart from refs and keys. + */ +class SubtreeShapes { + readonly #ids = new Map(); + readonly #byNode = new Map(); + + of(node: TreeNode): number { + const known = this.#byNode.get(node); + if (known !== undefined) return known; + const signature = [ + node.name, + node.elementType, + node.errors, + node.warnings, + ...node.children.map((child) => this.of(child)), + ].join('\0'); + let id = this.#ids.get(signature); + if (id === undefined) { + id = this.#ids.size; + this.#ids.set(signature, id); + } + this.#byNode.set(node, id); + return id; + } +} + +/** + * Consecutive siblings of one shape, such as list rows, read as one line. Screens of one navigator + * share a name but not a shape, so the focused one stays open, and a row that logged a problem + * its neighbours did not stays on its own line. + */ +function siblingRuns(children: TreeNode[], shapes: SubtreeShapes): TreeNode[][] { + const runs: TreeNode[][] = []; + for (const child of children) { + const run = runs.at(-1); + const first = run?.[0]; + if (first && shapes.of(first) === shapes.of(child)) run.push(child); + else runs.push([child]); + } + return runs.flatMap((run) => + run.length >= MIN_COLLAPSED_RUN ? [run] : run.map((child) => [child]), + ); +} + +export type TreeView = { lines: string[]; shown: number; total: number }; + +/** + * Renders `roots` as indented lines, `depth` levels deep and at most `limit` lines long. Wrapper + * chains and sibling runs collapse so the lines go to components that branch. + */ +export function renderTree(roots: TreeNode[], depth: number, limit: number): TreeView { + const lines: string[] = []; + const shapes = new SubtreeShapes(); + let shown = 0; + const total = roots.reduce((sum, root) => sum + 1 + descendantCount(root), 0); + const visit = (node: TreeNode, level: number): void => { + if (lines.length >= limit) return; + const chain = chainOf(node); + const tail = chain.at(-1)!; + shown += chain.length; + const indent = ' '.repeat(level); + if (level + 1 >= depth && tail.children.length > 0) { + lines.push(`${indent}${chainLabel(chain)} (+${descendantCount(tail)} below)`); + return; + } + lines.push(`${indent}${chainLabel(chain)}`); + for (const run of siblingRuns(tail.children, shapes)) { + if (lines.length >= limit) return; + if (run.length === 1) { + visit(run[0]!, level + 1); + continue; + } + shown += run.length; + const below = run.reduce((sum, sibling) => sum + descendantCount(sibling), 0); + lines.push( + `${' '.repeat(level + 1)}${nodeLabel(run[0]!)} x${run.length}${below > 0 ? ` (+${below} below)` : ''}`, + ); + } + }; + for (const root of roots) visit(root, 0); + return { lines, shown, total }; +} + +function ownerChain(tree: ComponentTree, node: TreeNode, length: number): string[] { + const owners: string[] = []; + for ( + let owner = tree.byId.get(node.ownerId); + owner && owners.length < length; + owner = tree.byId.get(owner.ownerId) + ) { + owners.push(owner.name); + } + return owners; +} + +export function matchLine(tree: ComponentTree, node: TreeNode): string { + const owners = ownerChain(tree, node, 2); + return `${nodeLabel(node)}${owners.length > 0 ? ` in ${owners.join(' < ')}` : ''}`; +} + +/** Components whose name contains `query`, ignoring case, or equals it exactly with `exact`. */ +export function findNodes(tree: ComponentTree, query: string, exact: boolean): TreeNode[] { + const needle = query.toLowerCase(); + return [...tree.byId.values()].filter((node) => + exact ? node.name === query : node.name.toLowerCase().includes(needle), + ); +} + +const REF_PATTERN = /^@c(\d+)$/; + +/** The element id of an `@c` ref, or undefined when `target` is a component name. */ +export function parseRef(target: string): number | undefined { + const match = REF_PATTERN.exec(target); + return match ? Number(match[1]) : undefined; +} + +const AMBIGUOUS_LISTED = 5; + +/** The one mounted component a ref or a name selects; a name shared by several fails closed. */ +export function resolveNode(tree: ComponentTree, target: string): TreeNode { + const id = parseRef(target); + if (id !== undefined) { + const node = tree.byId.get(id); + if (!node) { + throw new AppError('COMMAND_FAILED', `No mounted component has ref ${target}.`, { + reason: 'element-not-found', + hint: 'Refs change when components remount or the app reloads; run react-devtools find or tree again.', + }); + } + return node; + } + const exact = findNodes(tree, target, true); + const lowered = target.toLowerCase(); + const matches = + exact.length > 0 + ? exact + : [...tree.byId.values()].filter((node) => node.name.toLowerCase() === lowered); + if (matches.length === 1) return matches[0]!; + if (matches.length === 0) { + throw new AppError('COMMAND_FAILED', `No mounted component is named ${target}.`, { + hint: 'Run react-devtools find to search.', + }); + } + const listed = matches.slice(0, AMBIGUOUS_LISTED).map(componentRef).join(', '); + const more = + matches.length > AMBIGUOUS_LISTED ? ` and ${matches.length - AMBIGUOUS_LISTED} more` : ''; + throw new AppError( + 'INVALID_ARGS', + `${matches.length} mounted components are named ${target}: ${listed}${more}.`, + { hint: `Pick one by ref, or run react-devtools find ${target} --exact to list them.` }, + ); +} diff --git a/src/cli/react-devtools/react-devtools-command.fixtures.ts b/src/cli/react-devtools/react-devtools-command.fixtures.ts new file mode 100644 index 0000000000..b04a0225bb --- /dev/null +++ b/src/cli/react-devtools/react-devtools-command.fixtures.ts @@ -0,0 +1,50 @@ +import { vi } from 'vitest'; +import vm from 'node:vm'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { InspectorSession } from './metro-inspector.ts'; +import type { ReactDevtoolsCommandOptions } from './react-devtools-connection.ts'; +import { FakeAppRuntime } from './react-devtools-runtime.fixtures.ts'; + +export 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' }, +}; + +/** + * Serves `targets` from `/json/list`, evaluates debugger calls in `app`'s realm, and captures what + * `command` prints. Callers restore the stubbed globals and spies after each test. + */ +export function commandHarness( + command: (args: string[], options: ReactDevtoolsCommandOptions) => Promise, + app = new FakeAppRuntime(), + targets: unknown[] = [TARGET], +) { + const stateDir = mkdtempForTestSync('react-profile-'); + const output: string[] = []; + const requested: string[] = []; + const options: ReactDevtoolsCommandOptions = { 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 command(args, options); + return { code, text: output.join('') }; + }; + return { app, run, options, stateDir, requested }; +} diff --git a/src/cli/react-devtools/react-devtools-connection.test.ts b/src/cli/react-devtools/react-devtools-connection.test.ts new file mode 100644 index 0000000000..6fc097df87 --- /dev/null +++ b/src/cli/react-devtools/react-devtools-connection.test.ts @@ -0,0 +1,63 @@ +import { afterEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { CliFlags } from '@agent-device/contracts/command'; +import { runReactProfileCommand } from './react-profile-command.ts'; +import { commandHarness } from './react-devtools-command.fixtures.ts'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const harness = () => commandHarness(runReactProfileCommand); + +function bindSession(stateDir: string, controlBaseUrl: string) { + fs.mkdirSync(path.join(stateDir, 'metro-sessions'), { recursive: true }); + fs.writeFileSync( + path.join(stateDir, 'metro-sessions', 'qa.json'), + JSON.stringify({ controlBaseUrl }), + ); +} + +test('explicit dev-server flags override the session binding', async () => { + const { run, requested } = harness(); + await run('start', '--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('start'); + assert.deepEqual(requested, ['http://127.0.0.1:19000/json/list']); +}); + +test('a remote bridge without a Metro binding stays on the local dev server, not the public URL', async () => { + const { run, requested, options } = harness(); + const flags: Partial = { + leaseBackend: 'android-instance', + metroPublicBaseUrl: 'https://metro.example.test/index.bundle?platform=android', + }; + options.flags = flags; + await run('start'); + assert.deepEqual(requested, ['http://localhost:8081/json/list']); +}); + +test('a host flag on its own keeps the port of the session binding', async () => { + const { run, requested, stateDir } = harness(); + bindSession(stateDir, 'http://127.0.0.1:19000/'); + await run('start', '--metro-host', '10.0.2.2'); + assert.deepEqual(requested, ['http://10.0.2.2:19000/json/list']); +}); + +test('a port that is not a whole number is refused before connecting', async () => { + const { run, requested } = harness(); + await assert.rejects(run('start', '--metro-port', '8081oops'), /Invalid Metro port: 8081oops/); + assert.deepEqual(requested, []); +}); diff --git a/src/cli/react-devtools/react-devtools-connection.ts b/src/cli/react-devtools/react-devtools-connection.ts new file mode 100644 index 0000000000..4eebb5718b --- /dev/null +++ b/src/cli/react-devtools/react-devtools-connection.ts @@ -0,0 +1,239 @@ +import { parseArgs } from 'node:util'; +import { AppError } from '@agent-device/kernel/errors'; +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 { + debuggerUrlOn, + InspectorSession, + listMetroInspectorTargets, + selectReactNativeTarget, + type MetroInspectorTarget, +} from './metro-inspector.ts'; +import { + REACT_DEVTOOLS_RUNTIME_GLOBAL, + REACT_DEVTOOLS_RUNTIME_SOURCE, + REACT_DEVTOOLS_RUNTIME_VERSION, +} from './react-devtools-runtime.ts'; + +export type ReactDevtoolsCommandOptions = { + flags?: Partial>; + stateDir: string; + session: string; +}; + +const DEVTOOLS_OPTIONS = { + limit: { type: 'string' }, + depth: { type: 'string' }, + exact: { type: 'boolean' }, + app: { type: 'string' }, + 'metro-device': { type: 'string' }, + 'metro-host': { type: 'string' }, + 'metro-port': { type: 'string' }, + 'bundle-url': { type: 'string' }, +} as const; + +export type DevtoolsOption = keyof typeof DEVTOOLS_OPTIONS; + +/** Options that pick the app runtime: every command that talks to the app takes them. */ +export const RUNTIME_OPTIONS: readonly DevtoolsOption[] = [ + 'app', + 'metro-device', + 'metro-host', + 'metro-port', + 'bundle-url', +]; + +/** What one command form accepts, named as its usage reads in errors. */ +export type CommandForm = { + usage: string; + positionals: number; + options: readonly DevtoolsOption[]; +}; + +export type RuntimeArgs = { + app?: string; + metroDevice?: string; + metroHost?: string; + metroPort?: string; + bundleUrl?: string; +}; + +export type DevtoolsArgs = RuntimeArgs & { + positionals: string[]; + limit?: number; + depth?: number; + exact: boolean; +}; + +const POSITIVE_INTEGER = /^[1-9]\d*$/; + +function positiveInteger(option: string, value: string | undefined): number | undefined { + if (value === undefined) return undefined; + if (!POSITIVE_INTEGER.test(value)) { + throw new AppError('INVALID_ARGS', `Invalid --${option}: ${value}. Use a positive integer.`); + } + return Number(value); +} + +/** Parses `args` for the form its positionals select; any option that form does not read is refused. */ +export function parseDevtoolsArgs( + args: string[], + formFor: (positionals: string[]) => CommandForm, +): DevtoolsArgs { + let parsed; + try { + parsed = parseArgs({ args, allowPositionals: true, strict: true, options: DEVTOOLS_OPTIONS }); + } catch (error) { + throw new AppError('INVALID_ARGS', error instanceof Error ? error.message : String(error)); + } + const { values, positionals } = parsed; + const form = formFor(positionals); + const unread = (Object.keys(values) as DevtoolsOption[]).find( + (option) => !form.options.includes(option), + ); + if (unread) { + throw new AppError('INVALID_ARGS', `react-devtools ${form.usage} does not take --${unread}.`); + } + if (positionals.length > form.positionals) { + throw new AppError( + 'INVALID_ARGS', + `Unexpected argument for react-devtools ${form.usage}: ${positionals[form.positionals]}.`, + ); + } + return { + positionals, + limit: positiveInteger('limit', values.limit), + depth: positiveInteger('depth', values.depth), + exact: values.exact === true, + app: values.app, + metroDevice: values['metro-device'], + metroHost: values['metro-host'], + metroPort: values['metro-port'], + bundleUrl: values['bundle-url'], + }; +} + +/** + * Explicit flags, then the session's Metro binding, then localhost:8081. A host or port flag on its + * own overrides that part of the binding and keeps the rest. The debugger socket is only accepted + * from the machine that runs Metro, so a device-facing URL such as a remote bridge's is never used. + */ +function resolveDevServerUrl(args: RuntimeArgs, options: ReactDevtoolsCommandOptions): URL { + const complete = + args.bundleUrl !== undefined || (args.metroHost !== undefined && args.metroPort !== undefined); + const binding = complete + ? 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, + }); +} + +/** Connects to the app's JavaScript runtime, installs the DevTools runtime there, and runs `action`. */ +export async function withAppRuntime( + args: RuntimeArgs, + options: ReactDevtoolsCommandOptions, + 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( + debuggerUrlOn(serverUrl, target), + ); + try { + await session.evaluate( + `(${REACT_DEVTOOLS_RUNTIME_SOURCE})(${JSON.stringify(REACT_DEVTOOLS_RUNTIME_VERSION)})`, + ); + return await action(session, target); + } finally { + session.close(); + } +} + +export async function callRuntime( + session: InspectorSession, + method: string, + argument?: unknown, +): Promise { + const call = `${REACT_DEVTOOLS_RUNTIME_GLOBAL}.${method}(${argument === undefined ? '' : JSON.stringify(argument)})`; + const value = await session.evaluate(call); + if (typeof value !== 'string') { + throw new AppError( + 'COMMAND_FAILED', + `The React DevTools runtime returned no result for ${method}.`, + ); + } + return JSON.parse(value) as T; +} + +export type RuntimeFailure = { ok: false; reason: string; elapsedMs?: number; detail?: string }; + +const RUNTIME_FAILURES: Record = { + 'no-devtools-hook': { + message: 'The app runtime has no React DevTools hook.', + hint: 'Use 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.', + }, + 'collect-failed': { + message: 'The React DevTools backend could not export the recorded profile.', + hint: 'Run react-devtools profile start and stop again; reload the app if it keeps failing.', + }, + 'tree-unavailable': { + message: 'Another React DevTools frontend owns the component tree of this app runtime.', + hint: 'Close the connected React DevTools window, reload the app, and retry.', + }, + 'element-not-found': { + message: 'No mounted component has that ref.', + hint: 'Refs change when components remount or the app reloads; run react-devtools find or tree again.', + }, + 'inspect-failed': { + message: 'The React DevTools backend could not inspect the component.', + hint: 'Retry after the component renders again, or inspect its owner.', + }, +}; + +export function runtimeFailure(result: RuntimeFailure): AppError { + const known = RUNTIME_FAILURES[result.reason]; + const message = known?.message ?? `React DevTools failed: ${result.reason}.`; + return new AppError('COMMAND_FAILED', result.detail ? `${message} ${result.detail}` : message, { + reason: result.reason, + ...(known ? { hint: known.hint } : {}), + }); +} + +export type Emit = (text: string, data: unknown) => void; + +export function emitTo(options: ReactDevtoolsCommandOptions): Emit { + const json = options.flags?.json === true; + return (text, data) => { + process.stdout.write( + json ? `${JSON.stringify({ success: true, data }, null, 2)}\n` : `${text}\n`, + ); + }; +} diff --git a/src/cli/react-devtools/react-devtools-inspect-command.test.ts b/src/cli/react-devtools/react-devtools-inspect-command.test.ts new file mode 100644 index 0000000000..bb082bc62d --- /dev/null +++ b/src/cli/react-devtools/react-devtools-inspect-command.test.ts @@ -0,0 +1,177 @@ +import { afterEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import { isInspectCommand, runReactInspectCommand } from './react-devtools-inspect-command.ts'; +import { commandHarness } from './react-devtools-command.fixtures.ts'; +import { FakeAppRuntime, MEMO_TYPE, encodeOperations } from './react-devtools-runtime.fixtures.ts'; +import type { ReactDevtoolsCommandOptions } from './react-devtools-connection.ts'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +async function inspectCommand(args: string[], options: ReactDevtoolsCommandOptions) { + const [command, ...rest] = args; + assert.ok(isInspectCommand(command)); + return await runReactInspectCommand(command, rest, options); +} + +/** An app whose list renders two memoized rows, one of which logged an error. */ +function listApp(): FakeAppRuntime { + const app = new FakeAppRuntime(); + app.mount([ + { id: 2, name: 'App' }, + { id: 3, name: 'List', parent: 2, owner: 2 }, + { id: 4, name: 'Row', type: MEMO_TYPE, parent: 3, owner: 3, key: 'a' }, + { id: 5, name: 'Row', type: MEMO_TYPE, parent: 3, owner: 3, key: 'b' }, + { id: 6, name: 'Footer', parent: 2, owner: 2 }, + ]); + app.renderer.commit(encodeOperations({ logs: [[5, 1, 0]] })); + app.renderer.inspected.set(5, { + type: MEMO_TYPE, + key: 'b', + props: { data: { label: 'Second' }, cleaned: [], unserializable: [] }, + hooks: { + data: [{ id: 0, name: 'State', value: true, subHooks: [] }], + cleaned: [], + unserializable: [], + }, + owners: [{ id: 3, displayName: 'List' }], + errors: [['Failed prop type: label', 2]], + warnings: [], + }); + return app; +} + +const harness = (app = listApp()) => commandHarness(inspectCommand, app); + +test('tree prints the mounted components with refs', async () => { + const { run } = harness(); + assert.equal( + (await run('tree')).text, + [ + 'React tree in com.example.app: 5 components', + '@c2 App', + ' @c3 List', + ' @c4 Row [memo] key=a', + ' @c5 Row [memo] key=b (1 error)', + ' @c6 Footer', + '', + ].join('\n'), + ); + assert.match( + (await run('tree', '@c3', '--depth', '1')).text, + /^React tree under @c3 List: 3 components\n@c3 List \(\+2 below\)\n\+2 components not shown/, + ); +}); + +test('find lists matching components with their owners', async () => { + const { run } = harness(); + assert.equal( + (await run('find', 'row')).text, + '2 mounted components match "row":\n @c4 Row [memo] key=a in List < App\n @c5 Row [memo] key=b (1 error) in List < App\n', + ); + assert.match((await run('find', 'row', '--exact')).text, /^No mounted component name is "row"\./); +}); + +test('inspect by ref prints props, hooks, owners, and logged problems', async () => { + const { run } = harness(); + assert.equal( + (await run('inspect', '@c5')).text, + [ + '@c5 Row [memo] key=b', + 'rendered by: List @c3', + 'props:', + ' label: "Second"', + 'hooks:', + ' State: true', + 'logged while rendering:', + ' error: Failed prop type: label (x2)', + '', + ].join('\n'), + ); +}); + +test('inspect by a name several components share asks for a ref', async () => { + const { run } = harness(); + await assert.rejects(run('inspect', 'Row'), /2 mounted components are named Row: @c4, @c5\./); + await assert.rejects( + run('inspect', '@c99'), + (error: { details: { reason: string } }) => error.details.reason === 'element-not-found', + ); +}); + +test('errors lists the components that logged while rendering, with their messages', async () => { + const { run } = harness(); + assert.equal( + (await run('errors')).text, + '1 mounted component logged errors or warnings while rendering:\n @c5 Row: 1 error\n error: Failed prop type: label (x2)\n', + ); +}); + +test('a runtime whose tree another DevTools frontend owns fails with a typed reason', async () => { + const app = new FakeAppRuntime(); + app.mount([{ id: 2, name: 'App' }]); + app.renderer.queue = null; + app.hook.sub('operations', () => {}); + const { run } = harness(app); + await assert.rejects( + run('tree'), + (error: { details: { reason: string } }) => error.details.reason === 'tree-unavailable', + ); +}); + +test('errors says when the messages of a component could not be read instead of listing none', async () => { + const app = listApp(); + app.renderer.inspectElement = () => ({ type: 'error', message: 'hooks threw' }); + const { run, options } = harness(app); + assert.match( + (await run('errors')).text, + /\n {2}@c5 Row: 1 error\n {4}messages unavailable: hooks threw\n$/, + ); + options.flags = { json: true }; + const parsed = JSON.parse((await run('errors')).text) as { + data: { components: Array<{ messagesUnavailable?: string; errors?: unknown }> }; + }; + assert.deepEqual(parsed.data.components[0]?.messagesUnavailable, 'hooks threw'); + assert.equal(parsed.data.components[0]?.errors, undefined); +}); + +test('errors returns the same shape when nothing logged', async () => { + const app = new FakeAppRuntime(); + app.mount([{ id: 2, name: 'App' }]); + const { run, options } = harness(app); + options.flags = { json: true }; + assert.deepEqual(JSON.parse((await run('errors')).text).data, { + total: 0, + logsTracked: true, + components: [], + }); +}); + +test('errors says its list can be incomplete when the backend does not track logs per component', async () => { + const app = new FakeAppRuntime({ consoleSettings: false }); + app.mount([{ id: 2, name: 'App' }]); + const { run } = harness(app); + assert.equal( + (await run('errors')).text, + [ + 'No mounted component logged an error or warning while rendering.', + "Note: this app's React DevTools backend does not attribute every console error and warning to a component, so this list can be incomplete; check agent-device logs as well.", + '', + ].join('\n'), + ); +}); + +test('each command refuses options it does not read', async () => { + const { run } = harness(); + await assert.rejects( + run('inspect', '@c5', '--limit', '2'), + /react-devtools inspect <@ref\|Name> does not take --limit/, + ); + await assert.rejects(run('find'), /react-devtools find needs an argument/); + await assert.rejects( + run('errors', 'extra'), + /Unexpected argument for react-devtools errors: extra/, + ); +}); diff --git a/src/cli/react-devtools/react-devtools-inspect-command.ts b/src/cli/react-devtools/react-devtools-inspect-command.ts new file mode 100644 index 0000000000..303035b60a --- /dev/null +++ b/src/cli/react-devtools/react-devtools-inspect-command.ts @@ -0,0 +1,242 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { targetAppId, type InspectorSession } from './metro-inspector.ts'; +import { + formatComponentLogs, + formatInspectedComponent, + type InspectedComponent, +} from './react-component-inspect.ts'; +import { + buildComponentTree, + componentRef, + findNodes, + logSummary, + matchLine, + parseRef, + renderTree, + resolveNode, + type ComponentTree, + type RawComponentTree, + type TreeNode, +} from './react-component-tree.ts'; +import { + callRuntime, + emitTo, + parseDevtoolsArgs, + runtimeFailure, + RUNTIME_OPTIONS, + withAppRuntime, + type CommandForm, + type DevtoolsArgs, + type Emit, + type ReactDevtoolsCommandOptions, + type RuntimeFailure, +} from './react-devtools-connection.ts'; +import { printable } from './react-devtools-text.ts'; + +const INSPECT_COMMANDS = ['tree', 'find', 'inspect', 'errors'] as const; + +export type InspectCommand = (typeof INSPECT_COMMANDS)[number]; + +export function isInspectCommand(value: string | undefined): value is InspectCommand { + return INSPECT_COMMANDS.includes(value as InspectCommand); +} + +type TreeResult = ({ ok: true } & RawComponentTree) | RuntimeFailure; +type InspectedResult = InspectedComponent | { missing: true } | { error: string }; +type InspectResult = { ok: true; elements: Record } | RuntimeFailure; + +const UNTRACKED_LOGS_NOTE = + "Note: this app's React DevTools backend does not attribute every console error and warning to a component, so this list can be incomplete; check agent-device logs as well."; + +const TREE_DEPTH = 8; +const TREE_LINES = 60; +const FIND_LIMIT = 20; +const ERRORS_LIMIT = 10; + +const FORMS: Record = { + tree: { usage: 'tree', positionals: 1, options: [...RUNTIME_OPTIONS, 'depth', 'limit'] }, + find: { usage: 'find ', positionals: 1, options: [...RUNTIME_OPTIONS, 'exact', 'limit'] }, + inspect: { usage: 'inspect <@ref|Name>', positionals: 1, options: RUNTIME_OPTIONS }, + errors: { usage: 'errors', positionals: 0, options: [...RUNTIME_OPTIONS, 'limit'] }, +}; + +async function readTree(session: InspectorSession): Promise { + const result = await callRuntime(session, 'tree'); + if (!result.ok) throw runtimeFailure(result); + return buildComponentTree(result); +} + +async function inspectIds( + session: InspectorSession, + ids: number[], +): Promise> { + const result = await callRuntime(session, 'inspect', ids); + if (!result.ok) throw runtimeFailure(result); + return result.elements; +} + +function requireInspected(result: InspectedResult | undefined): InspectedComponent { + if (!result || 'missing' in result) { + throw runtimeFailure({ ok: false, reason: 'element-not-found' }); + } + if ('error' in result) + throw runtimeFailure({ ok: false, reason: 'inspect-failed', detail: result.error }); + return result; +} + +function requireTarget(args: DevtoolsArgs, usage: string): string { + const target = args.positionals[0]; + if (!target) throw new AppError('INVALID_ARGS', `react-devtools ${usage} needs an argument.`); + return target; +} + +type Handler = ( + args: DevtoolsArgs, + options: ReactDevtoolsCommandOptions, + emit: Emit, +) => Promise; + +const HANDLERS: Record = { + tree: async (args, options, emit) => { + const { app, tree, root } = await withAppRuntime(args, options, async (session, target) => { + const tree = await readTree(session); + const root = args.positionals[0] ? resolveNode(tree, args.positionals[0]) : undefined; + return { app: targetAppId(target), tree, root }; + }); + const view = renderTree( + root ? [root] : tree.roots, + args.depth ?? TREE_DEPTH, + args.limit ?? TREE_LINES, + ); + const scope = root ? `under ${componentRef(root)} ${root.name}` : `in ${app}`; + const header = `React tree ${scope}: ${view.total} components`; + const hidden = view.total - view.shown; + const footer = + hidden > 0 + ? [ + `+${hidden} components not shown (raise --depth or --limit, or run tree <@ref> on a branch)`, + ] + : []; + emit([header, ...view.lines, ...footer].join('\n'), { + app, + root: root ? componentRef(root) : null, + total: view.total, + shown: view.shown, + lines: view.lines, + }); + }, + find: async (args, options, emit) => { + const query = requireTarget(args, 'find '); + const tree = await withAppRuntime(args, options, async (session) => await readTree(session)); + const matches = findNodes(tree, query, args.exact); + const limit = args.limit ?? FIND_LIMIT; + const shown = matches.slice(0, limit); + const header = + matches.length === 0 + ? `No mounted component name ${args.exact ? 'is' : 'contains'} ${JSON.stringify(query)}.` + : `${matches.length} mounted ${matches.length === 1 ? 'component matches' : 'components match'} ${JSON.stringify(query)}:`; + const more = + matches.length > shown.length + ? [` +${matches.length - shown.length} more (--limit ${matches.length})`] + : []; + emit([header, ...shown.map((node) => ` ${matchLine(tree, node)}`), ...more].join('\n'), { + total: matches.length, + matches: shown.map((node) => matchData(node)), + }); + }, + inspect: async (args, options, emit) => { + const target = requireTarget(args, 'inspect <@ref|Name>'); + const { id, component } = await withAppRuntime(args, options, async (session) => { + const id = parseRef(target) ?? resolveNode(await readTree(session), target).id; + const elements = await inspectIds(session, [id]); + return { id, component: requireInspected(elements[String(id)]) }; + }); + emit(formatInspectedComponent(id, component), { ref: componentRef({ id }), ...component }); + }, + errors: async (args, options, emit) => { + const limit = args.limit ?? ERRORS_LIMIT; + const { logged, inspected, logsTracked } = await withAppRuntime( + args, + options, + async (session) => { + const tree = await readTree(session); + const logged = [...tree.byId.values()] + .filter((node) => node.errors + node.warnings > 0) + .sort((a, b) => b.errors - a.errors || b.warnings - a.warnings); + const ids = logged.slice(0, limit).map((node) => node.id); + return { + logged, + inspected: ids.length > 0 ? await inspectIds(session, ids) : {}, + logsTracked: tree.logsTracked, + }; + }, + ); + const untracked = logsTracked ? [] : [UNTRACKED_LOGS_NOTE]; + if (logged.length === 0) { + emit( + ['No mounted component logged an error or warning while rendering.', ...untracked].join( + '\n', + ), + { total: 0, logsTracked, components: [] }, + ); + return; + } + const shown = logged.slice(0, limit); + const lines = [ + `${logged.length} mounted ${logged.length === 1 ? 'component' : 'components'} logged errors or warnings while rendering:`, + ]; + for (const node of shown) { + lines.push(` ${componentRef(node)} ${node.name}: ${logSummary(node)}`); + const messages = loggedMessages(inspected[String(node.id)]); + if ('unavailable' in messages) + lines.push(` messages unavailable: ${messages.unavailable}`); + else lines.push(...formatComponentLogs(messages.component).map((line) => ` ${line}`)); + } + if (logged.length > shown.length) + lines.push(` +${logged.length - shown.length} more (--limit ${logged.length})`); + lines.push(...untracked); + emit(lines.join('\n'), { + total: logged.length, + logsTracked, + components: shown.map((node) => { + const messages = loggedMessages(inspected[String(node.id)]); + return 'unavailable' in messages + ? { ...matchData(node), messagesUnavailable: messages.unavailable } + : { + ...matchData(node), + errors: messages.component.errors, + warnings: messages.component.warnings, + }; + }), + }); + }, +}; + +/** The inspected component behind a log count, or why its messages could not be read. */ +function loggedMessages( + result: InspectedResult | undefined, +): { component: InspectedComponent } | { unavailable: string } { + if (!result || 'missing' in result) return { unavailable: 'the component unmounted' }; + if ('error' in result) return { unavailable: printable(result.error) }; + return { component: result }; +} + +function matchData(node: TreeNode) { + return { + ref: componentRef(node), + name: node.name, + key: node.key, + errorCount: node.errors, + warningCount: node.warnings, + }; +} + +export async function runReactInspectCommand( + command: InspectCommand, + args: string[], + options: ReactDevtoolsCommandOptions, +): Promise { + const parsed = parseDevtoolsArgs(args, () => FORMS[command]); + await HANDLERS[command](parsed, options, emitTo(options)); + return 0; +} diff --git a/src/cli/react-devtools/react-devtools-runtime.fixtures.ts b/src/cli/react-devtools/react-devtools-runtime.fixtures.ts new file mode 100644 index 0000000000..3f9305f9cf --- /dev/null +++ b/src/cli/react-devtools/react-devtools-runtime.fixtures.ts @@ -0,0 +1,301 @@ +import vm from 'node:vm'; +import { + REACT_DEVTOOLS_RUNTIME_GLOBAL, + REACT_DEVTOOLS_RUNTIME_SOURCE, + REACT_DEVTOOLS_RUNTIME_VERSION, +} from './react-devtools-runtime.ts'; + +const OP_ADD = 1; +const OP_REMOVE = 2; +const OP_REORDER_CHILDREN = 3; +const OP_UPDATE_TREE_BASE_DURATION = 4; +const OP_UPDATE_ERRORS_OR_WARNINGS = 5; +const ROOT_TYPE = 11; +export const FUNCTION_TYPE = 5; +export const MEMO_TYPE = 8; +export const SUSPENSE_TYPE = 12; + +export type FakeElement = { + id: number; + name: string; + type?: number; + owner?: number; + parent?: number; + key?: string; + /** The `name` prop React DevTools 7+ shows for Suspense and Activity boundaries. */ + nameProp?: string; +}; + +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 }>; +}; + +type OperationsBatch = { + root?: boolean; + add?: FakeElement[]; + remove?: number[]; + baseDurations?: number[]; + reorder?: [parent: number, children: number[]]; + logs?: Array<[id: number, errors: number, warnings: number]>; + extra?: number[]; + /** React DevTools 7+ layout: each non-root ADD ends with a name-prop string id. */ + nameProps?: boolean; +}; + +/** Interns strings the way the backend's string table does: 1-based ids, 0 for none. */ +class StringTable { + readonly #strings: string[] = []; + + id(value: string | undefined): number { + if (value === undefined) return 0; + let index = this.#strings.indexOf(value); + if (index === -1) index = this.#strings.push(value) - 1; + return index + 1; + } + + encode(): number[] { + return this.#strings.flatMap((value) => { + const codePoints = [...value].map((char) => char.codePointAt(0)!); + return [codePoints.length, ...codePoints]; + }); + } +} + +function addOperations( + elements: FakeElement[], + strings: StringTable, + nameProps: boolean, +): number[] { + return elements.flatMap((element) => [ + OP_ADD, + element.id, + element.type ?? FUNCTION_TYPE, + element.parent ?? 0, + element.owner ?? 0, + strings.id(element.name), + strings.id(element.key), + ...(nameProps ? [strings.id(element.nameProp)] : []), + ]); +} + +function updateOperations(batch: OperationsBatch): number[] { + const ops: number[] = []; + if (batch.remove?.length) ops.push(OP_REMOVE, batch.remove.length, ...batch.remove); + if (batch.reorder) { + const [parent, children] = batch.reorder; + ops.push(OP_REORDER_CHILDREN, parent, children.length, ...children); + } + for (const [id, errors, warnings] of batch.logs ?? []) { + ops.push(OP_UPDATE_ERRORS_OR_WARNINGS, id, errors, warnings); + } + for (const id of batch.baseDurations ?? []) ops.push(OP_UPDATE_TREE_BASE_DURATION, id, 1); + return ops; +} + +/** + * 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(batch: OperationsBatch): number[] { + const strings = new StringTable(); + const ops = [ + ...(batch.root ? [OP_ADD, 1, ROOT_TYPE, 0, 1, 1, 1] : []), + ...addOperations(batch.add ?? [], strings, batch.nameProps === true), + ...updateOperations(batch), + ...(batch.extra ?? []), + ]; + const table = strings.encode(); + 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> }>(); + /** What `inspectElement` reports for an element, beyond the props above. */ + inspected = new Map>(); + 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); + const inspected = this.inspected.get(id); + if (!props && !inspected) return { type: 'not-found' }; + return { type: 'full-data', value: { props, ...inspected } }; + } +} + +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; + /** React DevTools 6 hooks carry console settings and attribute logs to components from app start. */ + consoleSettings?: boolean; + /** React DevTools 7+ renderer interfaces report Suspense toggling and add name props to ADD ops. */ + nameProps?: 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; + settings?: { showInlineWarningsAndErrors: boolean }; + }; + readonly nameProps: boolean; + 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.nameProps = options.nameProps === true; + if (this.nameProps) Object.assign(this.renderer, { supportsTogglingSuspense: true }); + 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, + ...(options.consoleSettings === false + ? {} + : { settings: { showInlineWarningsAndErrors: true } }), + }; + 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_DEVTOOLS_RUNTIME_VERSION): unknown { + return vm.runInContext( + `(${REACT_DEVTOOLS_RUNTIME_SOURCE})(${JSON.stringify(version)})`, + this.context, + ); + } + + call(method: string, argument?: unknown): T { + const value = vm.runInContext( + `${REACT_DEVTOOLS_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, nameProps: this.nameProps }), + ); + for (const element of elements) this.renderer.names.set(element.id, element.name); + } +} diff --git a/src/cli/react-devtools/react-devtools-runtime.test.ts b/src/cli/react-devtools/react-devtools-runtime.test.ts new file mode 100644 index 0000000000..3e2cc21f9a --- /dev/null +++ b/src/cli/react-devtools/react-devtools-runtime.test.ts @@ -0,0 +1,421 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { + FakeAppRuntime, + MEMO_TYPE, + SUSPENSE_TYPE, + encodeOperations, +} from './react-devtools-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: started.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, null]); + assert.deepEqual( + stopped.elements['2'], + ['App', 5, 0, 0, null], + '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, null]); + 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(); + assert.equal(app.call('start').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(started.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' }, + }, + ); +}); + +test('a renderer whose profiling data cannot be read fails the stop instead of returning no commits', () => { + const app = new FakeAppRuntime(); + app.install(); + app.call('start'); + app.renderer.getProfilingData = () => { + throw new Error('profiling data unavailable'); + }; + assert.deepEqual(app.call('stop'), { + ok: false, + reason: 'collect-failed', + detail: 'profiling data unavailable', + }); +}); + +test('a profile read from only some renderers says which ones failed', () => { + const app = new FakeAppRuntime(); + app.install(); + const broken = Object.assign(Object.create(app.renderer), { + getProfilingData: () => { + throw new Error('second root gone'); + }, + }); + app.hook.rendererInterfaces.set(2, broken); + app.call('start'); + const stopped = app.call('stop'); + assert.equal(stopped.ok, true); + assert.deepEqual(stopped.failures, ['second root gone']); +}); + +type Tree = { + ok: true; + nodes: Array<[number, string, number, number, number, string | null]>; + order: Record; + logs: Record; +}; + +test('tree exports every mounted component with its key, child order, and logged problems', () => { + const app = new FakeAppRuntime(); + app.mount([ + { id: 2, name: 'App', parent: 1 }, + { id: 3, name: 'Row', parent: 2, owner: 2, key: 'a' }, + { id: 4, name: 'Row', parent: 2, owner: 2, key: 'b' }, + ]); + app.install(); + app.renderer.commit( + encodeOperations({ reorder: [2, [4, 3]], logs: [[3, 1, 2]], add: [{ id: 5, name: 'Gone' }] }), + ); + app.renderer.commit(encodeOperations({ remove: [5] })); + + const tree = app.call('tree'); + assert.deepEqual(tree.nodes, [ + [2, 'App', 5, 1, 0, null], + [3, 'Row', 5, 2, 2, 'a'], + [4, 'Row', 5, 2, 2, 'b'], + ]); + assert.deepEqual(tree.order, { '2': [4, 3] }); + assert.deepEqual(tree.logs, { '3': [1, 2] }); +}); + +test('a React DevTools 7+ backend adds a name prop to each element and emits Suspense ops', () => { + const app = new FakeAppRuntime({ nameProps: true }); + app.mount([ + { id: 2, name: 'App', parent: 1 }, + { id: 3, name: 'Suspense', type: SUSPENSE_TYPE, parent: 2, owner: 2, nameProp: 'feed' }, + ]); + app.install(); + const suspenseAdd = [8, 3, 1, 0, 1, 2, 0, 0, 10, 20, 0, 0, 5, 5]; + const suspenseResize = [11, 3, -1]; + const suspenders = [12, 2, 3, 1, 1500, 0, 1, 1, 4, 0, 900, 1, 0]; + const activitySlice = [13, 0]; + app.renderer.commit( + encodeOperations({ + nameProps: true, + add: [{ id: 4, name: 'Row', parent: 3, owner: 2, key: 'a', nameProp: 'first' }], + extra: [...suspenseAdd, ...suspenseResize, ...suspenders, ...activitySlice, 10, 3, 1, 4], + }), + ); + app.renderer.commit(encodeOperations({ nameProps: true, logs: [[4, 1, 0]], extra: [9, 1, 3] })); + + const tree = app.call('tree'); + assert.deepEqual(tree.nodes, [ + [2, 'App', 5, 1, 0, null], + [3, 'Suspense', SUSPENSE_TYPE, 2, 2, null], + [4, 'Row', 5, 3, 2, 'a'], + ]); + assert.deepEqual(tree.logs, { '4': [1, 0] }); +}); + +test('a component that unmounts while a profile records leaves the tree but keeps its name', () => { + const app = new FakeAppRuntime(); + app.mount([{ id: 2, name: 'App' }]); + app.install(); + app.call('start'); + app.renderer.commit(encodeOperations({ add: [{ id: 9, name: 'Toast' }] })); + app.renderer.commit(encodeOperations({ remove: [9] })); + assert.deepEqual( + app.call('tree').nodes.map((node) => node[1]), + ['App'], + ); +}); + +test('without the operations history there is no tree to export', () => { + const app = new FakeAppRuntime({ fusebox: false }); + app.install(); + assert.deepEqual(app.call('tree'), { ok: false, reason: 'tree-unavailable' }); +}); + +test('inspect returns props, hooks, owners, and logged problems of mounted components', () => { + const app = new FakeAppRuntime(); + app.mount([{ id: 3, name: 'Row', key: 'a' }]); + app.install(); + const props = { data: { label: 'First' }, cleaned: [], unserializable: [] }; + const hooks = { + data: [{ id: 0, name: 'State', value: 2, subHooks: [] }], + cleaned: [], + unserializable: [], + }; + const owners = [{ id: 2, displayName: 'List', type: 5, key: null }]; + app.renderer.inspected.set(3, { + type: 5, + key: 'a', + props, + state: null, + hooks, + context: null, + owners, + errors: [['boom', 2]], + warnings: [], + isErrored: false, + }); + const result = app.call<{ ok: true; elements: Record> }>( + 'inspect', + [3, 99], + ); + assert.deepEqual(result.elements['3'], { + name: 'Row', + type: 5, + key: 'a', + props, + state: null, + hooks, + context: null, + owners, + errors: [['boom', 2]], + warnings: [], + isErrored: false, + }); + assert.deepEqual(result.elements['99'], { missing: true }); +}); + +test('stop reads only the renderers that started, so a failed export of the one recording fails', () => { + const app = new FakeAppRuntime(); + app.install(); + const unstarted = Object.assign(Object.create(app.renderer), { + startProfiling: () => { + throw new Error('profiling unsupported'); + }, + getProfilingData: () => ({ dataForRoots: [] }), + }); + app.hook.rendererInterfaces.set(2, unstarted); + app.call('start'); + app.renderer.getProfilingData = () => { + throw new Error('profiling data unavailable'); + }; + assert.deepEqual(app.call('stop'), { + ok: false, + reason: 'collect-failed', + detail: 'profiling data unavailable', + }); +}); diff --git a/src/cli/react-devtools/react-devtools-runtime.ts b/src/cli/react-devtools/react-devtools-runtime.ts new file mode 100644 index 0000000000..1f55708ba2 --- /dev/null +++ b/src/cli/react-devtools/react-devtools-runtime.ts @@ -0,0 +1,453 @@ +import { createHash } from 'node:crypto'; + +/** Global the runtime installs itself on inside the app's JavaScript runtime. */ +export const REACT_DEVTOOLS_RUNTIME_GLOBAL = '__AGENT_DEVICE_REACT_DEVTOOLS__'; + +/** + * Source evaluated inside the app through CDP `Runtime.evaluate`. It installs + * `globalThis.__AGENT_DEVICE_REACT_DEVTOOLS__` 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, keys, the tree, and per-component error and warning counts 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, and it has no tree to offer. + * + * Props, state, hooks, and owners come from the backend's `inspectElement`, the call the DevTools + * frontend makes when a component is selected. + */ +export const REACT_DEVTOOLS_RUNTIME_SOURCE = String.raw` +(function (VERSION) { + var g = globalThis; + var previous = g.__AGENT_DEVICE_REACT_DEVTOOLS__; + if (previous && previous.version === VERSION) return 'ready'; + var inherited = previous && previous.elements instanceof Map ? previous.elements : null; + var inheritedHistory = !!(previous && previous.history); + var inheritedOrder = previous && previous.order instanceof Map ? previous.order : null; + var inheritedLogs = previous && previous.logs instanceof Map ? previous.logs : null; + 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 order = inheritedOrder || new Map(); + var logs = inheritedLogs || new Map(); + var history = inheritedHistory; + var nextRequestID = 1; + 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); } + + /** + * React DevTools 7 added a name-prop field to each non-root ADD, in the same release that gave + * renderer interfaces supportsTogglingSuspense. + */ + function addLength(rendererID) { + var h = hook(); + var ri = h && h.rendererInterfaces && h.rendererInterfaces.get ? h.rendererInterfaces.get(rendererID) : null; + return ri && 'supportsTogglingSuspense' in ri ? 8 : 7; + } + + /** Length of an op this runtime skips: the Suspense tree and Activity slice ops of DevTools 7+. */ + function skippedLength(ops, i) { + var op = ops[i]; + if (op === 4 || op === 7) return 3; + if (op === 6) return 1; + if (op === 8) return 6 + 4 * Math.max(ops[i + 5], 0); + if (op === 9) return 2 + ops[i + 1]; + if (op === 10) return 3 + ops[i + 2]; + if (op === 11) return 3 + 4 * Math.max(ops[i + 2], 0); + if (op === 12) { + var j = i + 2; + for (var c = 0; c < ops[i + 1]; c++) j += 5 + ops[j + 4]; + return j - i; + } + if (op === 13) return 2; + return 0; + } + + function readOperations(ops) { + var nonRootAdd = addLength(ops[0]); + 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]; + var keyIndex = ops[i + 6]; + if (nameIndex >= strings.length || keyIndex >= strings.length) return false; + elements.set(id, [strings[nameIndex] || 'Anonymous', type, ops[i + 4], ops[i + 3], strings[keyIndex]]); + } + i += type === ROOT_TYPE ? 7 : nonRootAdd; + } else if (op === 2) { + for (var r = 0; r < ops[i + 1]; r++) forget(ops[i + 2 + r]); + i += 2 + ops[i + 1]; + } else if (op === 3) { + order.set(ops[i + 1], ops.slice(i + 3, i + 3 + ops[i + 2])); + i += 3 + ops[i + 2]; + } else if (op === 5) { + if (ops[i + 2] || ops[i + 3]) logs.set(ops[i + 1], [ops[i + 2], ops[i + 3]]); + else logs.delete(ops[i + 1]); + i += 4; + } else { + var skipped = skippedLength(ops, i); + if (!skipped) return false; + i += skipped; + } + } + return true; + } + + /** A profile keeps the names of what unmounts while it records; the tree drops them at once. */ + function forget(id) { + order.delete(id); + logs.delete(id); + if (session) session.removed.push(id); + else elements.delete(id); + } + + 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]; + } + + /** Reads back only the renderers this session started; another renderer has no recording. */ + function exportProfile(renderers) { + var commits = []; + var refs = Object.create(null); + var keyTable = { keys: [], index: Object.create(null) }; + var failures = []; + renderers.forEach(function (ri) { + var data; + try { ri.stopProfiling(); data = ri.getProfilingData(); } catch (e) { + failures.push(String(e && e.message || 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]; }); + Object.keys(refs).forEach(function (id) { + for (var parent = refs[id][3]; parent && !refs[parent] && elements.get(parent); parent = refs[parent][3]) { + refs[parent] = elements.get(parent); + } + }); + return { elements: refs, keys: keyTable.keys, commits: commits, failures: failures }; + } + + 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); + Object.keys(request).forEach(function (id) { + var keys = request[id]; + eachRenderer(function (ri) { + if (out[id]) return; + var inspected; + try { inspected = ri.inspectElement(nextRequestID++, 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 tree() { + var attached = attach(); + if (!attached.ok) return attached; + if (naming() !== 'operations') return { ok: false, reason: 'tree-unavailable' }; + var unmounted = Object.create(null); + if (session) session.removed.forEach(function (id) { unmounted[id] = true; }); + var nodes = []; + elements.forEach(function (element, id) { + if (!unmounted[id]) nodes.push([id, element[0], element[1], element[3], element[2], element[4] == null ? null : element[4]]); + }); + var childOrder = Object.create(null); + order.forEach(function (children, id) { childOrder[id] = children; }); + var counts = Object.create(null); + logs.forEach(function (count, id) { counts[id] = count; }); + var h = hook(); + var logsTracked = !!(h.settings && h.settings.showInlineWarningsAndErrors !== false); + return { ok: true, nodes: nodes, order: childOrder, logs: counts, logsTracked: logsTracked }; + } + + function inspectOne(id) { + var found = null; + eachRenderer(function (ri) { + if (found || (ri.hasElementWithId && !ri.hasElementWithId(id))) return; + var result; + try { result = ri.inspectElement(nextRequestID++, id, null, true); } catch (e) { + found = { error: String(e && e.message || e) }; + return; + } + if (!result || result.type === 'not-found') return; + if (result.type !== 'full-data') { found = { error: result.message || result.type }; return; } + var value = result.value; + var known = elements.get(id); + found = { + name: known ? known[0] : lookupName(ri, id), + type: value.type, + key: value.key == null ? null : value.key, + props: value.props, + state: value.state, + hooks: value.hooks, + context: value.context, + owners: value.owners, + errors: value.errors, + warnings: value.warnings, + isErrored: !!value.isErrored + }; + }); + return found || { missing: true }; + } + + function inspect(ids) { + var attached = attach(); + if (!attached.ok) return attached; + var out = Object.create(null); + ids.forEach(function (id) { out[id] = inspectOne(id); }); + return { ok: true, elements: 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 = []; + eachRenderer(function (ri) { + try { ri.startProfiling(true, false); started.push(ri); } catch (e) {} + }); + if (started.length === 0) return { ok: false, reason: 'start-failed' }; + session = { startedAt: Date.now(), renderers: started, removed: [] }; + return { ok: true, attach: attached.attach, naming: attached.naming }; + } + + function stop() { + if (!session) return { ok: false, reason: 'not-profiling' }; + var startedAt = session.startedAt; + var renderers = session.renderers; + var profile = exportProfile(renderers); + session.removed.forEach(function (id) { elements.delete(id); }); + session = null; + if (profile.failures.length === renderers.length) { + return { ok: false, reason: 'collect-failed', detail: profile.failures[0] }; + } + return { + ok: true, + startedAt: startedAt, + stoppedAt: Date.now(), + naming: naming(), + elements: profile.elements, + keys: profile.keys, + commits: profile.commits, + failures: profile.failures + }; + } + + function dispose() { + if (unsubscribe) unsubscribe(); + unsubscribe = null; + if (session) session.renderers.forEach(function (ri) { try { ri.stopProfiling(); } catch (e) {} }); + session = null; + } + + g.__AGENT_DEVICE_REACT_DEVTOOLS__ = { + version: VERSION, + elements: elements, + order: order, + logs: logs, + get history() { return history; }, + start: function () { return JSON.stringify(start()); }, + stop: function () { return JSON.stringify(stop()); }, + describeProps: function (request) { return JSON.stringify(describeProps(request)); }, + tree: function () { return JSON.stringify(tree()); }, + inspect: function (ids) { return JSON.stringify(inspect(ids)); }, + 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_DEVTOOLS_RUNTIME_VERSION = createHash('sha256') + .update(REACT_DEVTOOLS_RUNTIME_SOURCE) + .digest('hex') + .slice(0, 16); diff --git a/src/cli/react-devtools/react-devtools-text.ts b/src/cli/react-devtools/react-devtools-text.ts new file mode 100644 index 0000000000..67827e61a1 --- /dev/null +++ b/src/cli/react-devtools/react-devtools-text.ts @@ -0,0 +1,13 @@ +/** + * App-provided text for one line of output. Control characters, which a terminal would act on or + * which would start a line the app wrote, become spaces. + */ +export function printable(text: string): string { + // eslint-disable-next-line no-control-regex + return text.replaceAll(/[\u0000-\u001f\u007f-\u009f]/g, ' '); +} + +/** Output lines joined as text, each made printable; app text inside a line cannot start another. */ +export function printableLines(lines: string[]): string { + return lines.map(printable).join('\n'); +} diff --git a/src/cli/react-devtools/react-profile-analysis.test.ts b/src/cli/react-devtools/react-profile-analysis.test.ts new file mode 100644 index 0000000000..49c68ed4ed --- /dev/null +++ b/src/cli/react-devtools/react-profile-analysis.test.ts @@ -0,0 +1,548 @@ +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 finding about memo Row does not explain a slow commit spent in plain Row', () => { + const memoRows = repeat(12, (i) => 10 + i); + const plainRows = repeat(50, (i) => 100 + i); + const elements: Record = { 1: ['List'] }; + for (const id of memoRows) elements[id] = ['Memo(Row)', MEMO, 1]; + for (const id of plainRows) elements[id] = ['Row', FUNCTION, 1]; + const raw = rawProfile(elements, [ + ...repeat(3, (i): [number, number, number[], Render[]] => [ + i * 200, + 10, + [1], + [ + [1, 1, [0, null, [0], null]], + ...memoRows.map((id): Render => [id, 0.5, [0, [0], null, null]]), + ], + ]), + [ + 1_000, + 100, + [1], + [[1, 0, [0, null, [0], null], 100], ...plainRows.map((id): Render => [id, 2, 1])], + ], + ]); + + const findings = analyzeReactProfile(raw).findings; + const slowCommit = findings.find((finding) => finding.kind === 'slow-commit'); + assert.equal(slowCommit?.costMs, 100); + assert.match(slowCommit!.evidence, /^commit #3 took 100ms/); +}); + +test('time inside a subtree a finding accounts for is not counted twice', () => { + const elements: Record = { + 1: ['Screen'], + 2: ['Panel', FUNCTION, 1, 1], + 3: ['Box', FUNCTION, 2, 2], + 4: ['View', FUNCTION, 1, 1], + }; + const raw = rawProfile(elements, [ + ...repeat(12, (i): [number, number, number[], Render[]] => [ + i * 200, + 10, + [1], + [ + [1, 1, [0, null, [0], null], 10], + [2, 1, 'parent', 2], + [3, 1, 'parent'], + ], + ]), + [ + 3_000, + 100, + [1], + [ + [1, 2, [0, null, [0], null], 100], + [4, 55, 'parent'], + [2, 5, 'parent', 40], + [3, 35, 'parent'], + ], + ], + ]); + + const findings = analyzeReactProfile(raw).findings; + assert.ok( + findings.some((finding) => finding.kind === 'parent-cascade' && finding.component === 'Panel'), + ); + assert.ok( + findings.some((finding) => finding.kind === 'parent-cascade' && finding.component === 'Box'), + ); + assert.match( + findings.find((finding) => finding.kind === 'slow-commit')?.evidence ?? '', + /^commit #12 took 100ms/, + 'Panel accounts for 40ms including Box, which leaves most of the commit unexplained', + ); +}); + +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-devtools/react-profile-analysis.ts b/src/cli/react-devtools/react-profile-analysis.ts new file mode 100644 index 0000000000..8c97f668ad --- /dev/null +++ b/src/cli/react-devtools/react-profile-analysis.ts @@ -0,0 +1,488 @@ +import { + bump, + chronological, + commitRenders, + componentKey, + componentsOf, + formatMs, + FRAME_BUDGET_MS, + isFrameworkPrimitive, + parentOf, + sameComponent, + summarizeRenders, + type CommitComponent, + type CommitProfile, + type ComponentId, + type ComponentKind, + 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; + /** Absent only for a slow commit that no app component accounts for. */ + componentKind?: ComponentKind; + /** Estimated render time the pattern cost during the profile. */ + costMs: number; + evidence: string; + fix: string; +}; + +export type ReactProfileAnalysis = { + 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 about(component: ComponentId): Pick { + return { component: component.name, componentKind: component.kind }; +} + +export function isFindingFor(finding: Finding, component: ComponentId): boolean { + return ( + finding.componentKind !== undefined && + sameComponent({ name: finding.component, kind: finding.componentKind }, component) + ); +} + +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', + ...about(entry), + 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', + ...about(entry), + 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', + ...about(entry), + 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', + ...about(entry), + 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', + ...about(entry), + 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 mountsByComponent = 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 key = componentKey(render); + const group = groups.get(key) ?? { component: render, count: 0, actualMs: 0 }; + group.count += 1; + group.actualMs += render.actualMs; + groups.set(key, group); + } + const durationMs = commits[index]!.durationMs; + for (const [key, group] of groups) { + if (group.count < EAGER_MOUNT_INSTANCES) continue; + const costMs = Math.min(group.actualMs, durationMs); + const entry = mountsByComponent.get(key) ?? { component: group.component, mounts: [] }; + entry.mounts.push({ index, count: group.count, costMs, durationMs, mounted }); + mountsByComponent.set(key, entry); + } + }); + return [...mountsByComponent.values()].map(({ component, 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, + ...about(component), + 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 chainedUpdaters( + profile: RawReactProfile, + previous: RawProfileCommit, + commit: RawProfileCommit, +): ComponentId[] { + 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 componentsOf(profile, chained).filter((updater) => !isFrameworkPrimitive(updater.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 updater of chainedUpdaters(profile, previous, commit)) { + const key = componentKey(updater); + const chain = chains.get(key) ?? { component: updater, count: 0, costMs: 0 }; + chain.count += 1; + chain.costMs += commit.duration; + chains.set(key, chain); + } + } + return [...chains.values()] + .filter((chain) => chain.count >= 3) + .map((chain) => ({ + kind: 'effect-chain' as const, + ...about(chain.component), + 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 updater of componentsOf(profile, commit.updaters)) { + const key = componentKey(updater); + const entry = byUpdater.get(key) ?? { component: updater, indexes: [] }; + entry.indexes.push(index); + byUpdater.set(key, entry); + } + }); + const findings: Finding[] = []; + for (const { component, indexes } of byUpdater.values()) { + if (indexes.length < STORM_MIN_COMMITS || isFrameworkPrimitive(component.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', + ...about(component), + 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']); + +type Explanation = 'self' | 'subtree'; + +/** What each component's findings account for, by component key: its own render or its subtree. */ +function explanations(earlier: Finding[]): Map { + const byComponent = new Map(); + for (const finding of earlier) { + if (finding.componentKind === undefined) continue; + const key = componentKey({ name: finding.component, kind: finding.componentKind }); + if (!SELF_COST_FINDINGS.has(finding.kind)) byComponent.set(key, 'subtree'); + else if (!byComponent.has(key)) byComponent.set(key, 'self'); + } + return byComponent; +} + +/** + * Render time in a commit that earlier findings already account for. A subtree finding's time + * includes every render below it, so renders inside an accounted subtree are not counted again. + */ +function explainedMs( + profile: RawReactProfile, + commit: RawProfileCommit, + explained: Map, +): number { + const renders = [...commitRenders(profile, commit)]; + const accountedSubtrees = new Set( + renders.filter((render) => explained.get(componentKey(render)) === 'subtree').map((r) => r.id), + ); + const insideAccounted = (id: number): boolean => { + const visited = new Set(); + for ( + let parent = parentOf(profile, id); + parent && !visited.has(parent); + parent = parentOf(profile, parent) + ) { + if (accountedSubtrees.has(parent)) return true; + visited.add(parent); + } + return false; + }; + let total = 0; + for (const render of renders) { + const explanation = explained.get(componentKey(render)); + if (!explanation || insideAccounted(render.id)) continue; + total += explanation === 'subtree' ? render.actualMs : render.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.components.filter( + (entry) => + !isFrameworkPrimitive(entry.name) && entry.actualMs >= commit.durationMs * SUBTREE_SHARE, + ); + return ( + covering.find((entry) => commit.updaters.some((updater) => sameComponent(updater, entry))) ?? + covering.sort((a, b) => a.actualMs - b.actualMs)[0] + ); +} + +function slowCommitFinding(commit: CommitProfile): Finding { + const owner = subtreeOwner(commit); + const heaviest = commit.components + .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.some((updater) => sameComponent(updater, owner)) + ? ` inside ${owner.name}` + : ''; + const trigger = + commit.updaters.length > 0 + ? ` after ${commit.updaters.map((updater) => updater.name).join(', ')} updated` + : ''; + const component = owner ?? commit.updaters[0]; + return { + kind: 'slow-commit', + ...(component ? about(component) : { component: `#${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( + profile: RawReactProfile, + commits: CommitProfile[], + earlier: Finding[], +): Finding[] { + const explained = explanations(earlier); + return commits + .filter((commit) => commit.durationMs >= SLOW_COMMIT_MS) + .filter( + (commit) => + explainedMs(profile, profile.commits[commit.index]!, explained) < + commit.durationMs * EXPLAINED_SHARE, + ) + .sort((a, b) => b.durationMs - a.durationMs) + .slice(0, SLOW_COMMIT_FINDING_LIMIT) + .map(slowCommitFinding); +} + +function contextTriggers(profile: RawReactProfile, component: ComponentId): string[] { + const triggers: Record = {}; + for (const commit of profile.commits) { + let contextRender = false; + for (const render of commitRenders(profile, commit)) { + if (sameComponent(render, component) && render.causes.includes('context')) { + contextRender = true; + break; + } + } + if (!contextRender) continue; + for (const updater of componentsOf(profile, commit.updaters)) bump(triggers, updater.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) : []), + 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(profile, 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 { + 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-devtools/react-profile-command.test.ts b/src/cli/react-devtools/react-profile-command.test.ts new file mode 100644 index 0000000000..e08faf3866 --- /dev/null +++ b/src/cli/react-devtools/react-profile-command.test.ts @@ -0,0 +1,283 @@ +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 { runReactProfileCommand } from './react-profile-command.ts'; +import { InspectorSession } from './metro-inspector.ts'; +import { commandHarness, TARGET } from './react-devtools-command.fixtures.ts'; +import { FakeAppRuntime, MEMO_TYPE, encodeOperations } from './react-devtools-runtime.fixtures.ts'; + +const harness = (app?: FakeAppRuntime, targets?: unknown[]) => + commandHarness(runReactProfileCommand, app, targets); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +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('report')).text, + /^Components by self time:\n {2}Row \[memo\] self 108ms [^\n]*: props 36; changed props onPress 36\n/, + ); + assert.match( + (await run('commit', '--limit', '1')).text, + /^Commits \(1 slowest of 3, --limit 3 for all\):\n {2}#0 \+0\.0s 40ms 13 rendered <- List\n$/, + ); + assert.match( + (await run('commit', '#1')).text, + /^#1 \+0\.2s 40ms 13 rendered <- List\n {2}Row \[memo\] 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('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), + ['ws://localhost:8081/inspector/debug?device=tablet&page=1'], + ); +}); + +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('report'), /No React profile saved for session qa/); +}); + +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, '{"profile":'); + await assert.rejects(run('report'), /unsupported format/); + fs.writeFileSync(file, JSON.stringify({ version: 1, analysis: { components: [] } })); + await assert.rejects(run('report'), /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/], + [['report', '--limit', '0'], /Invalid --limit: 0/], + [['report', '--limit', '10garbage'], /Invalid --limit: 10garbage/], + [['commit', '--limit', '1.5'], /Invalid --limit: 1\.5/], + [['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/, + ], + [['commit', '--sort', 'duration'], /Unknown option '--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: { components: Array<{ name: string }>; rendered: number }; + }; + assert.deepEqual( + parsed.data.components.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 /); +}); + +test('a profile the backend cannot export fails the stop with its reason', async () => { + const { app, run } = harness(); + await run('start'); + app.renderer.getProfilingData = () => { + throw new Error('profiling data unavailable'); + }; + await assert.rejects( + run('stop'), + (error: { code: string; message: string; details: { reason: string } }) => { + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.details.reason, 'collect-failed'); + assert.match(error.message, /profiling data unavailable$/); + return true; + }, + ); +}); + +test('drill-down views read the saved recording, not a trimmed analysis', async () => { + const { app, run, stateDir } = harness(); + await run('start'); + recordRowRenders(app); + await run('stop'); + const saved = JSON.parse( + fs.readFileSync(path.join(stateDir, 'react-profiles', 'qa.json'), 'utf8'), + ) as { version: number; profile: { commits: unknown[] }; propKinds: unknown }; + assert.equal(saved.version, 2); + assert.equal(saved.profile.commits.length, 3); + assert.match((await run('report', 'Row')).text, /changed props: onPress 36 \(function\)/); +}); + +test('a renderer that fails while another records nothing is named in the empty summary', async () => { + const { app, run } = harness(); + const broken = Object.assign(Object.create(app.renderer), { + getProfilingData: () => { + throw new Error('second root gone'); + }, + }); + app.hook.rendererInterfaces.set(2, broken); + await run('start'); + assert.equal( + (await run('stop')).text, + [ + 'React profile: no commits recorded. Start profiling, then perform the interaction before stopping.', + "Note: A React renderer's profile could not be read: second root gone", + '', + ].join('\n'), + ); +}); diff --git a/src/cli/react-devtools/react-profile-command.ts b/src/cli/react-devtools/react-profile-command.ts new file mode 100644 index 0000000000..dcda86ccb5 --- /dev/null +++ b/src/cli/react-devtools/react-profile-command.ts @@ -0,0 +1,308 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { safeSessionName } from '@agent-device/host-kit/session-paths'; +import { targetAppId, type InspectorSession } from './metro-inspector.ts'; +import { + callRuntime, + emitTo, + parseDevtoolsArgs, + runtimeFailure, + RUNTIME_OPTIONS, + withAppRuntime, + type CommandForm, + type DevtoolsArgs, + type Emit, + type ReactDevtoolsCommandOptions, + type RuntimeFailure, +} from './react-devtools-connection.ts'; +import { + analyzeReactProfile, + isFindingFor, + type ReactProfileAnalysis, +} from './react-profile-analysis.ts'; +import { + selectPropKindRequests, + type CommitProfile, + type ComponentProfile, + type PropKindsById, + type RawReactProfile, +} from './react-profile-renders.ts'; +import { + DEFAULT_FINDING_LIMIT, + findComponents, + formatSeconds, + formatCommit, + formatCommits, + formatComponentReport, + formatComponents, + formatProfileSummary, + listedCommits, +} from './react-profile-format.ts'; + +const REACT_PROFILE_SUBCOMMANDS = ['start', 'stop', 'report', 'commit'] as const; + +type ReactProfileSubcommand = (typeof REACT_PROFILE_SUBCOMMANDS)[number]; + +type ProfileArgs = DevtoolsArgs & { subcommand: ReactProfileSubcommand; target?: string }; + +const SAVED_PROFILE_VERSION = 2; + +/** + * The recording exactly as the app exported it, with what was learned about it at stop. Every view + * analyzes it again, so none of them depends on what another view chose to keep. + */ +type SavedReactProfile = { + version: typeof SAVED_PROFILE_VERSION; + savedAt: string; + appId?: string; + deviceName?: string; + profile: RawReactProfile; + propKinds: PropKindsById; + notes: string[]; +}; + +type StartResult = { ok: true; attach: string } | RuntimeFailure; +type StopResult = ({ ok: true; failures: string[] } & RawReactProfile) | RuntimeFailure; + +function isReactProfileSubcommand(value: string | undefined): value is ReactProfileSubcommand { + return REACT_PROFILE_SUBCOMMANDS.includes(value as ReactProfileSubcommand); +} + +function profileForm(subcommand: ReactProfileSubcommand, target: string | undefined): CommandForm { + switch (subcommand) { + case 'start': + return { usage: 'profile start', positionals: 0, options: RUNTIME_OPTIONS }; + case 'stop': + return { usage: 'profile stop', positionals: 0, options: [...RUNTIME_OPTIONS, 'limit'] }; + case 'report': + return target === undefined + ? { usage: 'profile report', positionals: 1, options: ['limit'] } + : { usage: 'profile report ', positionals: 1, options: [] }; + case 'commit': + return { usage: 'profile commit', positionals: 1, options: ['limit'] }; + } +} + +function parseProfileArgs(args: string[]): ProfileArgs { + const [subcommand, ...rest] = args; + if (!isReactProfileSubcommand(subcommand)) { + throw new AppError( + 'INVALID_ARGS', + `Unknown react-devtools profile command: ${subcommand ?? '(none)'}.`, + { hint: `Use one of: ${REACT_PROFILE_SUBCOMMANDS.join(', ')}.` }, + ); + } + const parsed = parseDevtoolsArgs(rest, (positionals) => profileForm(subcommand, positionals[0])); + return { ...parsed, subcommand, target: parsed.positionals[0] }; +} + +function savedProfilePath(options: ReactDevtoolsCommandOptions): string { + return path.join(options.stateDir, 'react-profiles', `${safeSessionName(options.session)}.json`); +} + +function writeSavedProfile( + options: ReactDevtoolsCommandOptions, + profile: SavedReactProfile, +): string { + const filePath = savedProfilePath(options); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(profile)}\n`, 'utf8'); + return filePath; +} + +function isSavedProfile(value: SavedReactProfile | undefined): value is SavedReactProfile { + return ( + value?.version === SAVED_PROFILE_VERSION && + Array.isArray(value.profile?.commits) && + typeof value.profile.elements === 'object' && + Array.isArray(value.notes) + ); +} + +function readSavedAnalysis(options: ReactDevtoolsCommandOptions): ReactProfileAnalysis { + let raw: string; + try { + raw = fs.readFileSync(savedProfilePath(options), 'utf8'); + } catch { + throw new AppError('COMMAND_FAILED', `No React profile saved for session ${options.session}.`, { + hint: 'Run react-devtools profile start, perform the interaction, then react-devtools profile stop.', + }); + } + let saved: SavedReactProfile | undefined; + try { + saved = JSON.parse(raw) as SavedReactProfile; + } catch { + saved = undefined; + } + if (!isSavedProfile(saved)) { + throw new AppError('COMMAND_FAILED', 'The saved React profile has an unsupported format.', { + hint: 'Record a new profile with react-devtools profile start and stop.', + }); + } + return analyzeReactProfile(saved.profile, saved.propKinds, saved.notes); +} + +const elapsed = (elapsedMs: number) => formatSeconds(Math.max(0, elapsedMs)); + +async function startProfile( + args: ProfileArgs, + options: ReactDevtoolsCommandOptions, +): Promise<{ app: string; message: string }> { + return await withAppRuntime(args, options, async (session, target) => { + const result = await callRuntime(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: ReactDevtoolsCommandOptions, +): Promise<{ appId: string; analysis: ReactProfileAnalysis }> { + return await withAppRuntime(args, options, async (session, target) => { + const result = await callRuntime(session, 'stop'); + if (!result.ok) throw runtimeFailure(result); + const { ok: _ok, failures, ...profile } = result; + const described = await describeChangedProps(session, profile); + const notes = [ + ...failures.map((failure) => `A React renderer's profile could not be read: ${failure}`), + ...described.notes, + ]; + const appId = targetAppId(target); + writeSavedProfile(options, { + version: SAVED_PROFILE_VERSION, + savedAt: new Date().toISOString(), + appId, + deviceName: target.deviceName, + profile, + propKinds: described.propKinds, + notes, + }); + return { appId, analysis: analyzeReactProfile(profile, described.propKinds, notes) }; + }); +} + +type SubcommandHandler = ( + args: ProfileArgs, + options: ReactDevtoolsCommandOptions, + emit: Emit, +) => Promise; + +function requireComponents(analysis: ReactProfileAnalysis, name: string): ComponentProfile[] { + const components = findComponents(analysis, name); + if (components.length === 0) { + throw new AppError('COMMAND_FAILED', `No renders of ${name} in the saved profile.`, { + hint: 'Use a name that react-devtools profile report lists.', + }); + } + return components; +} + +function requireCommit(analysis: ReactProfileAnalysis, target: string): CommitProfile { + const digits = target.replace(/^#/, ''); + const commit = /^\d+$/.test(digits) ? analysis.commits[Number(digits)] : undefined; + if (!commit) { + throw new AppError('INVALID_ARGS', `No commit ${target} in the saved profile.`, { + hint: `Use a commit index from 0 to ${Math.max(analysis.commits.length - 1, 0)}.`, + }); + } + return commit; +} + +const COMPONENT_LIMIT = 10; +const COMMIT_LIMIT = 20; + +const SUBCOMMAND_HANDLERS: Record = { + start: async (args, options, emit) => { + const started = await startProfile(args, options); + emit(started.message, started); + }, + stop: async (args, options, emit) => { + const { appId, analysis } = await stopProfile(args, options); + emit(formatProfileSummary(analysis, args.limit ?? DEFAULT_FINDING_LIMIT), { + appId, + wallMs: analysis.wallMs, + commitCount: analysis.commitCount, + renderMs: analysis.renderMs, + slowCommitCount: analysis.slowCommitCount, + worstCommit: analysis.worstCommit, + findings: analysis.findings.slice(0, args.limit ?? analysis.findings.length), + notes: analysis.notes, + savedTo: savedProfilePath(options), + }); + }, + report: async (args, options, emit) => { + const analysis = readSavedAnalysis(options); + if (args.target === undefined) { + const limit = args.limit ?? COMPONENT_LIMIT; + emit(formatComponents(analysis, limit), { + components: analysis.components.slice(0, limit), + }); + return; + } + const components = requireComponents(analysis, args.target); + emit(formatComponentReport(analysis, components), { + components, + findings: analysis.findings.filter((finding) => + components.some((component) => isFindingFor(finding, component)), + ), + }); + }, + commit: async (args, options, emit) => { + const analysis = readSavedAnalysis(options); + if (args.target === undefined) { + const limit = args.limit ?? COMMIT_LIMIT; + emit(formatCommits(analysis, limit), { commits: listedCommits(analysis, limit) }); + return; + } + const commit = requireCommit(analysis, args.target); + const limit = args.limit ?? COMPONENT_LIMIT; + emit(formatCommit(commit, limit), { + ...commit, + components: commit.components.slice(0, limit), + }); + }, +}; + +export async function runReactProfileCommand( + args: string[], + options: ReactDevtoolsCommandOptions, +): Promise { + const parsed = parseProfileArgs(args); + await SUBCOMMAND_HANDLERS[parsed.subcommand](parsed, options, emitTo(options)); + return 0; +} diff --git a/src/cli/react-devtools/react-profile-format.test.ts b/src/cli/react-devtools/react-profile-format.test.ts new file mode 100644 index 0000000000..364dd32d14 --- /dev/null +++ b/src/cli/react-devtools/react-profile-format.test.ts @@ -0,0 +1,215 @@ +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, + formatCommits, + formatComponentReport, + formatComponents, + formatProfileSummary, +} 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}`, + componentKind: 'function', + costMs: 100 - index, + evidence: 'slow', + fix: 'memoize', + }; +} + +function analysis(overrides: Partial = {}): ReactProfileAnalysis { + return { + 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: [{ name: 'List', kind: 'function' }], + components: [], + }, + { + index: 1, + atMs: 900, + durationMs: 41.5, + rendered: 30, + mounts: 30, + updaters: [], + components: [], + }, + ], + 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 report [Component] | profile commit [N]', + ].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('an empty recording still reports why part of it could not be read', () => { + assert.equal( + formatProfileSummary( + analysis({ commitCount: 0, notes: ["A React renderer's profile could not be read: gone"] }), + ), + [ + 'React profile: no commits recorded. Start profiling, then perform the interaction before stopping.', + "Note: A React renderer's profile could not be read: gone", + ].join('\n'), + ); +}); + +test('lookup naming warns that unmounted components may be unnamed', () => { + assert.match( + formatProfileSummary(analysis({ naming: 'lookup' })), + /\nNote: component names came from mounted elements/, + ); +}); + +test('the component table ranks by self time and says why each rendered', () => { + assert.equal( + formatComponents(analysis(), 1), + [ + 'Components by self time:', + ' Chart [function] self 40ms avg 10ms max 12ms renders 4: props 3, mount 1; changed props onPress 3', + ' +1 more (--limit 2)', + ].join('\n'), + ); +}); + +test('the commit list keeps order and, when capped, keeps the slowest commits', () => { + assert.equal( + formatCommits(analysis(), 5), + 'Commits:\n #0 +0.0s 10ms 3 rendered <- List\n #1 +0.9s 42ms 30 rendered (30 mounted)', + ); + assert.match( + formatCommits(analysis(), 1), + /^Commits \(1 slowest of 2, --limit 2 for all\):\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, each with its own findings', () => { + 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', + componentKind: 'function' as const, + 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\][\s\S]*\nfinding parent-cascade[^\n]*\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: [], + components: [ + { name: 'Row', kind: 'function', instances: 2, selfMs: 12, actualMs: 18, causes: [] }, + ], + }, + 5, + ); + assert.equal(text, '#3 +1.2s 20ms 2 rendered\n Row [function] x2 self 12ms unrecorded'); +}); + +test('app-provided names and keys print without control characters, one line each', () => { + const text = formatComponents( + analysis({ + components: [component('Row\u001b[2J\nFake', { changedProps: { 'id\r': 2 } })], + }), + 5, + ); + assert.deepEqual(text.split('\n'), [ + 'Components by self time:', + ' Row [2J Fake [function] self 40ms avg 10ms max 12ms renders 4: props 3, mount 1; changed props id 2', + ]); +}); diff --git a/src/cli/react-devtools/react-profile-format.ts b/src/cli/react-devtools/react-profile-format.ts new file mode 100644 index 0000000000..f8344cf0f1 --- /dev/null +++ b/src/cli/react-devtools/react-profile-format.ts @@ -0,0 +1,190 @@ +import { isFindingFor, type ReactProfileAnalysis } from './react-profile-analysis.ts'; +import { + FRAME_BUDGET_MS, + formatMs, + type CommitProfile, + type ComponentProfile, + type RenderCauses, +} from './react-profile-renders.ts'; +import { printableLines } from './react-devtools-text.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 { + const notes = analysis.notes.map((note) => `Note: ${note}`); + if (analysis.commitCount === 0) { + return printableLines([ + 'React profile: no commits recorded. Start profiling, then perform the interaction before stopping.', + ...notes, + ]); + } + 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})`); + } + lines.push(...notes); + 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 report [Component] | profile commit [N]'); + return printableLines(lines); +} + +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}`; +} + +/** One line per component, costliest first: its cost, then why it rendered. */ +export function formatComponents(analysis: ReactProfileAnalysis, limit: number): string { + if (analysis.components.length === 0) return 'No component renders recorded.'; + const shown = analysis.components.slice(0, limit); + return printableLines([ + 'Components by self time:', + ...shown.map((c) => { + const props = keyCounts(c.changedProps); + return ` ${componentLine(c)}: ${causeSummary(c.causes)}${props ? `; changed props ${props}` : ''}`; + }), + ...omittedRows(shown.length, analysis.components.length), + ]); +} + +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 with the findings about it. */ +export function formatComponentReport( + analysis: ReactProfileAnalysis, + components: ComponentProfile[], +): string { + return printableLines( + components.flatMap((component) => [ + ...componentDetail(analysis, component), + ...analysis.findings + .filter((finding) => isFindingFor(finding, component)) + .map((finding) => `finding ${finding.kind}: ${finding.evidence} -> ${finding.fix}`), + ]), + ); +} + +function commitLine(commit: CommitProfile): string { + const trigger = + commit.updaters.length > 0 + ? ` <- ${commit.updaters.map((updater) => updater.name).join(', ')}` + : ''; + const mounts = commit.mounts > 0 ? ` (${commit.mounts} mounted)` : ''; + return `#${commit.index} +${seconds(commit.atMs)} ${ms(commit.durationMs)} ${commit.rendered} rendered${mounts}${trigger}`; +} + +/** Every commit in order, or the slowest `limit` of them, still in order. */ +export function listedCommits(analysis: ReactProfileAnalysis, limit: number): CommitProfile[] { + if (analysis.commits.length <= limit) return analysis.commits; + return [...analysis.commits] + .sort((a, b) => b.durationMs - a.durationMs) + .slice(0, limit) + .sort((a, b) => a.index - b.index); +} + +export function formatCommits(analysis: ReactProfileAnalysis, limit: number): string { + if (analysis.commits.length === 0) return 'No commits recorded.'; + const shown = listedCommits(analysis, limit); + const header = + shown.length < analysis.commits.length + ? `Commits (${shown.length} slowest of ${analysis.commits.length}, --limit ${analysis.commits.length} for all):` + : 'Commits:'; + return printableLines([header, ...shown.map((commit) => ` ${commitLine(commit)}`)]); +} + +export function formatCommit(commit: CommitProfile, limit: number): string { + const lines = [commitLine(commit)]; + const shown = commit.components.slice(0, limit); + for (const entry of shown) { + const instances = entry.instances > 1 ? ` x${entry.instances}` : ''; + lines.push( + ` ${entry.name} [${entry.kind}]${instances} self ${ms(entry.selfMs)} ${entry.causes.join('+') || 'unrecorded'}`, + ); + } + lines.push(...omittedRows(shown.length, commit.components.length)); + return printableLines(lines); +} diff --git a/src/cli/react-devtools/react-profile-renders.fixtures.ts b/src/cli/react-devtools/react-profile-renders.fixtures.ts new file mode 100644 index 0000000000..5ae15df344 --- /dev/null +++ b/src/cli/react-devtools/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, parent = 0]]) => [ + id, + [name, type, owner, parent], + ]), + ), + 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-devtools/react-profile-renders.test.ts b/src/cli/react-devtools/react-profile-renders.test.ts new file mode 100644 index 0000000000..3867789e25 --- /dev/null +++ b/src/cli/react-devtools/react-profile-renders.test.ts @@ -0,0 +1,108 @@ +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]?.name]), + [ + [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); +}); + +test('a commit keeps every component that rendered in it', () => { + const ids = repeat(20, (i) => 10 + i); + const elements: Record = {}; + for (const id of ids) elements[id] = [`Widget${id}`]; + const raw = rawProfile(elements, [[0, 40, [], ids.map((id): Render => [id, 1, 'parent'])]]); + assert.equal(summarizeRenders(raw).commits[0]?.components.length, 20); +}); + +test('names and prop keys that differ only by control characters stay apart', () => { + const raw = rawProfile( + { 1: ['Row'], 2: ['Row\u0007'] }, + [ + [ + 0, + 10, + [], + [ + [1, 1, [0, [0], null, null]], + [2, 1, [0, [1], null, null]], + ], + ], + ], + ['id', 'id\u001b'], + ); + assert.deepEqual( + summarizeRenders(raw).components.map((entry) => [entry.name, entry.changedProps]), + [ + ['Row', { id: 1 }], + ['Row\u0007', { 'id\u001b': 1 }], + ], + ); +}); diff --git a/src/cli/react-devtools/react-profile-renders.ts b/src/cli/react-devtools/react-profile-renders.ts new file mode 100644 index 0000000000..dcee961113 --- /dev/null +++ b/src/cli/react-devtools/react-profile-renders.ts @@ -0,0 +1,429 @@ +/** 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, key]` as recorded from the backend's tree operations. */ +type RawProfileElement = [string, number, number, number, (string | null)?]; +/** `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'; + +/** A component as the profile can tell it apart: `Row` and memo-wrapped `Row` are two components. */ +export type ComponentId = { name: string; kind: ComponentKind }; + +export function componentKey(component: ComponentId): string { + return `${component.name}\0${component.kind}`; +} + +export function sameComponent(a: ComponentId, b: ComponentId): boolean { + return a.name === b.name && a.kind === b.kind; +} + +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 = ComponentId & { + 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 = ComponentId & { + 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: ComponentId[]; + /** Every component that rendered in this commit, largest self time first. */ + components: CommitComponent[]; +}; + +/** Mounted instance to inspect, per component name, for the value kinds of its changed props. */ +type PropKindRequest = Record; +export type PropKindsById = Record>; + +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 = ComponentId & { + id: 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 componentOf(element: RawProfileElement): ComponentId { + const { name, wrappers } = renderName(element[0]); + return { name, kind: componentKind(wrappers.includes('Memo') ? ELEMENT_MEMO : element[1]) }; +} + +/** The distinct components behind these elements, leaving out any the profile could not name. */ +export function componentsOf(profile: RawReactProfile, ids: number[]): ComponentId[] { + const components = new Map(); + for (const id of ids) { + const element = elementOf(profile, id); + if (!element) continue; + const component = componentOf(element); + components.set(componentKey(component), component); + } + return [...components.values()]; +} + +/** The element that contains `id` in the tree, when the profile recorded it. */ +export function parentOf(profile: RawReactProfile, id: number): number | undefined { + return elementOf(profile, id)?.[3] || undefined; +} + +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; + yield { + ...componentOf(element), + id, + 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: render.kind, + 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 ? elementOf(profile, ownerId) : undefined; + if (owner) bump(entry.owners, componentOf(owner).name); +} + +function accumulateComponents(profile: RawReactProfile): Map { + const components = new Map(); + profile.commits.forEach((commit, commitIndex) => { + for (const render of commitRenders(profile, commit)) { + const key = componentKey(render); + 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 byComponent = 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 key = componentKey(render); + const entry = byComponent.get(key) ?? { + name: render.name, + kind: render.kind, + 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); + byComponent.set(key, entry); + } + return { + index, + atMs: Math.round(commit.timestamp - origin), + durationMs: commit.duration, + rendered, + mounts, + updaters: componentsOf(profile, commit.updaters), + components: [...byComponent.values()].sort((a, b) => b.selfMs - a.selfMs), + }; + }); +} + +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/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.test.ts b/src/client/client-companion-tunnel-worker.test.ts index f2a339ba94..2631d2fcbe 100644 --- a/src/client/client-companion-tunnel-worker.test.ts +++ b/src/client/client-companion-tunnel-worker.test.ts @@ -242,29 +242,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', }, ); }); @@ -676,42 +673,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/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/debugging/index.ts b/src/commands/debugging/index.ts index 8975e2fe39..ed0b586917 100644 --- a/src/commands/debugging/index.ts +++ b/src/commands/debugging/index.ts @@ -11,7 +11,7 @@ const DEBUG_COMMAND_NAME = 'debug'; const DEBUG_ACTION_VALUES = ['symbols'] as const; const debugCommandDescription = - 'Symbolicate Apple crash artifacts with matching dSYM UUIDs. This debug namespace is intentionally narrow: 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 profiles.'; + 'Symbolicate Apple crash artifacts with matching dSYM UUIDs. This debug namespace is intentionally narrow: 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 components and render profiles.'; export const debugCommandMetadata = defineFieldCommandMetadata( DEBUG_COMMAND_NAME, 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..cb87b4fec0 100644 --- a/src/commands/schema/cli-help-topics.test.ts +++ b/src/commands/schema/cli-help-topics.test.ts @@ -522,37 +522,35 @@ 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, /React Native internals the accessibility tree cannot show/); + assert.match(help, /agent-device react-devtools tree \[@ref\|Name\]/); + assert.match(help, /agent-device react-devtools find \[--exact\]/); + assert.match(help, /agent-device react-devtools inspect <@ref\|Name>/); 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, /agent-device react-devtools profile start/); + assert.match(help, /agent-device react-devtools profile report \[Component\]/); + assert.match(help, /agent-device react-devtools profile commit \[N\]/); + assert.doesNotMatch(help, /profile (slow|rerenders|timeline|status)/); + 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, /through agent-device proxy, and on remote bridge sessions/); + 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 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 +584,11 @@ 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 React DevTools command/); + assert.match( + help, + /Use help react-devtools for component trees, props\/state\/hooks, 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 +596,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 +630,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 components, props\/state\/hooks/); 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..012b363ef9 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 component props, state, hooks, the component tree, 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 93645b3a32..38a6bc2903 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -132,7 +132,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 components, props/state/hooks, render profiling help react-native RN hazards, Metro/Re.Pack, routing help remote remote/cloud config, lease, tunnels help macos desktop, frontmost-app, menu bar @@ -278,69 +278,58 @@ Focus and visual truth: }, ...debuggingHelpTopics, 'react-devtools': { - summary: 'React Native performance, profiling, and component internals', + summary: 'React Native components, props/state/hooks, and render 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 +Use this for React Native internals the accessibility tree cannot show: the component tree, a component's props, state, hooks, and owners, errors and warnings components logged while rendering, and render performance (slow renders, rerenders and their causes, commits over a 16 ms frame). + +Components: + agent-device react-devtools tree [@ref|Name] [--depth 8] [--limit 60] + agent-device react-devtools find [--exact] + agent-device react-devtools inspect <@ref|Name> agent-device react-devtools errors + +Render profiling: agent-device react-devtools profile start 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 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 report [Component] + agent-device react-devtools profile commit [N] + +Connection: + Every command except profile report and profile commit, which read the saved profile, 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 and no start or connect step. It is verified on Android emulators and iOS simulators, through agent-device proxy, and on remote bridge sessions, where it uses the Metro server next to the agent: the session's binding from metro prepare, or --metro-host/--metro-port. A device-facing Metro URL, such as the bridge's, refuses the debugger connection. + 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. + +Component inspection: + Start with find or tree; both print @c refs. Wrapper chains such as providers collapse onto one line, and three or more consecutive siblings with identical subtrees, such as list rows, collapse into one line with a count; run tree @ref to open a branch. + inspect takes an @c ref, or a name only one mounted component has. It prints the owners that rendered it, its props, state, hooks (custom hooks list the hooks they call), and what it logged while rendering. Deep values print as short previews. + errors lists the mounted components that logged console errors or warnings while rendering, with the messages. When the app's React DevTools backend does not attribute every log to a component, as on React Native 0.74 and 0.76, errors says its list can be incomplete; check logs as well. + Refs are DevTools element ids: they change when a component remounts or the app reloads, so find again after either. + With a React DevTools window connected to the app, tree, find, errors, and inspect by name fail with tree-unavailable; close it and reload the app. 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 . Without an argument, report lists components by self time with why they rendered, and commit lists commits in order (the slowest when capped). + 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 command is an agent-device subcommand: agent-device react-devtools ... + 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: @@ -348,7 +337,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 component internals or React rendering performance matter.`, }, cdp: { summary: 'React Native CDP targets, JS heap snapshots, and leak triage', @@ -400,7 +389,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', @@ -414,7 +403,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. + Component tree, props/state/hooks, 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. @@ -445,10 +434,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"' @@ -461,8 +449,8 @@ Overlays and busy RN UIs: 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. + Use help react-devtools for component trees, props/state/hooks, render profiles, slow renders, rerenders, and their causes. + If React DevTools 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. @@ -474,11 +462,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.`, @@ -681,7 +666,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. @@ -689,14 +674,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..1d3fa753c5 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 components, props/state/hooks, and render profiling', description: - 'Run pinned agent-react-devtools commands for React Native performance profiling, component trees, props/state/hooks, and render analysis', + 'Inspect React Native components and profile their renders through the Metro dev server', }, - usageOverride: 'react-devtools [...args]', + usageOverride: + 'react-devtools tree [@ref|Name]|find |inspect <@ref|Name>|errors|profile start|stop|report [Component]|commit [N]', listUsageOverride: 'react-devtools', positionalArgs: ['args?'], allowsExtraPositionals: true, diff --git a/src/metro/client-metro-companion.test.ts b/src/metro/client-metro-companion.test.ts index e336e9f260..c56efb1535 100644 --- a/src/metro/client-metro-companion.test.ts +++ b/src/metro/client-metro-companion.test.ts @@ -29,7 +29,6 @@ import { } from '@agent-device/host-kit/process'; import { ensureMetroCompanion, stopMetroCompanion } from './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/metro/metro-reload-endpoints.test.ts b/src/metro/metro-reload-endpoints.test.ts new file mode 100644 index 0000000000..94661c3a30 --- /dev/null +++ b/src/metro/metro-reload-endpoints.test.ts @@ -0,0 +1,11 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { parsePort } from './metro-reload-endpoints.ts'; + +test('a Metro port is a whole number from 1 to 65535', () => { + assert.equal(parsePort('8082', 8081), 8082); + assert.equal(parsePort(undefined, 8081), 8081); + for (const value of ['8081oops', '80.5', '0', '65536', '-1']) { + assert.throws(() => parsePort(value, 8081), /Invalid Metro port/, value); + } +}); diff --git a/src/metro/metro-reload-endpoints.ts b/src/metro/metro-reload-endpoints.ts index 5a0181b887..60691104e5 100644 --- a/src/metro/metro-reload-endpoints.ts +++ b/src/metro/metro-reload-endpoints.ts @@ -27,7 +27,7 @@ export function parsePort(value: number | string | undefined, fallback: number): if (value === undefined || value === null || value === '') { return fallback; } - const parsed = Number.parseInt(String(value), 10); + const parsed = /^\d+$/.test(String(value).trim()) ? Number(String(value).trim()) : Number.NaN; if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { throw new AppError('INVALID_ARGS', `Invalid Metro port: ${String(value)}. Use 1-65535.`); } diff --git a/website/docs/docs/agent-setup.md b/website/docs/docs/agent-setup.md index bda5a25cfe..a96d1294c3 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 component internals (tree, props, state, hooks) and 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 47f95e63e8..d48a7d2907 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -825,30 +825,41 @@ 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 components + +```bash +agent-device react-devtools tree [@ref|Name] [--depth 8] [--limit 60] +agent-device react-devtools find [--exact] [--limit 20] +agent-device react-devtools inspect <@ref|Name> +agent-device react-devtools errors [--limit 10] +``` + +- `tree` prints the mounted component tree with `@c` refs. A chain of single-child wrappers, such as providers, prints on one line. Three or more consecutive siblings whose subtrees match exactly, in names, types, and logged errors and warnings, print as one counted line; other siblings, including same-name screens with different contents, keep their own lines. Pass a ref or a unique name to print one branch. +- `find` lists components whose name contains the text, ignoring case, or equals it with `--exact`, each with the components that rendered it. +- `inspect` prints a component's owners, props, state, hooks, and the errors and warnings it logged while rendering. It takes a ref, or a name only one mounted component has. +- `errors` lists the components that logged console errors or warnings while rendering, with the messages. When the app's React DevTools backend does not attribute every log to a component, as on React Native 0.74 and 0.76, it says the list can be incomplete. +- These commands connect the same way as profiling, below. A connected React DevTools window owns the component tree, so `tree`, `find`, and `errors` fail with `tree-unavailable` until it is closed and the app reloads. + +## 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 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 stop --limit 10 +agent-device react-devtools profile report +agent-device react-devtools profile report +agent-device react-devtools profile commit +agent-device react-devtools profile commit ``` -- `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 recording for the session, so `report` and `commit` read it without the app. +- `profile report` lists components by self time with why they rendered; `profile report ` shows every component of that name, such as `Row` and memoized `Row`, each with its causes, changed props, owners, costliest commits, and findings. `profile commit` lists commits in order, or the slowest ones in order when there are more than `--limit` (default 20); `profile commit ` shows what rendered in one commit. `--limit` caps the rows of the list views. +- 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 on Android emulators, iOS simulators, `agent-device proxy` connections, and remote bridge sessions. On a remote bridge it uses the Metro server next to the agent: the session's binding from `metro prepare`, or `--metro-host`/`--metro-port`. Metro accepts the debugger connection only from its own machine, so the bridge or `metroPublicBaseUrl` cannot carry it. +- `--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..3796fe9ee4 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -15,49 +15,83 @@ 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 components -To inspect the React Native component tree, props, state, hooks, or render profiling, use `react-devtools`: +To see what React renders behind a screen, read the component tree and inspect a component: ```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 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 find ContactRow +agent-device react-devtools inspect @c42 +agent-device react-devtools tree @c40 --depth 3 +agent-device react-devtools errors ``` -`react-devtools` runs a pinned version of `agent-react-devtools` and passes your arguments to it. +`find` and `tree` print `@c` refs. `inspect` shows the owners that rendered a component, its props, state, and hooks, and the errors and warnings it logged while rendering: -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 +@c42 ContactRow key=3 +rendered by: ContactList @c40 < MemoDefeatedScenario @c38 < PerfLabScreen @c12 +props: + contact: {id: 3, name: "Ada Lovelace"} + onSelect: ƒ onSelect() {} +hooks: + State: false +``` -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. +`inspect` also takes a component name when only one mounted component has it. `errors` lists the components that logged console errors or warnings while rendering, with the messages; on older React Native versions, such as 0.74 and 0.76, it notes that the list can be incomplete. Refs change when a component remounts or the app reloads. -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. +These commands use the same connection as profiling, described below. If a React DevTools window is connected to the app, close it and reload the app first. -For permission prompts and other alerts already on screen, see [Wait and alerts](/docs/commands#wait-and-alerts). +## React Native performance profiling + +To find out why an interaction renders slowly, record a React profile around it: + +```bash +agent-device react-devtools profile start +agent-device fill 'id="catalog-search"' "tart" --delay-ms 80 +agent-device react-devtools profile stop +``` + +`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: -### Remote bridge sessions +```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 +``` -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`. +| 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 tries to reach DevTools once, during JavaScript startup. On remote iOS, start DevTools before that launch: +To dig into a finding, use the saved profile: ```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 +agent-device react-devtools profile report ContactRow +agent-device react-devtools profile commit 6 +agent-device react-devtools profile report +agent-device react-devtools profile commit ``` -The first `open` creates the bridge session; the relaunch lets the app find the running DevTools daemon. +Without an argument, `report` lists components by self time with why they rendered, and `commit` lists the commits in order. + +Profiling works with development builds of React Native 0.74 and later, including Expo, and is verified on Android emulators and iOS simulators. It connects to the app through the Metro dev server, so the app, Metro config, and Babel config need no changes. If several apps use the same dev server, pass `--app `. If one app runs on several devices connected to the same dev server, pass `--metro-device ` with a device name the error lists. If the dev server is not the session's Metro server or `localhost:8081`, pass `--metro-port` and `--metro-host`. + +Development builds render slower than release builds. Compare profiles of the same interaction before and after a change rather than reading the numbers as absolute. -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. +Component inspection and profiling also work through `agent-device proxy` and on remote bridge sessions, where they use the Metro server next to the agent: the session's binding from `metro prepare`, or `--metro-host`/`--metro-port`. Metro accepts the debugger connection only from its own machine, so the bridge or `metroPublicBaseUrl` cannot carry it. + +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. + +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. + +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 +169,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 components and 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..1bd9b98005 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 component inspection and 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 6c67680459..242c058f51 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 2554de4da6..f95df6ef0f 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..29ae503029 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` to inspect React Native components and profile their renders, and `agent-device cdp` for JS heap snapshots, diffs, and leak retainers. ---