From 1b7100f9fe4943b041b035a737d04bbdb6c0bfe6 Mon Sep 17 00:00:00 2001 From: Tommy Nguyen <4123478+tido64@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:41:44 +0200 Subject: [PATCH 1/2] feat: allow external packages to configure `react-native.config.js` --- packages/app/example/test/config.test.mjs | 189 +++++++++--------- packages/app/scripts/configure-projects.js | 34 ++-- packages/app/scripts/configure.mjs | 75 +------ packages/app/scripts/template.mjs | 101 ++++++++++ packages/app/scripts/types.ts | 21 ++ packages/app/test/configure-projects.test.mts | 3 +- packages/app/test/ios/xcode.test.mts | 2 +- 7 files changed, 243 insertions(+), 182 deletions(-) diff --git a/packages/app/example/test/config.test.mjs b/packages/app/example/test/config.test.mjs index a21f5c2f9..3cc57c2ab 100644 --- a/packages/app/example/test/config.test.mjs +++ b/packages/app/example/test/config.test.mjs @@ -1,8 +1,9 @@ // @ts-check -import { equal, match, notEqual, ok } from "node:assert/strict"; +import { deepEqual, equal, match, notEqual, ok } from "node:assert/strict"; import * as fs from "node:fs"; import * as path from "node:path"; -import { test } from "node:test"; +import { describe, it } from "node:test"; +import { configureProjects } from "../../scripts/configure-projects.js"; import { readJSONFile } from "../../scripts/helpers.js"; /** @@ -45,13 +46,20 @@ function requiresDependency(spec, projectRoot) { return Object.hasOwn(dependencies, spec); } -test("react-native config", async (t) => { +describe("react-native config", async () => { const currentDir = process.cwd(); const loadConfig = await getLoadConfig(currentDir); const reactNativePath = path.join(currentDir, "node_modules", "react-native"); - await t.test("contains Android config", () => { + const shouldSkipIOS = process.platform === "win32"; + const shouldSkipMacOS = + shouldSkipIOS || !requiresDependency("react-native-macos", currentDir); + const shouldSkipWindows = + process.platform !== "win32" || + !requiresDependency("react-native-windows", currentDir); + + it("contains Android config", () => { const sourceDir = path.join(currentDir, "android"); const config = loadConfig(); @@ -71,97 +79,90 @@ test("react-native config", async (t) => { equal(config.project.android.packageName, "com.microsoft.reacttestapp"); }); - await t.test( - "contains iOS config", - { skip: process.platform === "win32" }, - () => { - const sourceDir = path.join(currentDir, "ios"); - const config = loadConfig(); - - equal(typeof config, "object"); - match(config.root, regexp(currentDir)); - match(config.reactNativePath, regexp(reactNativePath)); - equal( - config.dependencies["react-native-test-app"].name, - "react-native-test-app" - ); - notEqual(config.platforms.ios, undefined); - match(config.project.ios.sourceDir, regexp(sourceDir)); - - if (fs.existsSync("ios/Pods")) { - equal(config.project.ios.xcodeProject.name, "Example.xcworkspace"); - ok(config.project.ios.xcodeProject.isWorkspace); - } else { - equal(config.project.ios.xcodeProject, null); - } + it("contains iOS config", { skip: shouldSkipIOS }, () => { + const sourceDir = path.join(currentDir, "ios"); + const config = loadConfig(); + + equal(typeof config, "object"); + match(config.root, regexp(currentDir)); + match(config.reactNativePath, regexp(reactNativePath)); + equal( + config.dependencies["react-native-test-app"].name, + "react-native-test-app" + ); + notEqual(config.platforms.ios, undefined); + match(config.project.ios.sourceDir, regexp(sourceDir)); + + if (fs.existsSync("ios/Pods")) { + equal(config.project.ios.xcodeProject.name, "Example.xcworkspace"); + ok(config.project.ios.xcodeProject.isWorkspace); + } else { + equal(config.project.ios.xcodeProject, null); } - ); - - await t.test( - "contains macOS config", - { - skip: - process.platform === "win32" || - !requiresDependency("react-native-macos", currentDir), - }, - () => { - const sourceDir = path.join(currentDir, "macos"); - const config = loadConfig(); - - equal(typeof config, "object"); - match(config.root, regexp(currentDir)); - match(config.reactNativePath, regexp(reactNativePath)); - equal( - config.dependencies["react-native-test-app"].name, - "react-native-test-app" - ); - notEqual(config.platforms.macos, undefined); - match(config.project.macos.sourceDir, regexp(sourceDir)); - - if (fs.existsSync("macos/Pods")) { - equal(config.project.macos.xcodeProject.name, "Example.xcworkspace"); - ok(config.project.macos.xcodeProject.isWorkspace); - } else { - equal(config.project.macos.xcodeProject, null); - } + }); + + it("contains macOS config", { skip: shouldSkipMacOS }, () => { + const sourceDir = path.join(currentDir, "macos"); + const config = loadConfig(); + + equal(typeof config, "object"); + match(config.root, regexp(currentDir)); + match(config.reactNativePath, regexp(reactNativePath)); + equal( + config.dependencies["react-native-test-app"].name, + "react-native-test-app" + ); + notEqual(config.platforms.macos, undefined); + match(config.project.macos.sourceDir, regexp(sourceDir)); + + if (fs.existsSync("macos/Pods")) { + equal(config.project.macos.xcodeProject.name, "Example.xcworkspace"); + ok(config.project.macos.xcodeProject.isWorkspace); + } else { + equal(config.project.macos.xcodeProject, null); } - ); - - await t.test( - "contains Windows config", - { - skip: - process.platform !== "win32" || - !requiresDependency("react-native-windows", currentDir), - }, - () => { - const projectFile = path.join( - "node_modules", - ".generated", - "windows", - "ReactTestApp", - "ReactTestApp.vcxproj" - ); - - if (!fs.existsSync(projectFile)) { - console.warn(`No such file: ${projectFile}`); - return; - } - - const config = loadConfig(); - - equal(typeof config, "object"); - match(config.root, regexp(currentDir)); - match(config.reactNativePath, regexp(reactNativePath)); - equal( - config.dependencies["react-native-test-app"].name, - "react-native-test-app" - ); - equal(config.platforms.windows.npmPackageName, "react-native-windows"); - match(config.project.windows.folder, regexp(currentDir)); - match(config.project.windows.sourceDir, /windows/); - match(config.project.windows.solutionFile, /Example.sln/); - match(config.project.windows.project.projectFile, regexp(projectFile)); + }); + + it("contains Windows config", { skip: shouldSkipWindows }, () => { + const projectFile = path.join( + "node_modules", + ".generated", + "windows", + "ReactTestApp", + "ReactTestApp.vcxproj" + ); + + if (!fs.existsSync(projectFile)) { + console.warn(`No such file: ${projectFile}`); + return; } - ); + + const config = loadConfig(); + + equal(typeof config, "object"); + match(config.root, regexp(currentDir)); + match(config.reactNativePath, regexp(reactNativePath)); + equal( + config.dependencies["react-native-test-app"].name, + "react-native-test-app" + ); + equal(config.platforms.windows.npmPackageName, "react-native-windows"); + match(config.project.windows.folder, regexp(currentDir)); + match(config.project.windows.sourceDir, /windows/); + match(config.project.windows.solutionFile, /Example.sln/); + match(config.project.windows.project.projectFile, regexp(projectFile)); + }); +}); + +describe("configureProjects()", () => { + const isMain = path.basename(process.cwd()) === "example"; + + // Only the main example app includes web + it("returns externally provided platform config", { skip: !isMain }, () => { + deepEqual(configureProjects({ web: true }), { + web: { + "@rnx-kit/react-native-template-web": true, + }, + }); + }); }); diff --git a/packages/app/scripts/configure-projects.js b/packages/app/scripts/configure-projects.js index a9d03391d..0973f317e 100644 --- a/packages/app/scripts/configure-projects.js +++ b/packages/app/scripts/configure-projects.js @@ -9,15 +9,9 @@ */ const nodefs = require("node:fs"); const path = require("node:path"); -const { - configure: configureAndroid, - getAndroidPackageName, -} = require("../android/template.config.mjs"); -const { configure: configureIOS } = require("../ios/template.config.mjs"); -const { - configure: configureWindows, -} = require("../windows/template.config.mjs"); -const { findNearest } = require("./helpers"); +const { getAndroidPackageName } = require("../android/template.config.mjs"); +const { findNearest } = require("./helpers.js"); +const { loadPlatformTemplates } = require("./template.mjs"); /** * Finds `react-native.config.[ts,mjs,cjs,js]`. @@ -76,24 +70,26 @@ function findReactNativeConfig(fs = nodefs) { } /** - * @param {ProjectConfig} configuration + * @param {ProjectConfig} projectConfig * @returns {Partial} */ -function configureProjects({ android, ios, windows }, fs = nodefs) { +function configureProjects(projectConfig, fs = nodefs) { const reactNativeConfig = findReactNativeConfig(fs); /** @type {Partial} */ const config = {}; const projectRoot = path.dirname(reactNativeConfig); - if (android) { - config.android = configureAndroid(projectRoot, android, fs); - } - if (ios) { - config.ios = configureIOS(projectRoot, ios, fs); - } - if (windows) { - config.windows = configureWindows(projectRoot, windows, fs); + const params = { + packagePath: projectRoot, + testAppPath: path.dirname(__dirname), + }; + const templates = loadPlatformTemplates(params, fs); + for (const [platform, { configure }] of Object.entries(templates)) { + const platformConfig = projectConfig[platform]; + if (platformConfig) { + config[platform] = configure(projectRoot, platformConfig, fs); + } } return config; diff --git a/packages/app/scripts/configure.mjs b/packages/app/scripts/configure.mjs index 9595b6b72..5cb92920d 100755 --- a/packages/app/scripts/configure.mjs +++ b/packages/app/scripts/configure.mjs @@ -5,13 +5,11 @@ * Configuration, * ConfigureParams, * FileCopy, - * Manifest, * Platform, * PlatformConfiguration, * PlatformPackage, * } from "./types.js"; */ -import { loadContext } from "@rnx-kit/tools-react-native/context"; import * as nodefs from "node:fs"; import { createRequire } from "node:module"; import * as path from "node:path"; @@ -21,7 +19,6 @@ import semverSatisfies from "semver/functions/satisfies.js"; import { getPackageVersion, isMain, - memo, readJSONFile, readTextFile, } from "./helpers.js"; @@ -30,6 +27,8 @@ import { bundleConfig, copyFrom, findGitIgnore, + loadPlatformTemplates, + readManifest, serialize, } from "./template.mjs"; import * as colors from "./utils/colors.mjs"; @@ -48,11 +47,6 @@ function mergeObjects(lhs, rhs) { : sortByKeys(rhs); } -/** @type {() => Required} */ -const readManifest = memo(() => - readJSONFile(new URL("../package.json", import.meta.url)) -); - /** * Prints an error message to the console. * @param {string} message @@ -99,17 +93,6 @@ export function mergeConfig(lhs, rhs) { }; } -/** - * @param {string} root - * @param {string} subpath - * @returns {string | false} - */ -function resolvePath(root, subpath) { - const resolved = path.resolve(root, subpath); - const rel = path.relative(root, resolved); - return !path.isAbsolute(rel) && !rel.startsWith("..") && resolved; -} - /** * Sort the keys in specified object. * @param {Record} obj @@ -223,51 +206,6 @@ export function reactNativeConfig({ name, testAppPath }, fs = nodefs) { return readTextFile(config, fs).replaceAll("Example", name); } -/** - * @param {ConfigureParams} params - * @param {NodeJS.Require} require - * @param {PlatformConfiguration} configuration - * @returns {PlatformConfiguration} - */ -function loadPlatformTemplates(params, require, configuration, fs = nodefs) { - const { packagePath, testAppPath } = params; - const { defaultPlatformPackages } = readManifest(); - const platformPackages = { ...defaultPlatformPackages }; - - try { - const config = loadContext(packagePath); - for (const [, { root }] of Object.entries(config.dependencies)) { - const manifest = readJSONFile(path.join(root, "package.json"), fs); - const { reactNativeTemplateConfig: config } = manifest; - if (config && typeof config === "object" && !Array.isArray(config)) { - console.log( - "Loading template config:", - path.relative(packagePath, root) - ); - for (const [key, { template, ...rest }] of Object.entries(config)) { - const resolved = resolvePath(root, template); - if (resolved) { - platformPackages[key] = { ...rest, template: resolved }; - } - } - } - } - } catch (_) { - // If this was executed outside any projects, `@react-native-community/cli` - // will not be available and error here. - } - - for (const [platform, { template }] of Object.entries(platformPackages)) { - const templatePath = template.startsWith(".") - ? path.resolve(testAppPath, template) - : template; - const { getTemplate } = require(templatePath); - configuration[platform] = getTemplate(params, fs); - } - - return configuration; -} - /** * Returns a {@link Configuration} object for specified platform. * @@ -304,7 +242,7 @@ export const getConfig = (() => { path.dirname(require.resolve("react-native/template/package.json")) ); - configuration = loadPlatformTemplates(params, require, { + configuration = { common: { files: { ".gitignore": findGitIgnore(path.join(testAppPath, "example"), fs), @@ -337,7 +275,12 @@ export const getConfig = (() => { }, dependencies: {}, }, - }); + }; + + const templates = loadPlatformTemplates(params, fs); + for (const [platform, { getTemplate }] of Object.entries(templates)) { + configuration[platform] = getTemplate(params, fs); + } } return configuration[platform]; }; diff --git a/packages/app/scripts/template.mjs b/packages/app/scripts/template.mjs index c1afca183..aeb18c613 100644 --- a/packages/app/scripts/template.mjs +++ b/packages/app/scripts/template.mjs @@ -1,6 +1,11 @@ // @ts-check import * as nodefs from "node:fs"; +import { createRequire } from "node:module"; import * as path from "node:path"; +import { URL } from "node:url"; +import { memo, readJSONFile } from "./helpers.js"; + +/** @import { ConfigureParams, Manifest, Plugin } from "./types.js"; */ /** * @param {...string} paths @@ -26,6 +31,22 @@ export function findGitIgnore(dir, fs = nodefs) { return ""; } +/** @type {() => Required} */ +export const readManifest = memo(() => + readJSONFile(new URL("../package.json", import.meta.url)) +); + +/** + * @param {string} root + * @param {string} subpath + * @returns {string | false} + */ +function resolvePath(root, subpath) { + const resolved = path.resolve(root, subpath); + const rel = path.relative(root, resolved); + return !path.isAbsolute(rel) && !rel.startsWith("..") && resolved; +} + /** * Converts an object or value to a pretty JSON string. * @param {Record} obj @@ -72,3 +93,83 @@ export function bundleConfig() { BUNDLE_FORCE_RUBY_PLATFORM: 1 `; } + +/** + * @param {string} packagePath + * @returns {string[]} + */ +function getDependencies(packagePath, fs = nodefs) { + const manifest = path.join(packagePath, "package.json"); + if (!fs.existsSync(manifest)) { + return []; + } + + const { dependencies, peerDependencies, devDependencies } = readJSONFile( + manifest, + fs + ); + + /** @type {Set} */ + const set = new Set(); + for (const section of [dependencies, peerDependencies, devDependencies]) { + if (section) { + for (const key of Object.keys(section)) { + set.add(key); + } + } + } + return Array.from(set); +} + +/** + * @param {Pick} params + * @returns {Record} + */ +export function loadPlatformTemplates( + { packagePath, testAppPath }, + fs = nodefs +) { + const require = createRequire(import.meta.url); + const verbose = process.env["VERBOSE"]; + + const { defaultPlatformPackages } = readManifest(); + const platformPackages = { ...defaultPlatformPackages }; + + // We have to manually load project dependencies to avoid recursive calls + const opts = { paths: [packagePath] }; + for (const dependency of getDependencies(packagePath)) { + try { + const pkg = require.resolve(dependency + "/package.json", opts); + const { reactNativeTemplateConfig: config } = readJSONFile(pkg, fs); + if (config && typeof config === "object" && !Array.isArray(config)) { + const root = path.dirname(pkg); + if (verbose) { + const pkgPath = path.relative(packagePath, root); + console.log("Loading template config:", pkgPath); + } + for (const [key, { template, ...rest }] of Object.entries(config)) { + const resolved = resolvePath(root, template); + if (resolved && fs.existsSync(resolved)) { + platformPackages[key] = { ...rest, template: resolved }; + } + } + } + } catch (_) { + // `./package.json` may not always be defined by `exports` + } + } + + /** @type {Record} */ + const templates = {}; + for (const [platform, { template }] of Object.entries(platformPackages)) { + const templatePath = template.startsWith(".") + ? path.resolve(testAppPath, template) + : template; + templates[platform] = { + configure: (...args) => require(templatePath).configure(...args), + getTemplate: (...args) => require(templatePath).getTemplate(...args), + }; + } + + return templates; +} diff --git a/packages/app/scripts/types.ts b/packages/app/scripts/types.ts index 52468585a..eb62f787f 100644 --- a/packages/app/scripts/types.ts +++ b/packages/app/scripts/types.ts @@ -138,12 +138,17 @@ export type ProjectParams = { solutionFile: string; project: { projectFile: string }; }; + [platform: string]: { + sourceDir?: string; + [key: string]: unknown; + }; }; export type ProjectConfig = { android?: Pick; ios?: Pick; windows?: Pick; + [platform: string]: unknown; }; /*************************** @@ -351,3 +356,19 @@ export type BuildConfig = { variant: "fabric" | "paper"; engine?: "hermes" | "jsc"; }; + +/*********** + * Plugins * + ***********/ + +export type Plugin = { + configure( + projectRoot: string, + config: C, + fs?: typeof import("node:fs") + ): R; + getTemplate( + params: ConfigureParams, + fs?: typeof import("node:fs") + ): Configuration; +}; diff --git a/packages/app/test/configure-projects.test.mts b/packages/app/test/configure-projects.test.mts index 6c729f6f3..488a19d4a 100644 --- a/packages/app/test/configure-projects.test.mts +++ b/packages/app/test/configure-projects.test.mts @@ -63,8 +63,7 @@ describe("configureProjects()", () => { }); it("returns iOS config", () => { - const sourceDir = "ios"; - const config = { ios: { sourceDir } }; + const config = { ios: { sourceDir: "ios" } }; deepEqual(configureProjects(config), config); }); diff --git a/packages/app/test/ios/xcode.test.mts b/packages/app/test/ios/xcode.test.mts index ab9993f1b..2aa8085d0 100644 --- a/packages/app/test/ios/xcode.test.mts +++ b/packages/app/test/ios/xcode.test.mts @@ -8,7 +8,7 @@ import { } from "node:assert/strict"; import * as path from "node:path"; import { afterEach, beforeEach, describe, it } from "node:test"; -import { fileURLToPath, URL } from "node:url"; +import { URL, fileURLToPath } from "node:url"; import { isObject } from "../../ios/utils.mjs"; import { applyBuildSettings as applyBuildSettingsActual, From 727a4325fb0ebb1e5ad00ffcb7f22d884416b576 Mon Sep 17 00:00:00 2001 From: Tommy Nguyen <4123478+tido64@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:11:46 +0200 Subject: [PATCH 2/2] isolate plugins? --- packages/app/scripts/template.mjs | 33 ++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/app/scripts/template.mjs b/packages/app/scripts/template.mjs index aeb18c613..5de3e2a2c 100644 --- a/packages/app/scripts/template.mjs +++ b/packages/app/scripts/template.mjs @@ -1,8 +1,9 @@ // @ts-check import * as nodefs from "node:fs"; -import { createRequire } from "node:module"; +import { Module, createRequire } from "node:module"; import * as path from "node:path"; import { URL } from "node:url"; +import * as vm from "node:vm"; import { memo, readJSONFile } from "./helpers.js"; /** @import { ConfigureParams, Manifest, Plugin } from "./types.js"; */ @@ -94,6 +95,26 @@ BUNDLE_FORCE_RUBY_PLATFORM: 1 `; } +/** + * @template {unknown} T + * @param {string} script + * @param {string} spec + * @param {Record} context + * @returns {T} + */ +function execute(script, spec, context) { + const code = `require(${JSON.stringify(spec)}).${script};`; + const module = new Module(spec); + const result = vm.runInNewContext(code, { + ...context, + module, + exports: module.exports, + require: module.require, + process, + }); + return /** @type {T} */ (result); +} + /** * @param {string} packagePath * @returns {string[]} @@ -166,8 +187,14 @@ export function loadPlatformTemplates( ? path.resolve(testAppPath, template) : template; templates[platform] = { - configure: (...args) => require(templatePath).configure(...args), - getTemplate: (...args) => require(templatePath).getTemplate(...args), + configure: (projectRoot, config, fs) => { + const context = { projectRoot, config, fs }; + const script = "configure(projectRoot, config, fs)"; + return execute(script, templatePath, context); + }, + getTemplate: (params, fs) => { + return execute("getTemplate(params, fs)", templatePath, { params, fs }); + }, }; }