Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .nx/version-plans/version-plan-1788287414882.md
Original file line number Diff line number Diff line change
@@ -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.
77 changes: 77 additions & 0 deletions packages/bundler-metro/src/__tests__/withRnHarness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
});
15 changes: 14 additions & 1 deletion packages/bundler-metro/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ export const getMetroInstance = async (
const {
projectRoot,
harnessConfig,
metroConfigEnhancer,
platformId,
platformConfig,
websocketEndpoints = {},
watchMode = false,
} = options;
Expand Down Expand Up @@ -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();
Expand Down
47 changes: 47 additions & 0 deletions packages/bundler-metro/src/types.ts
Original file line number Diff line number Diff line change
@@ -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<TPlatformConfig = unknown> = {
/** 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<TPlatformConfig = unknown> = (
metroConfig: MetroConfig,
context: MetroConfigEnhancerContext<TPlatformConfig>
) => MetroConfig | Promise<MetroConfig>;

export type MetroWebSocketEndpoints = NonNullable<
RunServerOptions['websocketEndpoints']
>;
Expand All @@ -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
Expand Down
53 changes: 53 additions & 0 deletions packages/bundler-metro/src/withRnHarness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 = <T extends MetroConfig>(
config: T | Promise<T>,
isInvokedByHarness = false,
enhancer?: MetroConfigEnhancerRef,
): (() => Promise<T>) => {
return async () => {
if (!isInvokedByHarness) {
Expand Down Expand Up @@ -183,6 +195,47 @@ export const withRnHarness = <T extends MetroConfig>(
});
}

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 <T extends MetroConfig>(
enhancer: MetroConfigEnhancerRef,
metroConfig: T,
projectRoot: string
): Promise<T> => {
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;
};
47 changes: 47 additions & 0 deletions packages/config/src/__tests__/runner-schema.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
4 changes: 4 additions & 0 deletions packages/config/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});

Expand Down
3 changes: 3 additions & 0 deletions packages/jest/src/harness-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
7 changes: 7 additions & 0 deletions packages/platforms/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,13 @@ export type HarnessPlatform<TConfig = Record<string, unknown>> = {
cli?: string;
platformId: string;
getResourceLockKey?: () => string | Promise<string>;
/**
* 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 = {
Expand Down
Loading