diff --git a/.nx/version-plans/version-plan-1788287414882.md b/.nx/version-plans/version-plan-1788287414882.md new file mode 100644 index 00000000..2bec7714 --- /dev/null +++ b/.nx/version-plans/version-plan-1788287414882.md @@ -0,0 +1,9 @@ +--- +__default__: minor +--- + +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/__tests__/withRnHarness.test.ts b/packages/bundler-metro/src/__tests__/withRnHarness.test.ts index 4f64046f..38a7ceb3 100644 --- a/packages/bundler-metro/src/__tests__/withRnHarness.test.ts +++ b/packages/bundler-metro/src/__tests__/withRnHarness.test.ts @@ -310,4 +310,81 @@ 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 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'); + + 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, with the runner context', async () => { + const { withRnHarness } = await import('../withRnHarness.js'); + + 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( + { projectRoot: '/tmp/app', serializer: {} }, + true, + enhancer, + )()) as unknown as MinimalMetroConfig & { + 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:/); + }); + + it('awaits an async enhancer', async () => { + const { withRnHarness } = await import('../withRnHarness.js'); + + const enhancer = enhancerRef( + '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, + 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 2203bb40..d12a8abb 100644 --- a/packages/bundler-metro/src/factory.ts +++ b/packages/bundler-metro/src/factory.ts @@ -96,6 +96,9 @@ export const getMetroInstance = async ( const { projectRoot, harnessConfig, + metroConfigEnhancer, + platformId, + platformConfig, websocketEndpoints = {}, watchMode = false, } = options; @@ -126,7 +129,17 @@ export const getMetroInstance = async ( port: metroPort, projectRoot, }); - const config = await withRnHarness(projectMetroConfig, true)(); + const config = await withRnHarness( + projectMetroConfig, + true, + metroConfigEnhancer + ? { + module: metroConfigEnhancer, + platformId: platformId ?? '', + platformConfig, + } + : undefined + )(); const reporter = withReporter(config); abortSignal.throwIfAborted(); diff --git a/packages/bundler-metro/src/types.ts b/packages/bundler-metro/src/types.ts index 3991a38a..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'] >; @@ -13,6 +49,17 @@ 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 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 71e4dab0..e163fbdf 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 './types.js'; import type { NotReadOnly } from './utils.js'; const require = createRequire(import.meta.url); @@ -34,9 +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, + enhancer?: MetroConfigEnhancerRef, ): (() => Promise) => { return async () => { if (!isInvokedByHarness) { @@ -183,6 +195,47 @@ export const withRnHarness = ( }); } + if (enhancer) { + return (await runMetroConfigEnhancer( + enhancer, + 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 Harness has composed. + * + * 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 ( + enhancer: MetroConfigEnhancerRef, + metroConfig: T, + projectRoot: string +): Promise => { + const enhancerModule = (await import(enhancer.module)) as { + default?: MetroConfigEnhancer; + }; + const enhance = enhancerModule.default; + + if (typeof enhance !== 'function') { + throw new Error( + `metroConfigEnhancer module "${enhancer.module}" has no default export function` + ); + } + + return (await enhance(metroConfig, { + projectRoot, + platformId: enhancer.platformId, + platformConfig: enhancer.platformConfig, + })) as T; +}; 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..346afdc4 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -18,6 +18,10 @@ 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. + 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..528e7dcc 100644 --- a/packages/jest/src/harness-session.ts +++ b/packages/jest/src/harness-session.ts @@ -623,6 +623,9 @@ 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/types.ts b/packages/platforms/src/types.ts index 85006436..7f4dd932 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -176,6 +176,13 @@ export type HarnessPlatform> = { cli?: string; platformId: string; getResourceLockKey?: () => string | Promise; + /** + * Module specifier (as `import.meta.resolve('./…')` produces) whose default + * 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; }; export type AndroidEmulatorRunTarget = {