Skip to content

feat(platforms): add a per-platform metroConfigEnhancer hook - #190

Merged
V3RON merged 3 commits into
callstackincubator:mainfrom
StasDoskalenko:feat/metro-config-enhancer
Sep 2, 2026
Merged

feat(platforms): add a per-platform metroConfigEnhancer hook#190
V3RON merged 3 commits into
callstackincubator:mainfrom
StasDoskalenko:feat/metro-config-enhancer

Conversation

@StasDoskalenko

Copy link
Copy Markdown
Contributor

Description

Adds a per-platform metroConfigEnhancer hook so a platform package can adjust the Metro config the harness composes, without the bundler needing to know the platform exists.

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 Libraries/Core/InitializeCore, and the extra resolver.platforms entries. A project targeting one of those currently has to reproduce that wiring by hand in its metro.config.js.

This gives the platform a seam to do it itself:

// HarnessPlatform
metroConfigEnhancer?: string; // a module specifier, like `runner` / `cli`

// the module's default export
type MetroConfigEnhancer<TMetroConfig> = (
  metroConfig: TMetroConfig,
  context: { projectRoot: string }
) => TMetroConfig | Promise<TMetroConfig>;

A platform factory sets it with import.meta.resolve('./metro-config-enhancer.js'), the same way it already sets runner. Nothing changes for a platform that doesn't set one.

Related Issue

Comes out of the review discussion on #187 (React Native Windows support), where the suggestion was to move the Metro-config augmentations into the platform packages instead of teaching bundler-metro about specific platforms — "expose an enhanceMetroConfig method that would be called when a platform declares one". This PR is that mechanism on its own; the RNW platform package that uses it lands separately.

Happy to open a tracking issue if you'd prefer one on file.

Context

  • The enhancer runs last, once withRnHarness has finished composing the config, so it sees the harness resolver, cache config, serializer, everything, and returns a further-adjusted config.
  • It runs only for the selected runnerharness-session already has the resolved runner right before it initializes Metro, so it just passes platform.metroConfigEnhancer down through MetroOptions.
  • It's a module path, not a function on the config object, for two reasons: it matches the existing runner / cli fields, and RunnerSchema is a bare z.object() that strips unknown keys, so a function would be dropped during config parsing.
  • @react-native-harness/bundler-metro gains a workspace dependency on @react-native-harness/platforms for the MetroConfigEnhancer type (type-only import).

Additive and opt-in: no platform in this repo sets metroConfigEnhancer, so existing runs produce an identical Metro config.

Testing

  • packages/config: new runner-schema.test.ts — the enhancer path survives ConfigSchema.parse, is optional, and a non-string is rejected.
  • packages/bundler-metro: withRnHarness.test.ts — with no enhancer the composed config is returned untouched; with one, it runs against the composed config and receives { projectRoot }; an async enhancer is awaited; a module with no default export function throws.
  • nx run-many -t typecheck build lint pass for the affected projects.

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<metroConfig>

`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`
@vercel

vercel Bot commented Sep 1, 2026

Copy link
Copy Markdown

@StasDoskalenko is attempting to deploy a commit to the Callstack Team on Vercel.

A member of the Team first needs to authorize it.

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.

@V3RON V3RON left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The mechanism is sound and genuinely additive — one call site, one consumer, no dependency cycle, and bundler-metro has no existing platform-specific config logic left stranded by it. Running the enhancer last, on the fully composed config, is the right call: the platform sees the real resolver, cache config and serializer rather than a half-built config.

I pushed one commit (c63a8ab) rewording the comments and the version plan. They described the hook by reference to how another tool wires Metro, which dates the comments to a detail outside this project and doesn't tell a reader what the seam is for. They now describe it in Harness terms — a platform declaring the bundler configuration its own runtime needs, so that wiring lives in the platform package instead of the bundler. Also dropped the note in RunnerSchema explaining that z.object() strips unknown keys: true, but it's the reason every field in that schema is declared, so it reads as justification rather than information.

Two non-blocking things worth considering, either here or in the follow-up that actually uses this:

