From 3d78b4b6d34e4cddb6f3314fb8ec5ab643e9825a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 13 Jul 2026 09:22:34 +0200 Subject: [PATCH 1/8] feat: add React Native 0.87 client bootstrap Restore the legacy DevTools socket connection removed in React Native 0.87 while preserving older runtimes' built-in connection. --- .../src/__tests__/react-native-client.test.ts | 172 ++++++++++++++++++ .../src/react-devtools-core.d.ts | 1 + .../src/react-native-client.ts | 103 +++++++++++ .../src/react-native-runtime.d.ts | 27 +++ .../agent-react-devtools/src/react-native.ts | 21 +++ 5 files changed, 324 insertions(+) create mode 100644 packages/agent-react-devtools/src/__tests__/react-native-client.test.ts create mode 100644 packages/agent-react-devtools/src/react-native-client.ts create mode 100644 packages/agent-react-devtools/src/react-native-runtime.d.ts create mode 100644 packages/agent-react-devtools/src/react-native.ts diff --git a/packages/agent-react-devtools/src/__tests__/react-native-client.test.ts b/packages/agent-react-devtools/src/__tests__/react-native-client.test.ts new file mode 100644 index 0000000..6e3d03d --- /dev/null +++ b/packages/agent-react-devtools/src/__tests__/react-native-client.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from 'vitest'; +import { startReactNativeDevTools } from '../react-native-client.js'; + +type StartOptions = Parameters[0]; + +function createOptions(overrides: Partial = {}): StartOptions { + return { + isDev: true, + platform: 'ios', + version: { major: 0, minor: 87, patch: 0 }, + appState: { currentState: 'active', isAvailable: true }, + getDevServer: () => ({ + bundleLoadedFromServer: true, + url: 'http://192.168.1.23:8081/index.bundle?platform=ios', + }), + globalObject: { __REACT_DEVTOOLS_GLOBAL_HOOK__: {} }, + connectToDevTools: vi.fn(), + ...overrides, + }; +} + +describe('startReactNativeDevTools', () => { + it('connects React Native 0.87 to the agent using the Metro host and DevTools port', () => { + const connectToDevTools = vi.fn(); + + startReactNativeDevTools( + createOptions({ + version: { major: 0, minor: 87, patch: 0, prerelease: 'rc.0' }, + connectToDevTools, + }), + ); + + expect(connectToDevTools).toHaveBeenCalledOnce(); + expect(connectToDevTools).toHaveBeenCalledWith({ + host: '192.168.1.23', + port: 8097, + isAppActive: expect.any(Function), + }); + }); + + it('leaves React Native versions before 0.87 on their built-in connection', () => { + const connectToDevTools = vi.fn(); + + startReactNativeDevTools( + createOptions({ + version: { major: 0, minor: 86, patch: 9 }, + connectToDevTools, + }), + ); + + expect(connectToDevTools).not.toHaveBeenCalled(); + }); + + it('does not connect in production', () => { + const connectToDevTools = vi.fn(); + startReactNativeDevTools(createOptions({ isDev: false, connectToDevTools })); + expect(connectToDevTools).not.toHaveBeenCalled(); + }); + + it('does not connect on React Native Web', () => { + const connectToDevTools = vi.fn(); + startReactNativeDevTools( + createOptions({ platform: 'web', connectToDevTools }), + ); + expect(connectToDevTools).not.toHaveBeenCalled(); + }); + + it('fails safely when React Native has not initialized the DevTools hook', () => { + const connectToDevTools = vi.fn(); + const warn = vi.fn(); + + startReactNativeDevTools( + createOptions({ globalObject: {}, connectToDevTools, warn }), + ); + + expect(connectToDevTools).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('DevTools hook is unavailable'), + ); + }); + + it('does not create a second connection when the bootstrap runs again', () => { + const connectToDevTools = vi.fn(); + const options = createOptions({ connectToDevTools }); + + startReactNativeDevTools(options); + startReactNativeDevTools(options); + + expect(connectToDevTools).toHaveBeenCalledOnce(); + }); + + it('falls back to localhost when Metro does not provide a valid server URL', () => { + const connectToDevTools = vi.fn(); + + expect(() => + startReactNativeDevTools( + createOptions({ + getDevServer: () => ({ + bundleLoadedFromServer: true, + url: 'not a URL', + }), + connectToDevTools, + }), + ), + ).not.toThrow(); + + expect(connectToDevTools).toHaveBeenCalledWith( + expect.objectContaining({ host: 'localhost' }), + ); + }); + + it('does not block application startup when the connection cannot start', () => { + const warn = vi.fn(); + + expect(() => + startReactNativeDevTools( + createOptions({ + connectToDevTools: () => { + throw new Error('WebSocket is unavailable'); + }, + warn, + }), + ), + ).not.toThrow(); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('could not start'), + ); + }); + + it('lets the backend defer connection while AppState reports background', () => { + const connectToDevTools = vi.fn(); + const appState = { currentState: 'background', isAvailable: true }; + + startReactNativeDevTools( + createOptions({ + appState, + globalObject: { + __REACT_DEVTOOLS_GLOBAL_HOOK__: {}, + __REACT_DEVTOOLS_PORT__: 8098, + }, + connectToDevTools, + }), + ); + + const options = connectToDevTools.mock.calls[0][0]; + expect(options.port).toBe(8098); + expect(options.isAppActive()).toBe(false); + + appState.isAvailable = false; + expect(options.isAppActive()).toBe(true); + }); + + it('keeps IPv6 brackets when deriving the host from Metro', () => { + const connectToDevTools = vi.fn(); + + startReactNativeDevTools( + createOptions({ + version: { major: 1, minor: 0, patch: 0 }, + getDevServer: () => ({ + bundleLoadedFromServer: true, + url: 'http://[::1]:8081/index.bundle', + }), + connectToDevTools, + }), + ); + + expect(connectToDevTools).toHaveBeenCalledWith( + expect.objectContaining({ host: '[::1]' }), + ); + }); +}); diff --git a/packages/agent-react-devtools/src/react-devtools-core.d.ts b/packages/agent-react-devtools/src/react-devtools-core.d.ts index 570fbe0..c174462 100644 --- a/packages/agent-react-devtools/src/react-devtools-core.d.ts +++ b/packages/agent-react-devtools/src/react-devtools-core.d.ts @@ -4,5 +4,6 @@ declare module 'react-devtools-core' { port?: number; websocket?: WebSocket; host?: string; + isAppActive?: () => boolean; }): void; } diff --git a/packages/agent-react-devtools/src/react-native-client.ts b/packages/agent-react-devtools/src/react-native-client.ts new file mode 100644 index 0000000..d3c4955 --- /dev/null +++ b/packages/agent-react-devtools/src/react-native-client.ts @@ -0,0 +1,103 @@ +const DEFAULT_DEVTOOLS_PORT = 8097; +const CONNECTION_STARTED_KEY = '__AGENT_REACT_DEVTOOLS_CONNECTION_STARTED__'; + +interface ReactNativeVersion { + major: number; + minor: number; + patch: number; + prerelease?: string | null; +} + +interface AppStateLike { + currentState?: string | null; + isAvailable?: boolean; +} + +interface DevServerInfo { + bundleLoadedFromServer: boolean; + url: string; +} + +interface ReactNativeGlobal { + __REACT_DEVTOOLS_GLOBAL_HOOK__?: unknown; + __REACT_DEVTOOLS_PORT__?: number; + [CONNECTION_STARTED_KEY]?: boolean; +} + +interface ConnectOptions { + host: string; + port: number; + isAppActive: () => boolean; +} + +interface StartReactNativeDevToolsOptions { + isDev: boolean; + platform: string; + version?: ReactNativeVersion | null; + appState?: AppStateLike | null; + getDevServer: () => DevServerInfo; + globalObject: ReactNativeGlobal; + connectToDevTools: (options: ConnectOptions) => void; + warn?: (message: string) => void; +} + +function getDevServerHost(getDevServer: () => DevServerInfo): string { + try { + const devServer = getDevServer(); + if (devServer.bundleLoadedFromServer) { + const host = devServer.url.match( + /^https?:\/\/(\[[^\]]+\]|[^/:?#]+)/i, + )?.[1]; + return host ?? 'localhost'; + } + } catch { + return 'localhost'; + } + + return 'localhost'; +} + +export function startReactNativeDevTools({ + appState, + connectToDevTools, + getDevServer, + globalObject, + isDev, + platform, + version, + warn = console.warn, +}: StartReactNativeDevToolsOptions): void { + if ( + !isDev || + platform === 'web' || + version == null || + (version.major === 0 && version.minor < 87) || + globalObject[CONNECTION_STARTED_KEY] === true + ) { + return; + } + + if (globalObject.__REACT_DEVTOOLS_GLOBAL_HOOK__ == null) { + warn( + '[agent-react-devtools] React Native DevTools hook is unavailable; ' + + 'ensure the bootstrap runs after React Native initialization.', + ); + return; + } + + try { + connectToDevTools({ + host: getDevServerHost(getDevServer), + port: globalObject.__REACT_DEVTOOLS_PORT__ ?? DEFAULT_DEVTOOLS_PORT, + isAppActive: () => + appState?.isAvailable === false || + appState?.currentState !== 'background', + }); + + globalObject[CONNECTION_STARTED_KEY] = true; + } catch { + warn( + '[agent-react-devtools] React Native DevTools connection could not start.', + ); + } +} diff --git a/packages/agent-react-devtools/src/react-native-runtime.d.ts b/packages/agent-react-devtools/src/react-native-runtime.d.ts new file mode 100644 index 0000000..0978fee --- /dev/null +++ b/packages/agent-react-devtools/src/react-native-runtime.d.ts @@ -0,0 +1,27 @@ +declare module 'react-native' { + export const AppState: { + currentState?: string | null; + isAvailable?: boolean; + }; + + export const Platform: { + OS: string; + constants: { + reactNativeVersion?: { + major: number; + minor: number; + patch: number; + prerelease?: string | null; + } | null; + }; + }; +} + +declare module 'react-native/Libraries/Core/Devtools/getDevServer' { + interface DevServerInfo { + bundleLoadedFromServer: boolean; + url: string; + } + + export default function getDevServer(): DevServerInfo; +} diff --git a/packages/agent-react-devtools/src/react-native.ts b/packages/agent-react-devtools/src/react-native.ts new file mode 100644 index 0000000..d5cdd4d --- /dev/null +++ b/packages/agent-react-devtools/src/react-native.ts @@ -0,0 +1,21 @@ +import { AppState, Platform } from 'react-native'; +import getDevServer from 'react-native/Libraries/Core/Devtools/getDevServer'; +import { connectToDevTools } from 'react-devtools-core'; +import { startReactNativeDevTools } from './react-native-client.js'; + +declare const __DEV__: boolean; + +const runtimeGlobal = globalThis as typeof globalThis & { + __REACT_DEVTOOLS_GLOBAL_HOOK__?: unknown; + __REACT_DEVTOOLS_PORT__?: number; +}; + +startReactNativeDevTools({ + isDev: __DEV__, + platform: Platform.OS, + version: Platform.constants?.reactNativeVersion, + appState: AppState, + getDevServer, + globalObject: runtimeGlobal, + connectToDevTools, +}); From e19420a23fb8e56a541b468f99913b94dcba6b15 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 13 Jul 2026 09:29:23 +0200 Subject: [PATCH 2/8] feat: add React Native Metro integration Compose the native bootstrap after Metro's existing pre-main modules and publish both CommonJS and ESM entry points for React Native projects. --- packages/agent-react-devtools/package.json | 15 ++++ .../src/__tests__/metro-plugin.test.ts | 72 +++++++++++++++++++ .../agent-react-devtools/src/metro-plugin.ts | 43 +++++++++++ .../src/react-native-noop.ts | 1 + packages/agent-react-devtools/tsup.config.ts | 25 +++++++ 5 files changed, 156 insertions(+) create mode 100644 packages/agent-react-devtools/src/__tests__/metro-plugin.test.ts create mode 100644 packages/agent-react-devtools/src/metro-plugin.ts create mode 100644 packages/agent-react-devtools/src/react-native-noop.ts diff --git a/packages/agent-react-devtools/package.json b/packages/agent-react-devtools/package.json index 503ee41..3589570 100644 --- a/packages/agent-react-devtools/package.json +++ b/packages/agent-react-devtools/package.json @@ -12,6 +12,17 @@ "types": "./dist/connect.d.ts", "import": "./dist/connect.js" }, + "./metro": { + "types": "./dist/metro.d.ts", + "import": "./dist/metro.js", + "require": "./dist/metro.cjs" + }, + "./react-native": { + "types": "./dist/react-native.d.ts", + "browser": "./dist/react-native-noop.js", + "react-native": "./dist/react-native.js", + "default": "./dist/react-native-noop.js" + }, "./vite": { "types": "./dist/vite.d.ts", "import": "./dist/vite.js" @@ -53,10 +64,14 @@ "zod": "^3.24.0" }, "peerDependencies": { + "react-native": ">=0.0.0", "react-devtools-core": ">=5.0.0", "vite": ">=5.0.0" }, "peerDependenciesMeta": { + "react-native": { + "optional": true + }, "react-devtools-core": { "optional": true }, diff --git a/packages/agent-react-devtools/src/__tests__/metro-plugin.test.ts b/packages/agent-react-devtools/src/__tests__/metro-plugin.test.ts new file mode 100644 index 0000000..cce2b0d --- /dev/null +++ b/packages/agent-react-devtools/src/__tests__/metro-plugin.test.ts @@ -0,0 +1,72 @@ +import { isAbsolute } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import packageJson from '../../package.json'; +import { withAgentReactDevTools } from '../metro-plugin.js'; + +describe('withAgentReactDevTools', () => { + it('runs the React Native bootstrap after Metro pre-main modules', () => { + const config = withAgentReactDevTools({ + serializer: { + getModulesRunBeforeMainModule: () => ['/react-native/InitializeCore.js'], + }, + }); + + const modules = config.serializer.getModulesRunBeforeMainModule( + '/app/index.js', + ); + + expect(modules[0]).toBe('/react-native/InitializeCore.js'); + expect(isAbsolute(modules[1])).toBe(true); + expect(modules[1]).toMatch(/react-native\.js$/); + }); + + it('does not add the bootstrap more than once', () => { + const once = withAgentReactDevTools({ serializer: {} }); + const twice = withAgentReactDevTools(once); + + const modules = twice.serializer.getModulesRunBeforeMainModule( + '/app/index.js', + ); + + expect( + modules.filter((module) => /react-native\.js$/.test(module)), + ).toHaveLength(1); + }); + + it('preserves the existing serializer callback context without mutating it', () => { + const serializer = { + marker: 'existing-serializer', + getModulesRunBeforeMainModule(this: { marker: string }, entry: string) { + expect(this).toBe(serializer); + return [`${this.marker}:${entry}`]; + }, + }; + const originalCallback = serializer.getModulesRunBeforeMainModule; + + const wrapped = withAgentReactDevTools({ serializer }); + const modules = + wrapped.serializer.getModulesRunBeforeMainModule('/entry.js'); + + expect(wrapped.serializer).not.toBe(serializer); + expect(serializer.getModulesRunBeforeMainModule).toBe(originalCallback); + expect(modules[0]).toBe('existing-serializer:/entry.js'); + }); +}); + +describe('React Native package exports', () => { + it('publishes Metro for import and require plus the native bootstrap', () => { + expect(packageJson.exports).toMatchObject({ + './metro': { + types: './dist/metro.d.ts', + import: './dist/metro.js', + require: './dist/metro.cjs', + }, + './react-native': { + types: './dist/react-native.d.ts', + browser: './dist/react-native-noop.js', + 'react-native': './dist/react-native.js', + default: './dist/react-native-noop.js', + }, + }); + }); +}); diff --git a/packages/agent-react-devtools/src/metro-plugin.ts b/packages/agent-react-devtools/src/metro-plugin.ts new file mode 100644 index 0000000..cfef672 --- /dev/null +++ b/packages/agent-react-devtools/src/metro-plugin.ts @@ -0,0 +1,43 @@ +import { resolve } from 'node:path'; + +type GetModulesRunBeforeMainModule = (entryFilePath: string) => string[]; + +interface SerializerConfig { + getModulesRunBeforeMainModule?: GetModulesRunBeforeMainModule; + [key: string]: unknown; +} + +interface MetroConfig { + serializer?: SerializerConfig; + [key: string]: unknown; +} + +type AgentMetroConfig = T & { + serializer: SerializerConfig & { + getModulesRunBeforeMainModule: GetModulesRunBeforeMainModule; + }; +}; + +const REACT_NATIVE_BOOTSTRAP_PATH = resolve(__dirname, 'react-native.js'); + +export function withAgentReactDevTools( + config: T, +): AgentMetroConfig { + const serializer = config.serializer ?? {}; + const getModulesRunBeforeMainModule = + serializer.getModulesRunBeforeMainModule; + + return { + ...config, + serializer: { + ...serializer, + getModulesRunBeforeMainModule(entryFilePath) { + const modules = + getModulesRunBeforeMainModule?.call(serializer, entryFilePath) ?? []; + return modules.includes(REACT_NATIVE_BOOTSTRAP_PATH) + ? modules + : [...modules, REACT_NATIVE_BOOTSTRAP_PATH]; + }, + }, + } as AgentMetroConfig; +} diff --git a/packages/agent-react-devtools/src/react-native-noop.ts b/packages/agent-react-devtools/src/react-native-noop.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/agent-react-devtools/src/react-native-noop.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/agent-react-devtools/tsup.config.ts b/packages/agent-react-devtools/tsup.config.ts index ec4947c..df2b829 100644 --- a/packages/agent-react-devtools/tsup.config.ts +++ b/packages/agent-react-devtools/tsup.config.ts @@ -41,4 +41,29 @@ export default defineConfig([ sourcemap: true, external: ['vite'], }, + // Node entry — Metro config wrapper (ESM + CommonJS) + { + entry: { + metro: 'src/metro-plugin.ts', + }, + format: ['esm', 'cjs'], + target: 'node18', + platform: 'node', + dts: true, + sourcemap: true, + shims: true, + }, + // React Native client entries — transformed by Metro in the consuming app + { + entry: { + 'react-native': 'src/react-native.ts', + 'react-native-noop': 'src/react-native-noop.ts', + }, + format: ['esm'], + target: 'es2019', + platform: 'neutral', + dts: true, + sourcemap: true, + external: ['react-native', 'react-devtools-core'], + }, ]); From 7df076aa951abe10cc0472a4c94885d5b77429d3 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 13 Jul 2026 09:35:37 +0200 Subject: [PATCH 3/8] fix: correct React Native init guidance Explain the manual Metro and entry setup required for React Native 0.87+, and cover the built conditional exports with a lightweight package smoke test. --- .../src/__tests__/init.test.ts | 49 +++++++++++- packages/agent-react-devtools/src/init.ts | 23 +++--- .../e2e-tests/src/package-exports.test.ts | 77 +++++++++++++++++++ 3 files changed, 138 insertions(+), 11 deletions(-) create mode 100644 packages/e2e-tests/src/package-exports.test.ts diff --git a/packages/agent-react-devtools/src/__tests__/init.test.ts b/packages/agent-react-devtools/src/__tests__/init.test.ts index b961750..31850d7 100644 --- a/packages/agent-react-devtools/src/__tests__/init.test.ts +++ b/packages/agent-react-devtools/src/__tests__/init.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, rmSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -199,6 +199,35 @@ describe('runInit', () => { const content = readFileSync(join(dir, 'vite.config.ts'), 'utf-8'); expect(content).toBe(original); }); + + it('prints manual React Native 0.87 setup without modifying files', async () => { + const packageJson = JSON.stringify({ + dependencies: { 'react-native': '^0.87.0' }, + }); + const metroConfig = `module.exports = { serializer: {} };\n`; + const entry = `import { AppRegistry } from 'react-native';\n`; + writeFileSync(join(dir, 'package.json'), packageJson); + writeFileSync(join(dir, 'metro.config.js'), metroConfig); + writeFileSync(join(dir, 'index.js'), entry); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + try { + await runInit(dir, false); + await runInit(dir, true); + + const output = log.mock.calls.flat().join('\n'); + expect(output).toContain('React Native 0.87+ requires manual setup'); + expect(output).toContain('withAgentReactDevTools'); + expect(output).toContain("import 'agent-react-devtools/react-native'"); + expect(output).not.toContain('connect to DevTools automatically'); + } finally { + log.mockRestore(); + } + + expect(readFileSync(join(dir, 'package.json'), 'utf-8')).toBe(packageJson); + expect(readFileSync(join(dir, 'metro.config.js'), 'utf-8')).toBe(metroConfig); + expect(readFileSync(join(dir, 'index.js'), 'utf-8')).toBe(entry); + }); }); describe('runUninit', () => { @@ -338,4 +367,22 @@ describe('runUninit', () => { const afterInit2 = readFileSync(join(dir, 'src/index.tsx'), 'utf-8'); expect(afterInit2).toContain('agent-react-devtools'); }); + + it('explains that React Native manual setup has nothing to uninit', async () => { + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ dependencies: { 'react-native': '^0.87.0' } }), + ); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + try { + await runUninit(dir, false); + const output = log.mock.calls.flat().join('\n'); + expect(output).toContain('manual setup'); + expect(output).toContain('no changes to remove'); + expect(output).not.toContain('configuration has been removed'); + } finally { + log.mockRestore(); + } + }); }); diff --git a/packages/agent-react-devtools/src/init.ts b/packages/agent-react-devtools/src/init.ts index c961346..1d7a7ae 100644 --- a/packages/agent-react-devtools/src/init.ts +++ b/packages/agent-react-devtools/src/init.ts @@ -310,7 +310,9 @@ export async function runUninit( } if (framework === 'react-native') { - console.log('\nReact Native detected - no code changes were made by init.'); + console.log( + '\nReact Native init only prints manual setup; no changes to remove.', + ); return; } @@ -360,17 +362,18 @@ export async function runInit( } if (framework === 'react-native') { - console.log('\nReact Native detected — no code changes needed!'); - console.log('React Native apps connect to DevTools automatically.\n'); + console.log('\nReact Native 0.87+ requires manual setup.'); + console.log('No files were changed by init.\n'); + console.log('1. Wrap your final Metro config:'); + console.log( + " const { withAgentReactDevTools } = require('agent-react-devtools/metro');", + ); + console.log(' module.exports = withAgentReactDevTools(config);\n'); + console.log('2. Import the bootstrap from your entry graph:'); + console.log(" import 'agent-react-devtools/react-native';\n"); console.log('Next steps:'); console.log(' 1. Start the daemon: agent-react-devtools start'); - console.log(' 2. Start your app: npx react-native start'); - console.log('\nFor physical devices:'); - console.log(' adb reverse tcp:8097 tcp:8097'); - console.log('\nFor Expo:'); - console.log(' The connection works automatically with Expo dev client.'); - console.log('\nCustom port:'); - console.log(' Set REACT_DEVTOOLS_PORT= environment variable'); + console.log(' 2. Start Metro and your app'); return; } diff --git a/packages/e2e-tests/src/package-exports.test.ts b/packages/e2e-tests/src/package-exports.test.ts new file mode 100644 index 0000000..8c8c691 --- /dev/null +++ b/packages/e2e-tests/src/package-exports.test.ts @@ -0,0 +1,77 @@ +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const PACKAGE_DIR = path.resolve( + import.meta.dirname, + '../../agent-react-devtools', +); + +function runNode(args: string[]): string { + const result = spawnSync(process.execPath, args, { + cwd: PACKAGE_DIR, + encoding: 'utf8', + timeout: 10_000, + }); + + if (result.error != null) { + throw result.error; + } + + if (result.status !== 0) { + throw new Error( + `Node smoke check failed (${result.status}):\n${result.stderr}`, + ); + } + + return result.stdout.trim(); +} + +function resolveReactNativeEntry(conditions: string[] = []): string { + const output = runNode([ + ...conditions.map((condition) => `--conditions=${condition}`), + '--input-type=module', + '--eval', + "console.log(import.meta.resolve('agent-react-devtools/react-native'))", + ]); + return fileURLToPath(output); +} + +describe('published React Native package shape', () => { + it('loads the Metro wrapper from CommonJS and appends the built bootstrap', () => { + const output = runNode([ + '--eval', + `const { withAgentReactDevTools } = require('agent-react-devtools/metro'); +const config = withAgentReactDevTools({ + serializer: { getModulesRunBeforeMainModule: () => ['/existing-init.js'] }, +}); +console.log(JSON.stringify({ + exportType: typeof withAgentReactDevTools, + modules: config.serializer.getModulesRunBeforeMainModule('/entry.js'), +}));`, + ]); + const result = JSON.parse(output) as { + exportType: string; + modules: string[]; + }; + + expect(result.exportType).toBe('function'); + expect(result.modules).toEqual([ + '/existing-init.js', + path.join(PACKAGE_DIR, 'dist/react-native.js'), + ]); + }); + + it('resolves default and browser clients to the no-op entry', () => { + const noOpPath = path.join(PACKAGE_DIR, 'dist/react-native-noop.js'); + expect(resolveReactNativeEntry()).toBe(noOpPath); + expect(resolveReactNativeEntry(['browser'])).toBe(noOpPath); + }); + + it('resolves the React Native condition to the native bootstrap', () => { + expect(resolveReactNativeEntry(['react-native'])).toBe( + path.join(PACKAGE_DIR, 'dist/react-native.js'), + ); + }); +}); From 2d26f117d9ed8e02f9fac60acbefbcce93b9083e Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 13 Jul 2026 09:44:15 +0200 Subject: [PATCH 4/8] docs: add React Native 0.87 setup Document the required Metro wrapper and entry import, align the Expo example, and add a minor release note. --- .changeset/quiet-walls-connect.md | 7 ++ README.md | 92 +++++++++++++++++-- examples/expo-app/README.md | 27 ++++-- examples/expo-app/app/_layout.tsx | 1 + examples/expo-app/metro.config.js | 6 ++ examples/expo-app/package.json | 2 + .../skills/react-devtools/SKILL.md | 2 +- .../react-devtools/references/commands.md | 2 +- .../skills/react-devtools/references/setup.md | 79 +++++++++++++--- 9 files changed, 187 insertions(+), 31 deletions(-) create mode 100644 .changeset/quiet-walls-connect.md create mode 100644 examples/expo-app/metro.config.js diff --git a/.changeset/quiet-walls-connect.md b/.changeset/quiet-walls-connect.md new file mode 100644 index 0000000..3d16056 --- /dev/null +++ b/.changeset/quiet-walls-connect.md @@ -0,0 +1,7 @@ +--- +'agent-react-devtools': minor +--- + +Add React Native 0.87+ support through a Metro config wrapper and native +bootstrap entry. React Native apps now wrap their final Metro config and import +`agent-react-devtools/react-native` from the application entry graph. diff --git a/README.md b/README.md index 782a5c7..bf0433f 100644 --- a/README.md +++ b/README.md @@ -192,13 +192,15 @@ agent-react-devtools profile diff [--limit N] [--thre ### Quick setup -Run the init command in your project root to auto-configure your framework: +For Vite, Next.js, and Create React App, run `init` in the project root to +patch the appropriate web entry or config: ```sh npx agent-react-devtools init ``` -This detects your framework (Vite, Next.js, CRA) and patches the appropriate config file. +React Native is detected too, but `init` only prints the manual React Native +setup shown below; it does not edit Metro or application files. To undo these changes: @@ -206,7 +208,7 @@ To undo these changes: npx agent-react-devtools uninit ``` -### One-line import +### Web one-line import Add a single import as the first line of your entry point (e.g. `src/main.tsx`): @@ -239,24 +241,98 @@ Options: reactDevtools({ port: 8097, host: "localhost" }); ``` -### React Native +### React Native 0.87+ -React Native apps connect to DevTools automatically — no code changes needed: +React Native 0.87 removed the legacy standalone React DevTools auto-connect +path. Projects must now add the agent bootstrap to Metro explicitly: ```sh +npm install --save-dev agent-react-devtools react-devtools-core +``` + +Both of the following steps are required. + +#### 1. Wrap the final Metro config + +For a bare React Native app: + +```js +// metro.config.js +const { getDefaultConfig, mergeConfig } = require("@react-native/metro-config"); +const { withAgentReactDevTools } = require("agent-react-devtools/metro"); + +const projectConfig = {}; +const config = mergeConfig(getDefaultConfig(__dirname), projectConfig); + +module.exports = withAgentReactDevTools(config); +``` + +For Expo: + +```js +// metro.config.js +const { getDefaultConfig } = require("expo/metro-config"); +const { withAgentReactDevTools } = require("agent-react-devtools/metro"); + +const config = getDefaultConfig(__dirname); + +module.exports = withAgentReactDevTools(config); +``` + +Apply `withAgentReactDevTools` outermost, after all other Metro configuration +and wrappers. It preserves the final config's existing serializer hooks and +adds the agent bootstrap after React Native's own pre-main initialization. + +#### 2. Import the bootstrap from the entry graph + +Add this import to a user-owned module that is always reachable from the app +entry—for example, bare React Native's `index.js` or Expo Router's +`app/_layout.tsx`: + +```ts +import "agent-react-devtools/react-native"; +``` + +The import makes the bootstrap part of Metro's dependency graph. The Metro +wrapper then executes that module before application modules; its textual +position among imports does not control the execution order. + +#### Run and verify + +The client and daemon use port 8097 by default: + +```sh +# Terminal 1 agent-react-devtools start + +# Terminal 2 — restart Metro after changing metro.config.js npx react-native start +# Expo: npx expo start + +# Terminal 3 +agent-react-devtools status +agent-react-devtools wait --connected --timeout 30 +agent-react-devtools get tree ``` -For physical devices, forward the port: +For an Android device connected over USB, forward the DevTools port before +launching the app: ```sh adb reverse tcp:8097 tcp:8097 ``` -For Expo, the connection works automatically with the Expo dev client. +This integration connects only from a native development runtime and only on +React Native 0.87 or newer. Native production builds exit without connecting; +browser and default/server imports resolve to no-op modules. + +If `status` reports zero connected apps: -To use a custom port, set the `REACT_DEVTOOLS_PORT` environment variable. +1. Confirm both the Metro wrapper and graph import are present. +2. Ensure `withAgentReactDevTools` wraps the final config, outside other Metro wrappers. +3. Stop and restart Metro; if its cache is stale, use `--reset-cache` (bare) or `npx expo start -c`. +4. Confirm the daemon is listening on 8097 and repeat `adb reverse` for Android devices. +5. Check that the app is a development build running React Native 0.87+. ## Using with agent-browser diff --git a/examples/expo-app/README.md b/examples/expo-app/README.md index fbd67f1..18edeaf 100644 --- a/examples/expo-app/README.md +++ b/examples/expo-app/README.md @@ -1,6 +1,12 @@ # Expo Example App -A minimal React Native app using Expo to test `agent-react-devtools` integration. +A minimal Expo app showing the React Native 0.87+ `agent-react-devtools` +configuration. + +> This fixture currently uses React Native 0.81.5, so its client-side version +> guard intentionally makes the bootstrap a no-op. It validates the Metro and +> application configuration shape, but does not validate the React Native 0.87 +> runtime connection. ## Setup @@ -11,7 +17,17 @@ bun install ## Testing the DevTools Connection -React Native apps connect to React DevTools automatically — no code changes needed. +The example already contains both required integration points: + +- [`metro.config.js`](metro.config.js) applies `withAgentReactDevTools` to the + final Expo Metro config. +- [`app/_layout.tsx`](app/_layout.tsx) imports + `agent-react-devtools/react-native` so the bootstrap is in Metro's dependency + graph. + +This checked-in React Native 0.81 fixture cannot test a live connection. To +exercise the same configuration in an Expo project based on React Native 0.87 +or newer, restart Metro and run: ```sh # Terminal 1: Start the daemon @@ -19,10 +35,11 @@ agent-react-devtools start # Terminal 2: Start the Expo dev server cd examples/expo-app -bun start +bun start --clear # Terminal 3: Inspect the app agent-react-devtools status +agent-react-devtools wait --connected --timeout 30 agent-react-devtools get tree ``` @@ -33,7 +50,3 @@ Forward the DevTools port over USB: ```sh adb reverse tcp:8097 tcp:8097 ``` - -### Custom port - -Set the `REACT_DEVTOOLS_PORT` environment variable before starting both the daemon and the app. diff --git a/examples/expo-app/app/_layout.tsx b/examples/expo-app/app/_layout.tsx index 624cfe6..9859b1f 100644 --- a/examples/expo-app/app/_layout.tsx +++ b/examples/expo-app/app/_layout.tsx @@ -1,3 +1,4 @@ +import 'agent-react-devtools/react-native'; import { Stack } from 'expo-router'; export default function RootLayout() { diff --git a/examples/expo-app/metro.config.js b/examples/expo-app/metro.config.js new file mode 100644 index 0000000..9acd3a8 --- /dev/null +++ b/examples/expo-app/metro.config.js @@ -0,0 +1,6 @@ +const { getDefaultConfig } = require('expo/metro-config'); +const { withAgentReactDevTools } = require('agent-react-devtools/metro'); + +const config = getDefaultConfig(__dirname); + +module.exports = withAgentReactDevTools(config); diff --git a/examples/expo-app/package.json b/examples/expo-app/package.json index 0f7cb64..aa5ae98 100644 --- a/examples/expo-app/package.json +++ b/examples/expo-app/package.json @@ -24,6 +24,8 @@ }, "devDependencies": { "@types/react": "~19.1.10", + "agent-react-devtools": "workspace:*", + "react-devtools-core": "^6.1.0", "typescript": "^5.5.0" } } diff --git a/packages/agent-react-devtools/skills/react-devtools/SKILL.md b/packages/agent-react-devtools/skills/react-devtools/SKILL.md index af5896c..fe4a1cc 100644 --- a/packages/agent-react-devtools/skills/react-devtools/SKILL.md +++ b/packages/agent-react-devtools/skills/react-devtools/SKILL.md @@ -167,7 +167,7 @@ agent-react-devtools status # Should show 1 connected app ## Important Rules - **Labels reset** when the app reloads or components unmount/remount. After a reload, use `wait --connected` then re-check with `get tree` or `find`. -- **`status` first** — if status shows 0 connected apps, the React app is not connected. The user may need to run `npx agent-react-devtools init` in their project first. +- **`status` first** — if status shows 0 connected apps, the React app is not connected. Web users may need to run `npx agent-react-devtools init`; React Native 0.87+ users need both manual steps in [setup.md](references/setup.md). - **Headed browser required** — if using `agent-browser`, always use `--headed` mode. Headless Chromium does not properly load the devtools connect script. - **Profile while interacting** — profiling only captures renders that happen between `profile start` and `profile stop`. Make sure the relevant interaction happens during that window. - **Use `--depth`** on large trees — a deep tree can produce a lot of output. Start with `--depth 3` or `--depth 4` and go deeper only on the subtree you care about. diff --git a/packages/agent-react-devtools/skills/react-devtools/references/commands.md b/packages/agent-react-devtools/skills/react-devtools/references/commands.md index 70a7a2a..015ff13 100644 --- a/packages/agent-react-devtools/skills/react-devtools/references/commands.md +++ b/packages/agent-react-devtools/skills/react-devtools/references/commands.md @@ -119,6 +119,6 @@ Categories with no changes are omitted. Keys are deduplicated across commits in ## Setup ### `agent-react-devtools init [--dry-run]` -Auto-detect the framework in the current directory and configure the devtools connection. Supports Vite, Next.js, CRA, and Expo/React Native. +Auto-detect the framework in the current directory. It configures Vite, Next.js, and CRA automatically. For Expo/React Native, it prints the required manual Metro-wrapper and entry-import steps without editing files. Use `--dry-run` to preview changes without writing files. diff --git a/packages/agent-react-devtools/skills/react-devtools/references/setup.md b/packages/agent-react-devtools/skills/react-devtools/references/setup.md index 3d14b55..121299b 100644 --- a/packages/agent-react-devtools/skills/react-devtools/references/setup.md +++ b/packages/agent-react-devtools/skills/react-devtools/references/setup.md @@ -1,15 +1,18 @@ # Setup Guide -agent-react-devtools works with any React or React Native app. The `init` command auto-detects your framework and configures everything. +agent-react-devtools works with React web and React Native apps. The `init` +command auto-configures Vite, Next.js, and Create React App. For React Native, +it prints the manual setup steps without editing files. -## Auto Setup (Recommended) +## Web Auto Setup (Recommended) ```bash cd your-react-app npx agent-react-devtools init ``` -This detects the framework and applies the minimal configuration needed. +This detects a supported web framework and applies the minimal configuration +needed. Use `--dry-run` to preview changes without modifying files: ```bash @@ -54,22 +57,68 @@ Then imports it in `app/layout.tsx`. import 'agent-react-devtools/connect'; ``` -### React Native / Expo +### React Native 0.87+ / Expo -React Native apps auto-connect to the devtools WebSocket on port 8097 — no code changes needed. +React Native 0.87 removed the legacy standalone DevTools auto-connect path. +The Metro wrapper and entry-graph import below are both mandatory. ```bash -agent-react-devtools start -npx react-native start -# or: npx expo start +npm install --save-dev agent-react-devtools react-devtools-core +``` + +For bare React Native, wrap the final composed config: + +```js +// metro.config.js +const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); +const { withAgentReactDevTools } = require('agent-react-devtools/metro'); + +const projectConfig = {}; +const config = mergeConfig(getDefaultConfig(__dirname), projectConfig); + +module.exports = withAgentReactDevTools(config); +``` + +For Expo, extend Expo's Metro config: + +```js +// metro.config.js +const { getDefaultConfig } = require('expo/metro-config'); +const { withAgentReactDevTools } = require('agent-react-devtools/metro'); + +const config = getDefaultConfig(__dirname); + +module.exports = withAgentReactDevTools(config); +``` + +`withAgentReactDevTools` must be the outermost wrapper around the final config +so existing serializer hooks are retained. Then import the bootstrap from a +module in the application entry graph, such as `index.js` or Expo Router's +root layout: + +```ts +import 'agent-react-devtools/react-native'; ``` -For physical devices, reverse the port: +The import puts the module into Metro's dependency graph; the wrapper executes +it after React Native initialization and before application modules. Restart +Metro after changing `metro.config.js`. + +The daemon and client use port 8097 by default: + ```bash -adb reverse tcp:8097 tcp:8097 +agent-react-devtools start +adb reverse tcp:8097 tcp:8097 # Android device over USB +npx react-native start # or: npx expo start +agent-react-devtools wait --connected --timeout 30 +agent-react-devtools status ``` -## Manual Setup +The client connects only from React Native 0.87+ native development runtimes. +Native production builds exit without connecting; web and default/server +imports resolve to no-op modules. + +## Manual Web Setup If `init` doesn't cover your setup, add this as the first import in your entry point: @@ -96,9 +145,11 @@ Apps: 1 connected, 42 components If `Apps: 0 connected`: 1. Check the app is running in dev mode -2. Check the console for WebSocket connection errors -3. Ensure no other DevTools instance is using port 8097 -4. If using `agent-browser`, make sure you're using **headed mode** (`--headed`) — headless Chromium does not properly execute the devtools connect script +2. For React Native, verify both the outermost Metro wrapper and entry-graph import, then restart Metro +3. Check the console for WebSocket connection errors +4. Ensure no other DevTools instance is using port 8097 +5. For an Android device, repeat `adb reverse tcp:8097 tcp:8097` +6. If using `agent-browser`, make sure you're using **headed mode** (`--headed`) — headless Chromium does not properly execute the devtools connect script ## Using with agent-browser From 5264a04fb79cc73efae435ac07412c8f5942879b Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 13 Jul 2026 09:51:19 +0200 Subject: [PATCH 5/8] fix: align React DevTools core with React Native Pin React Native setup guidance and the Expo fixture to the DevTools core range used by React Native 0.87. --- .changeset/quiet-walls-connect.md | 3 ++- README.md | 2 +- examples/expo-app/package.json | 2 +- .../skills/react-devtools/references/setup.md | 2 +- packages/agent-react-devtools/src/__tests__/init.test.ts | 1 + packages/agent-react-devtools/src/init.ts | 8 ++++++-- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.changeset/quiet-walls-connect.md b/.changeset/quiet-walls-connect.md index 3d16056..ce1fd18 100644 --- a/.changeset/quiet-walls-connect.md +++ b/.changeset/quiet-walls-connect.md @@ -4,4 +4,5 @@ Add React Native 0.87+ support through a Metro config wrapper and native bootstrap entry. React Native apps now wrap their final Metro config and import -`agent-react-devtools/react-native` from the application entry graph. +`agent-react-devtools/react-native` from the application entry graph. Install +`react-devtools-core@^6.1.5` to match React Native's DevTools backend. diff --git a/README.md b/README.md index bf0433f..93dc8cb 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,7 @@ React Native 0.87 removed the legacy standalone React DevTools auto-connect path. Projects must now add the agent bootstrap to Metro explicitly: ```sh -npm install --save-dev agent-react-devtools react-devtools-core +npm install --save-dev agent-react-devtools react-devtools-core@^6.1.5 ``` Both of the following steps are required. diff --git a/examples/expo-app/package.json b/examples/expo-app/package.json index aa5ae98..667ea00 100644 --- a/examples/expo-app/package.json +++ b/examples/expo-app/package.json @@ -25,7 +25,7 @@ "devDependencies": { "@types/react": "~19.1.10", "agent-react-devtools": "workspace:*", - "react-devtools-core": "^6.1.0", + "react-devtools-core": "^6.1.5", "typescript": "^5.5.0" } } diff --git a/packages/agent-react-devtools/skills/react-devtools/references/setup.md b/packages/agent-react-devtools/skills/react-devtools/references/setup.md index 121299b..d2ad869 100644 --- a/packages/agent-react-devtools/skills/react-devtools/references/setup.md +++ b/packages/agent-react-devtools/skills/react-devtools/references/setup.md @@ -63,7 +63,7 @@ React Native 0.87 removed the legacy standalone DevTools auto-connect path. The Metro wrapper and entry-graph import below are both mandatory. ```bash -npm install --save-dev agent-react-devtools react-devtools-core +npm install --save-dev agent-react-devtools react-devtools-core@^6.1.5 ``` For bare React Native, wrap the final composed config: diff --git a/packages/agent-react-devtools/src/__tests__/init.test.ts b/packages/agent-react-devtools/src/__tests__/init.test.ts index 31850d7..35a37f1 100644 --- a/packages/agent-react-devtools/src/__tests__/init.test.ts +++ b/packages/agent-react-devtools/src/__tests__/init.test.ts @@ -217,6 +217,7 @@ describe('runInit', () => { const output = log.mock.calls.flat().join('\n'); expect(output).toContain('React Native 0.87+ requires manual setup'); + expect(output).toContain('react-devtools-core@^6.1.5'); expect(output).toContain('withAgentReactDevTools'); expect(output).toContain("import 'agent-react-devtools/react-native'"); expect(output).not.toContain('connect to DevTools automatically'); diff --git a/packages/agent-react-devtools/src/init.ts b/packages/agent-react-devtools/src/init.ts index 1d7a7ae..dd61182 100644 --- a/packages/agent-react-devtools/src/init.ts +++ b/packages/agent-react-devtools/src/init.ts @@ -364,12 +364,16 @@ export async function runInit( if (framework === 'react-native') { console.log('\nReact Native 0.87+ requires manual setup.'); console.log('No files were changed by init.\n'); - console.log('1. Wrap your final Metro config:'); + console.log('1. Install compatible dependencies:'); + console.log( + ' npm install -D agent-react-devtools react-devtools-core@^6.1.5\n', + ); + console.log('2. Wrap your final Metro config:'); console.log( " const { withAgentReactDevTools } = require('agent-react-devtools/metro');", ); console.log(' module.exports = withAgentReactDevTools(config);\n'); - console.log('2. Import the bootstrap from your entry graph:'); + console.log('3. Import the bootstrap from your entry graph:'); console.log(" import 'agent-react-devtools/react-native';\n"); console.log('Next steps:'); console.log(' 1. Start the daemon: agent-react-devtools start'); From e8fa0262d6dd0d8eac65d8c7a624894a91356bc8 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 13 Jul 2026 10:34:19 +0200 Subject: [PATCH 6/8] fix: use React Native DevTools core dependency Rely on React Native's bundled react-devtools-core instead of asking users to install and version it separately. --- .changeset/quiet-walls-connect.md | 3 +-- README.md | 2 +- examples/expo-app/package.json | 1 - .../skills/react-devtools/references/setup.md | 2 +- packages/agent-react-devtools/src/__tests__/init.test.ts | 3 ++- packages/agent-react-devtools/src/init.ts | 6 ++---- 6 files changed, 7 insertions(+), 10 deletions(-) diff --git a/.changeset/quiet-walls-connect.md b/.changeset/quiet-walls-connect.md index ce1fd18..3d16056 100644 --- a/.changeset/quiet-walls-connect.md +++ b/.changeset/quiet-walls-connect.md @@ -4,5 +4,4 @@ Add React Native 0.87+ support through a Metro config wrapper and native bootstrap entry. React Native apps now wrap their final Metro config and import -`agent-react-devtools/react-native` from the application entry graph. Install -`react-devtools-core@^6.1.5` to match React Native's DevTools backend. +`agent-react-devtools/react-native` from the application entry graph. diff --git a/README.md b/README.md index 93dc8cb..e9f38e4 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,7 @@ React Native 0.87 removed the legacy standalone React DevTools auto-connect path. Projects must now add the agent bootstrap to Metro explicitly: ```sh -npm install --save-dev agent-react-devtools react-devtools-core@^6.1.5 +npm install --save-dev agent-react-devtools ``` Both of the following steps are required. diff --git a/examples/expo-app/package.json b/examples/expo-app/package.json index 667ea00..d6cf2b7 100644 --- a/examples/expo-app/package.json +++ b/examples/expo-app/package.json @@ -25,7 +25,6 @@ "devDependencies": { "@types/react": "~19.1.10", "agent-react-devtools": "workspace:*", - "react-devtools-core": "^6.1.5", "typescript": "^5.5.0" } } diff --git a/packages/agent-react-devtools/skills/react-devtools/references/setup.md b/packages/agent-react-devtools/skills/react-devtools/references/setup.md index d2ad869..861a299 100644 --- a/packages/agent-react-devtools/skills/react-devtools/references/setup.md +++ b/packages/agent-react-devtools/skills/react-devtools/references/setup.md @@ -63,7 +63,7 @@ React Native 0.87 removed the legacy standalone DevTools auto-connect path. The Metro wrapper and entry-graph import below are both mandatory. ```bash -npm install --save-dev agent-react-devtools react-devtools-core@^6.1.5 +npm install --save-dev agent-react-devtools ``` For bare React Native, wrap the final composed config: diff --git a/packages/agent-react-devtools/src/__tests__/init.test.ts b/packages/agent-react-devtools/src/__tests__/init.test.ts index 35a37f1..f8fefd5 100644 --- a/packages/agent-react-devtools/src/__tests__/init.test.ts +++ b/packages/agent-react-devtools/src/__tests__/init.test.ts @@ -217,7 +217,8 @@ describe('runInit', () => { const output = log.mock.calls.flat().join('\n'); expect(output).toContain('React Native 0.87+ requires manual setup'); - expect(output).toContain('react-devtools-core@^6.1.5'); + expect(output).toContain('npm install -D agent-react-devtools'); + expect(output).not.toContain('react-devtools-core'); expect(output).toContain('withAgentReactDevTools'); expect(output).toContain("import 'agent-react-devtools/react-native'"); expect(output).not.toContain('connect to DevTools automatically'); diff --git a/packages/agent-react-devtools/src/init.ts b/packages/agent-react-devtools/src/init.ts index dd61182..4967bfc 100644 --- a/packages/agent-react-devtools/src/init.ts +++ b/packages/agent-react-devtools/src/init.ts @@ -364,10 +364,8 @@ export async function runInit( if (framework === 'react-native') { console.log('\nReact Native 0.87+ requires manual setup.'); console.log('No files were changed by init.\n'); - console.log('1. Install compatible dependencies:'); - console.log( - ' npm install -D agent-react-devtools react-devtools-core@^6.1.5\n', - ); + console.log('1. Install agent-react-devtools:'); + console.log(' npm install -D agent-react-devtools\n'); console.log('2. Wrap your final Metro config:'); console.log( " const { withAgentReactDevTools } = require('agent-react-devtools/metro');", From fb3affed13aa6f885ae96982134686361d752405 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 13 Jul 2026 10:35:54 +0200 Subject: [PATCH 7/8] docs: simplify React Native setup guidance Present the Metro integration as the standard React Native setup and keep version history in one compatibility note. --- .changeset/quiet-walls-connect.md | 4 ++-- README.md | 15 ++++++++------- examples/expo-app/README.md | 10 +++++----- .../skills/react-devtools/SKILL.md | 2 +- .../skills/react-devtools/references/setup.md | 9 ++++----- .../src/__tests__/init.test.ts | 5 +++-- packages/agent-react-devtools/src/init.ts | 2 +- 7 files changed, 24 insertions(+), 23 deletions(-) diff --git a/.changeset/quiet-walls-connect.md b/.changeset/quiet-walls-connect.md index 3d16056..442026a 100644 --- a/.changeset/quiet-walls-connect.md +++ b/.changeset/quiet-walls-connect.md @@ -2,6 +2,6 @@ 'agent-react-devtools': minor --- -Add React Native 0.87+ support through a Metro config wrapper and native -bootstrap entry. React Native apps now wrap their final Metro config and import +Add React Native support through a Metro config wrapper and native bootstrap +entry. React Native apps now wrap their final Metro config and import `agent-react-devtools/react-native` from the application entry graph. diff --git a/README.md b/README.md index e9f38e4..98e2fbc 100644 --- a/README.md +++ b/README.md @@ -241,10 +241,11 @@ Options: reactDevtools({ port: 8097, host: "localhost" }); ``` -### React Native 0.87+ +### React Native -React Native 0.87 removed the legacy standalone React DevTools auto-connect -path. Projects must now add the agent bootstrap to Metro explicitly: +> Before React Native 0.87, standalone DevTools connected automatically without +> code changes. React Native 0.87 removed that path, so the setup below is now +> required. ```sh npm install --save-dev agent-react-devtools @@ -322,9 +323,9 @@ launching the app: adb reverse tcp:8097 tcp:8097 ``` -This integration connects only from a native development runtime and only on -React Native 0.87 or newer. Native production builds exit without connecting; -browser and default/server imports resolve to no-op modules. +This integration connects only from a native development runtime. Native +production builds exit without connecting; browser and default/server imports +resolve to no-op modules. If `status` reports zero connected apps: @@ -332,7 +333,7 @@ If `status` reports zero connected apps: 2. Ensure `withAgentReactDevTools` wraps the final config, outside other Metro wrappers. 3. Stop and restart Metro; if its cache is stale, use `--reset-cache` (bare) or `npx expo start -c`. 4. Confirm the daemon is listening on 8097 and repeat `adb reverse` for Android devices. -5. Check that the app is a development build running React Native 0.87+. +5. Check that the app is a development build. ## Using with agent-browser diff --git a/examples/expo-app/README.md b/examples/expo-app/README.md index 18edeaf..2d8add4 100644 --- a/examples/expo-app/README.md +++ b/examples/expo-app/README.md @@ -1,12 +1,12 @@ # Expo Example App -A minimal Expo app showing the React Native 0.87+ `agent-react-devtools` +A minimal Expo app showing the React Native `agent-react-devtools` configuration. > This fixture currently uses React Native 0.81.5, so its client-side version > guard intentionally makes the bootstrap a no-op. It validates the Metro and -> application configuration shape, but does not validate the React Native 0.87 -> runtime connection. +> application configuration shape, but does not validate the runtime +> connection. ## Setup @@ -26,8 +26,8 @@ The example already contains both required integration points: graph. This checked-in React Native 0.81 fixture cannot test a live connection. To -exercise the same configuration in an Expo project based on React Native 0.87 -or newer, restart Metro and run: +exercise the same configuration in an Expo project where the bootstrap is +active, restart Metro and run: ```sh # Terminal 1: Start the daemon diff --git a/packages/agent-react-devtools/skills/react-devtools/SKILL.md b/packages/agent-react-devtools/skills/react-devtools/SKILL.md index fe4a1cc..c9bee74 100644 --- a/packages/agent-react-devtools/skills/react-devtools/SKILL.md +++ b/packages/agent-react-devtools/skills/react-devtools/SKILL.md @@ -167,7 +167,7 @@ agent-react-devtools status # Should show 1 connected app ## Important Rules - **Labels reset** when the app reloads or components unmount/remount. After a reload, use `wait --connected` then re-check with `get tree` or `find`. -- **`status` first** — if status shows 0 connected apps, the React app is not connected. Web users may need to run `npx agent-react-devtools init`; React Native 0.87+ users need both manual steps in [setup.md](references/setup.md). +- **`status` first** — if status shows 0 connected apps, the React app is not connected. Web users may need to run `npx agent-react-devtools init`; React Native users need both manual steps in [setup.md](references/setup.md). - **Headed browser required** — if using `agent-browser`, always use `--headed` mode. Headless Chromium does not properly load the devtools connect script. - **Profile while interacting** — profiling only captures renders that happen between `profile start` and `profile stop`. Make sure the relevant interaction happens during that window. - **Use `--depth`** on large trees — a deep tree can produce a lot of output. Start with `--depth 3` or `--depth 4` and go deeper only on the subtree you care about. diff --git a/packages/agent-react-devtools/skills/react-devtools/references/setup.md b/packages/agent-react-devtools/skills/react-devtools/references/setup.md index 861a299..8288b32 100644 --- a/packages/agent-react-devtools/skills/react-devtools/references/setup.md +++ b/packages/agent-react-devtools/skills/react-devtools/references/setup.md @@ -57,9 +57,8 @@ Then imports it in `app/layout.tsx`. import 'agent-react-devtools/connect'; ``` -### React Native 0.87+ / Expo +### React Native / Expo -React Native 0.87 removed the legacy standalone DevTools auto-connect path. The Metro wrapper and entry-graph import below are both mandatory. ```bash @@ -114,9 +113,9 @@ agent-react-devtools wait --connected --timeout 30 agent-react-devtools status ``` -The client connects only from React Native 0.87+ native development runtimes. -Native production builds exit without connecting; web and default/server -imports resolve to no-op modules. +The client connects only from native development runtimes. Native production +builds exit without connecting; web and default/server imports resolve to +no-op modules. ## Manual Web Setup diff --git a/packages/agent-react-devtools/src/__tests__/init.test.ts b/packages/agent-react-devtools/src/__tests__/init.test.ts index f8fefd5..2be5388 100644 --- a/packages/agent-react-devtools/src/__tests__/init.test.ts +++ b/packages/agent-react-devtools/src/__tests__/init.test.ts @@ -200,7 +200,7 @@ describe('runInit', () => { expect(content).toBe(original); }); - it('prints manual React Native 0.87 setup without modifying files', async () => { + it('prints manual React Native setup without modifying files', async () => { const packageJson = JSON.stringify({ dependencies: { 'react-native': '^0.87.0' }, }); @@ -216,7 +216,8 @@ describe('runInit', () => { await runInit(dir, true); const output = log.mock.calls.flat().join('\n'); - expect(output).toContain('React Native 0.87+ requires manual setup'); + expect(output).toContain('React Native requires manual setup'); + expect(output).not.toContain('0.87'); expect(output).toContain('npm install -D agent-react-devtools'); expect(output).not.toContain('react-devtools-core'); expect(output).toContain('withAgentReactDevTools'); diff --git a/packages/agent-react-devtools/src/init.ts b/packages/agent-react-devtools/src/init.ts index 4967bfc..e2ad4f5 100644 --- a/packages/agent-react-devtools/src/init.ts +++ b/packages/agent-react-devtools/src/init.ts @@ -362,7 +362,7 @@ export async function runInit( } if (framework === 'react-native') { - console.log('\nReact Native 0.87+ requires manual setup.'); + console.log('\nReact Native requires manual setup.'); console.log('No files were changed by init.\n'); console.log('1. Install agent-react-devtools:'); console.log(' npm install -D agent-react-devtools\n'); From 6e328b1a4144c368bad310f7901553f27287be29 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 13 Jul 2026 14:28:05 +0200 Subject: [PATCH 8/8] feat: automate React Native Metro setup Configure supported React Native and Expo projects with reversible Metro and entry-graph edits. --- .changeset/quiet-walls-connect.md | 6 +- README.md | 20 +- .../react-devtools/references/commands.md | 7 +- .../skills/react-devtools/references/setup.md | 17 +- .../src/__tests__/init.test.ts | 208 +++++++++-- .../src/__tests__/metro-plugin.test.ts | 29 ++ packages/agent-react-devtools/src/init.ts | 334 ++++++++++++++++-- .../agent-react-devtools/src/metro-plugin.ts | 51 ++- 8 files changed, 599 insertions(+), 73 deletions(-) diff --git a/.changeset/quiet-walls-connect.md b/.changeset/quiet-walls-connect.md index 442026a..c8d0d7a 100644 --- a/.changeset/quiet-walls-connect.md +++ b/.changeset/quiet-walls-connect.md @@ -2,6 +2,6 @@ 'agent-react-devtools': minor --- -Add React Native support through a Metro config wrapper and native bootstrap -entry. React Native apps now wrap their final Metro config and import -`agent-react-devtools/react-native` from the application entry graph. +Automatically configure standard React Native and Expo projects through a +CommonJS Metro wrapper and a native bootstrap entry import. Unsupported Metro +formats and ambiguous projects retain the documented manual setup path. diff --git a/README.md b/README.md index 98e2fbc..5221a90 100644 --- a/README.md +++ b/README.md @@ -199,8 +199,10 @@ patch the appropriate web entry or config: npx agent-react-devtools init ``` -React Native is detected too, but `init` only prints the manual React Native -setup shown below; it does not edit Metro or application files. +For standard React Native and Expo projects, `init` also configures Metro and a +reachable app module. It supports existing CommonJS (`.js`/`.cjs`) Metro +configs and creates one when none exists. Use the manual setup below for ESM, +TypeScript, JSON/package-field, custom `--config`, or ambiguous Metro setups. To undo these changes: @@ -253,6 +255,13 @@ npm install --save-dev agent-react-devtools Both of the following steps are required. +`npx agent-react-devtools init` performs both steps automatically for the +common CommonJS Metro configurations and entries it recognizes: `package.json` +`main`, Expo Router's root layout, bare `index.*`, and Expo `App.*`. It patches +all available platform-specific entries when a shared entry does not exist. +The CLI first preflights every target and leaves files unchanged when it cannot +safely identify the config or entry. `uninit` removes only its marked edits. + #### 1. Wrap the final Metro config For a bare React Native app: @@ -335,6 +344,13 @@ If `status` reports zero connected apps: 4. Confirm the daemon is listening on 8097 and repeat `adb reverse` for Android devices. 5. Check that the app is a development build. +#### Manual fallback + +Configure the two steps above manually when Metro uses ESM (`.mjs`), +TypeScript, JSON or a package-field configuration; when your app starts Metro +with a custom `--config`; or when the CLI reports an ambiguous config or entry. +Keep `withAgentReactDevTools` as the final outermost wrapper. + ## Using with agent-browser When using `agent-browser` to drive the app (e.g. for profiling interactions), you **must use headed mode**. Headless Chromium does not properly execute the devtools connect script: diff --git a/packages/agent-react-devtools/skills/react-devtools/references/commands.md b/packages/agent-react-devtools/skills/react-devtools/references/commands.md index 015ff13..cea5384 100644 --- a/packages/agent-react-devtools/skills/react-devtools/references/commands.md +++ b/packages/agent-react-devtools/skills/react-devtools/references/commands.md @@ -119,6 +119,11 @@ Categories with no changes are omitted. Keys are deduplicated across commits in ## Setup ### `agent-react-devtools init [--dry-run]` -Auto-detect the framework in the current directory. It configures Vite, Next.js, and CRA automatically. For Expo/React Native, it prints the required manual Metro-wrapper and entry-import steps without editing files. +Auto-detect the framework in the current directory. It configures Vite, Next.js, +CRA, and standard Expo/React Native projects automatically. For React Native it +patches existing CommonJS Metro configs (or creates one), then adds the native +bootstrap import to a recognized reachable app module. ESM, TypeScript, JSON or +package-field Metro configs, custom `--config` use, and ambiguous targets are +left unchanged with manual setup instructions. Use `--dry-run` to preview changes without writing files. diff --git a/packages/agent-react-devtools/skills/react-devtools/references/setup.md b/packages/agent-react-devtools/skills/react-devtools/references/setup.md index 8288b32..a86196c 100644 --- a/packages/agent-react-devtools/skills/react-devtools/references/setup.md +++ b/packages/agent-react-devtools/skills/react-devtools/references/setup.md @@ -1,8 +1,8 @@ # Setup Guide agent-react-devtools works with React web and React Native apps. The `init` -command auto-configures Vite, Next.js, and Create React App. For React Native, -it prints the manual setup steps without editing files. +command auto-configures Vite, Next.js, Create React App, and standard React +Native/Expo projects with CommonJS Metro configs. ## Web Auto Setup (Recommended) @@ -59,7 +59,15 @@ import 'agent-react-devtools/connect'; ### React Native / Expo -The Metro wrapper and entry-graph import below are both mandatory. +The Metro wrapper and entry-graph import below are both mandatory. For a +standard project, `init` adds both automatically: it supports existing +`metro.config.js`/`.cjs` files, or creates the appropriate default config when +none exists, then patches `package.json` `main`, Expo Router's root layout, +bare `index.*`, or Expo `App.*`. + +It safely falls back without editing files for ESM, TypeScript, JSON or +package-field Metro configurations, custom `--config` usage, or ambiguous +targets. In those cases, apply the two manual steps below. ```bash npm install --save-dev agent-react-devtools @@ -103,6 +111,9 @@ The import puts the module into Metro's dependency graph; the wrapper executes it after React Native initialization and before application modules. Restart Metro after changing `metro.config.js`. +`uninit` removes only the ownership-marked import and Metro wrapper it added. +It deletes an auto-created config only if it is still unchanged. + The daemon and client use port 8097 by default: ```bash diff --git a/packages/agent-react-devtools/src/__tests__/init.test.ts b/packages/agent-react-devtools/src/__tests__/init.test.ts index 2be5388..f5228fe 100644 --- a/packages/agent-react-devtools/src/__tests__/init.test.ts +++ b/packages/agent-react-devtools/src/__tests__/init.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, rmSync, existsSync import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { detectFramework, runInit, runUninit } from '../init.js'; +import { withAgentReactDevTools } from '../metro-plugin.js'; function makeTempDir(): string { return mkdtempSync(join(tmpdir(), 'ard-test-')); @@ -200,35 +201,170 @@ describe('runInit', () => { expect(content).toBe(original); }); - it('prints manual React Native setup without modifying files', async () => { - const packageJson = JSON.stringify({ + it('patches a bare React Native Metro config and its reachable entry idempotently', async () => { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { 'react-native': '^0.87.0' }, - }); + })); const metroConfig = `module.exports = { serializer: {} };\n`; - const entry = `import { AppRegistry } from 'react-native';\n`; - writeFileSync(join(dir, 'package.json'), packageJson); + writeFileSync(join(dir, 'metro.config.js'), metroConfig); + writeFileSync(join(dir, 'index.js'), `import { AppRegistry } from 'react-native';\n`); + + await runInit(dir, false); + const configAfterFirstInit = readFileSync(join(dir, 'metro.config.js'), 'utf-8'); + const entryAfterFirstInit = readFileSync(join(dir, 'index.js'), 'utf-8'); + + expect(configAfterFirstInit).toContain('@agent-react-devtools:metro-wrapper:start'); + expect(configAfterFirstInit).toContain('module.exports = withAgentReactDevTools(module.exports);'); + expect(entryAfterFirstInit).toContain('@agent-react-devtools:react-native-bootstrap'); + expect(entryAfterFirstInit).toContain("import 'agent-react-devtools/react-native';"); + + await runInit(dir, false); + expect(readFileSync(join(dir, 'metro.config.js'), 'utf-8')).toBe(configAfterFirstInit); + expect(readFileSync(join(dir, 'index.js'), 'utf-8')).toBe(entryAfterFirstInit); + }); + + it('makes the native bootstrap reachable in the entry graph and schedules it after React Native initialization', async () => { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + dependencies: { 'react-native': '^0.87.0' }, + })); + writeFileSync(join(dir, 'index.js'), `export {};\n`); + + await runInit(dir, false); + + const entry = readFileSync(join(dir, 'index.js'), 'utf-8'); + const modules = withAgentReactDevTools({ + serializer: { + getModulesRunBeforeMainModule: () => ['/react-native/InitializeCore.js'], + }, + }).serializer.getModulesRunBeforeMainModule(join(dir, 'index.js')); + + expect(entry).toContain("import 'agent-react-devtools/react-native';"); + expect(modules[0]).toBe('/react-native/InitializeCore.js'); + expect(modules[1]).toMatch(/react-native\.js$/); + }); + + it('creates an Expo CommonJS Metro config in module packages and patches the router root layout', async () => { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + type: 'module', + dependencies: { expo: '^54.0.0', 'expo-router': '^5.0.0' }, + })); + mkdirSync(join(dir, 'app')); + writeFileSync(join(dir, 'app/_layout.tsx'), `export default function Layout() { return null; }\n`); + + await runInit(dir, false); + + const configPath = join(dir, 'metro.config.cjs'); + expect(existsSync(configPath)).toBe(true); + expect(readFileSync(configPath, 'utf-8')).toContain("require('expo/metro-config')"); + expect(readFileSync(join(dir, 'app/_layout.tsx'), 'utf-8')).toContain( + "import 'agent-react-devtools/react-native';", + ); + }); + + it('patches every platform entry when no shared React Native index exists', async () => { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + dependencies: { 'react-native': '^0.87.0' }, + })); + writeFileSync(join(dir, 'index.ios.js'), `export {};\n`); + writeFileSync(join(dir, 'index.android.ts'), `export {};\n`); + + await runInit(dir, false); + + expect(readFileSync(join(dir, 'index.ios.js'), 'utf-8')).toContain('agent-react-devtools/react-native'); + expect(readFileSync(join(dir, 'index.android.ts'), 'utf-8')).toContain('agent-react-devtools/react-native'); + }); + + it('uses a local package main entry before conventional entry files', async () => { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + main: './src/bootstrap.ts', + dependencies: { 'react-native': '^0.87.0' }, + })); + mkdirSync(join(dir, 'src')); + writeFileSync(join(dir, 'src/bootstrap.ts'), `export {};\n`); + writeFileSync(join(dir, 'index.js'), `export {};\n`); + + await runInit(dir, false); + + expect(readFileSync(join(dir, 'src/bootstrap.ts'), 'utf-8')).toContain('agent-react-devtools/react-native'); + expect(readFileSync(join(dir, 'index.js'), 'utf-8')).not.toContain('agent-react-devtools/react-native'); + }); + + it('uses the nearest Metro config found by Metro’s upward search', async () => { + const appDir = join(dir, 'app'); + mkdirSync(appDir); + writeFileSync(join(appDir, 'package.json'), JSON.stringify({ + dependencies: { 'react-native': '^0.87.0' }, + })); + writeFileSync(join(dir, 'metro.config.cjs'), `module.exports = {};\n`); + writeFileSync(join(appDir, 'index.js'), `export {};\n`); + + await runInit(appDir, false); + + expect(readFileSync(join(dir, 'metro.config.cjs'), 'utf-8')).toContain('@agent-react-devtools:metro-wrapper:start'); + expect(readFileSync(join(appDir, 'index.js'), 'utf-8')).toContain('agent-react-devtools/react-native'); + }); + + it('leaves a .js Metro config alone when package.json declares ESM', async () => { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + type: 'module', + dependencies: { 'react-native': '^0.87.0' }, + })); + const metroConfig = `export default {};\n`; + const entry = `export {};\n`; writeFileSync(join(dir, 'metro.config.js'), metroConfig); writeFileSync(join(dir, 'index.js'), entry); + + await runInit(dir, false); + + expect(readFileSync(join(dir, 'metro.config.js'), 'utf-8')).toBe(metroConfig); + expect(readFileSync(join(dir, 'index.js'), 'utf-8')).toBe(entry); + }); + + it('keeps unsupported Metro configs and entries unchanged', async () => { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + dependencies: { 'react-native': '^0.87.0' }, + })); + const metroConfig = `export default {};\n`; + const entry = `export {};\n`; + writeFileSync(join(dir, 'metro.config.mjs'), metroConfig); + writeFileSync(join(dir, 'index.js'), entry); const log = vi.spyOn(console, 'log').mockImplementation(() => {}); try { await runInit(dir, false); - await runInit(dir, true); - - const output = log.mock.calls.flat().join('\n'); - expect(output).toContain('React Native requires manual setup'); - expect(output).not.toContain('0.87'); - expect(output).toContain('npm install -D agent-react-devtools'); - expect(output).not.toContain('react-devtools-core'); - expect(output).toContain('withAgentReactDevTools'); - expect(output).toContain("import 'agent-react-devtools/react-native'"); - expect(output).not.toContain('connect to DevTools automatically'); + expect(log.mock.calls.flat().join('\n')).toContain('Manual setup required'); } finally { log.mockRestore(); } - expect(readFileSync(join(dir, 'package.json'), 'utf-8')).toBe(packageJson); + expect(readFileSync(join(dir, 'metro.config.mjs'), 'utf-8')).toBe(metroConfig); + expect(readFileSync(join(dir, 'index.js'), 'utf-8')).toBe(entry); + }); + + it('preserves manual Metro wrapper setup while completing a missing entry import', async () => { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + dependencies: { 'react-native': '^0.87.0' }, + })); + const metroConfig = `const { withAgentReactDevTools } = require('agent-react-devtools/metro');\nmodule.exports = withAgentReactDevTools({});\n`; + writeFileSync(join(dir, 'metro.config.js'), metroConfig); + writeFileSync(join(dir, 'index.js'), `export {};\n`); + + await runInit(dir, false); + expect(readFileSync(join(dir, 'metro.config.js'), 'utf-8')).toBe(metroConfig); + expect(readFileSync(join(dir, 'index.js'), 'utf-8')).toContain('agent-react-devtools/react-native'); + }); + + it('preflights React Native targets before a dry run writes any files', async () => { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + dependencies: { 'react-native': '^0.87.0' }, + })); + const entry = `export {};\n`; + writeFileSync(join(dir, 'index.js'), entry); + + await runInit(dir, true); + + expect(existsSync(join(dir, 'metro.config.js'))).toBe(false); expect(readFileSync(join(dir, 'index.js'), 'utf-8')).toBe(entry); }); }); @@ -371,21 +507,39 @@ describe('runUninit', () => { expect(afterInit2).toContain('agent-react-devtools'); }); - it('explains that React Native manual setup has nothing to uninit', async () => { - writeFileSync( - join(dir, 'package.json'), - JSON.stringify({ dependencies: { 'react-native': '^0.87.0' } }), - ); - const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + it('reverts only its marked React Native edits and deletes an unmodified generated config', async () => { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + dependencies: { 'react-native': '^0.87.0' }, + })); + const entry = `export {};\n`; + writeFileSync(join(dir, 'index.js'), entry); + + await runInit(dir, false); + await runUninit(dir, false); + + expect(existsSync(join(dir, 'metro.config.js'))).toBe(false); + expect(readFileSync(join(dir, 'index.js'), 'utf-8')).toBe(entry); + }); + + it('keeps a modified generated Metro config while removing marked entry imports', async () => { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + dependencies: { 'react-native': '^0.87.0' }, + })); + writeFileSync(join(dir, 'index.js'), `export {};\n`); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { + await runInit(dir, false); + const configPath = join(dir, 'metro.config.js'); + writeFileSync(configPath, `${readFileSync(configPath, 'utf-8')}\n// user customization\n`); + await runUninit(dir, false); - const output = log.mock.calls.flat().join('\n'); - expect(output).toContain('manual setup'); - expect(output).toContain('no changes to remove'); - expect(output).not.toContain('configuration has been removed'); + + expect(existsSync(configPath)).toBe(true); + expect(warn.mock.calls.flat().join('\n')).toContain('leaving it in place'); + expect(readFileSync(join(dir, 'index.js'), 'utf-8')).not.toContain('agent-react-devtools/react-native'); } finally { - log.mockRestore(); + warn.mockRestore(); } }); }); diff --git a/packages/agent-react-devtools/src/__tests__/metro-plugin.test.ts b/packages/agent-react-devtools/src/__tests__/metro-plugin.test.ts index cce2b0d..cc4244a 100644 --- a/packages/agent-react-devtools/src/__tests__/metro-plugin.test.ts +++ b/packages/agent-react-devtools/src/__tests__/metro-plugin.test.ts @@ -51,6 +51,35 @@ describe('withAgentReactDevTools', () => { expect(serializer.getModulesRunBeforeMainModule).toBe(originalCallback); expect(modules[0]).toBe('existing-serializer:/entry.js'); }); + + it('wraps Metro config factories without changing their arguments or serializer hooks', () => { + const factory = (projectRoot: string) => ({ + projectRoot, + serializer: { + getModulesRunBeforeMainModule: () => ['/react-native/InitializeCore.js'], + }, + }); + + const wrapped = withAgentReactDevTools(factory); + const config = wrapped('/app'); + + expect(config.projectRoot).toBe('/app'); + expect(config.serializer.getModulesRunBeforeMainModule('/app/index.js')).toEqual([ + '/react-native/InitializeCore.js', + expect.stringMatching(/react-native\.js$/), + ]); + }); + + it('wraps async and promise Metro config exports', async () => { + const asyncFactory = async () => ({ serializer: {} }); + const promisedConfig = Promise.resolve({ serializer: {} }); + + const fromFactory = await withAgentReactDevTools(asyncFactory)(); + const fromPromise = await withAgentReactDevTools(promisedConfig); + + expect(fromFactory.serializer.getModulesRunBeforeMainModule('/app/index.js')).toHaveLength(1); + expect(fromPromise.serializer.getModulesRunBeforeMainModule('/app/index.js')).toHaveLength(1); + }); }); describe('React Native package exports', () => { diff --git a/packages/agent-react-devtools/src/init.ts b/packages/agent-react-devtools/src/init.ts index e2ad4f5..7b643a8 100644 --- a/packages/agent-react-devtools/src/init.ts +++ b/packages/agent-react-devtools/src/init.ts @@ -1,13 +1,50 @@ import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'; -import { join, dirname } from 'node:path'; +import { join, dirname, resolve, sep, extname } from 'node:path'; -type Framework = 'vite' | 'nextjs' | 'cra' | 'react-native' | 'unknown'; +type Framework = 'vite' | 'nextjs' | 'cra' | 'react-native' | 'expo' | 'unknown'; -export function detectFramework(cwd: string): Framework { +interface PackageJson { + type?: string; + main?: string; + metro?: unknown; + dependencies?: Record; + devDependencies?: Record; +} + +interface NativeConfigPlan { + path: string; + content: string | null; +} + +interface NativeSetupPlan { + config: NativeConfigPlan; + entries: string[]; +} + +const NATIVE_IMPORT_MARKER = '// @agent-react-devtools:react-native-bootstrap'; +const NATIVE_IMPORT = "import 'agent-react-devtools/react-native';"; +const METRO_WRAPPER_START = '// @agent-react-devtools:metro-wrapper:start'; +const METRO_WRAPPER_END = '// @agent-react-devtools:metro-wrapper:end'; +const GENERATED_METRO_MARKER = '// @agent-react-devtools:generated-metro-config'; +const METRO_CONFIG_CANDIDATES = [ + 'metro.config.js', + 'metro.config.cjs', + 'metro.config.mjs', + 'metro.config.ts', + 'metro.config.json', +]; +const SOURCE_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx']; +const PLATFORM_SUFFIXES = ['ios', 'android', 'native']; + +function readPackageJson(cwd: string): PackageJson | null { const pkgPath = join(cwd, 'package.json'); - if (!existsSync(pkgPath)) return 'unknown'; + if (!existsSync(pkgPath)) return null; + return JSON.parse(readFileSync(pkgPath, 'utf-8')) as PackageJson; +} - const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); +export function detectFramework(cwd: string): Framework { + const pkg = readPackageJson(cwd); + if (!pkg) return 'unknown'; const allDeps = { ...pkg.dependencies, ...pkg.devDependencies, @@ -16,6 +53,7 @@ export function detectFramework(cwd: string): Framework { if (allDeps['@vitejs/plugin-react']) return 'vite'; if (allDeps['next']) return 'nextjs'; if (allDeps['react-scripts']) return 'cra'; + if (allDeps['expo']) return 'expo'; if (allDeps['react-native']) return 'react-native'; return 'unknown'; } @@ -192,6 +230,241 @@ function patchCRA(cwd: string, dryRun: boolean): string[] { return result ? [result] : []; } +function nativeMetroWrapperBlock(): string { + return `\n${METRO_WRAPPER_START}\nconst { withAgentReactDevTools } = require('agent-react-devtools/metro');\nmodule.exports = withAgentReactDevTools(module.exports);\n${METRO_WRAPPER_END}\n`; +} + +function generatedMetroConfig(isExpo: boolean): string { + const metroPackage = isExpo ? 'expo/metro-config' : '@react-native/metro-config'; + return `${GENERATED_METRO_MARKER}\nconst { getDefaultConfig } = require('${metroPackage}');\nconst { withAgentReactDevTools } = require('agent-react-devtools/metro');\n\nconst config = getDefaultConfig(__dirname);\nmodule.exports = withAgentReactDevTools(config);\n`; +} + +function sourceFile(cwd: string, stem: string): string | null { + for (const extension of SOURCE_EXTENSIONS) { + const path = join(cwd, `${stem}.${extension}`); + if (existsSync(path)) return path; + } + return null; +} + +function platformSourceFiles(cwd: string, stem: string): string[] { + const paths: string[] = []; + for (const platform of PLATFORM_SUFFIXES) { + for (const extension of SOURCE_EXTENSIONS) { + const path = join(cwd, `${stem}.${platform}.${extension}`); + if (existsSync(path)) paths.push(path); + } + } + return paths; +} + +function resolvePackageMain(cwd: string, main: string | undefined): string | null { + if (!main || main.startsWith('@') || (!main.startsWith('.') && main.includes('/'))) { + return null; + } + + const path = resolve(cwd, main); + if (path !== cwd && !path.startsWith(`${cwd}${sep}`)) return null; + if (existsSync(path)) return path; + + if (extname(path) === '') { + for (const extension of SOURCE_EXTENSIONS) { + const withExtension = `${path}.${extension}`; + if (existsSync(withExtension)) return withExtension; + } + } + + return null; +} + +function unique(paths: Array): string[] { + return [...new Set(paths.filter((path): path is string => path !== null))]; +} + +function findMetroConfigPaths(cwd: string): string[] { + let directory = resolve(cwd); + while (true) { + const configs = METRO_CONFIG_CANDIDATES + .map((candidate) => join(directory, candidate)) + .filter(existsSync); + if (configs.length > 0) return configs; + + const parent = dirname(directory); + if (parent === directory) return []; + directory = parent; + } +} + +function findNativeEntryFiles(cwd: string, pkg: PackageJson, isExpo: boolean): string[] { + const packageMain = resolvePackageMain(cwd, pkg.main); + if (packageMain) return [packageMain]; + + if (isExpo) { + const routerLayout = findFile( + cwd, + ...SOURCE_EXTENSIONS.flatMap((extension) => [ + `app/_layout.${extension}`, + `src/app/_layout.${extension}`, + ]), + ); + if (routerLayout) return [routerLayout]; + } + + const index = sourceFile(cwd, 'index'); + if (index) return [index]; + const platformIndexes = platformSourceFiles(cwd, 'index'); + if (platformIndexes.length > 0) return platformIndexes; + + if (isExpo) { + const app = sourceFile(cwd, 'App'); + if (app) return [app]; + const platformApps = platformSourceFiles(cwd, 'App'); + if (platformApps.length > 0) return platformApps; + } + + return []; +} + +function findAllNativeEntryFiles(cwd: string, pkg: PackageJson): string[] { + const packageMain = resolvePackageMain(cwd, pkg.main); + const layouts = SOURCE_EXTENSIONS.flatMap((extension) => [ + join(cwd, `app/_layout.${extension}`), + join(cwd, `src/app/_layout.${extension}`), + ]).filter(existsSync); + const roots = [sourceFile(cwd, 'index'), sourceFile(cwd, 'App')]; + return unique([ + packageMain, + ...layouts, + ...roots, + ...platformSourceFiles(cwd, 'index'), + ...platformSourceFiles(cwd, 'App'), + ]); +} + +function preflightNativeSetup( + cwd: string, + pkg: PackageJson, + isExpo: boolean, +): NativeSetupPlan | string { + const existingConfigs = findMetroConfigPaths(cwd); + + if (pkg.metro !== undefined) { + return 'a package.json Metro configuration was found'; + } + if (existingConfigs.length > 1) { + return `multiple Metro config files were found (${existingConfigs.map((path) => path.split(sep).pop()).join(', ')})`; + } + + const entries = findNativeEntryFiles(cwd, pkg, isExpo); + if (entries.length === 0) { + return 'no supported reachable app entry was found'; + } + + if (existingConfigs.length === 0) { + const path = join(cwd, pkg.type === 'module' ? 'metro.config.cjs' : 'metro.config.js'); + return { + config: { path, content: generatedMetroConfig(isExpo) }, + entries, + }; + } + + const path = existingConfigs[0]; + if (!path.endsWith('.js') && !path.endsWith('.cjs')) { + return `${path.split(sep).pop()} is not a CommonJS .js or .cjs Metro config`; + } + if (path.endsWith('.js') && pkg.type === 'module') { + return 'metro.config.js is interpreted as ESM because package.json declares type: module'; + } + + const content = readFileSync(path, 'utf-8'); + return { + config: { + path, + content: content.includes('agent-react-devtools/metro') + ? null + : `${content.replace(/\n?$/, '\n')}${nativeMetroWrapperBlock()}`, + }, + entries, + }; +} + +function patchNativeEntry(path: string, dryRun: boolean): string | null { + const content = readFileSync(path, 'utf-8'); + if (content.includes('agent-react-devtools/react-native')) return null; + const patched = `${NATIVE_IMPORT_MARKER}\n${NATIVE_IMPORT}\n${content}`; + if (!dryRun) writeFileSync(path, patched, 'utf-8'); + return path; +} + +function removeNativeEntryImport(path: string, dryRun: boolean): string | null { + const content = readFileSync(path, 'utf-8'); + const patched = content.replace( + /\/\/ @agent-react-devtools:react-native-bootstrap\nimport 'agent-react-devtools\/react-native';\n?/g, + '', + ); + if (patched === content) return null; + if (!dryRun) writeFileSync(path, patched, 'utf-8'); + return path; +} + +function patchReactNative(cwd: string, dryRun: boolean, isExpo: boolean): string[] { + const pkg = readPackageJson(cwd); + if (!pkg) return []; + const plan = preflightNativeSetup(cwd, pkg, isExpo); + if (typeof plan === 'string') { + console.log(`\nReact Native/Expo automatic setup could not safely continue: ${plan}.`); + console.log('Manual setup required: wrap your final CommonJS Metro config with withAgentReactDevTools and import agent-react-devtools/react-native from a reachable app module.'); + return []; + } + + const modified: string[] = []; + if (plan.config.content !== null) { + if (!dryRun) writeFileSync(plan.config.path, plan.config.content, 'utf-8'); + modified.push(plan.config.path); + } + for (const entry of plan.entries) { + const patched = patchNativeEntry(entry, dryRun); + if (patched) modified.push(patched); + } + return modified; +} + +function unpatchReactNative(cwd: string, dryRun: boolean, isExpo: boolean): string[] { + const pkg = readPackageJson(cwd); + if (!pkg) return []; + const modified: string[] = []; + + for (const entry of findAllNativeEntryFiles(cwd, pkg)) { + const patched = removeNativeEntryImport(entry, dryRun); + if (patched) modified.push(entry); + } + + for (const path of findMetroConfigPaths(cwd)) { + const content = readFileSync(path, 'utf-8'); + const generated = generatedMetroConfig(isExpo); + if (content === generated) { + if (!dryRun) unlinkSync(path); + modified.push(path); + continue; + } + if (content.startsWith(GENERATED_METRO_MARKER)) { + console.warn(` ${path} was generated by init but has been modified; leaving it in place.`); + continue; + } + + const patched = content.replace( + /\n?\/\/ @agent-react-devtools:metro-wrapper:start\nconst \{ withAgentReactDevTools \} = require\('agent-react-devtools\/metro'\);\nmodule\.exports = withAgentReactDevTools\(module\.exports\);\n\/\/ @agent-react-devtools:metro-wrapper:end\n?/g, + '\n', + ); + if (patched !== content) { + if (!dryRun) writeFileSync(path, patched, 'utf-8'); + modified.push(path); + } + } + + return modified; +} + function unpatchViteConfig(cwd: string, dryRun: boolean): string[] { const configPath = findFile( cwd, @@ -309,13 +582,6 @@ export async function runUninit( return; } - if (framework === 'react-native') { - console.log( - '\nReact Native init only prints manual setup; no changes to remove.', - ); - return; - } - let modified: string[] = []; if (dryRun) { @@ -332,6 +598,12 @@ export async function runUninit( case 'cra': modified = unpatchCRA(cwd, dryRun); break; + case 'react-native': + modified = unpatchReactNative(cwd, dryRun, false); + break; + case 'expo': + modified = unpatchReactNative(cwd, dryRun, true); + break; } if (modified.length === 0) { @@ -361,24 +633,6 @@ export async function runInit( return; } - if (framework === 'react-native') { - console.log('\nReact Native requires manual setup.'); - console.log('No files were changed by init.\n'); - console.log('1. Install agent-react-devtools:'); - console.log(' npm install -D agent-react-devtools\n'); - console.log('2. Wrap your final Metro config:'); - console.log( - " const { withAgentReactDevTools } = require('agent-react-devtools/metro');", - ); - console.log(' module.exports = withAgentReactDevTools(config);\n'); - console.log('3. Import the bootstrap from your entry graph:'); - console.log(" import 'agent-react-devtools/react-native';\n"); - console.log('Next steps:'); - console.log(' 1. Start the daemon: agent-react-devtools start'); - console.log(' 2. Start Metro and your app'); - return; - } - let modified: string[] = []; if (dryRun) { @@ -395,6 +649,12 @@ export async function runInit( case 'cra': modified = patchCRA(cwd, dryRun); break; + case 'react-native': + modified = patchReactNative(cwd, dryRun, false); + break; + case 'expo': + modified = patchReactNative(cwd, dryRun, true); + break; } if (modified.length === 0) { @@ -407,8 +667,14 @@ export async function runInit( } console.log('\nNext steps:'); - console.log(' 1. Install: npm install -D agent-react-devtools react-devtools-core'); - console.log(' 2. Start daemon: agent-react-devtools start'); - console.log(' 3. Start dev server and open your app'); - console.log(' 4. Inspect: agent-react-devtools get tree'); + if (framework === 'react-native' || framework === 'expo') { + console.log(' 1. Start daemon: agent-react-devtools start'); + console.log(' 2. Restart Metro and your app'); + console.log(' 3. Inspect: agent-react-devtools get tree'); + } else { + console.log(' 1. Install: npm install -D agent-react-devtools react-devtools-core'); + console.log(' 2. Start daemon: agent-react-devtools start'); + console.log(' 3. Start dev server and open your app'); + console.log(' 4. Inspect: agent-react-devtools get tree'); + } } diff --git a/packages/agent-react-devtools/src/metro-plugin.ts b/packages/agent-react-devtools/src/metro-plugin.ts index cfef672..948fc7b 100644 --- a/packages/agent-react-devtools/src/metro-plugin.ts +++ b/packages/agent-react-devtools/src/metro-plugin.ts @@ -18,11 +18,21 @@ type AgentMetroConfig = T & { }; }; +type MetroConfigFactory = ( + ...args: Args +) => T; + +type AsyncMetroConfigFactory = ( + ...args: Args +) => Promise; + const REACT_NATIVE_BOOTSTRAP_PATH = resolve(__dirname, 'react-native.js'); -export function withAgentReactDevTools( - config: T, -): AgentMetroConfig { +function isPromise(value: T | Promise): value is Promise { + return typeof (value as Promise)?.then === 'function'; +} + +function wrapMetroConfig(config: T): AgentMetroConfig { const serializer = config.serializer ?? {}; const getModulesRunBeforeMainModule = serializer.getModulesRunBeforeMainModule; @@ -41,3 +51,38 @@ export function withAgentReactDevTools( }, } as AgentMetroConfig; } + +export function withAgentReactDevTools( + config: T, +): AgentMetroConfig; +export function withAgentReactDevTools( + configFactory: MetroConfigFactory, +): (...args: Args) => AgentMetroConfig; +export function withAgentReactDevTools( + configFactory: AsyncMetroConfigFactory, +): (...args: Args) => Promise>; +export function withAgentReactDevTools( + configPromise: Promise, +): Promise>; +export function withAgentReactDevTools( + config: + | T + | MetroConfigFactory + | AsyncMetroConfigFactory + | Promise, +): + | AgentMetroConfig + | ((...args: Args) => AgentMetroConfig | Promise>) + | Promise> { + if (typeof config === 'function') { + const factory = config as (this: unknown, ...args: Args) => T | Promise; + return function wrappedMetroConfigFactory(this: unknown, ...args: Args) { + const resolvedConfig = factory.apply(this, args); + return isPromise(resolvedConfig) + ? resolvedConfig.then(wrapMetroConfig) + : wrapMetroConfig(resolvedConfig); + }; + } + + return isPromise(config) ? config.then(wrapMetroConfig) : wrapMetroConfig(config); +}