Skip to content

Commit 6e328b1

Browse files
committed
feat: automate React Native Metro setup
Configure supported React Native and Expo projects with reversible Metro and entry-graph edits.
1 parent fb3affe commit 6e328b1

8 files changed

Lines changed: 599 additions & 73 deletions

File tree

.changeset/quiet-walls-connect.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
'agent-react-devtools': minor
33
---
44

5-
Add React Native support through a Metro config wrapper and native bootstrap
6-
entry. React Native apps now wrap their final Metro config and import
7-
`agent-react-devtools/react-native` from the application entry graph.
5+
Automatically configure standard React Native and Expo projects through a
6+
CommonJS Metro wrapper and a native bootstrap entry import. Unsupported Metro
7+
formats and ambiguous projects retain the documented manual setup path.

README.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,10 @@ patch the appropriate web entry or config:
199199
npx agent-react-devtools init
200200
```
201201

202-
React Native is detected too, but `init` only prints the manual React Native
203-
setup shown below; it does not edit Metro or application files.
202+
For standard React Native and Expo projects, `init` also configures Metro and a
203+
reachable app module. It supports existing CommonJS (`.js`/`.cjs`) Metro
204+
configs and creates one when none exists. Use the manual setup below for ESM,
205+
TypeScript, JSON/package-field, custom `--config`, or ambiguous Metro setups.
204206

205207
To undo these changes:
206208

@@ -253,6 +255,13 @@ npm install --save-dev agent-react-devtools
253255

254256
Both of the following steps are required.
255257

258+
`npx agent-react-devtools init` performs both steps automatically for the
259+
common CommonJS Metro configurations and entries it recognizes: `package.json`
260+
`main`, Expo Router's root layout, bare `index.*`, and Expo `App.*`. It patches
261+
all available platform-specific entries when a shared entry does not exist.
262+
The CLI first preflights every target and leaves files unchanged when it cannot
263+
safely identify the config or entry. `uninit` removes only its marked edits.
264+
256265
#### 1. Wrap the final Metro config
257266

258267
For a bare React Native app:
@@ -335,6 +344,13 @@ If `status` reports zero connected apps:
335344
4. Confirm the daemon is listening on 8097 and repeat `adb reverse` for Android devices.
336345
5. Check that the app is a development build.
337346

347+
#### Manual fallback
348+
349+
Configure the two steps above manually when Metro uses ESM (`.mjs`),
350+
TypeScript, JSON or a package-field configuration; when your app starts Metro
351+
with a custom `--config`; or when the CLI reports an ambiguous config or entry.
352+
Keep `withAgentReactDevTools` as the final outermost wrapper.
353+
338354
## Using with agent-browser
339355

340356
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:

packages/agent-react-devtools/skills/react-devtools/references/commands.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@ Categories with no changes are omitted. Keys are deduplicated across commits in
119119
## Setup
120120

121121
### `agent-react-devtools init [--dry-run]`
122-
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.
122+
Auto-detect the framework in the current directory. It configures Vite, Next.js,
123+
CRA, and standard Expo/React Native projects automatically. For React Native it
124+
patches existing CommonJS Metro configs (or creates one), then adds the native
125+
bootstrap import to a recognized reachable app module. ESM, TypeScript, JSON or
126+
package-field Metro configs, custom `--config` use, and ambiguous targets are
127+
left unchanged with manual setup instructions.
123128

124129
Use `--dry-run` to preview changes without writing files.

packages/agent-react-devtools/skills/react-devtools/references/setup.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Setup Guide
22

33
agent-react-devtools works with React web and React Native apps. The `init`
4-
command auto-configures Vite, Next.js, and Create React App. For React Native,
5-
it prints the manual setup steps without editing files.
4+
command auto-configures Vite, Next.js, Create React App, and standard React
5+
Native/Expo projects with CommonJS Metro configs.
66

77
## Web Auto Setup (Recommended)
88

@@ -59,7 +59,15 @@ import 'agent-react-devtools/connect';
5959

6060
### React Native / Expo
6161

62-
The Metro wrapper and entry-graph import below are both mandatory.
62+
The Metro wrapper and entry-graph import below are both mandatory. For a
63+
standard project, `init` adds both automatically: it supports existing
64+
`metro.config.js`/`.cjs` files, or creates the appropriate default config when
65+
none exists, then patches `package.json` `main`, Expo Router's root layout,
66+
bare `index.*`, or Expo `App.*`.
67+
68+
It safely falls back without editing files for ESM, TypeScript, JSON or
69+
package-field Metro configurations, custom `--config` usage, or ambiguous
70+
targets. In those cases, apply the two manual steps below.
6371

6472
```bash
6573
npm install --save-dev agent-react-devtools
@@ -103,6 +111,9 @@ The import puts the module into Metro's dependency graph; the wrapper executes
103111
it after React Native initialization and before application modules. Restart
104112
Metro after changing `metro.config.js`.
105113

