Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .changeset/good-jobs-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@rnx-kit/types-metro-serializer-esbuild": patch
"@rnx-kit/metro-serializer-esbuild": patch
---

Add new ways of configuring metro-serializer-esbuild for both serialization and transformation
16 changes: 1 addition & 15 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"editor.trimAutoWhitespace": true,
"editor.insertSpaces": true,
"editor.tabSize": 2,
"editor.defaultFormatter": "oxc.oxc-vscode",

"eslint.enable": true,
"eslint.workingDirectories": [{ "mode": "auto" }], // infer working directory based on .eslintrc/package.json location
Expand Down Expand Up @@ -44,21 +45,6 @@
"**/lib-commonjs": true,
"**/dist": true
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[handlebars]": {
"editor.formatOnSave": false
},
Expand Down
20 changes: 20 additions & 0 deletions packages/metro-serializer-esbuild/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,26 @@ make any changes.
Next, configure Metro to use the esbuild serializer by making the following
changes to `metro.config.js`:

```diff
const { makeMetroConfig } = require("@rnx-kit/metro-config");
+const { MetroEsbuildFactory } = require("@rnx-kit/metro-serializer-esbuild");
+const { makeSerializer, makeTransformer } = MetroEsbuildFactory();

module.exports = makeMetroConfig({
serializer: {
+ customSerializer: makeSerializer()
},
+ transformer: makeTransformer({
// standard transformer options if needed, can be omitted
babelTransformerPath: require.resolve('react-native-svg-transformer')
+ })
});
```

> This will automatically configure the transformer options for development and production.

Alternatively the legacy pattern can still be used as follows:

