Skip to content

Commit fe22e77

Browse files
committed
fix(react-devtools): preserve best-effort hints and inspection ownership
1 parent 15adb41 commit fe22e77

14 files changed

Lines changed: 139 additions & 53 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { afterEach, test, vi } from 'vitest';
2+
import assert from 'node:assert/strict';
3+
import * as daemonResolution from '@agent-device/daemon-contracts/daemon-resolution';
4+
import { createAgentDeviceClient } from './agent-device-client.ts';
5+
import { createTransport } from './__tests__/client-transport-fixture.ts';
6+
7+
afterEach(() => vi.restoreAllMocks());
8+
9+
for (const command of ['open', 'close'] as const) {
10+
test(`${command} succeeds when optional Metro hint scope resolution fails`, async () => {
11+
const setup = createTransport(async () => ({ ok: true, data: {} }));
12+
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });
13+
vi.spyOn(daemonResolution, 'resolveDaemonPaths').mockImplementation(() => {
14+
throw new Error('home directory unavailable');
15+
});
16+
const result =
17+
command === 'open'
18+
? await client.apps.open({ app: 'com.example.app', platform: 'ios' })
19+
: await client.sessions.close();
20+
assert.equal(result.session, setup.config.session);
21+
assert.equal(setup.calls[0]?.command, command);
22+
});
23+
}

