fix(metro-config): workaround for Expo unintentionally overwriting fields (#3266)

This commit is contained in:
Tommy Nguyen 2024-08-12 10:33:00 +02:00 коммит произвёл GitHub
Родитель 71156f4a8d
Коммит f0a85ac855
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
9 изменённых файлов: 228 добавлений и 41 удалений

Просмотреть файл

@ -0,0 +1,6 @@
---
"@rnx-kit/metro-config": patch
---
Let users know if they're missing `@react-native/metro-config` when on React
Native 0.72+

Просмотреть файл

@ -0,0 +1,5 @@
---
"@rnx-kit/metro-config": patch
---
Workaround for Expo unintentionally overwriting our Metro config

Просмотреть файл

@ -24,7 +24,6 @@
"test": "rnx-kit-scripts test"
},
"dependencies": {
"@rnx-kit/console": "^1.0.0",
"@rnx-kit/tools-node": "^2.0.0",
"@rnx-kit/tools-react-native": "^1.3.4",
"@rnx-kit/tools-workspaces": "^0.1.3"

Просмотреть файл

@ -1,7 +1,10 @@
// @ts-check
const {
readPackage,
findPackageDependencyDir,
} = require("@rnx-kit/tools-node/package");
const { findUp } = require("@rnx-kit/tools-node/path");
const { requireModuleFromMetro } = require("@rnx-kit/tools-react-native/metro");
const fs = require("fs");
/**
* @typedef {import("metro-config").MetroConfig} MetroConfig;
@ -39,6 +42,33 @@ function getPreludeModules(availablePlatforms, projectRoot) {
return Array.from(mainModules);
}
/**
* Returns whether we need to build a complete Metro config.
*
* This is a requirement starting with 0.72 as `@react-native-community/cli`
* will no longer provide defaults.
*
* @param {string} projectRoot
* @returns {boolean}
*/
function needsFullConfig(projectRoot) {
const options = { startDir: projectRoot };
const pkgJson = findUp("package.json", options);
if (!pkgJson) {
return false;
}
const rnDir = findPackageDependencyDir("react-native", options);
if (!rnDir) {
return false;
}
const { version } = readPackage(rnDir);
const [major, minor = 0] = version.split(".");
const v = Number(major) * 1000 + Number(minor);
return v === 0 || v >= 72;
}
/**
* @param {string} moduleName
* @param {string} implementation
@ -94,6 +124,22 @@ function outOfTreePlatformResolver(implementations, projectRoot) {
return platformResolver;
}
/**
* Tries to resolve `@react-native/metro-config` from specified directory.
* @param {string} fromDir
* @returns {string}
*/
function resolveMetroConfig(fromDir) {
const options = { paths: [fromDir] };
try {
return require.resolve("@react-native/metro-config", options);
} catch (_) {
throw new Error(
"Cannot find module '@react-native/metro-config'; as of React Native 0.72, it is required for configuring Metro correctly"
);
}
}
/**
* Returns default Metro config.
*
@ -104,41 +150,30 @@ function outOfTreePlatformResolver(implementations, projectRoot) {
* @returns {MetroConfig[]}
*/
function getDefaultConfig(projectRoot) {
const pkgJson = findUp("package.json", { startDir: projectRoot });
if (!pkgJson) {
if (!needsFullConfig(projectRoot)) {
return [];
}
const manifest = fs.readFileSync(pkgJson, { encoding: "utf-8" });
if (manifest.includes("@react-native/metro-config")) {
try {
const metroConfigPath = require.resolve("@react-native/metro-config", {
paths: [projectRoot],
});
const { getDefaultConfig } = require(metroConfigPath);
const { getAvailablePlatforms } = require("@rnx-kit/tools-react-native");
const metroConfigPath = resolveMetroConfig(projectRoot);
const defaultConfig = getDefaultConfig(projectRoot);
const { getDefaultConfig } = require(metroConfigPath);
const { getAvailablePlatforms } = require("@rnx-kit/tools-react-native");
const availablePlatforms = getAvailablePlatforms(projectRoot);
defaultConfig.resolver.platforms = Object.keys(availablePlatforms);
defaultConfig.resolver.resolveRequest = outOfTreePlatformResolver(
availablePlatforms,
projectRoot
);
const defaultConfig = getDefaultConfig(projectRoot);
const preludeModules = getPreludeModules(availablePlatforms, projectRoot);
defaultConfig.serializer.getModulesRunBeforeMainModule = () => {
return preludeModules;
};
const availablePlatforms = getAvailablePlatforms(projectRoot);
defaultConfig.resolver.platforms = Object.keys(availablePlatforms);
defaultConfig.resolver.resolveRequest = outOfTreePlatformResolver(
availablePlatforms,
projectRoot
);
return [defaultConfig];
} catch (_) {
// Ignore
}
}
const preludeModules = getPreludeModules(availablePlatforms, projectRoot);
defaultConfig.serializer.getModulesRunBeforeMainModule = () => {
return preludeModules;
};
return [];
return [defaultConfig];
}
exports.getDefaultConfig = getDefaultConfig;

Просмотреть файл

@ -0,0 +1,65 @@
// @ts-check
/** @typedef {import("metro-config").MetroConfig} MetroConfig */
/**
* Determines whether this is an Expo config.
* @param {MetroConfig=} config
* @returns {boolean}
*/
function isExpoConfig(config) {
if (!config) {
return false;
}
// https://github.com/expo/expo/blob/sdk-51/packages/%40expo/metro-config/src/ExpoMetroConfig.ts#L256
const transformer = config.transformer;
if (transformer) {
if (
"_expoRelativeProjectRoot" in transformer ||
transformer.babelTransformerPath?.includes("@expo")
) {
return true;
}
}
return Boolean(config.transformerPath?.includes("@expo"));
}
/**
* Applies workarounds to make the Expo config work.
* @param {MetroConfig} config
* @param {MetroConfig} defaultConfig
* @returns {void}
*/
function applyExpoWorkarounds(config, defaultConfig) {
// Expo's default config is based on Metro's default config, and Metro's
// default config sets some fields (like `resolveRequest`) to `null`, which
// then overwrites our fields:
// https://github.com/facebook/metro/blob/v0.80.10/packages/metro-config/src/defaults/index.js#L51
if (config.resolver?.resolveRequest === null) {
delete config.resolver.resolveRequest;
}
// Expo _always_ sets `getModulesRunBeforeMainModule`:
// https://github.com/expo/expo/blob/sdk-51/packages/%40expo/metro-config/src/ExpoMetroConfig.ts#L207
const getModulesRunBeforeMainModule =
config.serializer?.getModulesRunBeforeMainModule;
if (getModulesRunBeforeMainModule) {
const core = /Libraries[/\\]Core[/\\]InitializeCore/;
const prelude =
defaultConfig.serializer?.getModulesRunBeforeMainModule?.("") ?? [];
config.serializer.getModulesRunBeforeMainModule = (entryFilePath) => {
const modules = prelude.slice();
for (const m of getModulesRunBeforeMainModule(entryFilePath)) {
if (!core.test(m)) {
modules.push(m);
}
}
return modules;
};
}
}
exports.applyExpoWorkarounds = applyExpoWorkarounds;
exports.isExpoConfig = isExpoConfig;

Просмотреть файл

@ -7,8 +7,10 @@ const {
} = require("@rnx-kit/tools-react-native/metro");
const fs = require("fs");
const path = require("path");
const { applyExpoWorkarounds, isExpoConfig } = require("./expoConfig");
/**
* @typedef {import("metro-config").InputConfigT} InputConfigT;
* @typedef {import("metro-config").MetroConfig} MetroConfig;
*/
@ -261,11 +263,11 @@ module.exports = {
/**
* Helper function for configuring Metro.
* @param {MetroConfig=} customConfig
* @param {MetroConfig=} inputConfig
* @returns {MetroConfig}
*/
makeMetroConfig: (customConfig = {}) => {
const projectRoot = customConfig.projectRoot || process.cwd();
makeMetroConfig: (inputConfig = {}) => {
const projectRoot = inputConfig.projectRoot || process.cwd();
const { mergeConfig } = requireModuleFromMetro("metro-config", projectRoot);
const { enhanceMiddleware } = require("./assetPluginForMonorepos");
@ -273,9 +275,10 @@ module.exports = {
const blockList = exclusionList([], projectRoot);
const customBlockList =
customConfig.resolver &&
(customConfig.resolver.blockList || customConfig.resolver.blacklistRE);
inputConfig.resolver &&
(inputConfig.resolver.blockList || inputConfig.resolver.blacklistRE);
/** @type {InputConfigT[]} */
const [defaultConfig, ...configs] = [
...getDefaultConfig(projectRoot),
{
@ -295,12 +298,12 @@ module.exports = {
},
}),
},
watchFolders: customConfig.watchFolders ?? defaultWatchFolders(),
watchFolders: inputConfig.watchFolders ?? defaultWatchFolders(),
},
{
...customConfig,
...inputConfig,
resolver: {
...customConfig.resolver,
...inputConfig.resolver,
...(customBlockList
? {
// Metro introduced `blockList` in 0.60, but still prefers
@ -317,13 +320,17 @@ module.exports = {
* @see exclusionList for further information.
*/
...resolveUniqueModules(UNIQUE_MODULES, projectRoot),
...(customConfig.resolver
? customConfig.resolver.extraNodeModules
: {}),
...inputConfig.resolver?.extraNodeModules,
},
},
},
];
const userConfig = configs[configs.length - 1];
if (isExpoConfig(userConfig)) {
applyExpoWorkarounds(userConfig, defaultConfig);
}
return mergeConfig(defaultConfig, ...configs);
},
};

Просмотреть файл

@ -0,0 +1,69 @@
import type { MetroConfig } from "metro-config";
import { deepEqual, ok } from "node:assert/strict";
import { describe, it } from "node:test";
import { applyExpoWorkarounds, isExpoConfig } from "../src/expoConfig";
describe("isExpoConfig()", () => {
it("returns true when it's likely a config comes from Expo", () => {
ok(!isExpoConfig());
ok(!isExpoConfig({}));
ok(
!isExpoConfig({
transformer: { babelTransformerPath: "metro-babel-transformer" },
})
);
ok(!isExpoConfig({ transformerPath: "metro-transform-worker" }));
ok(
isExpoConfig({
transformer: { _expoRelativeProjectRoot: null },
} as MetroConfig)
);
ok(isExpoConfig({ transformer: { babelTransformerPath: "@expo" } }));
ok(isExpoConfig({ transformerPath: "@expo" }));
});
});
describe("applyExpoWorkarounds()", () => {
it("removes `config.resolver.resolveRequest` when it's `null`", () => {
const config = { resolver: { resolveRequest: null } };
ok("resolveRequest" in config.resolver);
applyExpoWorkarounds(config, {});
ok(!("resolveRequest" in config.resolver));
});
it("replaces `config.serializer.getModulesRunBeforeMainModule`", () => {
const expoConfig = {
serializer: {
getModulesRunBeforeMainModule: () => [
"react-native/Libraries/Core/InitializeCore.js",
"expo/build/winter",
"@expo/metro-runtime",
],
},
};
const defaultConfig = {
serializer: {
getModulesRunBeforeMainModule: () => [
"react-native/Libraries/Core/InitializeCore.js",
"react-native-macos/Libraries/Core/InitializeCore.js",
"react-native-windows/Libraries/Core/InitializeCore.js",
],
},
};
applyExpoWorkarounds(expoConfig, defaultConfig);
deepEqual(expoConfig.serializer.getModulesRunBeforeMainModule(), [
"react-native/Libraries/Core/InitializeCore.js",
"react-native-macos/Libraries/Core/InitializeCore.js",
"react-native-windows/Libraries/Core/InitializeCore.js",
"expo/build/winter",
"@expo/metro-runtime",
]);
});
});

Просмотреть файл

@ -405,6 +405,7 @@ describe("makeMetroConfig", () => {
it("sets both `blacklistRE` and `blockList`", () => {
const configWithBlacklist = makeMetroConfig({
projectRoot,
resolver: {
blacklistRE: /.*/,
},
@ -415,6 +416,7 @@ describe("makeMetroConfig", () => {
equal(blacklistRE, configWithBlacklist.resolver?.blockList);
const configWithBlockList = makeMetroConfig({
projectRoot,
resolver: {
blockList: /.*/,
},

Просмотреть файл

@ -4008,7 +4008,6 @@ __metadata:
dependencies:
"@babel/core": "npm:^7.20.0"
"@babel/preset-env": "npm:^7.20.0"
"@rnx-kit/console": "npm:^1.0.0"
"@rnx-kit/eslint-config": "npm:*"
"@rnx-kit/scripts": "npm:*"
"@rnx-kit/tools-node": "npm:^2.0.0"