diff --git a/.changeset/tall-dolls-reply.md b/.changeset/tall-dolls-reply.md new file mode 100644 index 0000000000..3835ddd3a8 --- /dev/null +++ b/.changeset/tall-dolls-reply.md @@ -0,0 +1,6 @@ +--- +"@rnx-kit/tools-react-native": patch +"@rnx-kit/cli": patch +--- + +Add function to merge transformer configs to tools-react-native and use it in the cli diff --git a/packages/cli/src/helpers/metro-config.ts b/packages/cli/src/helpers/metro-config.ts index 77e576b495..5a97b2b28a 100644 --- a/packages/cli/src/helpers/metro-config.ts +++ b/packages/cli/src/helpers/metro-config.ts @@ -8,6 +8,7 @@ import { esbuildTransformerConfig, MetroSerializer as MetroSerializerEsbuild, } from "@rnx-kit/metro-serializer-esbuild"; +import { mergeTransformerConfigs } from "@rnx-kit/tools-react-native/metro-utils"; import type { BundleParameters } from "@rnx-kit/types-bundle-config"; import type { ConfigT, SerializerConfigT } from "metro-config"; import type { WritableDeep } from "type-fest"; @@ -170,7 +171,10 @@ export function customizeMetroConfig( ? extraParams.treeShake : undefined ); - Object.assign(metroConfig.transformer, esbuildTransformerConfig); + metroConfig.transformer = mergeTransformerConfigs( + metroConfig.transformer, + esbuildTransformerConfig + ) as WritableDeep["transformer"]; } else if (metroPlugins.length > 0) { // MetroSerializer acts as a CustomSerializer, and it works with both // older and newer versions of Metro. Older versions expect a return diff --git a/packages/tools-react-native/README.md b/packages/tools-react-native/README.md index 718c72089f..b551d46b08 100644 --- a/packages/tools-react-native/README.md +++ b/packages/tools-react-native/README.md @@ -20,21 +20,22 @@ import * as platformTools from "@rnx-kit/tools-react-native/platform"; -| Category | Function | Description | -| -------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| context | `loadContext(projectRoot)` | Equivalent to calling `loadConfig()` from `@react-native-community/cli`, but the result is cached for faster subsequent accesses. | -| context | `loadContextAsync(projectRoot)` | Equivalent to calling `loadConfigAsync()` (with fallback to `loadConfig()`) from `@react-native-community/cli`, but the result is cached for faster subsequent accesses. | -| context | `resolveCommunityCLI(root, reactNativePath)` | Finds path to `@react-native-community/cli`. | -| metro | `findMetroPath(projectRoot)` | Finds the installation path of Metro. | -| metro | `getMetroVersion(projectRoot)` | Returns Metro version number. | -| metro | `requireModuleFromMetro(moduleName, fromDir)` | Imports specified module starting from the installation directory of the currently used `metro` version. | -| platform | `expandPlatformExtensions(platform, extensions)` | Returns a list of extensions that should be tried for the target platform in prioritized order. | -| platform | `getAvailablePlatforms(startDir)` | Returns a map of available React Native platforms. The result is cached. | -| platform | `getAvailablePlatformsUncached(startDir, platformMap)` | Returns a map of available React Native platforms. The result is NOT cached. | -| platform | `getModuleSuffixes(platform, appendEmpty)` | Get the module suffixes array for a given platform, suitable for use with TypeScript's moduleSuffixes setting in the form of ['.ios', '.native', ''] or ['.windows', '.win', '.native', ''] or similar | -| platform | `parsePlatform(val)` | Parse a string to ensure it maps to a valid react-native platform. | -| platform | `platformExtensions(platform)` | Returns file extensions that can be mapped to the target platform. | -| platform | `platformValues()` | | -| platform | `tryParsePlatform(val)` | | +| Category | Function | Description | +| ----------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| context | `loadContext(projectRoot)` | Equivalent to calling `loadConfig()` from `@react-native-community/cli`, but the result is cached for faster subsequent accesses. | +| context | `loadContextAsync(projectRoot)` | Equivalent to calling `loadConfigAsync()` (with fallback to `loadConfig()`) from `@react-native-community/cli`, but the result is cached for faster subsequent accesses. | +| context | `resolveCommunityCLI(root, reactNativePath)` | Finds path to `@react-native-community/cli`. | +| metro | `findMetroPath(projectRoot)` | Finds the installation path of Metro. | +| metro | `getMetroVersion(projectRoot)` | Returns Metro version number. | +| metro | `requireModuleFromMetro(moduleName, fromDir)` | Imports specified module starting from the installation directory of the currently used `metro` version. | +| metro-utils | `mergeTransformerConfigs(...configs)` | Merges multiple Metro transformer configurations into one. Properties from later configs override earlier ones. If multiple configs provide a `getTransformOptions` function, the returned config wraps them so that each is called in order and their results are deep-merged. | +| platform | `expandPlatformExtensions(platform, extensions)` | Returns a list of extensions that should be tried for the target platform in prioritized order. | +| platform | `getAvailablePlatforms(startDir)` | Returns a map of available React Native platforms. The result is cached. | +| platform | `getAvailablePlatformsUncached(startDir, platformMap)` | Returns a map of available React Native platforms. The result is NOT cached. | +| platform | `getModuleSuffixes(platform, appendEmpty)` | Get the module suffixes array for a given platform, suitable for use with TypeScript's moduleSuffixes setting in the form of ['.ios', '.native', ''] or ['.windows', '.win', '.native', ''] or similar | +| platform | `parsePlatform(val)` | Parse a string to ensure it maps to a valid react-native platform. | +| platform | `platformExtensions(platform)` | Returns file extensions that can be mapped to the target platform. | +| platform | `platformValues()` | | +| platform | `tryParsePlatform(val)` | | diff --git a/packages/tools-react-native/metro-utils.d.ts b/packages/tools-react-native/metro-utils.d.ts new file mode 100644 index 0000000000..26ff1ac4f5 --- /dev/null +++ b/packages/tools-react-native/metro-utils.d.ts @@ -0,0 +1 @@ +export { mergeTransformerConfigs } from "./lib/metro-utils.js"; diff --git a/packages/tools-react-native/metro-utils.js b/packages/tools-react-native/metro-utils.js new file mode 100644 index 0000000000..df1f6d816c --- /dev/null +++ b/packages/tools-react-native/metro-utils.js @@ -0,0 +1 @@ +module.exports = require("./lib/metro-utils.js"); diff --git a/packages/tools-react-native/package.json b/packages/tools-react-native/package.json index 8300418f80..6ee4516c32 100644 --- a/packages/tools-react-native/package.json +++ b/packages/tools-react-native/package.json @@ -28,6 +28,7 @@ "platform.js", "src" ], + "type": "commonjs", "main": "lib/index.js", "types": "lib/index.d.ts", "exports": { @@ -56,6 +57,11 @@ "typescript": "./src/metro.ts", "default": "./lib/metro.js" }, + "./metro-utils": { + "types": "./lib/metro-utils.d.ts", + "typescript": "./src/metro-utils.ts", + "default": "./lib/metro-utils.js" + }, "./platform": { "types": "./lib/platform.d.ts", "typescript": "./src/platform.ts", diff --git a/packages/tools-react-native/src/index.ts b/packages/tools-react-native/src/index.ts index 7cd74ba476..a0d2bb32f0 100644 --- a/packages/tools-react-native/src/index.ts +++ b/packages/tools-react-native/src/index.ts @@ -9,6 +9,7 @@ export { getMetroVersion, requireModuleFromMetro, } from "./metro.ts"; +export { mergeTransformerConfigs } from "./metro-utils.ts"; export { expandPlatformExtensions, getAvailablePlatforms, diff --git a/packages/tools-react-native/src/metro-utils.ts b/packages/tools-react-native/src/metro-utils.ts new file mode 100644 index 0000000000..3ed06c356b --- /dev/null +++ b/packages/tools-react-native/src/metro-utils.ts @@ -0,0 +1,82 @@ +import type { GetTransformOptions, TransformerConfigT } from "metro-config"; + +/** + * Type guard to check if a value is a plain object (i.e., a record). This is used to ensure that we only attempt to + * recursively merge plain objects in the `simpleObjectMerge` function. + */ +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** + * Simple merge helper that recursively merges plain objects. Note that array merges are not supported, + * as the behavior isn't deterministic (e.g., should we concatenate arrays, or override them?). If a property is an array in + * multiple configs, the value from the last config will win. + */ +function simpleObjectMerge( + ...options: Record[] +): Record { + const result: Record = {}; + for (const option of options) { + for (const [key, value] of Object.entries(option)) { + if (isRecord(value) && isRecord(result[key])) { + result[key] = simpleObjectMerge(result[key], value); + } else { + result[key] = value; + } + } + } + return result; +} + +/** + * Creates a function that sequentially calls multiple `GetTransformOptions` functions and merges their results. + */ +function createGetTransformOptions( + ...subFunctions: GetTransformOptions[] +): GetTransformOptions { + if (subFunctions.length === 0) { + throw new Error("At least one getTransformOptions function is required"); + } else if (subFunctions.length === 1) { + return subFunctions[0]; + } else { + return async (entryPoints, options, getDepsOf) => { + const results = await Promise.all( + subFunctions.map((fn) => fn(entryPoints, options, getDepsOf)) + ); + return simpleObjectMerge(...results); + }; + } +} + +/** + * Merges multiple Metro transformer configurations into one. Properties from later configs override earlier + * ones. If multiple configs provide a `getTransformOptions` function, the returned config wraps them so that each + * is called in order and their results are deep-merged. + * + * @param configs one or more transformer config objects to merge. Later configs take precedence over earlier ones. + * @returns transformer configuration suitable for use by Metro + */ +export function mergeTransformerConfigs( + ...configs: Partial[] +): Partial { + // collect the getTransformOptions functions from all configs, and if there are multiple, we'll create a wrapper function for them + const getTransformOptionsFns = configs.reduce< + TransformerConfigT["getTransformOptions"][] + >((result, config) => { + const getTransformOptions = config?.getTransformOptions; + if (typeof getTransformOptions === "function") { + result.push(getTransformOptions); + } + return result; + }, []); + + // if there are multiple getTransformOptions functions, create a wrapper function that calls in sequence and merges their results + if (getTransformOptionsFns.length > 1) { + configs.push({ + getTransformOptions: createGetTransformOptions(...getTransformOptionsFns), + }); + } + + return Object.assign({}, ...configs); +} diff --git a/packages/tools-react-native/test/metro-utils.test.ts b/packages/tools-react-native/test/metro-utils.test.ts new file mode 100644 index 0000000000..c886d52d86 --- /dev/null +++ b/packages/tools-react-native/test/metro-utils.test.ts @@ -0,0 +1,267 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { describe, it } from "node:test"; +import { mergeTransformerConfigs } from "../src/metro-utils.ts"; + +describe("mergeTransformerConfigs()", () => { + it("returns an empty config when called with no arguments", () => { + const result = mergeTransformerConfigs(); + deepEqual(result, {}); + }); + + it("passes through a single config unchanged", () => { + const config = { + minifierPath: "/path/to/minifier", + hermesParser: true, + }; + const result = mergeTransformerConfigs(config); + deepEqual(result, config); + }); + + it("merges scalar properties from multiple configs", () => { + const result = mergeTransformerConfigs( + { minifierPath: "/minifier-a", hermesParser: false }, + { minifierPath: "/minifier-b", enableBabelRuntime: true } + ); + deepEqual(result, { + minifierPath: "/minifier-b", + hermesParser: false, + enableBabelRuntime: true, + }); + }); + + it("later configs override earlier ones", () => { + const result = mergeTransformerConfigs( + { hermesParser: false }, + { hermesParser: true } + ); + deepEqual(result, { hermesParser: true }); + }); + + it("skips nullish configs", () => { + const config = { hermesParser: true }; + // @ts-expect-error testing nullish values + const result = mergeTransformerConfigs(null, config, undefined); + deepEqual(result, config); + }); + + it("preserves a single getTransformOptions without wrapping", () => { + const getTransformOptions = async () => ({ + transform: { experimentalImportSupport: true }, + }); + const result = mergeTransformerConfigs({ getTransformOptions }); + equal(result.getTransformOptions, getTransformOptions); + }); + + it("merges multiple getTransformOptions functions", async () => { + const result = mergeTransformerConfigs( + { + getTransformOptions: async () => ({ + transform: { + experimentalImportSupport: false, + inlineRequires: false, + }, + }), + }, + { + getTransformOptions: async () => ({ + transform: { + experimentalImportSupport: true, + nonInlinedRequires: ["react"], + }, + }), + } + ); + + const options = await result.getTransformOptions!( + [], + { dev: true, hot: true, platform: "ios" }, + async () => [] + ); + + deepEqual(options, { + transform: { + experimentalImportSupport: true, + inlineRequires: false, + nonInlinedRequires: ["react"], + }, + }); + }); + + it("overwrites arrays when merging multiple getTransformOptions functions", async () => { + const result = mergeTransformerConfigs( + { + getTransformOptions: async () => ({ + transform: { + experimentalImportSupport: false, + inlineRequires: false, + nonInlinedRequires: ["lodash"], + }, + }), + }, + { + getTransformOptions: async () => ({ + transform: { + experimentalImportSupport: true, + nonInlinedRequires: ["react"], + }, + }), + } + ); + + const options = await result.getTransformOptions!( + [], + { dev: true, hot: true, platform: "ios" }, + async () => [] + ); + + deepEqual(options, { + transform: { + experimentalImportSupport: true, + inlineRequires: false, + nonInlinedRequires: ["react"], + }, + }); + }); + + it("merges inlineRequires blockLists when both are objects", async () => { + const result = mergeTransformerConfigs( + { + getTransformOptions: async () => ({ + transform: { + inlineRequires: { blockList: { "/path/a.js": true as const } }, + }, + }), + }, + { + getTransformOptions: async () => ({ + transform: { + inlineRequires: { blockList: { "/path/b.js": true as const } }, + }, + }), + } + ); + + const options = await result.getTransformOptions!( + [], + { dev: true, hot: true, platform: null }, + async () => [] + ); + + deepEqual(options.transform?.inlineRequires, { + blockList: { "/path/a.js": true, "/path/b.js": true }, + }); + }); + + it("overrides inlineRequires when types differ", async () => { + const result = mergeTransformerConfigs( + { + getTransformOptions: async () => ({ + transform: { + inlineRequires: { blockList: { "/path/a.js": true as const } }, + }, + }), + }, + { + getTransformOptions: async () => ({ + transform: { inlineRequires: true }, + }), + } + ); + + const options = await result.getTransformOptions!( + [], + { dev: true, hot: true, platform: null }, + async () => [] + ); + + equal(options.transform?.inlineRequires, true); + }); + + it("merges preloadedModules maps", async () => { + const result = mergeTransformerConfigs( + { + getTransformOptions: async () => ({ + preloadedModules: { "/mod/a.js": true as const }, + }), + }, + { + getTransformOptions: async () => ({ + preloadedModules: { "/mod/b.js": true as const }, + }), + } + ); + + const options = await result.getTransformOptions!( + [], + { dev: true, hot: true, platform: null }, + async () => [] + ); + + deepEqual(options.preloadedModules, { + "/mod/a.js": true, + "/mod/b.js": true, + }); + }); + + it("passes arguments through to all getTransformOptions functions", async () => { + const calls: unknown[][] = []; + + const result = mergeTransformerConfigs( + { + getTransformOptions: async (entryPoints, options, _getDepsOf) => { + calls.push(["first", [...entryPoints], { ...options }]); + return {}; + }, + }, + { + getTransformOptions: async (entryPoints, options, _getDepsOf) => { + calls.push(["second", [...entryPoints], { ...options }]); + return {}; + }, + } + ); + + const entryPoints = ["/app/index.js"]; + const opts = { dev: false, hot: true as const, platform: "android" }; + const getDepsOf = async () => ["/dep.js"]; + + await result.getTransformOptions!(entryPoints, opts, getDepsOf); + + equal(calls.length, 2); + deepEqual(calls[0], ["first", ["/app/index.js"], opts]); + deepEqual(calls[1], ["second", ["/app/index.js"], opts]); + }); + + it("merges non-getTransformOptions props alongside wrapped getTransformOptions", async () => { + const result = mergeTransformerConfigs( + { + minifierPath: "/minifier", + getTransformOptions: async () => ({ + transform: { inlineRequires: false }, + }), + }, + { + hermesParser: true, + getTransformOptions: async () => ({ + transform: { experimentalImportSupport: true }, + }), + } + ); + + equal(result.minifierPath, "/minifier"); + equal(result.hermesParser, true); + + const options = await result.getTransformOptions!( + [], + { dev: true, hot: true, platform: null }, + async () => [] + ); + + deepEqual(options, { + transform: { + inlineRequires: false, + experimentalImportSupport: true, + }, + }); + }); +});