Skip to content

Commit f21ea8a

Browse files
committed
refactor(react-devtools): share output and Metro session ownership
1 parent af282ef commit f21ea8a

21 files changed

Lines changed: 399 additions & 391 deletions

‎src/agent-device-client.ts‎

Lines changed: 12 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,7 @@ import type {
2929
} from '@agent-device/contracts/client';
3030
import type { AgentArtifactsResult } from '@agent-device/contracts/observability';
3131
import type { MetroPrepareOptions } from '@agent-device/contracts/remote';
32-
import {
33-
isNonDefaultResponseLevel,
34-
type ResponseLevel,
35-
type SessionRuntimeHints,
36-
} from '@agent-device/kernel/contracts';
32+
import { isNonDefaultResponseLevel, type ResponseLevel } from '@agent-device/kernel/contracts';
3733
import { AppError, throwDaemonError } from '@agent-device/kernel/errors';
3834
import { createRequestId } from '@agent-device/host-kit/diagnostics';
3935
import {
@@ -54,7 +50,7 @@ import {
5450
readSnapshotNodes,
5551
resolveSessionName,
5652
} from './client/client-normalizers.ts';
57-
import type { AgentDeviceClient, MetroPrepareResult } from './client/client-types.ts';
53+
import type { AgentDeviceClient } from './client/client-types.ts';
5854
import { INTERNAL_COMMANDS } from '@agent-device/command-registry/catalog';
5955
import { buildRequestFlags } from './commands/command-flags.ts';
6056
import {
@@ -64,13 +60,12 @@ import {
6460
import type { CommandResult } from '@agent-device/command-registry/command-result';
6561
import { sendToDaemon } from './daemon-client/daemon-client.ts';
6662
import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution';
67-
import { resolveMetroServerUrl } from './metro/metro-reload-endpoints.ts';
6863
import { prepareMetroRuntime, reloadMetro } from './metro/client-metro.ts';
6964
import {
70-
clearMetroSessionHints,
71-
readMetroSessionHints,
72-
writeMetroSessionHints,
73-
type MetroSessionHints,
65+
clearMetroSessionHintsQuietly,
66+
recordPreparedMetroSession,
67+
recordOpenedMetroSession,
68+
readMetroSessionRuntime,
7469
} from './metro/metro-session-hints.ts';
7570
import {
7671
asOptionalRecord,
@@ -226,7 +221,7 @@ export function createAgentDeviceClient(
226221
};
227222
} finally {
228223
// Close is teardown intent: drop the dev-server binding even if the daemon call fails.
229-
clearMetroSessionHintsQuietly(config, options);
224+
clearMetroSessionHintsQuietly(metroSessionHintsScope(config, options));
230225
}
231226
},
232227
saveScript: async (
@@ -281,9 +276,8 @@ export function createAgentDeviceClient(
281276
open: async (options: AppOpenOptions) => {
282277
const session = resolveRequestSession(options);
283278
const data = await executeCommand<Record<string, unknown>>('open', options);
284-
recordMetroSessionHintsAfterOpen({
285-
config,
286-
options,
279+
recordOpenedMetroSession(metroSessionHintsScope(config, options), {
280+
explicitRuntime: options.runtime !== undefined,
287281
runtime: mergeClientOptions(config, options).runtime,
288282
sessionReused: data.sessionReused === true,
289283
});
@@ -367,16 +361,13 @@ export function createAgentDeviceClient(
367361
runtimeFilePath: options.runtimeFilePath,
368362
logPath: options.logPath,
369363
});
370-
persistMetroSessionHints(config, result);
364+
recordPreparedMetroSession(metroSessionHintsScope(config), result);
371365
return result;
372366
},
373367
reload: async (options = {}) =>
374368
await reloadMetro({
375-
metroHost: options.metroHost,
376-
metroPort: options.metroPort,
377-
bundleUrl: options.bundleUrl,
378-
runtime: resolveMetroSessionHints(config) ?? config.runtime,
379-
timeoutMs: options.timeoutMs,
369+
...options,
370+
runtime: readMetroSessionRuntime(metroSessionHintsScope(config)) ?? config.runtime,
380371
}),
381372
},
382373
capture: {
@@ -597,90 +588,4 @@ function metroSessionHintsScope(
597588
};
598589
}
599590

600-
// The metro-sessions file is the session's dev-server binding; see MetroSessionHints.
601-
function persistMetroSessionHints(
602-
config: AgentDeviceClientConfig,
603-
result: Pick<MetroPrepareResult, 'statusUrl' | 'iosRuntime' | 'androidRuntime'>,
604-
): void {
605-
try {
606-
const deviceBaseUrls = new Set<string>();
607-
for (const runtime of [result.iosRuntime, result.androidRuntime]) {
608-
try {
609-
const hints = metroHintsFromRuntime(runtime);
610-
if (hints) deviceBaseUrls.add(hints.controlBaseUrl);
611-
} catch {}
612-
}
613-
writeMetroSessionHints({
614-
...metroSessionHintsScope(config),
615-
hints: {
616-
controlBaseUrl: new URL('.', result.statusUrl).toString(),
617-
deviceBaseUrls: [...deviceBaseUrls],
618-
},
619-
});
620-
} catch {
621-
// Session-hint persistence is best-effort; reload still works with explicit flags.
622-
}
623-
}
624-
625-
function resolveMetroSessionHints(
626-
config: AgentDeviceClientConfig,
627-
): SessionRuntimeHints | undefined {
628-
try {
629-
const binding = readMetroSessionHints(metroSessionHintsScope(config));
630-
return binding ? { bundleUrl: binding.controlBaseUrl } : undefined;
631-
} catch {
632-
return undefined;
633-
}
634-
}
635-
636-
function metroHintsFromRuntime(
637-
runtime: SessionRuntimeHints | undefined,
638-
): MetroSessionHints | undefined {
639-
const { metroHost, metroPort, bundleUrl } = runtime ?? {};
640-
if (metroHost === undefined && metroPort === undefined && bundleUrl === undefined)
641-
return undefined;
642-
return { controlBaseUrl: resolveMetroServerUrl({ runtime }).toString() };
643-
}
644-
645-
// Hint flags rebind the session's dev server; a hintless open that created the session clears
646-
// any leftover binding from a previous same-name session.
647-
function recordMetroSessionHintsAfterOpen(params: {
648-
config: AgentDeviceClientConfig;
649-
options: InternalRequestOptions;
650-
runtime: SessionRuntimeHints | undefined;
651-
sessionReused: boolean;
652-
}): void {
653-
try {
654-
const scope = metroSessionHintsScope(params.config, params.options);
655-
const hints = metroHintsFromRuntime(params.runtime);
656-
if (hints) {
657-
const binding =
658-
params.options.runtime === undefined ? readMetroSessionHints(scope) : undefined;
659-
if (
660-
binding &&
661-
(params.sessionReused || binding.deviceBaseUrls?.includes(hints.controlBaseUrl))
662-
)
663-
return;
664-
writeMetroSessionHints({ ...scope, hints });
665-
return;
666-
}
667-
if (!params.sessionReused) {
668-
clearMetroSessionHints(scope);
669-
}
670-
} catch {
671-
// Session-hint sync is best-effort; reload still works with explicit flags.
672-
}
673-
}
674-
675-
function clearMetroSessionHintsQuietly(
676-
config: AgentDeviceClientConfig,
677-
options: InternalRequestOptions,
678-
): void {
679-
try {
680-
clearMetroSessionHints(metroSessionHintsScope(config, options));
681-
} catch {
682-
// Session-hint cleanup is best-effort; close must not fail on local file state.
683-
}
684-
}
685-
686591
export type * from './client/client-types.ts';

‎src/cli/commands/generic.ts‎

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,11 @@ import { runCliCommandWithOutput } from '../../commands/cli-runner.ts';
33
import { isParseableOutputCommand } from '../../commands/cli-output.ts';
44
import { routeResponseWarnings } from '../../commands/output-common.ts';
55
import type { CommandName } from '../../commands/command-metadata.ts';
6-
import type { CliOutput } from '../../commands/command-contract.ts';
76
import type { ReplaySuiteResult } from '@agent-device/contracts/replay';
87
import type { CliFlags } from '@agent-device/contracts/command';
98
import { readCommandMessage } from '@agent-device/kernel/success-text';
109
import { isNonDefaultResponseLevel } from '@agent-device/kernel/contracts';
11-
import { writeCommandOutput } from './shared.ts';
10+
import { writeCommandOutput, writeCliOutput } from './shared.ts';
1211
import type { ClientBackedCliCommandName } from './client-backed.ts';
1312
import type { ClientCommandParams } from './router-types.ts';
1413

@@ -82,14 +81,3 @@ async function writeGenericCliOutput(
8281
);
8382
return 0;
8483
}
85-
86-
async function writeCliOutput(flags: CliFlags, output: CliOutput): Promise<void> {
87-
if (!flags.json && output.stderr) {
88-
process.stderr.write(output.stderr);
89-
}
90-
await writeCommandOutput(
91-
flags,
92-
flags.json ? (output.jsonData ?? output.data) : output.data,
93-
() => output.text,
94-
);
95-
}

‎src/cli/commands/react-devtools.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { CliFlags } from '@agent-device/contracts/command';
2+
import { writeCliOutput } from './shared.ts';
13
import { AppError } from '@agent-device/kernel/errors';
24
import type { ReactDevtoolsCommandOptions } from '../react-devtools/react-devtools-connection.ts';
35
import {
@@ -8,12 +10,19 @@ import { runReactProfileCommand } from '../react-devtools/react-profile-command.
810

911
export async function runReactDevtoolsCommand(
1012
args: string[],
11-
options: ReactDevtoolsCommandOptions,
13+
options: ReactDevtoolsCommandOptions & { flags?: Partial<Pick<CliFlags, 'json'>> },
1214
): Promise<number> {
1315
const [command, ...rest] = args;
14-
if (command === 'profile') return await runReactProfileCommand(rest, options);
15-
if (isInspectCommand(command)) return await runReactInspectCommand(command, rest, options);
16-
throw new AppError('INVALID_ARGS', `Unknown react-devtools command: ${command ?? '(none)'}.`, {
17-
hint: 'Use react-devtools tree, find, inspect, errors, or profile.',
18-
});
16+
const output =
17+
command === 'profile'
18+
? await runReactProfileCommand(rest, options)
19+
: isInspectCommand(command)
20+
? await runReactInspectCommand(command, rest, options)
21+
: undefined;
22+
if (!output)
23+
throw new AppError('INVALID_ARGS', `Unknown react-devtools command: ${command ?? '(none)'}.`, {
24+
hint: 'Use react-devtools tree, find, inspect, errors, or profile.',
25+
});
26+
await writeCliOutput(options.flags ?? {}, output);
27+
return 0;
1928
}

‎src/cli/commands/shared.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import type { CliOutput } from '../../commands/command-contract.ts';
12
import type { CliFlags } from '@agent-device/contracts/command';
23

34
export async function writeCommandOutput(
4-
flags: CliFlags,
5+
flags: Partial<Pick<CliFlags, 'json'>>,
56
data: unknown,
67
renderHuman?: () => string | null | undefined,
78
): Promise<void> {
@@ -17,3 +18,15 @@ export async function writeCommandOutput(
1718
function writeLine(text: string): void {
1819
process.stdout.write(text.endsWith('\n') ? text : `${text}\n`);
1920
}
21+
22+
export async function writeCliOutput(
23+
flags: Partial<Pick<CliFlags, 'json'>>,
24+
output: CliOutput,
25+
): Promise<void> {
26+
if (!flags.json && output.stderr) process.stderr.write(output.stderr);
27+
await writeCommandOutput(
28+
flags,
29+
flags.json ? (output.jsonData ?? output.data) : output.data,
30+
() => output.text,
31+
);
32+
}

‎src/cli/react-devtools/react-component-inspect.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { componentName, componentRef, kindLabel } from './react-component-tree.ts';
1+
import { componentName, componentRef, kindLabel } from './react-component.ts';
22
import { printable, printableLines } from './react-devtools-text.ts';
33

44
type ValuePath = Array<string | number>;

‎src/cli/react-devtools/react-component-tree.ts‎

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { componentName, componentRef, kindLabel } from './react-component.ts';
12
import { AppError } from '@agent-device/kernel/errors';
23
import { printable } from './react-devtools-text.ts';
34

@@ -36,21 +37,6 @@ export type ComponentTree = {
3637
logsTracked: boolean;
3738
};
3839

39-
const ELEMENT_CLASS = 1;
40-
const ELEMENT_FORWARD_REF = 6;
41-
const ELEMENT_MEMO = 8;
42-
43-
const TYPE_WRAPPER = /^(?:Memo|ForwardRef)\((.+)\)$/;
44-
45-
/** `Memo(Row)` reads as `Row`: the element type already says it is memoized. */
46-
export function componentName(displayName: string): string {
47-
let name = displayName;
48-
for (let match = TYPE_WRAPPER.exec(name); match; match = TYPE_WRAPPER.exec(name)) {
49-
name = match[1]!;
50-
}
51-
return name;
52-
}
53-
5440
export function buildComponentTree(raw: RawComponentTree): ComponentTree {
5541
const byId = new Map<number, TreeNode>();
5642
for (const [id, displayName, elementType, , ownerId, key] of raw.nodes) {
@@ -89,17 +75,6 @@ export function buildComponentTree(raw: RawComponentTree): ComponentTree {
8975
return { roots, byId, logsTracked: raw.logsTracked };
9076
}
9177

92-
export function componentRef(node: { id: number }): string {
93-
return `@c${node.id}`;
94-
}
95-
96-
export function kindLabel(elementType: number): string {
97-
if (elementType === ELEMENT_MEMO) return ' [memo]';
98-
if (elementType === ELEMENT_CLASS) return ' [class]';
99-
if (elementType === ELEMENT_FORWARD_REF) return ' [forwardRef]';
100-
return '';
101-
}
102-
10378
function plural(count: number, noun: string): string {
10479
return `${count} ${noun}${count === 1 ? '' : 's'}`;
10580
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { test } from 'vitest';
2+
import assert from 'node:assert/strict';
3+
import { componentName, splitDisplayName } from './react-component.ts';
4+
5+
test('splitDisplayName separates DevTools wrapper names from the component', () => {
6+
assert.deepEqual(splitDisplayName('Memo(ForwardRef(Row))'), {
7+
name: 'Row',
8+
wrappers: ['Memo', 'ForwardRef'],
9+
});
10+
assert.deepEqual(splitDisplayName('Row'), { name: 'Row', wrappers: [] });
11+
});
12+
13+
test('tree names preserve non-React wrappers', () => {
14+
assert.equal(componentName('Memo(Animated(Row))'), 'Animated(Row)');
15+
assert.equal(componentName('ForwardRef(Memo(Row))'), 'Row');
16+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
export type ComponentKind = 'class' | 'function' | 'forwardRef' | 'memo' | 'unknown';
2+
3+
export const COMPONENT_KINDS: Readonly<Record<number, ComponentKind>> = {
4+
0: 'unknown',
5+
1: 'class',
6+
5: 'function',
7+
6: 'forwardRef',
8+
8: 'memo',
9+
15: 'function',
10+
};
11+
12+
export function componentKind(type: number): ComponentKind {
13+
return COMPONENT_KINDS[type] ?? 'unknown';
14+
}
15+
16+
const HOC_PATTERN = /^([A-Za-z0-9_$]+)\((.+)\)$/;
17+
18+
/** Splits DevTools wrapper names; tree views unwrap only React's transparent wrappers. */
19+
export function splitDisplayName(
20+
displayName: string,
21+
reactWrappersOnly = false,
22+
): { name: string; wrappers: string[] } {
23+
const wrappers: string[] = [];
24+
let name = displayName;
25+
for (let match = HOC_PATTERN.exec(name); match; match = HOC_PATTERN.exec(name)) {
26+
if (reactWrappersOnly && match[1] !== 'Memo' && match[1] !== 'ForwardRef') break;
27+
wrappers.push(match[1]!);
28+
name = match[2]!;
29+
}
30+
return { name, wrappers };
31+
}
32+
33+
export function componentName(displayName: string): string {
34+
return splitDisplayName(displayName, true).name;
35+
}
36+
37+
export function componentRef(node: { id: number }): string {
38+
return `@c${node.id}`;
39+
}
40+
41+
export function kindLabel(elementType: number): string {
42+
const kind = componentKind(elementType);
43+
return kind === 'memo' || kind === 'class' || kind === 'forwardRef' ? ` [${kind}]` : '';
44+
}

0 commit comments

Comments
 (0)