114+
`uninit` removes only the ownership-marked import and Metro wrapper it added.
115+
It deletes an auto-created config only if it is still unchanged.
116+
106117
The daemon and client use port 8097 by default:
107118

108119
```bash

packages/agent-react-devtools/src/__tests__/init.test.ts

Lines changed: 181 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, rmSync, existsSync
33
import { join } from 'node:path';
44
import { tmpdir } from 'node:os';
55
import { detectFramework, runInit, runUninit } from '../init.js';
6+
import { withAgentReactDevTools } from '../metro-plugin.js';
67

78
function makeTempDir(): string {
89
return mkdtempSync(join(tmpdir(), 'ard-test-'));
@@ -200,35 +201,170 @@ describe('runInit', () => {
200201
expect(content).toBe(original);
201202
});
202203

203-
it('prints manual React Native setup without modifying files', async () => {
204-
const packageJson = JSON.stringify({
204+
it('patches a bare React Native Metro config and its reachable entry idempotently', async () => {
205+
writeFileSync(join(dir, 'package.json'), JSON.stringify({
205206
dependencies: { 'react-native': '^0.87.0' },
206-
});
207+
}));
207208
const metroConfig = `module.exports = { serializer: {} };\n`;
208-
const entry = `import { AppRegistry } from 'react-native';\n`;
209-
writeFileSync(join(dir, 'package.json'), packageJson);
209+
writeFileSync(join(dir, 'metro.config.js'), metroConfig);
210+
writeFileSync(join(dir, 'index.js'), `import { AppRegistry } from 'react-native';\n`);
211+
212+
await runInit(dir, false);
213+
const configAfterFirstInit = readFileSync(join(dir, 'metro.config.js'), 'utf-8');
214+
const entryAfterFirstInit = readFileSync(join(dir, 'index.js'), 'utf-8');
215+
216+
expect(configAfterFirstInit).toContain('@agent-react-devtools:metro-wrapper:start');
217+
expect(configAfterFirstInit).toContain('module.exports = withAgentReactDevTools(module.exports);');
218+
expect(entryAfterFirstInit).toContain('@agent-react-devtools:react-native-bootstrap');
219+
expect(entryAfterFirstInit).toContain("import 'agent-react-devtools/react-native';");
220+
221+
await runInit(dir, false);
222+
expect(readFileSync(join(dir, 'metro.config.js'), 'utf-8')).toBe(configAfterFirstInit);
223+
expect(readFileSync(join(dir, 'index.js'), 'utf-8')).toBe(entryAfterFirstInit);
224+
});
225+
226+
it('makes the native bootstrap reachable in the entry graph and schedules it after React Native initialization', async () => {
227+
writeFileSync(join(dir, 'package.json'), JSON.stringify({
228+
dependencies: { 'react-native': '^0.87.0' },
229+
}));
230+
writeFileSync(join(dir, 'index.js'), `export {};\n`);
231+
232+
await runInit(dir, false);
233+
234+
const entry = readFileSync(join(dir, 'index.js'), 'utf-8');
235+
const modules = withAgentReactDevTools({
236+
serializer: {
237+
getModulesRunBeforeMainModule: () => ['/react-native/InitializeCore.js'],
238+
},
239+
}).serializer.getModulesRunBeforeMainModule(join(dir, 'index.js'));
240+
241+
expect(entry).toContain("import 'agent-react-devtools/react-native';");
242+
expect(modules[0]).toBe('/react-native/InitializeCore.js');
243+
expect(modules[1]).toMatch(/react-native\.js$/);
244+
});
245+
246+
it('creates an Expo CommonJS Metro config in module packages and patches the router root layout', async () => {
247+
writeFileSync(join(dir, 'package.json'), JSON.stringify({
248+
type: 'module',
249+
dependencies: { expo: '^54.0.0', 'expo-router': '^5.0.0' },
250+
}));
251+
mkdirSync(join(dir, 'app'));
252+
writeFileSync(join(dir, 'app/_layout.tsx'), `export default function Layout() { return null; }\n`);
253+
254+
await runInit(dir, false);
255+
256+
const configPath = join(dir, 'metro.config.cjs');
257+
expect(existsSync(configPath)).toBe(true);
258+
expect(readFileSync(configPath, 'utf-8')).toContain("require('expo/metro-config')");
259+
expect(readFileSync(join(dir, 'app/_layout.tsx'), 'utf-8')).toContain(
260+
"import 'agent-react-devtools/react-native';",
261+
);
262+
});
263+
264+
it('patches every platform entry when no shared React Native index exists', async () => {
265+
writeFileSync(join(dir, 'package.json'), JSON.stringify({
266+
dependencies: { 'react-native': '^0.87.0' },
267+
}));
268+
writeFileSync(join(dir, 'index.ios.js'), `export {};\n`);
269+
writeFileSync(join(dir, 'index.android.ts'), `export {};\n`);
270+
271+
await runInit(dir, false);
272+
273+
expect(readFileSync(join(dir, 'index.ios.js'), 'utf-8')).toContain('agent-react-devtools/react-native');
274+
expect(readFileSync(join(dir, 'index.android.ts'), 'utf-8')).toContain('agent-react-devtools/react-native');
275+
});
276+
277+
it('uses a local package main entry before conventional entry files', async () => {
278+
writeFileSync(join(dir, 'package.json'), JSON.stringify({
279+
main: './src/bootstrap.ts',
280+
dependencies: { 'react-native': '^0.87.0' },
281+
}));
282+
mkdirSync(join(dir, 'src'));
283+
writeFileSync(join(dir, 'src/bootstrap.ts'), `export {};\n`);
284+
writeFileSync(join(dir, 'index.js'), `export {};\n`);
285+
286+
await runInit(dir, false);
287+
288+
expect(readFileSync(join(dir, 'src/bootstrap.ts'), 'utf-8')).toContain('agent-react-devtools/react-native');
289+
expect(readFileSync(join(dir, 'index.js'), 'utf-8')).not.toContain('agent-react-devtools/react-native');
290+
});
291+
292+
it('uses the nearest Metro config found by Metro’s upward search', async () => {
293+
const appDir = join(dir, 'app');
294+
mkdirSync(appDir);
295+
writeFileSync(join(appDir, 'package.json'), JSON.stringify({
296+
dependencies: { 'react-native': '^0.87.0' },
297+
}));
298+
writeFileSync(join(dir, 'metro.config.cjs'), `module.exports = {};\n`);
299+
writeFileSync(join(appDir, 'index.js'), `export {};\n`);
300+
301+
await runInit(appDir, false);
302+
303+
expect(readFileSync(join(dir, 'metro.config.cjs'), 'utf-8')).toContain('@agent-react-devtools:metro-wrapper:start');
304+
expect(readFileSync(join(appDir, 'index.js'), 'utf-8')).toContain('agent-react-devtools/react-native');
305+
});
306+
307+
it('leaves a .js Metro config alone when package.json declares ESM', async () => {
308+
writeFileSync(join(dir, 'package.json'), JSON.stringify({
309+
type: 'module',
310+
dependencies: { 'react-native': '^0.87.0' },
311+
}));
312+
const metroConfig = `export default {};\n`;
313+
const entry = `export {};\n`;
210314
writeFileSync(join(dir, 'metro.config.js'), metroConfig);
211315
writeFileSync(join(dir, 'index.js'), entry);
316+
317+
await runInit(dir, false);
318+
319+
expect(readFileSync(join(dir, 'metro.config.js'), 'utf-8')).toBe(metroConfig);
320+
expect(readFileSync(join(dir, 'index.js'), 'utf-8')).toBe(entry);
321+
});
322+
323+
it('keeps unsupported Metro configs and entries unchanged', async () => {
324+
writeFileSync(join(dir, 'package.json'), JSON.stringify({
325+
dependencies: { 'react-native': '^0.87.0' },
326+
}));
327+
const metroConfig = `export default {};\n`;
328+
const entry = `export {};\n`;
329+
writeFileSync(join(dir, 'metro.config.mjs'), metroConfig);
330+
writeFileSync(join(dir, 'index.js'), entry);
212331
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
213332

214333
try {
215334
await runInit(dir, false);
216-
await runInit(dir, true);
217-
218-
const output = log.mock.calls.flat().join('\n');
219-
expect(output).toContain('React Native requires manual setup');
220-
expect(output).not.toContain('0.87');
221-
expect(output).toContain('npm install -D agent-react-devtools');
222-
expect(output).not.toContain('react-devtools-core');
223-
expect(output).toContain('withAgentReactDevTools');
224-
expect(output).toContain("import 'agent-react-devtools/react-native'");
225-
expect(output).not.toContain('connect to DevTools automatically');
335+
expect(log.mock.calls.flat().join('\n')).toContain('Manual setup required');
226336
} finally {
227337
log.mockRestore();
228338
}
229339

230-
expect(readFileSync(join(dir, 'package.json'), 'utf-8')).toBe(packageJson);
340+
expect(readFileSync(join(dir, 'metro.config.mjs'), 'utf-8')).toBe(metroConfig);
341+
expect(readFileSync(join(dir, 'index.js'), 'utf-8')).toBe(entry);
342+
});
343+
344+
it('preserves manual Metro wrapper setup while completing a missing entry import', async () => {
345+
writeFileSync(join(dir, 'package.json'), JSON.stringify({
346+
dependencies: { 'react-native': '^0.87.0' },
347+
}));
348+
const metroConfig = `const { withAgentReactDevTools } = require('agent-react-devtools/metro');\nmodule.exports = withAgentReactDevTools({});\n`;
349+
writeFileSync(join(dir, 'metro.config.js'), metroConfig);
350+
writeFileSync(join(dir, 'index.js'), `export {};\n`);
351+
352+
await runInit(dir, false);
353+
231354
expect(readFileSync(join(dir, 'metro.config.js'), 'utf-8')).toBe(metroConfig);
355+
expect(readFileSync(join(dir, 'index.js'), 'utf-8')).toContain('agent-react-devtools/react-native');
356+
});
357+
358+
it('preflights React Native targets before a dry run writes any files', async () => {
359+
writeFileSync(join(dir, 'package.json'), JSON.stringify({
360+
dependencies: { 'react-native': '^0.87.0' },
361+
}));
362+
const entry = `export {};\n`;
363+
writeFileSync(join(dir, 'index.js'), entry);
364+
365+
await runInit(dir, true);
366+
367+
expect(existsSync(join(dir, 'metro.config.js'))).toBe(false);
232368
expect(readFileSync(join(dir, 'index.js'), 'utf-8')).toBe(entry);
233369
});
234370
});
@@ -371,21 +507,39 @@ describe('runUninit', () => {
371507
expect(afterInit2).toContain('agent-react-devtools');
372508
});
373509

374-
it('explains that React Native manual setup has nothing to uninit', async () => {
375-
writeFileSync(
376-
join(dir, 'package.json'),
377-
JSON.stringify({ dependencies: { 'react-native': '^0.87.0' } }),
378-
);
379-
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
510+
it('reverts only its marked React Native edits and deletes an unmodified generated config', async () => {
511+
writeFileSync(join(dir, 'package.json'), JSON.stringify({
512+
dependencies: { 'react-native': '^0.87.0' },
513+
}));
514+
const entry = `export {};\n`;
515+
writeFileSync(join(dir, 'index.js'), entry);
516+
517+
await runInit(dir, false);
518+
await runUninit(dir, false);
519+
520+
expect(existsSync(join(dir, 'metro.config.js'))).toBe(false);
521+
expect(readFileSync(join(dir, 'index.js'), 'utf-8')).toBe(entry);
522+
});
523+
524+
it('keeps a modified generated Metro config while removing marked entry imports', async () => {
525+
writeFileSync(join(dir, 'package.json'), JSON.stringify({
526+
dependencies: { 'react-native': '^0.87.0' },
527+
}));
528+
writeFileSync(join(dir, 'index.js'), `export {};\n`);
529+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
380530

381531
try {
532+
await runInit(dir, false);
533+
const configPath = join(dir, 'metro.config.js');
534+
writeFileSync(configPath, `${readFileSync(configPath, 'utf-8')}\n// user customization\n`);
535+
382536
await runUninit(dir, false);
383-
const output = log.mock.calls.flat().join('\n');
384-
expect(output).toContain('manual setup');
385-
expect(output).toContain('no changes to remove');
386-
expect(output).not.toContain('configuration has been removed');
537+
538+
expect(existsSync(configPath)).toBe(true);
539+
expect(warn.mock.calls.flat().join('\n')).toContain('leaving it in place');
540+
expect(readFileSync(join(dir, 'index.js'), 'utf-8')).not.toContain('agent-react-devtools/react-native');
387541
} finally {
388-
log.mockRestore();
542+
warn.mockRestore();
389543
}
390544
});
391545
});

