diff --git a/src/agent-device-client-metro-hints.test.ts b/src/agent-device-client-metro-hints.test.ts new file mode 100644 index 0000000000..f8e64015af --- /dev/null +++ b/src/agent-device-client-metro-hints.test.ts @@ -0,0 +1,23 @@ +import { afterEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import * as daemonResolution from '@agent-device/daemon-contracts/daemon-resolution'; +import { createAgentDeviceClient } from './agent-device-client.ts'; +import { createTransport } from './__tests__/client-transport-fixture.ts'; + +afterEach(() => vi.restoreAllMocks()); + +for (const command of ['open', 'close'] as const) { + test(`${command} succeeds when optional Metro hint scope resolution fails`, async () => { + const setup = createTransport(async () => ({ ok: true, data: {} })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + vi.spyOn(daemonResolution, 'resolveDaemonPaths').mockImplementation(() => { + throw new Error('home directory unavailable'); + }); + const result = + command === 'open' + ? await client.apps.open({ app: 'com.example.app', platform: 'ios' }) + : await client.sessions.close(); + assert.equal(result.session, setup.config.session); + assert.equal(setup.calls[0]?.command, command); + }); +} diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 199662a0b3..1b662d201a 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -29,11 +29,7 @@ import type { } from '@agent-device/contracts/client'; import type { AgentArtifactsResult } from '@agent-device/contracts/observability'; import type { MetroPrepareOptions } from '@agent-device/contracts/remote'; -import { - isNonDefaultResponseLevel, - type ResponseLevel, - type SessionRuntimeHints, -} from '@agent-device/kernel/contracts'; +import { isNonDefaultResponseLevel, type ResponseLevel } from '@agent-device/kernel/contracts'; import { AppError, throwDaemonError } from '@agent-device/kernel/errors'; import { createRequestId } from '@agent-device/host-kit/diagnostics'; import { @@ -54,7 +50,7 @@ import { readSnapshotNodes, resolveSessionName, } from './client/client-normalizers.ts'; -import type { AgentDeviceClient, MetroPrepareResult } from './client/client-types.ts'; +import type { AgentDeviceClient } from './client/client-types.ts'; import { INTERNAL_COMMANDS } from '@agent-device/command-registry/catalog'; import { buildRequestFlags } from './commands/command-flags.ts'; import { @@ -64,13 +60,12 @@ import { import type { CommandResult } from '@agent-device/command-registry/command-result'; import { sendToDaemon } from './daemon-client/daemon-client.ts'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { resolveMetroServerUrl } from './metro/metro-reload-endpoints.ts'; import { prepareMetroRuntime, reloadMetro } from './metro/client-metro.ts'; import { - clearMetroSessionHints, - readMetroSessionHints, - writeMetroSessionHints, - type MetroSessionHints, + clearMetroSessionHintsQuietly, + recordPreparedMetroSession, + recordOpenedMetroSession, + readMetroSessionRuntime, } from './metro/metro-session-hints.ts'; import { asOptionalRecord, @@ -226,7 +221,7 @@ export function createAgentDeviceClient( }; } finally { // Close is teardown intent: drop the dev-server binding even if the daemon call fails. - clearMetroSessionHintsQuietly(config, options); + clearMetroSessionHintsQuietly(mergeClientOptions(config, options)); } }, saveScript: async ( @@ -281,9 +276,8 @@ export function createAgentDeviceClient( open: async (options: AppOpenOptions) => { const session = resolveRequestSession(options); const data = await executeCommand>('open', options); - recordMetroSessionHintsAfterOpen({ - config, - options, + recordOpenedMetroSession(mergeClientOptions(config, options), { + explicitRuntime: options.runtime !== undefined, runtime: mergeClientOptions(config, options).runtime, sessionReused: data.sessionReused === true, }); @@ -367,16 +361,13 @@ export function createAgentDeviceClient( runtimeFilePath: options.runtimeFilePath, logPath: options.logPath, }); - persistMetroSessionHints(config, result); + recordPreparedMetroSession(config, result); return result; }, reload: async (options = {}) => await reloadMetro({ - metroHost: options.metroHost, - metroPort: options.metroPort, - bundleUrl: options.bundleUrl, - runtime: resolveMetroSessionHints(config) ?? config.runtime, - timeoutMs: options.timeoutMs, + ...options, + runtime: readMetroSessionRuntime(config) ?? config.runtime, }), }, capture: { @@ -586,101 +577,4 @@ function mergeClientOptions( return { ...config, ...options }; } -function metroSessionHintsScope( - config: AgentDeviceClientConfig, - options: InternalRequestOptions = {}, -): { stateDir: string; session: string } { - const merged = mergeClientOptions(config, options); - return { - stateDir: resolveDaemonPaths(merged.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR).baseDir, - session: resolveSessionName(merged.session), - }; -} - -// The metro-sessions file is the session's dev-server binding; see MetroSessionHints. -function persistMetroSessionHints( - config: AgentDeviceClientConfig, - result: Pick, -): void { - try { - const deviceBaseUrls = new Set(); - for (const runtime of [result.iosRuntime, result.androidRuntime]) { - try { - const hints = metroHintsFromRuntime(runtime); - if (hints) deviceBaseUrls.add(hints.controlBaseUrl); - } catch {} - } - writeMetroSessionHints({ - ...metroSessionHintsScope(config), - hints: { - controlBaseUrl: new URL('.', result.statusUrl).toString(), - deviceBaseUrls: [...deviceBaseUrls], - }, - }); - } catch { - // Session-hint persistence is best-effort; reload still works with explicit flags. - } -} - -function resolveMetroSessionHints( - config: AgentDeviceClientConfig, -): SessionRuntimeHints | undefined { - try { - const binding = readMetroSessionHints(metroSessionHintsScope(config)); - return binding ? { bundleUrl: binding.controlBaseUrl } : undefined; - } catch { - return undefined; - } -} - -function metroHintsFromRuntime( - runtime: SessionRuntimeHints | undefined, -): MetroSessionHints | undefined { - const { metroHost, metroPort, bundleUrl } = runtime ?? {}; - if (metroHost === undefined && metroPort === undefined && bundleUrl === undefined) - return undefined; - return { controlBaseUrl: resolveMetroServerUrl({ runtime }).toString() }; -} - -// Hint flags rebind the session's dev server; a hintless open that created the session clears -// any leftover binding from a previous same-name session. -function recordMetroSessionHintsAfterOpen(params: { - config: AgentDeviceClientConfig; - options: InternalRequestOptions; - runtime: SessionRuntimeHints | undefined; - sessionReused: boolean; -}): void { - try { - const scope = metroSessionHintsScope(params.config, params.options); - const hints = metroHintsFromRuntime(params.runtime); - if (hints) { - const binding = - params.options.runtime === undefined ? readMetroSessionHints(scope) : undefined; - if ( - binding && - (params.sessionReused || binding.deviceBaseUrls?.includes(hints.controlBaseUrl)) - ) - return; - writeMetroSessionHints({ ...scope, hints }); - return; - } - if (!params.sessionReused) { - clearMetroSessionHints(scope); - } - } catch { - // Session-hint sync is best-effort; reload still works with explicit flags. - } -} - -function clearMetroSessionHintsQuietly( - config: AgentDeviceClientConfig, - options: InternalRequestOptions, -): void { - try { - clearMetroSessionHints(metroSessionHintsScope(config, options)); - } catch { - // Session-hint cleanup is best-effort; close must not fail on local file state. - } -} - export type * from './client/client-types.ts'; diff --git a/src/cli/commands/generic.ts b/src/cli/commands/generic.ts index 81bf636877..ac96d94782 100644 --- a/src/cli/commands/generic.ts +++ b/src/cli/commands/generic.ts @@ -3,12 +3,11 @@ import { runCliCommandWithOutput } from '../../commands/cli-runner.ts'; import { isParseableOutputCommand } from '../../commands/cli-output.ts'; import { routeResponseWarnings } from '../../commands/output-common.ts'; import type { CommandName } from '../../commands/command-metadata.ts'; -import type { CliOutput } from '../../commands/command-contract.ts'; import type { ReplaySuiteResult } from '@agent-device/contracts/replay'; import type { CliFlags } from '@agent-device/contracts/command'; import { readCommandMessage } from '@agent-device/kernel/success-text'; import { isNonDefaultResponseLevel } from '@agent-device/kernel/contracts'; -import { writeCommandOutput } from './shared.ts'; +import { writeCommandOutput, writeCliOutput } from './shared.ts'; import type { ClientBackedCliCommandName } from './client-backed.ts'; import type { ClientCommandParams } from './router-types.ts'; @@ -82,14 +81,3 @@ async function writeGenericCliOutput( ); return 0; } - -async function writeCliOutput(flags: CliFlags, output: CliOutput): Promise { - if (!flags.json && output.stderr) { - process.stderr.write(output.stderr); - } - await writeCommandOutput( - flags, - flags.json ? (output.jsonData ?? output.data) : output.data, - () => output.text, - ); -} diff --git a/src/cli/commands/react-devtools.ts b/src/cli/commands/react-devtools.ts index 3e0c83a8c3..2979265628 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -1,3 +1,5 @@ +import type { CliFlags } from '@agent-device/contracts/command'; +import { writeCliOutput } from './shared.ts'; import { AppError } from '@agent-device/kernel/errors'; import type { ReactDevtoolsCommandOptions } from '../react-devtools/react-devtools-connection.ts'; import { @@ -8,12 +10,19 @@ import { runReactProfileCommand } from '../react-devtools/react-profile-command. export async function runReactDevtoolsCommand( args: string[], - options: ReactDevtoolsCommandOptions, + options: ReactDevtoolsCommandOptions & { flags?: Partial> }, ): Promise { 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.', - }); + const output = + command === 'profile' + ? await runReactProfileCommand(rest, options) + : isInspectCommand(command) + ? await runReactInspectCommand(command, rest, options) + : undefined; + if (!output) + throw new AppError('INVALID_ARGS', `Unknown react-devtools command: ${command ?? '(none)'}.`, { + hint: 'Use react-devtools tree, find, inspect, errors, or profile.', + }); + await writeCliOutput(options.flags ?? {}, output); + return 0; } diff --git a/src/cli/commands/shared.ts b/src/cli/commands/shared.ts index 00071fbb76..5989bf94d3 100644 --- a/src/cli/commands/shared.ts +++ b/src/cli/commands/shared.ts @@ -1,7 +1,8 @@ +import type { CliOutput } from '../../commands/command-contract.ts'; import type { CliFlags } from '@agent-device/contracts/command'; export async function writeCommandOutput( - flags: CliFlags, + flags: Partial>, data: unknown, renderHuman?: () => string | null | undefined, ): Promise { @@ -17,3 +18,15 @@ export async function writeCommandOutput( function writeLine(text: string): void { process.stdout.write(text.endsWith('\n') ? text : `${text}\n`); } + +export async function writeCliOutput( + flags: Partial>, + output: CliOutput, +): Promise { + if (!flags.json && output.stderr) process.stderr.write(output.stderr); + await writeCommandOutput( + flags, + flags.json ? (output.jsonData ?? output.data) : output.data, + () => output.text, + ); +} diff --git a/src/cli/react-devtools/react-component-inspect.ts b/src/cli/react-devtools/react-component-inspect.ts index 71ac907ec2..a0a804cbaf 100644 --- a/src/cli/react-devtools/react-component-inspect.ts +++ b/src/cli/react-devtools/react-component-inspect.ts @@ -1,4 +1,4 @@ -import { componentName, componentRef, kindLabel } from './react-component-tree.ts'; +import { componentName, componentRef, kindLabel } from './react-component.ts'; import { printable, printableLines } from './react-devtools-text.ts'; type ValuePath = Array; diff --git a/src/cli/react-devtools/react-component-tree.ts b/src/cli/react-devtools/react-component-tree.ts index 6cf1c100c6..aa50efdd94 100644 --- a/src/cli/react-devtools/react-component-tree.ts +++ b/src/cli/react-devtools/react-component-tree.ts @@ -1,3 +1,4 @@ +import { componentName, componentRef, kindLabel } from './react-component.ts'; import { AppError } from '@agent-device/kernel/errors'; import { printable } from './react-devtools-text.ts'; @@ -36,21 +37,6 @@ export type ComponentTree = { 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) { @@ -89,17 +75,6 @@ export function buildComponentTree(raw: RawComponentTree): ComponentTree { 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'}`; } diff --git a/src/cli/react-devtools/react-component.test.ts b/src/cli/react-devtools/react-component.test.ts new file mode 100644 index 0000000000..9e053b1a9a --- /dev/null +++ b/src/cli/react-devtools/react-component.test.ts @@ -0,0 +1,22 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { componentKind, componentName, splitDisplayName } from './react-component.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('tree names preserve non-React wrappers', () => { + assert.equal(componentName('Memo(Animated(Row))'), 'Animated(Row)'); + assert.equal(componentName('ForwardRef(Memo(Row))'), 'Row'); +}); + +for (const type of ['toString', 'constructor', '__proto__']) { + test(`inherited key ${type} is not a component kind`, () => { + assert.equal(componentKind(JSON.parse(JSON.stringify(type))), 'unknown'); + }); +} diff --git a/src/cli/react-devtools/react-component.ts b/src/cli/react-devtools/react-component.ts new file mode 100644 index 0000000000..1b040d195c --- /dev/null +++ b/src/cli/react-devtools/react-component.ts @@ -0,0 +1,44 @@ +export type ComponentKind = 'class' | 'function' | 'forwardRef' | 'memo' | 'unknown'; + +export const COMPONENT_KINDS: Readonly> = { + 0: 'unknown', + 1: 'class', + 5: 'function', + 6: 'forwardRef', + 8: 'memo', + 15: 'function', +}; + +export function componentKind(type: number): ComponentKind { + return Object.hasOwn(COMPONENT_KINDS, type) ? COMPONENT_KINDS[type]! : 'unknown'; +} + +const HOC_PATTERN = /^([A-Za-z0-9_$]+)\((.+)\)$/; + +/** Splits DevTools wrapper names; tree views unwrap only React's transparent wrappers. */ +export function splitDisplayName( + displayName: string, + reactWrappersOnly = false, +): { name: string; wrappers: string[] } { + const wrappers: string[] = []; + let name = displayName; + for (let match = HOC_PATTERN.exec(name); match; match = HOC_PATTERN.exec(name)) { + if (reactWrappersOnly && match[1] !== 'Memo' && match[1] !== 'ForwardRef') break; + wrappers.push(match[1]!); + name = match[2]!; + } + return { name, wrappers }; +} + +export function componentName(displayName: string): string { + return splitDisplayName(displayName, true).name; +} + +export function componentRef(node: { id: number }): string { + return `@c${node.id}`; +} + +export function kindLabel(elementType: number): string { + const kind = componentKind(elementType); + return kind === 'memo' || kind === 'class' || kind === 'forwardRef' ? ` [${kind}]` : ''; +} diff --git a/src/cli/react-devtools/react-devtools-command.fixtures.ts b/src/cli/react-devtools/react-devtools-command.fixtures.ts index b04a0225bb..ebe4af259c 100644 --- a/src/cli/react-devtools/react-devtools-command.fixtures.ts +++ b/src/cli/react-devtools/react-devtools-command.fixtures.ts @@ -1,3 +1,5 @@ +import type { CliFlags } from '@agent-device/contracts/command'; +import { runReactDevtoolsCommand } from '../commands/react-devtools.ts'; import { vi } from 'vitest'; import vm from 'node:vm'; import { mkdtempForTestSync } from '@agent-device/test-kit'; @@ -15,17 +17,20 @@ export const TARGET = { /** * 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. + * the CLI prints. Callers restore the stubbed globals and spies after each test. */ export function commandHarness( - command: (args: string[], options: ReactDevtoolsCommandOptions) => Promise, + prefix: string[] = [], app = new FakeAppRuntime(), targets: unknown[] = [TARGET], ) { const stateDir = mkdtempForTestSync('react-profile-'); const output: string[] = []; const requested: string[] = []; - const options: ReactDevtoolsCommandOptions = { stateDir, session: 'qa' }; + const options: ReactDevtoolsCommandOptions & { flags?: Partial> } = { + stateDir, + session: 'qa', + }; vi.stubGlobal('fetch', async (url: URL) => { requested.push(String(url)); return new Response(JSON.stringify(targets), { status: 200 }); @@ -43,7 +48,7 @@ export function commandHarness( }); const run = async (...args: string[]) => { output.length = 0; - const code = await command(args, options); + const code = await runReactDevtoolsCommand([...prefix, ...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 index 6fc097df87..9a3f5b2c83 100644 --- a/src/cli/react-devtools/react-devtools-connection.test.ts +++ b/src/cli/react-devtools/react-devtools-connection.test.ts @@ -3,7 +3,6 @@ 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(() => { @@ -11,7 +10,7 @@ afterEach(() => { vi.unstubAllGlobals(); }); -const harness = () => commandHarness(runReactProfileCommand); +const harness = () => commandHarness(['profile']); function bindSession(stateDir: string, controlBaseUrl: string) { fs.mkdirSync(path.join(stateDir, 'metro-sessions'), { recursive: true }); diff --git a/src/cli/react-devtools/react-devtools-connection.ts b/src/cli/react-devtools/react-devtools-connection.ts index 4eebb5718b..59772b2d43 100644 --- a/src/cli/react-devtools/react-devtools-connection.ts +++ b/src/cli/react-devtools/react-devtools-connection.ts @@ -1,8 +1,6 @@ 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 { resolveSessionMetroServerUrl } from '../../metro/metro-session-hints.ts'; import { debuggerUrlOn, InspectorSession, @@ -17,7 +15,6 @@ import { } from './react-devtools-runtime.ts'; export type ReactDevtoolsCommandOptions = { - flags?: Partial>; stateDir: string; session: string; }; @@ -114,32 +111,13 @@ export function parseDevtoolsArgs( }; } -/** - * 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 serverUrl = resolveSessionMetroServerUrl(options, args); const target = selectReactNativeTarget(await listMetroInspectorTargets(serverUrl), { appId: args.app, device: args.metroDevice, @@ -226,14 +204,3 @@ export function runtimeFailure(result: RuntimeFailure): AppError { ...(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 index bb082bc62d..33ecfa3b61 100644 --- a/src/cli/react-devtools/react-devtools-inspect-command.test.ts +++ b/src/cli/react-devtools/react-devtools-inspect-command.test.ts @@ -1,21 +1,13 @@ 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(); @@ -43,7 +35,7 @@ function listApp(): FakeAppRuntime { return app; } -const harness = (app = listApp()) => commandHarness(inspectCommand, app); +const harness = (app = listApp()) => commandHarness([], app); test('tree prints the mounted components with refs', async () => { const { run } = harness(); diff --git a/src/cli/react-devtools/react-devtools-inspect-command.ts b/src/cli/react-devtools/react-devtools-inspect-command.ts index 303035b60a..561e210335 100644 --- a/src/cli/react-devtools/react-devtools-inspect-command.ts +++ b/src/cli/react-devtools/react-devtools-inspect-command.ts @@ -1,3 +1,5 @@ +import { componentRef } from './react-component.ts'; +import type { CliOutput } from '../../commands/command-contract.ts'; import { AppError } from '@agent-device/kernel/errors'; import { targetAppId, type InspectorSession } from './metro-inspector.ts'; import { @@ -7,7 +9,6 @@ import { } from './react-component-inspect.ts'; import { buildComponentTree, - componentRef, findNodes, logSummary, matchLine, @@ -20,14 +21,12 @@ import { } 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'; @@ -90,14 +89,10 @@ function requireTarget(args: DevtoolsArgs, usage: string): string { return target; } -type Handler = ( - args: DevtoolsArgs, - options: ReactDevtoolsCommandOptions, - emit: Emit, -) => Promise; +type Handler = (args: DevtoolsArgs, options: ReactDevtoolsCommandOptions) => Promise; const HANDLERS: Record = { - tree: async (args, options, emit) => { + tree: async (args, options) => { 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; @@ -117,15 +112,18 @@ const HANDLERS: Record = { `+${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, - }); + return { + text: [header, ...view.lines, ...footer].join('\n'), + data: { + app, + root: root ? componentRef(root) : null, + total: view.total, + shown: view.shown, + lines: view.lines, + }, + }; }, - find: async (args, options, emit) => { + find: async (args, options) => { const query = requireTarget(args, 'find '); const tree = await withAppRuntime(args, options, async (session) => await readTree(session)); const matches = findNodes(tree, query, args.exact); @@ -139,21 +137,27 @@ const HANDLERS: Record = { 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)), - }); + return { + text: [header, ...shown.map((node) => ` ${matchLine(tree, node)}`), ...more].join('\n'), + data: { + total: matches.length, + matches: shown.map((node) => matchData(node)), + }, + }; }, - inspect: async (args, options, emit) => { + inspect: async (args, options) => { 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 }); + return { + text: formatInspectedComponent(id, component), + data: { ref: componentRef({ id }), ...component }, + }; }, - errors: async (args, options, emit) => { + errors: async (args, options) => { const limit = args.limit ?? ERRORS_LIMIT; const { logged, inspected, logsTracked } = await withAppRuntime( args, @@ -173,13 +177,13 @@ const HANDLERS: Record = { ); 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; + return { + text: [ + 'No mounted component logged an error or warning while rendering.', + ...untracked, + ].join('\n'), + data: { total: 0, logsTracked, components: [] }, + }; } const shown = logged.slice(0, limit); const lines = [ @@ -195,20 +199,23 @@ const HANDLERS: Record = { 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, - }; - }), - }); + return { + text: lines.join('\n'), + data: { + 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, + }; + }), + }, + }; }, }; @@ -235,8 +242,7 @@ export async function runReactInspectCommand( command: InspectCommand, args: string[], options: ReactDevtoolsCommandOptions, -): Promise { +): Promise { const parsed = parseDevtoolsArgs(args, () => FORMS[command]); - await HANDLERS[command](parsed, options, emitTo(options)); - return 0; + return await HANDLERS[command](parsed, options); } diff --git a/src/cli/react-devtools/react-devtools-runtime.test.ts b/src/cli/react-devtools/react-devtools-runtime.test.ts index 3e2cc21f9a..2e7aa2773a 100644 --- a/src/cli/react-devtools/react-devtools-runtime.test.ts +++ b/src/cli/react-devtools/react-devtools-runtime.test.ts @@ -249,6 +249,53 @@ test('describeProps classifies changed prop values on a mounted instance', () => ); }); +test('prop sampling and inspection use the renderer that owns the element', () => { + const app = new FakeAppRuntime(); + app.install(); + app.renderer.props.set(3, { data: { label: 'owned' }, cleaned: [] }); + const foreign = Object.assign(Object.create(app.renderer), { + hasElementWithId: () => false, + inspectElement: () => ({ type: 'full-data', value: { props: { data: { label: 1 } } } }), + }); + app.hook.rendererInterfaces.clear(); + app.hook.rendererInterfaces.set(1, foreign); + app.hook.rendererInterfaces.set(2, app.renderer); + assert.deepEqual(app.call('describeProps', { '3': ['label'] }), { '3': { label: 'string' } }); + const inspected = app.call<{ ok: boolean; elements: Record }>( + 'inspect', + [3], + ); + assert.deepEqual(inspected.elements['3']?.props, { data: { label: 'owned' }, cleaned: [] }); +}); + +for (const confirmedOwner of [false, true]) { + test(`${confirmedOwner ? 'confirmed' : 'unconfirmed'} renderer failure respects element ownership`, () => { + const app = new FakeAppRuntime(); + app.install(); + app.renderer.props.set(3, { data: { label: 'owned' }, cleaned: [] }); + const foreign = Object.assign(Object.create(app.renderer), { + hasElementWithId: confirmedOwner ? () => true : undefined, + inspectElement: () => { + throw new Error('renderer unavailable'); + }, + }); + app.hook.rendererInterfaces.clear(); + app.hook.rendererInterfaces.set(1, foreign); + app.hook.rendererInterfaces.set(2, app.renderer); + assert.deepEqual( + app.call('describeProps', { '3': ['label'] }), + confirmedOwner ? {} : { '3': { label: 'string' } }, + ); + const inspected = app.call<{ elements: Record }>( + 'inspect', + [3], + ); + if (confirmedOwner) assert.equal(inspected.elements['3']?.error, 'renderer unavailable'); + else + assert.deepEqual(inspected.elements['3']?.props, { data: { label: 'owned' }, cleaned: [] }); + }); +} + test('a renderer whose profiling data cannot be read fails the stop instead of returning no commits', () => { const app = new FakeAppRuntime(); app.install(); diff --git a/src/cli/react-devtools/react-devtools-runtime.ts b/src/cli/react-devtools/react-devtools-runtime.ts index 1f55708ba2..c8a623eb4d 100644 --- a/src/cli/react-devtools/react-devtools-runtime.ts +++ b/src/cli/react-devtools/react-devtools-runtime.ts @@ -312,17 +312,13 @@ export const REACT_DEVTOOLS_RUNTIME_SOURCE = String.raw` 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; - }); + var inspected = inspectBackendElement(Number(id)); + if (!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; } @@ -346,34 +342,43 @@ export const REACT_DEVTOOLS_RUNTIME_SOURCE = String.raw` return { ok: true, nodes: nodes, order: childOrder, logs: counts, logsTracked: logsTracked }; } - function inspectOne(id) { + function inspectBackendElement(id) { var found = null; + var failure = 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; + result = { type: 'error', message: String(e && e.message || e) }; } 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 - }; + if (result.type === 'full-data') found = { value: result.value, name: lookupName(ri, id) }; + else { + failure = { error: result.message || result.type }; + if (ri.hasElementWithId) found = failure; + } }); - return found || { missing: true }; + return found || failure || { missing: true }; + } + + function inspectOne(id) { + var result = inspectBackendElement(id); + if (!result.value) return result; + var value = result.value; + var known = elements.get(id); + return { + name: known ? known[0] : result.name, + 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 + }; } function inspect(ids) { diff --git a/src/cli/react-devtools/react-profile-analysis.ts b/src/cli/react-devtools/react-profile-analysis.ts index 8c97f668ad..5af45a8dec 100644 --- a/src/cli/react-devtools/react-profile-analysis.ts +++ b/src/cli/react-devtools/react-profile-analysis.ts @@ -1,3 +1,4 @@ +import type { ComponentKind } from './react-component.ts'; import { bump, chronological, @@ -13,7 +14,6 @@ import { type CommitComponent, type CommitProfile, type ComponentId, - type ComponentKind, type ComponentProfile, type PropKindsById, type RawProfileCommit, diff --git a/src/cli/react-devtools/react-profile-command.test.ts b/src/cli/react-devtools/react-profile-command.test.ts index e08faf3866..39554589d4 100644 --- a/src/cli/react-devtools/react-profile-command.test.ts +++ b/src/cli/react-devtools/react-profile-command.test.ts @@ -3,13 +3,12 @@ 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); + commandHarness(['profile'], app, targets); afterEach(() => { vi.restoreAllMocks(); @@ -261,6 +260,35 @@ test('drill-down views read the saved recording, not a trimmed analysis', async assert.equal(saved.version, 2); assert.equal(saved.profile.commits.length, 3); assert.match((await run('report', 'Row')).text, /changed props: onPress 36 \(function\)/); + assert.doesNotMatch((await run('report', 'Row')).text, /collection may have been interrupted/); +}); + +test('stop saves the raw recording before optional prop inspection', async () => { + const { app, run, stateDir } = harness(); + await run('start'); + recordRowRenders(app); + let checkpoint: + | { profile: { commits: unknown[] }; propKinds: unknown; notes: string[] } + | undefined; + vi.mocked(InspectorSession.connect).mockResolvedValue({ + evaluate: async (expression: string) => { + if (expression.includes('.describeProps(')) { + const file = path.join(stateDir, 'react-profiles', 'qa.json'); + if (fs.existsSync(file)) checkpoint = JSON.parse(fs.readFileSync(file, 'utf8')); + throw new Error('inspection unavailable'); + } + return vm.runInContext(expression, app.context); + }, + close: () => {}, + } as unknown as InspectorSession); + const stopped = await run('stop'); + assert.equal(checkpoint?.profile.commits.length, 3); + assert.deepEqual(checkpoint?.propKinds, {}); + assert.deepEqual(checkpoint?.notes, [ + 'Changed prop values have not been inspected; collection may have been interrupted.', + ]); + assert.match(stopped.text, /Changed prop values could not be inspected: inspection unavailable/); + assert.match((await run('report', 'Row')).text, /renders 36/); }); test('a renderer that fails while another records nothing is named in the empty summary', async () => { diff --git a/src/cli/react-devtools/react-profile-command.ts b/src/cli/react-devtools/react-profile-command.ts index dcda86ccb5..5b7eb1f846 100644 --- a/src/cli/react-devtools/react-profile-command.ts +++ b/src/cli/react-devtools/react-profile-command.ts @@ -1,18 +1,18 @@ +import type { CliOutput } from '../../commands/command-contract.ts'; import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; import { safeSessionName } from '@agent-device/host-kit/session-paths'; +import { publishFileSync } from '@agent-device/host-kit/file'; import { targetAppId, type InspectorSession } from './metro-inspector.ts'; import { callRuntime, - emitTo, parseDevtoolsArgs, runtimeFailure, RUNTIME_OPTIONS, withAppRuntime, type CommandForm, type DevtoolsArgs, - type Emit, type ReactDevtoolsCommandOptions, type RuntimeFailure, } from './react-devtools-connection.ts'; @@ -107,7 +107,7 @@ function writeSavedProfile( ): string { const filePath = savedProfilePath(options); fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(profile)}\n`, 'utf8'); + publishFileSync({ destination: filePath, contents: `${JSON.stringify(profile)}\n` }); return filePath; } @@ -197,30 +197,35 @@ async function stopProfile( 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, { + const saved: SavedReactProfile = { version: SAVED_PROFILE_VERSION, savedAt: new Date().toISOString(), appId, deviceName: target.deviceName, profile, - propKinds: described.propKinds, - notes, + propKinds: {}, + notes: failures.map((failure) => `A React renderer's profile could not be read: ${failure}`), + }; + writeSavedProfile(options, { + ...saved, + notes: [ + ...saved.notes, + 'Changed prop values have not been inspected; collection may have been interrupted.', + ], }); - return { appId, analysis: analyzeReactProfile(profile, described.propKinds, notes) }; + const described = await describeChangedProps(session, profile); + saved.propKinds = described.propKinds; + saved.notes.push(...described.notes); + writeSavedProfile(options, saved); + return { appId, analysis: analyzeReactProfile(profile, saved.propKinds, saved.notes) }; }); } type SubcommandHandler = ( args: ProfileArgs, options: ReactDevtoolsCommandOptions, - emit: Emit, -) => Promise; +) => Promise; function requireComponents(analysis: ReactProfileAnalysis, name: string): ComponentProfile[] { const components = findComponents(analysis, name); @@ -247,62 +252,74 @@ const COMPONENT_LIMIT = 10; const COMMIT_LIMIT = 20; const SUBCOMMAND_HANDLERS: Record = { - start: async (args, options, emit) => { + start: async (args, options) => { const started = await startProfile(args, options); - emit(started.message, started); + return { text: started.message, data: started }; }, - stop: async (args, options, emit) => { + stop: async (args, options) => { 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), - }); + return { + text: formatProfileSummary(analysis, args.limit ?? DEFAULT_FINDING_LIMIT), + data: { + appId, + wallMs: analysis.wallMs, + commitCount: analysis.commitCount, + renderMs: analysis.renderMs, + slowCommitCount: analysis.slowCommitCount, + worstCommit: analysis.worstCommit, + findings: analysis.findings.slice(0, args.limit ?? analysis.findings.length), + notes: analysis.notes, + savedTo: savedProfilePath(options), + }, + }; }, - report: async (args, options, emit) => { + report: async (args, options) => { 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; + return { + text: formatComponents(analysis, limit), + data: { + components: analysis.components.slice(0, limit), + }, + }; } const components = requireComponents(analysis, args.target); - emit(formatComponentReport(analysis, components), { - components, - findings: analysis.findings.filter((finding) => - components.some((component) => isFindingFor(finding, component)), - ), - }); + return { + text: formatComponentReport(analysis, components), + data: { + components, + findings: analysis.findings.filter((finding) => + components.some((component) => isFindingFor(finding, component)), + ), + }, + }; }, - commit: async (args, options, emit) => { + commit: async (args, options) => { const analysis = readSavedAnalysis(options); if (args.target === undefined) { const limit = args.limit ?? COMMIT_LIMIT; - emit(formatCommits(analysis, limit), { commits: listedCommits(analysis, limit) }); - return; + return { + text: formatCommits(analysis, limit), + data: { commits: listedCommits(analysis, limit) }, + }; } const commit = requireCommit(analysis, args.target); const limit = args.limit ?? COMPONENT_LIMIT; - emit(formatCommit(commit, limit), { - ...commit, - components: commit.components.slice(0, limit), - }); + return { + text: formatCommit(commit, limit), + data: { + ...commit, + components: commit.components.slice(0, limit), + }, + }; }, }; export async function runReactProfileCommand( args: string[], options: ReactDevtoolsCommandOptions, -): Promise { +): Promise { const parsed = parseProfileArgs(args); - await SUBCOMMAND_HANDLERS[parsed.subcommand](parsed, options, emitTo(options)); - return 0; + return await SUBCOMMAND_HANDLERS[parsed.subcommand](parsed, options); } diff --git a/src/cli/react-devtools/react-profile-renders.test.ts b/src/cli/react-devtools/react-profile-renders.test.ts index 3867789e25..eda2c3d7b3 100644 --- a/src/cli/react-devtools/react-profile-renders.test.ts +++ b/src/cli/react-devtools/react-profile-renders.test.ts @@ -3,19 +3,10 @@ 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]]], @@ -106,3 +97,22 @@ test('names and prop keys that differ only by control characters stay apart', () ], ); }); + +test('edited profiles cannot render inherited component-kind keys', () => { + const raw = rawProfile({ 1: ['Valid'], 2: ['Invalid'] }, [ + [ + 0, + 2, + [], + [ + [1, 1], + [2, 1], + ], + ], + ]); + raw.elements['2'] = JSON.parse('["Invalid","toString",0,0]'); + assert.deepEqual( + summarizeRenders(raw).components.map(({ name, kind }) => [name, kind]), + [['Valid', 'function']], + ); +}); diff --git a/src/cli/react-devtools/react-profile-renders.ts b/src/cli/react-devtools/react-profile-renders.ts index dcee961113..409618ffbe 100644 --- a/src/cli/react-devtools/react-profile-renders.ts +++ b/src/cli/react-devtools/react-profile-renders.ts @@ -1,22 +1,13 @@ +import { + COMPONENT_KINDS, + componentKind, + splitDisplayName, + type ComponentKind, +} from './react-component.ts'; + /** 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. @@ -48,19 +39,6 @@ const FRAMEWORK_PRIMITIVES = new Set([ '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. @@ -103,8 +81,6 @@ export type RawReactProfile = { 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 }; @@ -185,22 +161,6 @@ type ParsedRender = ComponentId & { 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)]; @@ -208,7 +168,7 @@ function elementOf(profile: RawReactProfile, id: number): RawProfileElement | un function componentOf(element: RawProfileElement): ComponentId { const { name, wrappers } = renderName(element[0]); - return { name, kind: componentKind(wrappers.includes('Memo') ? ELEMENT_MEMO : element[1]) }; + return { name, kind: wrappers.includes('Memo') ? 'memo' : componentKind(element[1]) }; } /** The distinct components behind these elements, leaving out any the profile could not name. */ @@ -243,7 +203,7 @@ export function* commitRenders( 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; + if (!element || !Object.hasOwn(COMPONENT_KINDS, element[1])) continue; yield { ...componentOf(element), id, diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 38a6bc2903..32bf5dcab7 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -309,7 +309,7 @@ Component inspection: Profiling loop: 1. Run profile start immediately before the interaction; keep the window narrow. 2. Drive the interaction with normal agent-device commands. When correlating with logs, run logs clear --restart before the first logs mark. - 3. Run profile stop. It prints the commit count, render time, commits over the 16 ms frame budget, and ranked findings, each naming a component, its evidence, and a fix. + 3. Run profile stop. It saves the recording, then 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. If changed-prop inspection is interrupted after collection, profile report and profile commit can still read the saved recording. 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. diff --git a/src/metro/metro-session-hints.ts b/src/metro/metro-session-hints.ts index 1cc640a738..b7999361ed 100644 --- a/src/metro/metro-session-hints.ts +++ b/src/metro/metro-session-hints.ts @@ -1,7 +1,11 @@ +import type { PrepareMetroRuntimeResult } from '@agent-device/contracts/remote'; +import type { SessionRuntimeHints } from '@agent-device/kernel/contracts'; +import { resolveSessionName } from '../client/client-normalizers.ts'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import fs from 'node:fs'; import path from 'node:path'; import { safeSessionName } from '@agent-device/host-kit/session-paths'; -import { resolveMetroServerUrl } from './metro-reload-endpoints.ts'; +import { resolveMetroServerUrl, type MetroReloadTargetInput } from './metro-reload-endpoints.ts'; /** Reload control and the prepared device addresses that may reuse it on a fresh open. */ export type MetroSessionHints = { @@ -9,24 +13,30 @@ export type MetroSessionHints = { deviceBaseUrls?: string[]; }; +export type MetroSessionScope = { stateDir: string; session: string }; + +type MetroSessionOptions = { stateDir?: string; session?: string }; + +function resolveMetroSessionScope(options: MetroSessionOptions): MetroSessionScope { + return { + stateDir: resolveDaemonPaths(options.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR).baseDir, + session: resolveSessionName(options.session), + }; +} + function metroSessionHintsPath(stateDir: string, session: string): string { return path.join(stateDir, 'metro-sessions', `${safeSessionName(session)}.json`); } -export function writeMetroSessionHints(options: { - stateDir: string; - session: string; - hints: MetroSessionHints; -}): void { +export function writeMetroSessionHints( + options: MetroSessionScope & { hints: MetroSessionHints }, +): void { const filePath = metroSessionHintsPath(options.stateDir, options.session); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, `${JSON.stringify(options.hints, null, 2)}\n`, 'utf8'); } -export function readMetroSessionHints(options: { - stateDir: string; - session: string; -}): MetroSessionHints | undefined { +export function readMetroSessionHints(options: MetroSessionScope): MetroSessionHints | undefined { const filePath = metroSessionHintsPath(options.stateDir, options.session); let raw: string; try { @@ -74,6 +84,86 @@ export function readMetroSessionHints(options: { } } -export function clearMetroSessionHints(options: { stateDir: string; session: string }): void { +export function clearMetroSessionHints(options: MetroSessionScope): void { fs.rmSync(metroSessionHintsPath(options.stateDir, options.session), { force: true }); } + +/** Resolves a control address; device-facing runtime hints are only an explicit fallback. */ +export function resolveSessionMetroServerUrl( + scope: MetroSessionScope, + input: MetroReloadTargetInput = {}, +): URL { + const complete = + input.bundleUrl !== undefined || + (input.metroHost !== undefined && input.metroPort !== undefined); + return resolveMetroServerUrl({ + ...input, + runtime: (complete ? undefined : readMetroSessionRuntime(scope)) ?? input.runtime, + }); +} + +export function readMetroSessionRuntime( + options: MetroSessionOptions, +): SessionRuntimeHints | undefined { + try { + const binding = readMetroSessionHints(resolveMetroSessionScope(options)); + return binding ? { bundleUrl: binding.controlBaseUrl } : undefined; + } catch { + return undefined; + } +} + +function runtimeServerUrl(runtime: SessionRuntimeHints | undefined): string | undefined { + const { metroHost, metroPort, bundleUrl } = runtime ?? {}; + return metroHost === undefined && metroPort === undefined && bundleUrl === undefined + ? undefined + : resolveMetroServerUrl({ runtime }).toString(); +} + +export function recordPreparedMetroSession( + options: MetroSessionOptions, + result: Pick, +): void { + try { + const scope = resolveMetroSessionScope(options); + const deviceBaseUrls = new Set(); + for (const runtime of [result.iosRuntime, result.androidRuntime]) { + try { + const url = runtimeServerUrl(runtime); + if (url) deviceBaseUrls.add(url); + } catch {} + } + writeMetroSessionHints({ + ...scope, + hints: { + controlBaseUrl: new URL('.', result.statusUrl).toString(), + deviceBaseUrls: [...deviceBaseUrls], + }, + }); + } catch {} +} + +export function recordOpenedMetroSession( + options: MetroSessionOptions, + input: { + runtime: SessionRuntimeHints | undefined; + explicitRuntime: boolean; + sessionReused: boolean; + }, +): void { + try { + const scope = resolveMetroSessionScope(options); + const url = runtimeServerUrl(input.runtime); + if (url) { + const binding = input.explicitRuntime ? undefined : readMetroSessionHints(scope); + if (binding && (input.sessionReused || binding.deviceBaseUrls?.includes(url))) return; + writeMetroSessionHints({ ...scope, hints: { controlBaseUrl: url } }); + } else if (!input.sessionReused) clearMetroSessionHints(scope); + } catch {} +} + +export function clearMetroSessionHintsQuietly(options: MetroSessionOptions): void { + try { + clearMetroSessionHints(resolveMetroSessionScope(options)); + } catch {} +} diff --git a/website/docs/docs/debugging-profiling.md b/website/docs/docs/debugging-profiling.md index 3796fe9ee4..a365757d29 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -52,7 +52,7 @@ 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: +`profile stop` saves the recording and 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. If changed-prop inspection is interrupted after collection, you can still read the saved recording with `profile report` and `profile commit`. Each finding names a component, shows the evidence, and suggests a fix: ```text React profile 8.3s: 8 commits, 1303ms rendering, 8 over 16ms (worst #6 188ms)