```diff
const { makeMetroConfig } = require("@rnx-kit/metro-config");
+const {
Expand Down
60 changes: 60 additions & 0 deletions packages/metro-serializer-esbuild/metro.transform.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const { exclusionList, makeMetroConfig } = require("@rnx-kit/metro-config");
const path = require("node:path");
const { MetroEsbuildFactory } = require(".");

// Metro will pick up mocks if we don't exclude them
const blockList = exclusionList([/[/\\]__fixtures__[/\\].*[/\\]package.json$/]);

const { makeSerializer, makeTransformer } = MetroEsbuildFactory({
minify: false,
sourcemap: false,
transformWithEsbuild: true,
});

// We can't install dependencies for our test fixtures so we need to resolve
// them here to help Metro find them.
const extraNodeModules = (() => {
const fluentUtils = require
.resolve("@fluentui/utilities")
.replace("lib-commonjs", "lib");
const fromFluentUtils = { paths: [fluentUtils] };
const resolveFromFluent = (name) =>
require.resolve(name, fromFluentUtils).replace("lib-commonjs", "lib");

const fromMetro = { paths: [require.resolve("metro/package.json")] };

return {
"@fluentui/dom-utilities": resolveFromFluent("@fluentui/dom-utilities"),
"@fluentui/merge-styles": resolveFromFluent("@fluentui/merge-styles"),
"@fluentui/set-version": resolveFromFluent("@fluentui/set-version"),
"@fluentui/utilities": fluentUtils,
"lodash-es": require.resolve("lodash-es"),
"metro-runtime/src/modules/asyncRequire.js": require.resolve(
"metro-runtime/src/modules/asyncRequire.js",
fromMetro
),
"metro-runtime/src/polyfills/require.js": require.resolve(
"metro-runtime/src/polyfills/require.js",
fromMetro
),
react: require.resolve("react"),
tslib: require.resolve("tslib", fromFluentUtils),
};
})();

module.exports = makeMetroConfig({
cacheStores: [], // Avoids issues with deleting the cache on Windows
reporter: { update: () => null },
resolver: {
resolverMainFields: ["react-native", "module", "browser", "main"],
extraNodeModules,
blacklistRE: blockList,
blockList,
},
serializer: {
customSerializer: makeSerializer([]),
getPolyfills: () => [],
},
transformer: makeTransformer(),
watchFolders: Object.values(extraNodeModules).map((dir) => path.dirname(dir)),
});
2 changes: 2 additions & 0 deletions packages/metro-serializer-esbuild/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@fluentui/utilities": "8.13.9",
"@react-native-community/cli-types": "^20.0.0",
"@react-native/babel-preset": "^0.83.0",
"@react-native/metro-babel-transformer": "^0.83.0",
"@react-native/metro-config": "^0.83.0",
"@rnx-kit/babel-plugin-import-path-remapper": "*",
"@rnx-kit/babel-preset-metro-react-native": "*",
Expand All @@ -58,6 +59,7 @@
"@types/node": "^24.0.0",
"lodash-es": "^4.17.21",
"metro": "^0.83.3",
"metro-babel-transformer": "^0.83.0",
"metro-config": "^0.83.3",
"metro-transform-worker": "^0.83.1",
"react": "19.2.0",
Expand Down
163 changes: 163 additions & 0 deletions packages/metro-serializer-esbuild/src/esbuildTransformer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import type { Loader } from "esbuild";
import { transform as esbuildTransform } from "esbuild";
import type {
BabelTransformerArgs,
BabelTransformer,
} from "metro-babel-transformer";
import type { MinifierResult } from "metro-transform-worker";
import { createHash } from "node:crypto";
import fs from "node:fs";
import { getDefine, getEsbuildTransformOptions, getSupported } from "./options";
import { patchSourceMapFilename } from "./sourceMap";
import { inferBuildTarget } from "./targets";

// marker of whether the cache key is valid. If we are using a non-standard transformer the key will change on the
// subsequent call to reflect the new upstream. To get this to happen this is used as a signal value
let cacheKeyValid = false;

const upstreamTransformer = (() => {
let upstreamPath = "@react-native/metro-babel-transformer";
let upstream: BabelTransformer = require(upstreamPath);

return (babelPath?: string) => {
if (babelPath && babelPath !== upstreamPath) {
upstreamPath = babelPath;
upstream = require(upstreamPath);
cacheKeyValid = false;
}
return upstream;
};
})();

export const getCacheKey = (() => {
let cacheKey: string | null = null;
return () => {
if (!cacheKeyValid || !cacheKey) {
const upstream = upstreamTransformer();
cacheKey = createHash("sha1")
.update(upstream.getCacheKey?.() ?? "upstream-unknown")
.update(fs.readFileSync(__filename, { encoding: "utf-8" }))
.update(require("esbuild/package.json").version)
.digest("hex");
}
return cacheKey;
};
})();

// Signal to @rnx-kit/babel-preset-metro-react-native that the esbuild
// transformer is active, so it can auto-select the "esbuild-transformer"
// profile and disable redundant babel plugins.
process.env["RNX_METRO_TRANSFORMER_ESBUILD"] = "1";

type WithMap<T> = T & { map?: MinifierResult["map"] };

/**
* Determine the appropriate esbuild loader for a given filename.
*
* Uses `"jsx"` as the default for `.js` files because many React Native
* ecosystem JS files contain JSX syntax. esbuild's `"jsx"` loader is a
* superset of `"js"` so this is safe for plain JS files too.
*/
function getLoader(filename: string): Loader | null {
if (/\.[mc]?tsx?$/.test(filename)) {
return filename.endsWith("x") ? "tsx" : "ts";
} else if (/\.[mc]?jsx?$/.test(filename)) {
return filename.endsWith("x") ? "jsx" : "js";
}
return null;
}

/**
* Metro transformer that uses esbuild as a first pass to strip TypeScript
* and optionally transform JSX, then delegates to
* `@react-native/metro-babel-transformer` for the full babel preset pipeline.
*
* Pipeline:
* 1. esbuild.transform() — TS stripping, optional JSX, preserves ESM imports
* 2. @react-native/metro-babel-transformer — babel preset, HMR, codegen, etc.
*
* TypeScript filenames are renamed to `.js` when passed to the upstream
* transformer to prevent redundant TS parsing by babel.
*/
export async function transform({
filename,
src,
options,
plugins,
}: BabelTransformerArgs) {
const esbuildOptions = getEsbuildTransformOptions(
options.customTransformOptions
);

const {
babelTransformerPath,
jsx = "automatic",
jsxFactory,
jsxFragment,
jsxImportSource = "react",
target = inferBuildTarget(),
} = esbuildOptions;

// parse the file to get the loader, if non-null esbuild will be used
const loader = getLoader(filename);

// we will use a different filename for babel if we are mapping from TS to JS to avoid the @react-native/metro-babel-transformer
// going down the slow codepath of trying to parse TS syntax and bypassing the hermes-parser.
const transformedFilename =
loader === "tsx" || loader === "ts"
? filename.replace(/\.[mc]?tsx?$/, ".js")
: filename;

if (loader) {
const jsxDev =
esbuildOptions.jsxDev ?? (jsx === "automatic" && options.dev);

const esbuildResult = await esbuildTransform(src, {
sourcefile: filename,
loader,
// don't downlevel here except for what is listed in the supported settings
target: "esnext",
supported: getSupported(target),
jsx,
jsxFactory,
jsxFragment,
jsxImportSource,
jsxDev,
define: getDefine(options),
// inline sourcemaps, babel will decode and return it as a separate map object
sourcemap: "inline",
// turn off sources content to save memory, babel doesn't use it and metro doesn't support it
sourcesContent: false,
// don't minify at this stage
minify: false,
});

// remember the transformed source to pass to babel
src = esbuildResult.code;
}

// Delegate to upstream babel transformer with hermesParser forced on and
const upstream = upstreamTransformer(babelTransformerPath);
const result = await upstream.transform({
src,
filename: transformedFilename,
options: {
...options,
hermesParser: true,
// experimentalImportSupport: true,
},
plugins,
});

// the exposed signatures for the babel transformer aren't correct, the map should be there but is not in the type definition
const withMap = result as WithMap<typeof result>;
if (withMap.map && transformedFilename !== filename) {
// if the filename has been transformed, change source references back to the original filename
withMap.map = patchSourceMapFilename(
withMap.map,
transformedFilename,
filename
);
}
return result;
}

This file was deleted.

Loading
Loading