‎src/agent-device-client.ts‎

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ export function createAgentDeviceClient(
221221
};
222222
} finally {
223223
// Close is teardown intent: drop the dev-server binding even if the daemon call fails.
224-
clearMetroSessionHintsQuietly(metroSessionHintsScope(config, options));
224+
clearMetroSessionHintsQuietly(mergeClientOptions(config, options));
225225
}
226226
},
227227
saveScript: async (
@@ -276,7 +276,7 @@ export function createAgentDeviceClient(
276276
open: async (options: AppOpenOptions) => {
277277
const session = resolveRequestSession(options);
278278
const data = await executeCommand<Record<string, unknown>>('open', options);
279-
recordOpenedMetroSession(metroSessionHintsScope(config, options), {
279+
recordOpenedMetroSession(mergeClientOptions(config, options), {
280280
explicitRuntime: options.runtime !== undefined,
281281
runtime: mergeClientOptions(config, options).runtime,
282282
sessionReused: data.sessionReused === true,
@@ -361,13 +361,13 @@ export function createAgentDeviceClient(
361361
runtimeFilePath: options.runtimeFilePath,
362362
logPath: options.logPath,
363363
});
364-
recordPreparedMetroSession(metroSessionHintsScope(config), result);
364+
recordPreparedMetroSession(config, result);
365365
return result;
366366
},
367367
reload: async (options = {}) =>
368368
await reloadMetro({
369369
...options,
370-
runtime: readMetroSessionRuntime(metroSessionHintsScope(config)) ?? config.runtime,
370+
runtime: readMetroSessionRuntime(config) ?? config.runtime,
371371
}),
372372
},
373373
capture: {
@@ -577,15 +577,4 @@ function mergeClientOptions(
577577
return { ...config, ...options };
578578
}
579579

580-
function metroSessionHintsScope(
581-
config: AgentDeviceClientConfig,
582-
options: InternalRequestOptions = {},
583-
): { stateDir: string; session: string } {
584-
const merged = mergeClientOptions(config, options);
585-
return {
586-
stateDir: resolveDaemonPaths(merged.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR).baseDir,
587-
session: resolveSessionName(merged.session),
588-
};
589-
}
590-
591580
export type * from './client/client-types.ts';

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { test } from 'vitest';
22
import assert from 'node:assert/strict';
3-
import { componentName, splitDisplayName } from './react-component.ts';
3+
import { componentKind, componentName, splitDisplayName } from './react-component.ts';
44

55
test('splitDisplayName separates DevTools wrapper names from the component', () => {
66
assert.deepEqual(splitDisplayName('Memo(ForwardRef(Row))'), {
@@ -14,3 +14,9 @@ test('tree names preserve non-React wrappers', () => {
1414
assert.equal(componentName('Memo(Animated(Row))'), 'Animated(Row)');
1515
assert.equal(componentName('ForwardRef(Memo(Row))'), 'Row');
1616
});
17+
18+
for (const type of ['toString', 'constructor', '__proto__']) {
19+
test(`inherited key ${type} is not a component kind`, () => {
20+
assert.equal(componentKind(JSON.parse(JSON.stringify(type))), 'unknown');
21+
});
22+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export const COMPONENT_KINDS: Readonly<Record<number, ComponentKind>> = {
1010
};
1111

1212
export function componentKind(type: number): ComponentKind {
13-
return COMPONENT_KINDS[type] ?? 'unknown';
13+
return Object.hasOwn(COMPONENT_KINDS, type) ? COMPONENT_KINDS[type]! : 'unknown';
1414
}
1515

1616
const HOC_PATTERN = /^([A-Za-z0-9_$]+)\((.+)\)$/;

‎src/cli/react-devtools/react-devtools-command.fixtures.ts‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import type { CliOutput } from '../../commands/command-contract.ts';
21
import type { CliFlags } from '@agent-device/contracts/command';
3-
import { writeCliOutput } from '../commands/shared.ts';
2+
import { runReactDevtoolsCommand } from '../commands/react-devtools.ts';
43
import { vi } from 'vitest';
54
import vm from 'node:vm';
65
import { mkdtempForTestSync } from '@agent-device/test-kit';
@@ -18,10 +17,10 @@ export const TARGET = {
1817

1918
/**
2019
* Serves `targets` from `/json/list`, evaluates debugger calls in `app`'s realm, and captures what
21-
* `command` prints. Callers restore the stubbed globals and spies after each test.
20+
* the CLI prints. Callers restore the stubbed globals and spies after each test.
2221
*/
2322
export function commandHarness(
24-
command: (args: string[], options: ReactDevtoolsCommandOptions) => Promise<CliOutput>,
23+
prefix: string[] = [],
2524
app = new FakeAppRuntime(),
2625
targets: unknown[] = [TARGET],
2726
) {
@@ -49,9 +48,8 @@ export function commandHarness(
4948
});
5049
const run = async (...args: string[]) => {
5150
output.length = 0;
52-
const result = await command(args, options);
53-
await writeCliOutput(options.flags ?? {}, result);
54-
return { code: 0, text: output.join('') };
51+
const code = await runReactDevtoolsCommand([...prefix, ...args], options);
52+
return { code, text: output.join('') };
5553
};
5654
return { app, run, options, stateDir, requested };
5755
}

‎src/cli/react-devtools/react-devtools-connection.test.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,14 @@ import assert from 'node:assert/strict';
33
import fs from 'node:fs';
44
import path from 'node:path';
55
import type { CliFlags } from '@agent-device/contracts/command';
6-
import { runReactProfileCommand } from './react-profile-command.ts';
76
import { commandHarness } from './react-devtools-command.fixtures.ts';
87

98
afterEach(() => {
109
vi.restoreAllMocks();
1110
vi.unstubAllGlobals();
1211
});
1312

14-
const harness = () => commandHarness(runReactProfileCommand);
13+
const harness = () => commandHarness(['profile']);
1514

1615
function bindSession(stateDir: string, controlBaseUrl: string) {
1716
fs.mkdirSync(path.join(stateDir, 'metro-sessions'), { recursive: true });

‎src/cli/react-devtools/react-devtools-inspect-command.test.ts‎

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,13 @@
11
import { afterEach, test, vi } from 'vitest';
22
import assert from 'node:assert/strict';
3-
import { isInspectCommand, runReactInspectCommand } from './react-devtools-inspect-command.ts';
43
import { commandHarness } from './react-devtools-command.fixtures.ts';
54
import { FakeAppRuntime, MEMO_TYPE, encodeOperations } from './react-devtools-runtime.fixtures.ts';
6-
import type { ReactDevtoolsCommandOptions } from './react-devtools-connection.ts';
75

86
afterEach(() => {
97
vi.restoreAllMocks();
108
vi.unstubAllGlobals();
119
});
1210

13-
async function inspectCommand(args: string[], options: ReactDevtoolsCommandOptions) {
14-
const [command, ...rest] = args;
15-
assert.ok(isInspectCommand(command));
16-
return await runReactInspectCommand(command, rest, options);
17-
}
18-
1911
/** An app whose list renders two memoized rows, one of which logged an error. */
2012
function listApp(): FakeAppRuntime {
2113
const app = new FakeAppRuntime();
@@ -43,7 +35,7 @@ function listApp(): FakeAppRuntime {
4335
return app;
4436
}
4537

46-
const harness = (app = listApp()) => commandHarness(inspectCommand, app);
38+
const harness = (app = listApp()) => commandHarness([], app);
4739

4840
test('tree prints the mounted components with refs', async () => {
4941
const { run } = harness();

‎src/cli/react-devtools/react-devtools-runtime.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,34 @@ test('prop sampling and inspection use the renderer that owns the element', () =
268268
assert.deepEqual(inspected.elements['3']?.props, { data: { label: 'owned' }, cleaned: [] });
269269
});
270270

271+
for (const confirmedOwner of [false, true]) {
272+
test(`${confirmedOwner ? 'confirmed' : 'unconfirmed'} renderer failure respects element ownership`, () => {
273+
const app = new FakeAppRuntime();
274+
app.install();
275+
app.renderer.props.set(3, { data: { label: 'owned' }, cleaned: [] });
276+
const foreign = Object.assign(Object.create(app.renderer), {
277+
hasElementWithId: confirmedOwner ? () => true : undefined,
278+
inspectElement: () => {
279+
throw new Error('renderer unavailable');
280+
},
281+
});
282+
app.hook.rendererInterfaces.clear();
283+
app.hook.rendererInterfaces.set(1, foreign);
284+
app.hook.rendererInterfaces.set(2, app.renderer);
285+
assert.deepEqual(
286+
app.call('describeProps', { '3': ['label'] }),
287+
confirmedOwner ? {} : { '3': { label: 'string' } },
288+
);
289+
const inspected = app.call<{ elements: Record<string, { props?: unknown; error?: string }> }>(
290+
'inspect',
291+
[3],
292+
);
293+
if (confirmedOwner) assert.equal(inspected.elements['3']?.error, 'renderer unavailable');
294+
else
295+
assert.deepEqual(inspected.elements['3']?.props, { data: { label: 'owned' }, cleaned: [] });
296+
});
297+
}
298+
271299
test('a renderer whose profiling data cannot be read fails the stop instead of returning no commits', () => {
272300
const app = new FakeAppRuntime();
273301
app.install();

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -344,19 +344,21 @@ export const REACT_DEVTOOLS_RUNTIME_SOURCE = String.raw`
344344
345345
function inspectBackendElement(id) {
346346
var found = null;
347+
var failure = null;
347348
eachRenderer(function (ri) {
348349
if (found || (ri.hasElementWithId && !ri.hasElementWithId(id))) return;
349350
var result;
350351
try { result = ri.inspectElement(nextRequestID++, id, null, true); } catch (e) {
351-
found = { error: String(e && e.message || e) };
352-
return;
352+
result = { type: 'error', message: String(e && e.message || e) };
353353
}
354354
if (!result || result.type === 'not-found') return;
355-
found = result.type === 'full-data'
356-
? { value: result.value, name: lookupName(ri, id) }
357-
: { error: result.message || result.type };
355+
if (result.type === 'full-data') found = { value: result.value, name: lookupName(ri, id) };
356+
else {
357+
failure = { error: result.message || result.type };
358+
if (ri.hasElementWithId) found = failure;
359+
}
358360
});
359-
return found || { missing: true };
361+
return found || failure || { missing: true };
360362
}
361363
362364
function inspectOne(id) {

‎src/cli/react-devtools/react-profile-command.test.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@ import assert from 'node:assert/strict';
33
import fs from 'node:fs';
44
import path from 'node:path';
55
import vm from 'node:vm';
6-
import { runReactProfileCommand } from './react-profile-command.ts';
76
import { InspectorSession } from './metro-inspector.ts';
87
import { commandHarness, TARGET } from './react-devtools-command.fixtures.ts';
98
import { FakeAppRuntime, MEMO_TYPE, encodeOperations } from './react-devtools-runtime.fixtures.ts';
109

1110
const harness = (app?: FakeAppRuntime, targets?: unknown[]) =>
12-
commandHarness(runReactProfileCommand, app, targets);
11+
commandHarness(['profile'], app, targets);
1312

1413
afterEach(() => {
1514
vi.restoreAllMocks();
@@ -261,13 +260,16 @@ test('drill-down views read the saved recording, not a trimmed analysis', async
261260
assert.equal(saved.version, 2);
262261
assert.equal(saved.profile.commits.length, 3);
263262
assert.match((await run('report', 'Row')).text, /changed props: onPress 36 \(function\)/);
263+
assert.doesNotMatch((await run('report', 'Row')).text, /collection may have been interrupted/);
264264
});
265265

266266
test('stop saves the raw recording before optional prop inspection', async () => {
267267
const { app, run, stateDir } = harness();
268268
await run('start');
269269
recordRowRenders(app);
270-
let checkpoint: { profile: { commits: unknown[] }; propKinds: unknown } | undefined;
270+
let checkpoint:
271+
| { profile: { commits: unknown[] }; propKinds: unknown; notes: string[] }
272+
| undefined;
271273
vi.mocked(InspectorSession.connect).mockResolvedValue({
272274
evaluate: async (expression: string) => {
273275
if (expression.includes('.describeProps(')) {
@@ -282,6 +284,9 @@ test('stop saves the raw recording before optional prop inspection', async () =>
282284
const stopped = await run('stop');
283285
assert.equal(checkpoint?.profile.commits.length, 3);
284286
assert.deepEqual(checkpoint?.propKinds, {});
287+
assert.deepEqual(checkpoint?.notes, [
288+
'Changed prop values have not been inspected; collection may have been interrupted.',
289+
]);
285290
assert.match(stopped.text, /Changed prop values could not be inspected: inspection unavailable/);
286291
assert.match((await run('report', 'Row')).text, /renders 36/);
287292
});

0 commit comments

Comments
 (0)