packages/agent-react-devtools/src/__tests__/metro-plugin.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,35 @@ describe('withAgentReactDevTools', () => {
5151
expect(serializer.getModulesRunBeforeMainModule).toBe(originalCallback);
5252
expect(modules[0]).toBe('existing-serializer:/entry.js');
5353
});
54+
55+
it('wraps Metro config factories without changing their arguments or serializer hooks', () => {
56+
const factory = (projectRoot: string) => ({
57+
projectRoot,
58+
serializer: {
59+
getModulesRunBeforeMainModule: () => ['/react-native/InitializeCore.js'],
60+
},
61+
});
62+
63+
const wrapped = withAgentReactDevTools(factory);
64+
const config = wrapped('/app');
65+
66+
expect(config.projectRoot).toBe('/app');
67+
expect(config.serializer.getModulesRunBeforeMainModule('/app/index.js')).toEqual([
68+
'/react-native/InitializeCore.js',
69+
expect.stringMatching(/react-native\.js$/),
70+
]);
71+
});
72+
73+
it('wraps async and promise Metro config exports', async () => {
74+
const asyncFactory = async () => ({ serializer: {} });
75+
const promisedConfig = Promise.resolve({ serializer: {} });
76+
77+
const fromFactory = await withAgentReactDevTools(asyncFactory)();
78+
const fromPromise = await withAgentReactDevTools(promisedConfig);
79+
80+
expect(fromFactory.serializer.getModulesRunBeforeMainModule('/app/index.js')).toHaveLength(1);
81+
expect(fromPromise.serializer.getModulesRunBeforeMainModule('/app/index.js')).toHaveLength(1);
82+
});
5483
});
5584

5685
describe('React Native package exports', () => {

0 commit comments

Comments
 (0)