The enhancer can't see the platform's own config. MetroConfigEnhancerContext is { projectRoot } only, but harness-session has platform.config and platform.platformId right there at the call site. A real enhancer will likely need to know where its platform package lives or which variant is being targeted, and would otherwise have to re-derive that from projectRoot. Worth settling while the type is still unreleased — widening the context later is additive, but each field added after release is a compat conversation.

MetroConfigEnhancer<TMetroConfig = unknown> defaults to a type you can't use. Writing the natural const enhance: MetroConfigEnhancer = (config) => ... yields unknown and forces casts at every access. The generic only exists because @react-native-harness/platforms can't name MetroConfig — its deps are tools and tslib. Cleanest fix is to move the function type into @react-native-harness/bundler-metro, which already has metro-config as a peer dependency and is the package that imports and calls the module: platforms keeps declaring "this field is a module specifier" (bundler-agnostic, correct there), bundler-metro declares the contract that module must satisfy. That also drops the new @react-native-harness/platforms dependency and both tsconfig reference edits from this PR, since the type import was their only reason to exist. Smaller alternative: keep it in platforms but delete the = unknown default, so forgetting the type argument is a compile error instead of a silent unknown.

One thing to be aware of rather than fix: the enhancer receives the composed config with resolver.resolveRequest already holding the harness composite resolver (entry-point redirect, @jest/globals mock, jsx-runtime, tsconfig paths). A platform that assigns that field without delegating to the function it was handed will silently lose all four — and a resolver redirect is exactly what the first consumer of this hook will want. Trusting platform authors here is reasonable, but it's worth a line in the docs when the first platform ships one.

…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.
@StasDoskalenko

Copy link
Copy Markdown
Contributor Author

@V3RON nice! Thanks for the feedback.
Both points resolved. Moved the type into bundler-metro (drops the platforms dep and both tsconfig edits), and widened the context to carry platformId and the runner's config. Also removed the unknown default now that the type can name MetroConfig. Resolver-delegation note will go in the windows docs when the first enhancer ships.

@V3RON
V3RON merged commit 2326a51 into callstackincubator:main Sep 2, 2026
1 of 2 checks passed
StasDoskalenko added a commit to StasDoskalenko/react-native-harness that referenced this pull request Sep 3, 2026
Brings in the per-platform metroConfigEnhancer hook (callstackincubator#190). The fork's
out-of-tree platform wiring in withRnHarness / metro-platforms stays as is;
the enhancer mechanism lands alongside it, additive and with no consumer yet.

Conflicts:
- bundler-metro/src/withRnHarness.ts: keep both the metro-platforms imports
  and the new MetroConfigEnhancer type import.
- bundler-metro/src/__tests__/withRnHarness.test.ts: keep the out-of-tree
  platform tests and the new metroConfigEnhancer describe block.
- config/src/__tests__/runner-schema.test.ts: keep both the getResourceLockKey
  and metroConfigEnhancer test blocks.
StasDoskalenko added a commit to StasDoskalenko/react-native-harness that referenced this pull request Sep 3, 2026
Move the out-of-tree platform Metro wiring out of bundler-metro and into
the platform-windows package, behind the metroConfigEnhancer hook (callstackincubator#190).

bundler-metro no longer reads @react-native-community/cli-config to detect
out-of-tree platforms, and withRnHarness drops the react-native ->
platform-package redirect, the extra resolver.platforms entries, and the
platform InitializeCore append. That logic now lives in
platform-windows/src/metro-config-enhancer.ts, which windowsPlatform()
points its metroConfigEnhancer at. withRnHarness returns to its upstream
shape.

- delete bundler-metro/src/metro-platforms.ts + its test
- drop the @react-native-community/cli-config optional peer from bundler-metro
- platform-windows: metro-config-enhancer.ts (redirect + resolver.platforms
  + RNW InitializeCore) + tests; factory sets metroConfigEnhancer
- export MetroConfigEnhancer / MetroConfigEnhancerContext from bundler-metro

Validated against the nitro react-native-windows e2e (RN 0.85 / RNW 0.85):
the windows bundle resolves and the app connects with the redirect coming
only from the enhancer and metro.config.js carrying no windows-specific
wiring. The earlier "base react-native devtools subtree leaks into the
windows graph" symptom did not reproduce; a cold-cache run and the
belt-and-suspenders case (project metro.config.js redirect still present)
both pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants