From 61ca758a82ee412289a319975fa56ec55a738fbc Mon Sep 17 00:00:00 2001 From: Stanislav Doskalenko Date: Tue, 1 Sep 2026 11:30:55 -0700 Subject: [PATCH 1/3] feat(platforms): add a per-platform metroConfigEnhancer hook The harness loads Metro's config with a bare `Metro.loadConfig`, bypassing `@react-native/community-cli-plugin`. That plugin is what teaches Metro about out-of-tree platforms (React Native Windows, macOS): the `react-native` -> platform-package resolver redirect, the platform's `InitializeCore`, and the extra `resolver.platforms` entries. Today a project targeting one of those has to reproduce that wiring in its own `metro.config.js`. Give a platform a seam to do it itself. `HarnessPlatform` gains an optional `metroConfigEnhancer`, a module specifier (as `import.meta.resolve('./...')` produces, like `runner` and `cli`) whose default export is a `MetroConfigEnhancer`: (metroConfig, { projectRoot }) => metroConfig | Promise `withRnHarness` imports that module and runs it against the config it has composed, for the selected runner only. A platform that doesn't set one is unaffected -- the composed config is returned as-is. - platforms: `MetroConfigEnhancer` / `MetroConfigEnhancerContext` types and `HarnessPlatform.metroConfigEnhancer` - config: `metroConfigEnhancer` in `RunnerSchema` so it survives parsing - bundler-metro: `withRnHarness` runs the enhancer; `MetroOptions` and `getMetroInstance` thread it through - jest: the session passes the selected runner's `metroConfigEnhancer` --- .../version-plan-1788287414882.md | 5 ++ packages/bundler-metro/package.json | 1 + .../src/__tests__/withRnHarness.test.ts | 67 +++++++++++++++++++ packages/bundler-metro/src/factory.ts | 7 +- packages/bundler-metro/src/types.ts | 6 ++ packages/bundler-metro/src/withRnHarness.ts | 38 +++++++++++ packages/bundler-metro/tsconfig.json | 5 +- packages/bundler-metro/tsconfig.lib.json | 5 +- .../src/__tests__/runner-schema.test.ts | 47 +++++++++++++ packages/config/src/types.ts | 6 ++ packages/jest/src/harness-session.ts | 1 + packages/platforms/src/index.ts | 2 + packages/platforms/src/types.ts | 32 +++++++++ pnpm-lock.yaml | 5 ++ 14 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 .nx/version-plans/version-plan-1788287414882.md create mode 100644 packages/config/src/__tests__/runner-schema.test.ts diff --git a/.nx/version-plans/version-plan-1788287414882.md b/.nx/version-plans/version-plan-1788287414882.md new file mode 100644 index 00000000..c6831030 --- /dev/null +++ b/.nx/version-plans/version-plan-1788287414882.md @@ -0,0 +1,5 @@ +--- +__default__: minor +--- + +A platform package can now adjust the Metro config the harness composes, via a `metroConfigEnhancer` module it points at. The harness imports that module while building the config for the selected runner and lets it apply further changes (a `resolver` redirect, extra `resolver.platforms` entries, its own `InitializeCore`, …). This is the seam an out-of-tree platform such as React Native Windows or macOS would use for the Metro wiring `react-native start` gets from `@react-native/community-cli-plugin`, which the harness bypasses. Nothing changes for platforms that do not set one. diff --git a/packages/bundler-metro/package.json b/packages/bundler-metro/package.json index 18d10bcf..ff915275 100644 --- a/packages/bundler-metro/package.json +++ b/packages/bundler-metro/package.json @@ -21,6 +21,7 @@ "@react-native-harness/cache": "workspace:*", "@react-native-harness/tools": "workspace:*", "@react-native-harness/config": "workspace:*", + "@react-native-harness/platforms": "workspace:*", "@react-native-harness/runtime": "workspace:*", "connect": "^3.7.0", "nocache": "^4.0.0", diff --git a/packages/bundler-metro/src/__tests__/withRnHarness.test.ts b/packages/bundler-metro/src/__tests__/withRnHarness.test.ts index 4f64046f..08292ad2 100644 --- a/packages/bundler-metro/src/__tests__/withRnHarness.test.ts +++ b/packages/bundler-metro/src/__tests__/withRnHarness.test.ts @@ -310,4 +310,71 @@ describe('withRnHarness', () => { /^react-native-harness:\d+\.\d+\.\d+.*:my-salt$/, ); }); + + describe('metroConfigEnhancer', () => { + // A `metroConfigEnhancer` module as a data: URL, so `withRnHarness`'s + // `await import()` has something real to load without a fixture file. + const enhancerModule = (body: string) => + `data:text/javascript,${encodeURIComponent(body)}`; + + it('returns the composed config untouched when no enhancer is set', async () => { + const { withRnHarness } = await import('../withRnHarness.js'); + + const config = (await withRnHarness( + { projectRoot: '/tmp/app', serializer: {} }, + true, + )()) as unknown as MinimalMetroConfig & { enhanced?: unknown }; + + expect(config.enhanced).toBeUndefined(); + expect(config.cacheVersion).toMatch(/^react-native-harness:/); + }); + + it('runs the enhancer against the composed config, in the project context', async () => { + const { withRnHarness } = await import('../withRnHarness.js'); + + const enhancer = enhancerModule( + 'export default (config, context) => ({ ...config, enhanced: { projectRoot: context.projectRoot, sawCacheVersion: config.cacheVersion } });', + ); + + const config = (await withRnHarness( + { projectRoot: '/tmp/app', serializer: {} }, + true, + enhancer, + )()) as unknown as MinimalMetroConfig & { + enhanced?: { projectRoot: string; sawCacheVersion: string }; + }; + + expect(config.enhanced?.projectRoot).toBe('/tmp/app'); + // The enhancer saw the config the harness had already composed. + expect(config.enhanced?.sawCacheVersion).toMatch(/^react-native-harness:/); + }); + + it('awaits an async enhancer', async () => { + const { withRnHarness } = await import('../withRnHarness.js'); + + const enhancer = enhancerModule( + 'export default async (config) => ({ ...config, enhanced: true });', + ); + + const config = (await withRnHarness( + { projectRoot: '/tmp/app', serializer: {} }, + true, + enhancer, + )()) as unknown as MinimalMetroConfig & { enhanced?: boolean }; + + expect(config.enhanced).toBe(true); + }); + + it('throws when the enhancer module has no default export function', async () => { + const { withRnHarness } = await import('../withRnHarness.js'); + + await expect( + withRnHarness( + { projectRoot: '/tmp/app', serializer: {} }, + true, + enhancerModule('export const notDefault = 1;'), + )(), + ).rejects.toThrow(/no default export function/); + }); + }); }); diff --git a/packages/bundler-metro/src/factory.ts b/packages/bundler-metro/src/factory.ts index 2203bb40..8f1c2fab 100644 --- a/packages/bundler-metro/src/factory.ts +++ b/packages/bundler-metro/src/factory.ts @@ -96,6 +96,7 @@ export const getMetroInstance = async ( const { projectRoot, harnessConfig, + metroConfigEnhancer, websocketEndpoints = {}, watchMode = false, } = options; @@ -126,7 +127,11 @@ export const getMetroInstance = async ( port: metroPort, projectRoot, }); - const config = await withRnHarness(projectMetroConfig, true)(); + const config = await withRnHarness( + projectMetroConfig, + true, + metroConfigEnhancer + )(); const reporter = withReporter(config); abortSignal.throwIfAborted(); diff --git a/packages/bundler-metro/src/types.ts b/packages/bundler-metro/src/types.ts index 3991a38a..ecda4491 100644 --- a/packages/bundler-metro/src/types.ts +++ b/packages/bundler-metro/src/types.ts @@ -13,6 +13,12 @@ export type MetroOptions = { projectRoot: string; harnessConfig: HarnessConfig; websocketEndpoints?: MetroWebSocketEndpoints; + /** + * `HarnessPlatform.metroConfigEnhancer` for the selected runner, if it sets + * one: a module specifier the bundler imports and runs against the composed + * Metro config so an out-of-tree platform can apply its own requirements. + */ + metroConfigEnhancer?: string; /** * Whether Jest is running in watch mode (`--watch` / `--watchAll`). Only * then does Metro need a file watcher; a one-shot run bundles once and diff --git a/packages/bundler-metro/src/withRnHarness.ts b/packages/bundler-metro/src/withRnHarness.ts index 71e4dab0..a8c0121b 100644 --- a/packages/bundler-metro/src/withRnHarness.ts +++ b/packages/bundler-metro/src/withRnHarness.ts @@ -16,6 +16,7 @@ import { getHarnessBlockList } from './metro-block-list.js'; import { getHarnessCacheStores } from './metro-cache.js'; import { getCappedMaxWorkers } from './metro-workers.js'; import { getHarnessResolver } from './resolvers/resolver.js'; +import type { MetroConfigEnhancer } from '@react-native-harness/platforms'; import type { NotReadOnly } from './utils.js'; const require = createRequire(import.meta.url); @@ -37,6 +38,7 @@ const getHarnessCacheVersion = (harnessConfig: Config): string => { export const withRnHarness = ( config: T | Promise, isInvokedByHarness = false, + metroConfigEnhancer?: string, ): (() => Promise) => { return async () => { if (!isInvokedByHarness) { @@ -183,6 +185,42 @@ export const withRnHarness = ( }); } + if (metroConfigEnhancer) { + return (await runMetroConfigEnhancer( + metroConfigEnhancer, + patchedConfig, + projectRoot + )) as T; + } + return patchedConfig as T; }; }; + +/** + * Imports the module the selected runner's `metroConfigEnhancer` points at and + * runs its default export against the config the harness has composed. + * + * This is how an out-of-tree platform (React Native Windows, macOS, …) supplies + * its own Metro requirements. `react-native start` gets them from + * `@react-native/community-cli-plugin`; the harness loads Metro's config + * directly and leaves it to the platform package. + */ +const runMetroConfigEnhancer = async ( + metroConfigEnhancer: string, + metroConfig: T, + projectRoot: string +): Promise => { + const enhancerModule = (await import(metroConfigEnhancer)) as { + default?: MetroConfigEnhancer; + }; + const enhance = enhancerModule.default; + + if (typeof enhance !== 'function') { + throw new Error( + `metroConfigEnhancer module "${metroConfigEnhancer}" has no default export function` + ); + } + + return (await enhance(metroConfig, { projectRoot })) as T; +}; diff --git a/packages/bundler-metro/tsconfig.json b/packages/bundler-metro/tsconfig.json index 5f3f6e8a..61e2dc6f 100644 --- a/packages/bundler-metro/tsconfig.json +++ b/packages/bundler-metro/tsconfig.json @@ -7,7 +7,7 @@ "path": "../runtime" }, { - "path": "../cache" + "path": "../platforms" }, { "path": "../config" @@ -15,6 +15,9 @@ { "path": "../tools" }, + { + "path": "../cache" + }, { "path": "../babel-preset" }, diff --git a/packages/bundler-metro/tsconfig.lib.json b/packages/bundler-metro/tsconfig.lib.json index 2eef96e4..034e6767 100644 --- a/packages/bundler-metro/tsconfig.lib.json +++ b/packages/bundler-metro/tsconfig.lib.json @@ -16,7 +16,7 @@ "path": "../runtime/tsconfig.lib.json" }, { - "path": "../cache/tsconfig.lib.json" + "path": "../platforms/tsconfig.lib.json" }, { "path": "../config/tsconfig.lib.json" @@ -24,6 +24,9 @@ { "path": "../tools/tsconfig.lib.json" }, + { + "path": "../cache/tsconfig.lib.json" + }, { "path": "../babel-preset/tsconfig.lib.json" } diff --git a/packages/config/src/__tests__/runner-schema.test.ts b/packages/config/src/__tests__/runner-schema.test.ts new file mode 100644 index 00000000..d34ebdd7 --- /dev/null +++ b/packages/config/src/__tests__/runner-schema.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { ConfigSchema } from '../types.js'; + +const baseConfig = { + entryPoint: './index.js', + appRegistryComponentName: 'App', +}; + +const runner = { + name: 'ios', + config: {}, + runner: 'file:///runner.js', + platformId: 'ios', +}; + +describe('ConfigSchema runner', () => { + it('preserves a platform-provided metroConfigEnhancer path', () => { + const parsed = ConfigSchema.parse({ + ...baseConfig, + runners: [ + { + ...runner, + metroConfigEnhancer: 'file:///pkg/dist/metro-config-enhancer.js', + }, + ], + }); + + expect(parsed.runners[0]?.metroConfigEnhancer).toBe( + 'file:///pkg/dist/metro-config-enhancer.js' + ); + }); + + it('leaves metroConfigEnhancer undefined when a runner does not set one', () => { + const parsed = ConfigSchema.parse({ ...baseConfig, runners: [runner] }); + + expect(parsed.runners[0]?.metroConfigEnhancer).toBeUndefined(); + }); + + it('rejects a non-string metroConfigEnhancer', () => { + expect(() => + ConfigSchema.parse({ + ...baseConfig, + runners: [{ ...runner, metroConfigEnhancer: 42 }], + }) + ).toThrow(); + }); +}); diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 3a4eb1d0..75fe369d 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -18,6 +18,12 @@ const RunnerSchema = z.object({ config: z.record(z.any()), runner: z.string(), cli: z.string().optional(), + // Module specifier whose default export adjusts the Metro config for this + // runner. Set by a platform factory (`HarnessPlatform.metroConfigEnhancer`); + // imported and run by the bundler while it composes the config. A bare + // `z.object()` strips unknown keys, so it has to be declared here to survive + // config parsing. + metroConfigEnhancer: z.string().optional(), platformId: z.string(), }); diff --git a/packages/jest/src/harness-session.ts b/packages/jest/src/harness-session.ts index aab2a6ef..604dfbd2 100644 --- a/packages/jest/src/harness-session.ts +++ b/packages/jest/src/harness-session.ts @@ -623,6 +623,7 @@ export const createHarnessSession = async ( { projectRoot, harnessConfig: runtimeConfig, + metroConfigEnhancer: platform.metroConfigEnhancer, websocketEndpoints: { [HARNESS_BRIDGE_PATH]: bridge.ws as unknown as MetroWebSocketEndpoint, }, diff --git a/packages/platforms/src/index.ts b/packages/platforms/src/index.ts index c5561b7e..b4446671 100644 --- a/packages/platforms/src/index.ts +++ b/packages/platforms/src/index.ts @@ -19,6 +19,8 @@ export type { HarnessPlatform, HarnessPlatformInitOptions, HarnessPlatformRunnerFactory, + MetroConfigEnhancer, + MetroConfigEnhancerContext, CollectNativeCoverageOptions, HarnessPlatformRunner, RunTarget, diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index 85006436..ebce2015 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -169,6 +169,32 @@ export type HarnessCliModule = { commands: HarnessCliCommand[]; }; +/** + * Context handed to a platform's Metro config enhancer. + */ +export type MetroConfigEnhancerContext = { + /** Absolute path of the project whose Metro config is being composed. */ + projectRoot: string; +}; + +/** + * The default export of the module a platform points `metroConfigEnhancer` at. + * + * The harness imports that module while composing the Metro config for the + * selected runner and calls the enhancer with the config it has built so far, + * plus the project context. The platform returns a further-adjusted config. + * + * This is the seam where an out-of-tree platform (React Native Windows, macOS, + * …) applies its own Metro requirements — the `react-native` package redirect, + * extra `resolver.platforms` entries, its `InitializeCore` — that + * `react-native start` would get from `@react-native/community-cli-plugin`, + * without the bundler needing to know the platform exists. + */ +export type MetroConfigEnhancer = ( + metroConfig: TMetroConfig, + context: MetroConfigEnhancerContext +) => TMetroConfig | Promise; + export type HarnessPlatform> = { name: string; config: TConfig; @@ -176,6 +202,12 @@ export type HarnessPlatform> = { cli?: string; platformId: string; getResourceLockKey?: () => string | Promise; + /** + * Module specifier (as `import.meta.resolve('./…')` produces) whose default + * export is a {@link MetroConfigEnhancer}. Imported and run in the project's + * context while the harness composes the Metro config for this runner. + */ + metroConfigEnhancer?: string; }; export type AndroidEmulatorRunTarget = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9912625e..6a95e2e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,6 +269,9 @@ importers: '@react-native-harness/config': specifier: workspace:* version: link:../config + '@react-native-harness/platforms': + specifier: workspace:* + version: link:../platforms '@react-native-harness/runtime': specifier: workspace:* version: link:../runtime @@ -4114,6 +4117,7 @@ packages: cron-parser@4.9.0: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} + deprecated: v4 is no longer maintained, upgrade to v5 cross-fetch@3.2.0: resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} @@ -4654,6 +4658,7 @@ packages: eslint@9.29.0: resolution: {integrity: sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' From c63a8abb8d1053735321379fedbf3f36de752366 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 2 Sep 2026 08:40:27 +0200 Subject: [PATCH 2/3] docs: describe the Metro config enhancer in Harness terms Reword the metroConfigEnhancer comments and the version plan so they explain the seam itself rather than referencing another tool's Metro wiring, and drop the schema note that only justified the field's placement. --- .nx/version-plans/version-plan-1788287414882.md | 6 +++++- packages/bundler-metro/src/types.ts | 2 +- packages/bundler-metro/src/withRnHarness.ts | 11 ++++++----- packages/config/src/types.ts | 4 +--- packages/platforms/src/types.ts | 14 +++++++------- 5 files changed, 20 insertions(+), 17 deletions(-) diff --git a/.nx/version-plans/version-plan-1788287414882.md b/.nx/version-plans/version-plan-1788287414882.md index c6831030..2bec7714 100644 --- a/.nx/version-plans/version-plan-1788287414882.md +++ b/.nx/version-plans/version-plan-1788287414882.md @@ -2,4 +2,8 @@ __default__: minor --- -A platform package can now adjust the Metro config the harness composes, via a `metroConfigEnhancer` module it points at. The harness imports that module while building the config for the selected runner and lets it apply further changes (a `resolver` redirect, extra `resolver.platforms` entries, its own `InitializeCore`, …). This is the seam an out-of-tree platform such as React Native Windows or macOS would use for the Metro wiring `react-native start` gets from `@react-native/community-cli-plugin`, which the harness bypasses. Nothing changes for platforms that do not set one. +Platform packages can now adjust the Metro configuration Harness composes for +their runner, through a `metroConfigEnhancer` module they point at. The bundler +wiring a platform's runtime needs — module resolution redirects, additional +resolver platforms, its own core initialization — lives in the platform package +instead of in the bundler. Nothing changes for platforms that do not set one. diff --git a/packages/bundler-metro/src/types.ts b/packages/bundler-metro/src/types.ts index ecda4491..2b2e722b 100644 --- a/packages/bundler-metro/src/types.ts +++ b/packages/bundler-metro/src/types.ts @@ -16,7 +16,7 @@ export type MetroOptions = { /** * `HarnessPlatform.metroConfigEnhancer` for the selected runner, if it sets * one: a module specifier the bundler imports and runs against the composed - * Metro config so an out-of-tree platform can apply its own requirements. + * Metro config so the platform can apply the wiring its runtime needs. */ metroConfigEnhancer?: string; /** diff --git a/packages/bundler-metro/src/withRnHarness.ts b/packages/bundler-metro/src/withRnHarness.ts index a8c0121b..35bebf07 100644 --- a/packages/bundler-metro/src/withRnHarness.ts +++ b/packages/bundler-metro/src/withRnHarness.ts @@ -199,12 +199,13 @@ export const withRnHarness = ( /** * Imports the module the selected runner's `metroConfigEnhancer` points at and - * runs its default export against the config the harness has composed. + * runs its default export against the config Harness has composed. * - * This is how an out-of-tree platform (React Native Windows, macOS, …) supplies - * its own Metro requirements. `react-native start` gets them from - * `@react-native/community-cli-plugin`; the harness loads Metro's config - * directly and leaves it to the platform package. + * A platform whose runtime needs its own bundler wiring — module resolution + * redirects, additional `resolver.platforms` entries, its own core + * initialization — declares it here rather than in this package, so nothing in + * the bundler has to know which platforms exist. It runs last, on the fully + * composed config, and whatever it returns is what Metro is started with. */ const runMetroConfigEnhancer = async ( metroConfigEnhancer: string, diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 75fe369d..346afdc4 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -20,9 +20,7 @@ const RunnerSchema = z.object({ cli: z.string().optional(), // Module specifier whose default export adjusts the Metro config for this // runner. Set by a platform factory (`HarnessPlatform.metroConfigEnhancer`); - // imported and run by the bundler while it composes the config. A bare - // `z.object()` strips unknown keys, so it has to be declared here to survive - // config parsing. + // imported and run by the bundler while it composes the config. metroConfigEnhancer: z.string().optional(), platformId: z.string(), }); diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index ebce2015..ceaec49d 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -180,15 +180,15 @@ export type MetroConfigEnhancerContext = { /** * The default export of the module a platform points `metroConfigEnhancer` at. * - * The harness imports that module while composing the Metro config for the + * Harness imports that module while composing the Metro config for the * selected runner and calls the enhancer with the config it has built so far, * plus the project context. The platform returns a further-adjusted config. * - * This is the seam where an out-of-tree platform (React Native Windows, macOS, - * …) applies its own Metro requirements — the `react-native` package redirect, - * extra `resolver.platforms` entries, its `InitializeCore` — that - * `react-native start` would get from `@react-native/community-cli-plugin`, - * without the bundler needing to know the platform exists. + * This is where a platform declares the bundler configuration its own runtime + * needs — module resolution redirects, additional `resolver.platforms` + * entries, its own core initialization — so that wiring lives in the platform + * package instead of in the bundler, which stays unaware of which platforms + * exist. */ export type MetroConfigEnhancer = ( metroConfig: TMetroConfig, @@ -205,7 +205,7 @@ export type HarnessPlatform> = { /** * Module specifier (as `import.meta.resolve('./…')` produces) whose default * export is a {@link MetroConfigEnhancer}. Imported and run in the project's - * context while the harness composes the Metro config for this runner. + * context while Harness composes the Metro config for this runner. */ metroConfigEnhancer?: string; }; From bc55b97b677a8532a3d0a968f0861df94408c293 Mon Sep 17 00:00:00 2001 From: Stanislav Doskalenko Date: Wed, 2 Sep 2026 10:31:34 -0700 Subject: [PATCH 3/3] refactor(bundler-metro): own the MetroConfigEnhancer contract, widen its context Move MetroConfigEnhancer / MetroConfigEnhancerContext from @react-native-harness/platforms into @react-native-harness/bundler-metro, which has metro-config as a peer and is the package that imports and runs the module. platforms keeps only `metroConfigEnhancer?: string` on HarnessPlatform (a bundler-agnostic module specifier). This drops bundler-metro's new dependency on platforms and both tsconfig reference edits. Widen the enhancer context from { projectRoot } to also carry platformId and the runner's own config, both already available at the harness-session call site. A real enhancer needs to know which platform it runs for without re-deriving it from projectRoot; widening an unreleased type now avoids a compat conversation per field later. Drop the `= unknown` default on the metro config parameter: the type now names MetroConfig directly, so `const enhance: MetroConfigEnhancer = ...` types the config instead of silently yielding unknown. --- packages/bundler-metro/package.json | 1 - .../src/__tests__/withRnHarness.test.ts | 26 ++++++++---- packages/bundler-metro/src/factory.ts | 8 ++++ packages/bundler-metro/src/types.ts | 41 +++++++++++++++++++ packages/bundler-metro/src/withRnHarness.ts | 32 +++++++++++---- packages/bundler-metro/tsconfig.json | 5 +-- packages/bundler-metro/tsconfig.lib.json | 5 +-- packages/jest/src/harness-session.ts | 2 + packages/platforms/src/index.ts | 2 - packages/platforms/src/types.ts | 31 ++------------ pnpm-lock.yaml | 5 --- 11 files changed, 97 insertions(+), 61 deletions(-) diff --git a/packages/bundler-metro/package.json b/packages/bundler-metro/package.json index ff915275..18d10bcf 100644 --- a/packages/bundler-metro/package.json +++ b/packages/bundler-metro/package.json @@ -21,7 +21,6 @@ "@react-native-harness/cache": "workspace:*", "@react-native-harness/tools": "workspace:*", "@react-native-harness/config": "workspace:*", - "@react-native-harness/platforms": "workspace:*", "@react-native-harness/runtime": "workspace:*", "connect": "^3.7.0", "nocache": "^4.0.0", diff --git a/packages/bundler-metro/src/__tests__/withRnHarness.test.ts b/packages/bundler-metro/src/__tests__/withRnHarness.test.ts index 08292ad2..38a7ceb3 100644 --- a/packages/bundler-metro/src/__tests__/withRnHarness.test.ts +++ b/packages/bundler-metro/src/__tests__/withRnHarness.test.ts @@ -314,8 +314,11 @@ describe('withRnHarness', () => { describe('metroConfigEnhancer', () => { // A `metroConfigEnhancer` module as a data: URL, so `withRnHarness`'s // `await import()` has something real to load without a fixture file. - const enhancerModule = (body: string) => - `data:text/javascript,${encodeURIComponent(body)}`; + const enhancerRef = (body: string) => ({ + module: `data:text/javascript,${encodeURIComponent(body)}`, + platformId: 'windows', + platformConfig: { appName: 'Demo' }, + }); it('returns the composed config untouched when no enhancer is set', async () => { const { withRnHarness } = await import('../withRnHarness.js'); @@ -329,11 +332,11 @@ describe('withRnHarness', () => { expect(config.cacheVersion).toMatch(/^react-native-harness:/); }); - it('runs the enhancer against the composed config, in the project context', async () => { + it('runs the enhancer against the composed config, with the runner context', async () => { const { withRnHarness } = await import('../withRnHarness.js'); - const enhancer = enhancerModule( - 'export default (config, context) => ({ ...config, enhanced: { projectRoot: context.projectRoot, sawCacheVersion: config.cacheVersion } });', + const enhancer = enhancerRef( + 'export default (config, context) => ({ ...config, enhanced: { projectRoot: context.projectRoot, platformId: context.platformId, platformConfig: context.platformConfig, sawCacheVersion: config.cacheVersion } });', ); const config = (await withRnHarness( @@ -341,10 +344,17 @@ describe('withRnHarness', () => { true, enhancer, )()) as unknown as MinimalMetroConfig & { - enhanced?: { projectRoot: string; sawCacheVersion: string }; + enhanced?: { + projectRoot: string; + platformId: string; + platformConfig: { appName: string }; + sawCacheVersion: string; + }; }; expect(config.enhanced?.projectRoot).toBe('/tmp/app'); + expect(config.enhanced?.platformId).toBe('windows'); + expect(config.enhanced?.platformConfig).toEqual({ appName: 'Demo' }); // The enhancer saw the config the harness had already composed. expect(config.enhanced?.sawCacheVersion).toMatch(/^react-native-harness:/); }); @@ -352,7 +362,7 @@ describe('withRnHarness', () => { it('awaits an async enhancer', async () => { const { withRnHarness } = await import('../withRnHarness.js'); - const enhancer = enhancerModule( + const enhancer = enhancerRef( 'export default async (config) => ({ ...config, enhanced: true });', ); @@ -372,7 +382,7 @@ describe('withRnHarness', () => { withRnHarness( { projectRoot: '/tmp/app', serializer: {} }, true, - enhancerModule('export const notDefault = 1;'), + enhancerRef('export const notDefault = 1;'), )(), ).rejects.toThrow(/no default export function/); }); diff --git a/packages/bundler-metro/src/factory.ts b/packages/bundler-metro/src/factory.ts index 8f1c2fab..d12a8abb 100644 --- a/packages/bundler-metro/src/factory.ts +++ b/packages/bundler-metro/src/factory.ts @@ -97,6 +97,8 @@ export const getMetroInstance = async ( projectRoot, harnessConfig, metroConfigEnhancer, + platformId, + platformConfig, websocketEndpoints = {}, watchMode = false, } = options; @@ -131,6 +133,12 @@ export const getMetroInstance = async ( projectMetroConfig, true, metroConfigEnhancer + ? { + module: metroConfigEnhancer, + platformId: platformId ?? '', + platformConfig, + } + : undefined )(); const reporter = withReporter(config); diff --git a/packages/bundler-metro/src/types.ts b/packages/bundler-metro/src/types.ts index 2b2e722b..897e1432 100644 --- a/packages/bundler-metro/src/types.ts +++ b/packages/bundler-metro/src/types.ts @@ -1,9 +1,45 @@ import type { Server as HttpServer } from 'node:http'; import type { Server as HttpsServer } from 'node:https'; import type { RunServerOptions } from 'metro'; +import type { MetroConfig } from 'metro-config'; import type { Reporter } from './reporter.js'; import type { Config as HarnessConfig } from '@react-native-harness/config'; +/** + * Context handed to a platform's Metro config enhancer alongside the composed + * config. `TPlatformConfig` is the shape of the runner's own `config` block; + * an enhancer that lives in a platform package types it as that platform's + * config. + */ +export type MetroConfigEnhancerContext = { + /** Absolute path of the project whose Metro config is being composed. */ + projectRoot: string; + /** `platformId` of the runner this config is being composed for. */ + platformId: string; + /** The runner's own `config` block, passed through verbatim. */ + platformConfig: TPlatformConfig; +}; + +/** + * The default export of the module a platform points `metroConfigEnhancer` at. + * + * The bundler imports that module while composing the Metro config for the + * selected runner and calls the enhancer with the config it has built so far + * plus {@link MetroConfigEnhancerContext}. The enhancer returns a + * further-adjusted config. + * + * This is where a platform declares the bundler configuration its own runtime + * needs — module resolution redirects, additional `resolver.platforms` + * entries, its own core initialization — so that wiring lives in the platform + * package instead of in the bundler, which stays unaware of which platforms + * exist. It runs last, on the fully composed config; whatever it returns is + * what Metro is started with. + */ +export type MetroConfigEnhancer = ( + metroConfig: MetroConfig, + context: MetroConfigEnhancerContext +) => MetroConfig | Promise; + export type MetroWebSocketEndpoints = NonNullable< RunServerOptions['websocketEndpoints'] >; @@ -17,8 +53,13 @@ export type MetroOptions = { * `HarnessPlatform.metroConfigEnhancer` for the selected runner, if it sets * one: a module specifier the bundler imports and runs against the composed * Metro config so the platform can apply the wiring its runtime needs. + * `platformId` and `platformConfig` are forwarded to it as context. */ metroConfigEnhancer?: string; + /** `platformId` of the selected runner. Forwarded to `metroConfigEnhancer`. */ + platformId?: string; + /** The selected runner's `config` block. Forwarded to `metroConfigEnhancer`. */ + platformConfig?: unknown; /** * Whether Jest is running in watch mode (`--watch` / `--watchAll`). Only * then does Metro need a file watcher; a one-shot run bundles once and diff --git a/packages/bundler-metro/src/withRnHarness.ts b/packages/bundler-metro/src/withRnHarness.ts index 35bebf07..e163fbdf 100644 --- a/packages/bundler-metro/src/withRnHarness.ts +++ b/packages/bundler-metro/src/withRnHarness.ts @@ -16,7 +16,7 @@ import { getHarnessBlockList } from './metro-block-list.js'; import { getHarnessCacheStores } from './metro-cache.js'; import { getCappedMaxWorkers } from './metro-workers.js'; import { getHarnessResolver } from './resolvers/resolver.js'; -import type { MetroConfigEnhancer } from '@react-native-harness/platforms'; +import type { MetroConfigEnhancer } from './types.js'; import type { NotReadOnly } from './utils.js'; const require = createRequire(import.meta.url); @@ -35,10 +35,20 @@ const getHarnessCacheVersion = (harnessConfig: Config): string => { : `react-native-harness:${version}`; }; +/** + * The selected runner's `metroConfigEnhancer`, resolved to a module specifier, + * plus the runner context the bundler forwards to it. + */ +export type MetroConfigEnhancerRef = { + module: string; + platformId: string; + platformConfig: unknown; +}; + export const withRnHarness = ( config: T | Promise, isInvokedByHarness = false, - metroConfigEnhancer?: string, + enhancer?: MetroConfigEnhancerRef, ): (() => Promise) => { return async () => { if (!isInvokedByHarness) { @@ -185,9 +195,9 @@ export const withRnHarness = ( }); } - if (metroConfigEnhancer) { + if (enhancer) { return (await runMetroConfigEnhancer( - metroConfigEnhancer, + enhancer, patchedConfig, projectRoot )) as T; @@ -208,20 +218,24 @@ export const withRnHarness = ( * composed config, and whatever it returns is what Metro is started with. */ const runMetroConfigEnhancer = async ( - metroConfigEnhancer: string, + enhancer: MetroConfigEnhancerRef, metroConfig: T, projectRoot: string ): Promise => { - const enhancerModule = (await import(metroConfigEnhancer)) as { - default?: MetroConfigEnhancer; + const enhancerModule = (await import(enhancer.module)) as { + default?: MetroConfigEnhancer; }; const enhance = enhancerModule.default; if (typeof enhance !== 'function') { throw new Error( - `metroConfigEnhancer module "${metroConfigEnhancer}" has no default export function` + `metroConfigEnhancer module "${enhancer.module}" has no default export function` ); } - return (await enhance(metroConfig, { projectRoot })) as T; + return (await enhance(metroConfig, { + projectRoot, + platformId: enhancer.platformId, + platformConfig: enhancer.platformConfig, + })) as T; }; diff --git a/packages/bundler-metro/tsconfig.json b/packages/bundler-metro/tsconfig.json index 61e2dc6f..5f3f6e8a 100644 --- a/packages/bundler-metro/tsconfig.json +++ b/packages/bundler-metro/tsconfig.json @@ -7,7 +7,7 @@ "path": "../runtime" }, { - "path": "../platforms" + "path": "../cache" }, { "path": "../config" @@ -15,9 +15,6 @@ { "path": "../tools" }, - { - "path": "../cache" - }, { "path": "../babel-preset" }, diff --git a/packages/bundler-metro/tsconfig.lib.json b/packages/bundler-metro/tsconfig.lib.json index 034e6767..2eef96e4 100644 --- a/packages/bundler-metro/tsconfig.lib.json +++ b/packages/bundler-metro/tsconfig.lib.json @@ -16,7 +16,7 @@ "path": "../runtime/tsconfig.lib.json" }, { - "path": "../platforms/tsconfig.lib.json" + "path": "../cache/tsconfig.lib.json" }, { "path": "../config/tsconfig.lib.json" @@ -24,9 +24,6 @@ { "path": "../tools/tsconfig.lib.json" }, - { - "path": "../cache/tsconfig.lib.json" - }, { "path": "../babel-preset/tsconfig.lib.json" } diff --git a/packages/jest/src/harness-session.ts b/packages/jest/src/harness-session.ts index 604dfbd2..528e7dcc 100644 --- a/packages/jest/src/harness-session.ts +++ b/packages/jest/src/harness-session.ts @@ -624,6 +624,8 @@ export const createHarnessSession = async ( projectRoot, harnessConfig: runtimeConfig, metroConfigEnhancer: platform.metroConfigEnhancer, + platformId: platform.platformId, + platformConfig: platform.config, websocketEndpoints: { [HARNESS_BRIDGE_PATH]: bridge.ws as unknown as MetroWebSocketEndpoint, }, diff --git a/packages/platforms/src/index.ts b/packages/platforms/src/index.ts index b4446671..c5561b7e 100644 --- a/packages/platforms/src/index.ts +++ b/packages/platforms/src/index.ts @@ -19,8 +19,6 @@ export type { HarnessPlatform, HarnessPlatformInitOptions, HarnessPlatformRunnerFactory, - MetroConfigEnhancer, - MetroConfigEnhancerContext, CollectNativeCoverageOptions, HarnessPlatformRunner, RunTarget, diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index ceaec49d..7f4dd932 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -169,32 +169,6 @@ export type HarnessCliModule = { commands: HarnessCliCommand[]; }; -/** - * Context handed to a platform's Metro config enhancer. - */ -export type MetroConfigEnhancerContext = { - /** Absolute path of the project whose Metro config is being composed. */ - projectRoot: string; -}; - -/** - * The default export of the module a platform points `metroConfigEnhancer` at. - * - * Harness imports that module while composing the Metro config for the - * selected runner and calls the enhancer with the config it has built so far, - * plus the project context. The platform returns a further-adjusted config. - * - * This is where a platform declares the bundler configuration its own runtime - * needs — module resolution redirects, additional `resolver.platforms` - * entries, its own core initialization — so that wiring lives in the platform - * package instead of in the bundler, which stays unaware of which platforms - * exist. - */ -export type MetroConfigEnhancer = ( - metroConfig: TMetroConfig, - context: MetroConfigEnhancerContext -) => TMetroConfig | Promise; - export type HarnessPlatform> = { name: string; config: TConfig; @@ -204,8 +178,9 @@ export type HarnessPlatform> = { getResourceLockKey?: () => string | Promise; /** * Module specifier (as `import.meta.resolve('./…')` produces) whose default - * export is a {@link MetroConfigEnhancer}. Imported and run in the project's - * context while Harness composes the Metro config for this runner. + * export adjusts the Metro config Harness composes for this runner. Imported + * and run in the project's context by the bundler; the contract that module + * must satisfy is `MetroConfigEnhancer` in `@react-native-harness/bundler-metro`. */ metroConfigEnhancer?: string; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a95e2e4..9912625e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,9 +269,6 @@ importers: '@react-native-harness/config': specifier: workspace:* version: link:../config - '@react-native-harness/platforms': - specifier: workspace:* - version: link:../platforms '@react-native-harness/runtime': specifier: workspace:* version: link:../runtime @@ -4117,7 +4114,6 @@ packages: cron-parser@4.9.0: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} - deprecated: v4 is no longer maintained, upgrade to v5 cross-fetch@3.2.0: resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} @@ -4658,7 +4654,6 @@ packages: eslint@9.29.0: resolution: {integrity: sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*'