diff --git a/packages/metro-config/package.json b/packages/metro-config/package.json index d28aa4d3f6..0a7ce48c50 100644 --- a/packages/metro-config/package.json +++ b/packages/metro-config/package.json @@ -13,6 +13,7 @@ }, "license": "MIT", "dependencies": { + "@topoconfig/extends": "^0.12.0", "connect": "^3.6.5", "cosmiconfig": "^5.0.5", "jest-validate": "^29.6.3", diff --git a/packages/metro-config/src/__tests__/__snapshots__/loadConfig-test.js.snap b/packages/metro-config/src/__tests__/__snapshots__/loadConfig-test.js.snap index a89a9cea08..05507c078b 100644 --- a/packages/metro-config/src/__tests__/__snapshots__/loadConfig-test.js.snap +++ b/packages/metro-config/src/__tests__/__snapshots__/loadConfig-test.js.snap @@ -543,6 +543,187 @@ Object { } `; +exports[`loadConfig can populate \`extends\` references 1`] = ` +Object { + "cacheStores": Array [], + "cacheVersion": "foo", + "maxWorkers": 2, + "projectRoot": "/", + "reporter": null, + "resetCache": false, + "resolver": Object { + "assetExts": Array [ + "bmp", + "gif", + "jpg", + "jpeg", + "png", + "psd", + "svg", + "webp", + "m4v", + "mov", + "mp4", + "mpeg", + "mpg", + "webm", + "aac", + "aiff", + "caf", + "m4a", + "mp3", + "wav", + "html", + "pdf", + "yaml", + "yml", + "otf", + "ttf", + "zip", + ], + "assetResolutions": Array [ + "1", + "1.5", + "2", + "3", + "4", + ], + "blockList": /\\(\\\\/__tests__\\\\/\\.\\*\\)\\$/, + "dependencyExtractor": undefined, + "disableHierarchicalLookup": false, + "emptyModulePath": "metro-runtime/src/modules/empty-module", + "enableGlobalPackages": false, + "extraNodeModules": Object {}, + "hasteImplModulePath": undefined, + "nodeModulesPaths": Array [], + "platforms": Array [ + "ios", + "android", + "windows", + "web", + ], + "requireCycleIgnorePatterns": Array [ + /\\(\\^\\|\\\\/\\|\\\\\\\\\\)node_modules\\(\\$\\|\\\\/\\|\\\\\\\\\\)/, + ], + "resolveRequest": null, + "resolverMainFields": Array [ + "browser", + "main", + ], + "sourceExts": Array [ + "js", + "jsx", + "json", + "ts", + "tsx", + ], + "unstable_conditionNames": Array [ + "require", + "import", + ], + "unstable_conditionsByPlatform": Object { + "web": Array [ + "browser", + ], + }, + "unstable_enablePackageExports": false, + "unstable_enableSymlinks": true, + "useWatchman": true, + }, + "serializer": Object { + "createModuleIdFactory": [Function], + "customSerializer": null, + "experimentalSerializerHook": [Function], + "getModulesRunBeforeMainModule": [Function], + "getPolyfills": [Function], + "getRunModuleStatement": [Function], + "isThirdPartyModule": [Function], + "polyfillModuleNames": Array [], + "processModuleFilter": [Function], + }, + "server": Object { + "enhanceMiddleware": [Function], + "forwardClientLogs": true, + "port": 8081, + "rewriteRequestUrl": [Function], + "unstable_serverRoot": null, + "useGlobalHotkey": true, + "verifyConnections": false, + }, + "stickyWorkers": true, + "symbolicator": Object { + "customizeFrame": [Function], + "customizeStack": [Function], + }, + "transformer": Object { + "allowOptionalDependencies": false, + "assetPlugins": Array [], + "assetRegistryPath": "missing-asset-registry-path", + "asyncRequireModulePath": "metro-runtime/src/modules/asyncRequire", + "babelTransformerPath": "metro-babel-transformer", + "dynamicDepsInPackages": "throwAtRuntime", + "enableBabelRCLookup": true, + "enableBabelRuntime": true, + "getTransformOptions": [Function], + "globalPrefix": "", + "hermesParser": false, + "minifierConfig": Object { + "compress": Object { + "reduce_funcs": false, + }, + "mangle": Object { + "toplevel": false, + }, + "output": Object { + "ascii_only": true, + "quote_style": 3, + "wrap_iife": true, + }, + "sourceMap": Object { + "includeSources": false, + }, + "toplevel": false, + }, + "minifierPath": "metro-minify-terser", + "optimizationSizeLimit": 153600, + "publicPath": "/assets", + "transformVariants": Object { + "default": Object {}, + }, + "unstable_allowRequireContext": false, + "unstable_compactOutput": false, + "unstable_dependencyMapReservedName": null, + "unstable_disableModuleWrapping": false, + "unstable_disableNormalizePseudoGlobals": false, + "unstable_workerThreads": false, + "workerPath": "metro/src/DeltaBundler/Worker", + }, + "transformerPath": "", + "unstable_perfLoggerFactory": [Function], + "watchFolders": Array [ + "/", + ], + "watcher": Object { + "additionalExts": Array [ + "cjs", + "mjs", + ], + "healthCheck": Object { + "enabled": false, + "filePrefix": ".metro-health-check", + "interval": 30000, + "timeout": 5000, + }, + "unstable_workerThreads": false, + "watchman": Object { + "deferStates": Array [ + "hg.update", + ], + }, + }, +} +`; + exports[`loadConfig injects \`metro-cache\` into the \`cacheStores\` callback 1`] = ` Object { "cacheStores": Array [], diff --git a/packages/metro-config/src/__tests__/loadConfig-test.js b/packages/metro-config/src/__tests__/loadConfig-test.js index 247913f8a7..b977a6de2c 100644 --- a/packages/metro-config/src/__tests__/loadConfig-test.js +++ b/packages/metro-config/src/__tests__/loadConfig-test.js @@ -16,6 +16,8 @@ jest.mock('cosmiconfig'); const getDefaultConfig = require('../defaults'); const {loadConfig} = require('../loadConfig'); const cosmiconfig = require('cosmiconfig'); +const fs = require('fs'); +const os = require('os'); const path = require('path'); const prettyFormat = require('pretty-format'); const stripAnsi = require('strip-ansi'); @@ -152,6 +154,38 @@ describe('loadConfig', () => { expect(cosmiconfig.hasLoadBeenCalled()).toBeTruthy(); }); + it('can populate `extends` references', async () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'temp-')); + await fs.promises.writeFile( + path.resolve(temp, 'config.json'), + '{"extends": "./base.json"}', + 'utf8', + ); + await fs.promises.writeFile( + path.resolve(temp, 'base.json'), + '{"cacheVersion": "foo"}', + 'utf8', + ); + + const config: any = { + reporter: null, + maxWorkers: 2, + cacheStores: [], + transformerPath: '', + resolver: { + emptyModulePath: 'metro-runtime/src/modules/empty-module', + }, + extends: path.resolve(temp, 'config.json'), + }; + + cosmiconfig.setResolvedConfig(config); + + const result = await loadConfig({}); + + expect(result).toMatchSnapshot(); + expect(result.cacheVersion).toBe('foo'); + }); + it('can load the config with no config present', async () => { cosmiconfig.setReturnNull(true); diff --git a/packages/metro-config/src/loadConfig.js b/packages/metro-config/src/loadConfig.js index d2c65b0438..79667e0f2e 100644 --- a/packages/metro-config/src/loadConfig.js +++ b/packages/metro-config/src/loadConfig.js @@ -15,6 +15,7 @@ import type {ConfigT, InputConfigT, YargArguments} from './configTypes.flow'; const getDefaultConfig = require('./defaults'); const validConfig = require('./defaults/validConfig'); +const xtends = require('@topoconfig/extends'); const cosmiconfig = require('cosmiconfig'); const fs = require('fs'); const {validate} = require('jest-validate'); @@ -102,86 +103,83 @@ async function resolveConfig( return result; } +const resolveExtra = (base: any = {}, key: string, resolver: Function) => { + const value = base[key]; + return value != null ? {[key]: resolver(value)} : {}; +}; + function mergeConfig>( defaultConfig: T, ...configs: Array ): T { // If the file is a plain object we merge the file with the default config, // for the function we don't do this since that's the responsibility of the user - return configs.reduce( - (totalConfig, nextConfig) => ({ - ...totalConfig, - ...nextConfig, - - cacheStores: - nextConfig.cacheStores != null - ? typeof nextConfig.cacheStores === 'function' - ? nextConfig.cacheStores(MetroCache) - : nextConfig.cacheStores - : totalConfig.cacheStores, - - resolver: { - ...totalConfig.resolver, - // $FlowFixMe[exponential-spread] - ...(nextConfig.resolver || {}), - dependencyExtractor: - nextConfig.resolver && nextConfig.resolver.dependencyExtractor != null - ? resolve(nextConfig.resolver.dependencyExtractor) - : // $FlowFixMe[incompatible-use] - totalConfig.resolver.dependencyExtractor, - hasteImplModulePath: - nextConfig.resolver && nextConfig.resolver.hasteImplModulePath != null - ? resolve(nextConfig.resolver.hasteImplModulePath) - : // $FlowFixMe[incompatible-use] - totalConfig.resolver.hasteImplModulePath, - }, - serializer: { - ...totalConfig.serializer, - // $FlowFixMe[exponential-spread] - ...(nextConfig.serializer || {}), - }, - transformer: { - ...totalConfig.transformer, - // $FlowFixMe[exponential-spread] - ...(nextConfig.transformer || {}), - babelTransformerPath: - nextConfig.transformer && - nextConfig.transformer.babelTransformerPath != null - ? resolve(nextConfig.transformer.babelTransformerPath) - : // $FlowFixMe[incompatible-use] - totalConfig.transformer.babelTransformerPath, - }, - server: { - ...totalConfig.server, - // $FlowFixMe[exponential-spread] - ...(nextConfig.server || {}), - }, - symbolicator: { - ...totalConfig.symbolicator, - // $FlowFixMe[exponential-spread] - ...(nextConfig.symbolicator || {}), - }, - watcher: { - ...totalConfig.watcher, - // $FlowFixMe[exponential-spread] - ...nextConfig.watcher, - watchman: { - // $FlowFixMe[exponential-spread] - ...totalConfig.watcher?.watchman, - ...nextConfig.watcher?.watchman, + const extras = [ + defaultConfig, + ...configs.map(c => { + return { + ...c, + transformer: { + ...(c.transformer || {}), + ...(resolveExtra(c.transformer, 'babelTransformerPath', resolve): { + babelTransformerPath?: string, + }), }, - healthCheck: { - // $FlowFixMe[exponential-spread] - ...totalConfig.watcher?.healthCheck, - // $FlowFixMe: Spreading shapes creates an explosion of union types - ...nextConfig.watcher?.healthCheck, + resolver: { + ...(c.resolver || {}), + ...(resolveExtra(c.resolver, 'dependencyExtractor', resolve): { + dependencyExtractor?: string, + }), + ...(resolveExtra(c.resolver, 'hasteImplModulePath', resolve): { + hasteImplModulePath?: string, + }), }, - }, + ...resolveExtra(c, 'cacheStores', stores => { + return typeof stores === 'function' ? stores(MetroCache) : stores; + }), + }; }), - defaultConfig, + ]; + + return xtends.populateSync( + {}, + { + // $FlowFixMe[prop-missing] + cwd: mergeConfig.cwd, + extends: extras, + rules: { + '*': 'override', + resolver: 'merge', + serializer: 'merge', + server: 'merge', + symbolicator: 'merge', + transformer: 'merge', + watcher: 'merge', + 'watcher.watchman': 'merge', + 'watcher.healthCheck': 'merge', + }, + }, ); } +// `mergeConfig` is public, so we should try to keep its API stable +// This trick is necessary to pass the proper cwd to the internal `populateSync` call +const mergeConfigWithCwd = >( + cwd: string, + base: T, + extra: ConfigT | InputConfigT, +): T => { + // $FlowFixMe[prop-missing] + mergeConfig.cwd = cwd; + + // $FlowFixMe[incompatible-variance] + // $FlowFixMe[incompatible-call] + const config = mergeConfig(base, extra); + // $FlowFixMe[prop-missing] + mergeConfig.cwd = undefined; + return config; +}; + async function loadMetroConfigFromDisk( path?: string, cwd?: string, @@ -196,23 +194,21 @@ async function loadMetroConfigFromDisk( const rootPath = dirname(filepath); const defaults = await getDefaultConfig(rootPath); - // $FlowFixMe[incompatible-variance] - // $FlowFixMe[incompatible-call] - const defaultConfig: ConfigT = mergeConfig(defaults, defaultConfigOverrides); + const defaultConfig: ConfigT = mergeConfigWithCwd( + rootPath, + defaults, + defaultConfigOverrides, + ); if (typeof configModule === 'function') { // Get a default configuration based on what we know, which we in turn can pass // to the function. const resultedConfig = await configModule(defaultConfig); - // $FlowFixMe[incompatible-call] - // $FlowFixMe[incompatible-variance] - return mergeConfig(defaultConfig, resultedConfig); + return mergeConfigWithCwd(rootPath, defaultConfig, resultedConfig); } - // $FlowFixMe[incompatible-variance] - // $FlowFixMe[incompatible-call] - return mergeConfig(defaultConfig, configModule); + return mergeConfigWithCwd(rootPath, defaultConfig, configModule); } function overrideConfigWithArguments( diff --git a/yarn.lock b/yarn.lock index a055989e86..0ae30b57e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1726,6 +1726,11 @@ dependencies: "@sinonjs/commons" "^3.0.0" +"@topoconfig/extends@^0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@topoconfig/extends/-/extends-0.12.0.tgz#82ed9196df8477f95b7822a77f53efcc8b765d00" + integrity sha512-+6NJxJsvUtJADzrp9h5O19Cqvw1xob4ZhQstNeQIr+hvkPn8zkI65vT3CvtaqsJQ3VDnVvzy+UOkqZ0zO6/viw== + "@tsconfig/node18@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@tsconfig/node18/-/node18-1.0.1.tgz#ea5b375a9ead6b09ccbd70c3894ea069829ea1bb"