From 5d7c509092a213d8d48e18276aff23cac2c274be Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 6 May 2026 15:47:34 +0200 Subject: [PATCH] Fix React Native target auto-selection --- .../agent-cdp/src/__tests__/discovery.test.ts | 45 ++++++++++ packages/agent-cdp/src/discovery.ts | 87 +++++++++++++------ 2 files changed, 107 insertions(+), 25 deletions(-) diff --git a/packages/agent-cdp/src/__tests__/discovery.test.ts b/packages/agent-cdp/src/__tests__/discovery.test.ts index 81919a2..d78c371 100644 --- a/packages/agent-cdp/src/__tests__/discovery.test.ts +++ b/packages/agent-cdp/src/__tests__/discovery.test.ts @@ -2,6 +2,7 @@ import { buildTargetId, decodeTargetSource, DEFAULT_DISCOVERY_URLS, + dedupeReactNativeTargets, discoverTargets, encodeTargetSource, getDiscoveryUrl, @@ -85,6 +86,50 @@ describe("discovery helpers", () => { }); }); + it("keeps only the newest react native target when ids differ only by numeric suffix", () => { + expect( + dedupeReactNativeTargets([ + { + id: "react-native:MTI3LjAuMC4xOjgwODE:device-1-1", + rawId: "device-1-1", + title: "Expo", + kind: "react-native", + description: "React Native Bridgeless [C++ connection]", + appId: "host.exp.Exponent", + webSocketDebuggerUrl: "ws://127.0.0.1:8081/inspector/debug?device=device-1&page=1", + sourceUrl: "http://127.0.0.1:8081", + reactNative: { + logicalDeviceId: "device-1", + capabilities: { + nativePageReloads: true, + }, + }, + }, + { + id: "react-native:MTI3LjAuMC4xOjgwODE:device-1-2", + rawId: "device-1-2", + title: "Expo", + kind: "react-native", + description: "React Native Bridgeless [C++ connection]", + appId: "host.exp.Exponent", + webSocketDebuggerUrl: "ws://127.0.0.1:8081/inspector/debug?device=device-1&page=2", + sourceUrl: "http://127.0.0.1:8081", + reactNative: { + logicalDeviceId: "device-1", + capabilities: { + nativePageReloads: true, + }, + }, + }, + ]), + ).toMatchObject([ + { + rawId: "device-1-2", + webSocketDebuggerUrl: "ws://127.0.0.1:8081/inspector/debug?device=device-1&page=2", + }, + ]); + }); + it("keeps ids unique across different source urls", () => { expect(buildTargetId("chrome", "http://127.0.0.1:9222", "page-1")).not.toBe( buildTargetId("chrome", "http://127.0.0.1:9229", "page-1"), diff --git a/packages/agent-cdp/src/discovery.ts b/packages/agent-cdp/src/discovery.ts index 87d44bd..7ed9018 100644 --- a/packages/agent-cdp/src/discovery.ts +++ b/packages/agent-cdp/src/discovery.ts @@ -137,6 +137,39 @@ export function mapReactNativeTarget(sourceUrl: string, target: ReactNativeJsonT }; } +function getReactNativeDuplicateKey(target: TargetDescriptor): string | null { + if (target.kind !== "react-native") { + return null; + } + + const logicalDeviceId = target.reactNative?.logicalDeviceId; + if (!logicalDeviceId) { + return null; + } + + const duplicateSuffix = target.rawId.slice(logicalDeviceId.length + 1); + if (!target.rawId.startsWith(`${logicalDeviceId}-`) || !/^\d+$/.test(duplicateSuffix)) { + return null; + } + + return [target.sourceUrl, target.appId || "", logicalDeviceId].join("::"); +} + +export function dedupeReactNativeTargets(targets: TargetDescriptor[]): TargetDescriptor[] { + const deduped = new Map(); + + for (const target of targets) { + const duplicateKey = getReactNativeDuplicateKey(target); + const mapKey = duplicateKey || target.id; + if (duplicateKey) { + deduped.delete(mapKey); + } + deduped.set(mapKey, target); + } + + return [...deduped.values()]; +} + export async function fetchJsonTargets(baseUrl: string): Promise { const response = await fetch(`${normalizeBaseUrl(baseUrl)}/json/list`); if (!response.ok) { @@ -150,33 +183,37 @@ export async function discoverTargets(options: DiscoveryOptions): Promise(urls[0]); - return targets - .map((target) => { - if (target.reactNative) { - return mapReactNativeTarget(urls[0], target); - } - - return mapChromeTarget(urls[0], target); - }) - .filter((target): target is TargetDescriptor => target !== null); + return dedupeReactNativeTargets( + targets + .map((target) => { + if (target.reactNative) { + return mapReactNativeTarget(urls[0], target); + } + + return mapChromeTarget(urls[0], target); + }) + .filter((target): target is TargetDescriptor => target !== null), + ); } const results = await Promise.allSettled(urls.map((url) => fetchJsonTargets(url))); - return results.flatMap((result, index) => { - if (result.status !== "fulfilled") { - return []; - } - - const url = urls[index]; - return result.value - .map((target) => { - if (target.reactNative) { - return mapReactNativeTarget(url, target); - } - - return mapChromeTarget(url, target); - }) - .filter((target): target is TargetDescriptor => target !== null); - }); + return dedupeReactNativeTargets( + results.flatMap((result, index) => { + if (result.status !== "fulfilled") { + return []; + } + + const url = urls[index]; + return result.value + .map((target) => { + if (target.reactNative) { + return mapReactNativeTarget(url, target); + } + + return mapChromeTarget(url, target); + }) + .filter((target): target is TargetDescriptor => target !== null); + }), + ); }