feat(platforms): add a per-platform metroConfigEnhancer hook - #190
Conversation
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`
|
@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
left a comment
There was a problem hiding this comment.
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.
|
@V3RON nice! Thanks for the feedback. |
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.
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.
Description
Adds a per-platform
metroConfigEnhancerhook 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): thereact-native→ platform-package resolver redirect, the platform'sLibraries/Core/InitializeCore, and the extraresolver.platformsentries. A project targeting one of those currently has to reproduce that wiring by hand in itsmetro.config.js.This gives the platform a seam to do it itself:
A platform factory sets it with
import.meta.resolve('./metro-config-enhancer.js'), the same way it already setsrunner. 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-metroabout specific platforms — "expose anenhanceMetroConfigmethod 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
withRnHarnesshas finished composing the config, so it sees the harness resolver, cache config, serializer, everything, and returns a further-adjusted config.harness-sessionalready has the resolved runner right before it initializes Metro, so it just passesplatform.metroConfigEnhancerdown throughMetroOptions.runner/clifields, andRunnerSchemais a barez.object()that strips unknown keys, so a function would be dropped during config parsing.@react-native-harness/bundler-metrogains a workspace dependency on@react-native-harness/platformsfor theMetroConfigEnhancertype (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: newrunner-schema.test.ts— the enhancer path survivesConfigSchema.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 lintpass for the affected projects.