Skip to content

feat(metro-transformer): Add @rnx-kit/metro-transformer package and transformer plugins#4022

Open
JasonVMo wants to merge 18 commits intomainfrom
user/jasonvmo/metro-transformer
Open

feat(metro-transformer): Add @rnx-kit/metro-transformer package and transformer plugins#4022
JasonVMo wants to merge 18 commits intomainfrom
user/jasonvmo/metro-transformer

Conversation

@JasonVMo
Copy link
Collaborator

Description

@rnx-kit/metro-transformer

The core part of this change is the addition of the @rnx-kit/metro-transformer package. This package:

  • Merges multiple transformer configurations together, in particular handling things like combining getTransformerOptions results which by default overwrite one another.
  • Ensures certain custom options are set so they can be passed to a babel transformer implementation. In particular the upstream transformer needs to be set so that if a user configures a specific transformer it can be known by another implementation.
  • Allows setting conditional babel transformers based on path. An example being the react-native-svg-transformer which really only needs to be run for .svg files but whenever this kind of switching had to be done people had to write their own transformer selector.
  • Experimental: Allows redirecting upstream resolution for babel transformers. Many of the standard transformers are aware of how to pick between the expo transformer or the react-native transformer. This allows you to effectively override the endpoint transformer to something else. (The default is typically @react-native/metro-babel-transformer.)

@rnx-kit/types-metro-config

This change also introduces the types only package @rnx-kit/types-metro-config which contains definitions for serializers, transformers, and the general shape of rnx-kit plugins.

Other changes

The transformer package is hooked into the bundle commands in the CLI by aggregating transformer configs. By default there is 1 config which comes from the metro config. If we have plugins that add additional transformer configs or if we apply the esbuild serializer transformer config the metro-transformer package will form them into a final transformer config.

  • the esbuild config is applied last so it will have the final word on settings.

@github-actions github-actions bot added feature: metro This is related to Metro feature: cli This is related to CLI labels Mar 10, 2026
@github-actions github-actions bot added the chore Improvements that don't directly affect features label Mar 11, 2026
Copy link
Member

@tido64 tido64 left a comment

Choose a reason for hiding this comment

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

I don't see the esbuild transformer that you've built so I'm going to assume it'll be in a separate PR. I have some questions about it:

  1. How does it preserve the source maps?
  2. I assume we're preprocessing source with esbuild, then pass the serialized content to upstream transformer. Are we still keeping the filename intact? Does that mean we're still using the Babel parser instead of Hermes?

@JasonVMo
Copy link
Collaborator Author

I don't see the esbuild transformer that you've built so I'm going to assume it'll be in a separate PR. I have some questions about it:

  1. How does it preserve the source maps?
  2. I assume we're preprocessing source with esbuild, then pass the serialized content to upstream transformer. Are we still keeping the filename intact? Does that mean we're still using the Babel parser instead of Hermes?

The esbuild transformer PR will be dependent on this one. I'll likely publish it as a draft if nothing else today though it will include these changes until they are checked in. With regards to your questions:

  1. It sets sourcemaps to inline, with source not included, which babel knows how to parse. This results in the sourcemaps being chained together.
  2. The esbuild transformer will take control end to end rather than routing to upstream. This will effectively replace @react-native/metro-babel-transformer. This allows removing plugins that esbuild is covering already and bypassing the ts behavior to treat those files as js files.

In my first draft of the transformer I tried renaming the files as .js and then patching the sourcemaps. This breaks the codegen plugin though as it needs the .ts filename.

@JasonVMo JasonVMo requested a review from tido64 March 15, 2026 08:35
: undefined
);
Object.assign(metroConfig.transformer, esbuildTransformerConfig);
// otherwise, add it to the list to be merged by the MetroTransformer
Copy link
Member

Choose a reason for hiding this comment

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

Why otherwise?

Comment on lines +119 to +121
const transformers: ExtendedTransformerConfig[] = metroConfig.transformer
? [metroConfig.transformer]
: [];
Copy link
Member

Choose a reason for hiding this comment

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

transformer is always set because Metro requires it. This also means that we will always run the merger logic below.

I tested this locally with only tree shaking enabled and this is what it returned:

  transformOptions: {
    transform: { experimentalImportSupport: false, inlineRequires: false },
    customTransformerOptions: {
      upstreamTransformerPath: '/~/node_modules/.store/@react-native-metro-babel-transformer-virtual-38655d5e06/package/src/index.js'
    }
  }

Ideally, we don't want this plugin to be running at all if it's not set.

Comment on lines +37 to +38
// start with a hash of this file's contents as the cache key
const cacheKeyParts: (string | Buffer)[] = [fs.readFileSync(__filename)];
Copy link
Member

Choose a reason for hiding this comment

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

Should this be moved inside getCacheKey? And can we avoid the fact that it's accessing disk on load, before it gets used at all?

getCacheKey?: () => string;
};

import type { BabelTransformerArgs as BaseTransformerArgs } from "metro-babel-transformer";
Copy link
Member

Choose a reason for hiding this comment

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

Import statements should be grouped together.

/**
* Get the cached cache key. Will be recalculated if additional parts are added to cacheKeyParts
*/
export const getCacheKey = (() => {
Copy link
Member

Choose a reason for hiding this comment

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

Should we be using metro-cache-key directly? I'm also not fond of using md5 here but it looks like this comes from upstream.

filename: string,
babelTransformers: Record<string, string>
): string | undefined {
for (const [pattern, transformerPath] of Object.entries(babelTransformers)) {
Copy link
Member

Choose a reason for hiding this comment

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

If this happens a lot in a loop (I assume it does), it's better to iterate over an actual Map than allocating a lot of arrays and thrashing the gc.

Comment on lines +42 to +55
let transformerPath = tryRequireResolve(baseTransformer, process.cwd());
if (!transformerPath) {
// next try to get metro-config from the package root and resolve from there
const metroConfigPath = tryRequireResolve(
`@react-native/metro-config/package.json`,
process.cwd()
);
if (metroConfigPath) {
transformerPath = tryRequireResolve(
baseTransformer,
path.dirname(metroConfigPath)
);
}
}
Copy link
Member

@tido64 tido64 Mar 16, 2026

Choose a reason for hiding this comment

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

This should be inverted. The local copy should always be preferred. The one you'll find at root (if you're using node-modules linkage) is the most popular one, which is not guaranteed to be the same version being used for the current project.

Comment on lines +74 to +77
const { customTransformerOptions } = args.options
.customTransformOptions as unknown as {
customTransformerOptions: CustomTransformerOptions;
};
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't we be using ExtendedTransformerConfig here?

{
"extends": "@rnx-kit/tsconfig/tsconfig.node.json",
"compilerOptions": {
"rootDir": "src",
Copy link
Member

Choose a reason for hiding this comment

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

rootDir is not necessary if noEmit: true

Suggested change
"rootDir": "src",

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

chore Improvements that don't directly affect features feature: cli This is related to CLI feature: metro This is related to Metro

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants