Skip to content

Commit eb8e892

Browse files
robhoganfacebook-github-bot
authored andcommitted
metro-config: Accept functions as arguments to mergeConfig (#1580)
Summary: Pull Request resolved: #1580 Extend the existing `mergeConfig` API so that it accepts functions as well as config objects. These functions are provided with the leftward merged config as an argument, so that they may re-use or extend it in ways not covered by `mergeConfig`'s simple spread. For example: # Example Suppose `awesome-lib` exports `addThirdPartyMagic` for configuring Metro for some nice functionality, and it wants to merge intelligently with previous config: ```js export function addThirdPartyMagic(baseConfig) { return { resolver: { assetExts: [...baseConfig.resolver.assetExts, 'magic'], }, }; } ``` ## Currently This would require a nest or sequence of `mergeConfig` calls with previous configs explicitly passed to `addThirdPartyMagic`, e.g: ```js const {mergeConfig} = require('metro-config'); const {getDefaultValues} = require('react-native/metro-config'); const {addThirdPartyMagic} = require('awesome-lib'); const defaults = getDefaultValues(__dirname); const myConfig = mergeConfig(defaults, { resolver: { assetExts: ['gif', ...defaults.resolver.assetExts], }, }) module.exports = mergeConfig(myConfig, addThirdPartyMagic(myConfig)); ``` There's a lot of boilerplate here, it's easy to get wrong and it doesn't get any nicer when you introduce a second and third library that wants to customise config. ## Proposed Instead, by allowing functions that supply the the previous config, users can do: ```js const {mergeConfig} = require('metro-config'); const {getDefaultValues} = require('react-native/metro-config'); const {addThirdPartyMagic} = require('awesome-lib'); module.exports = mergeConfig(getDefaultValues(__dirname), (baseConfig) => { resolver: { assetExts: ['gif', ...baseConfig.resolver.assetExts], }, }, addThirdPartyMagic); ``` And this scales - every additional library is just another argument to `mergeConfig`. ### Future? Out of scope for just now, but we *could* consider an array exported from `metro.config.js` to mean arguments to `mergeConfig`, and libraries like `react-native/metro-config` could export a (maybe async) function as a default export, so the future could look like this: ```ts // metro.config.ts import type {MetroConfig} from 'metro-config'; export default = [ import('react-native/metro-config'), (defaults) => ({ resolver: { sourceExts: [...defaults.resolver.sourceExts, 'custom'] } }), import('awesome-lib/metro-config'), ] satisfies MetroConfig[]; ``` Reviewed By: huntie Differential Revision: D82221532 fbshipit-source-id: ada49c39a2d5ec175eaa5c682b3ef851f0a03c0f
1 parent e3ad8e0 commit eb8e892

8 files changed

Lines changed: 240 additions & 85 deletions

File tree

docs/Configuration.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -726,9 +726,13 @@ The default value is `['hg.update']`.
726726
727727
Using the `metro-config` package it is possible to merge multiple configurations together.
728728
729-
| Method | Description |
730-
| --------------------------------------- | ---------------------------------------------------------------------- |
731-
| `mergeConfig(...configs): MergedConfig` | Returns the merged configuration of two or more configuration objects. |
729+
| Method | Description |
730+
| --------------------------------------- | ----------------------------------------------------------------------------------- |
731+
| `mergeConfig(...configs): MergedConfig` | Returns the merged configuration of two or more configuration objects or functions. |
732+
733+
`configs` may be any combination of configuration objects or functions (from Metro 0.83.2). Functions are called with the merged config of all configs to the left, which may be useful for complex merges with the previous config.
734+
735+
If any arguments are async functions, `mergeConfig` will return a `Promise`, otherwise it will return the merged config synchronously.
732736
733737
:::note
734738
@@ -777,5 +781,12 @@ const configB = {
777781
}
778782
};
779783
780-
module.exports = mergeConfig(configA, configB);
784+
// Function forms may be used to access the previous configuration
785+
configCFn = (previousConfig /* result of mergeConfig(configA, configB) */) => {
786+
return {
787+
watchFolders: [...previousConfig.watchFolders, 'my-watch-folder'],
788+
}
789+
}
790+
791+
module.exports = mergeConfig(configA, configB, configCFn);
781792
```
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
/*::
12+
import type {ConfigT, InputConfigT} from '../types';
13+
14+
type ConfigFn = (previous: ConfigT) => InputConfigT
15+
*/
16+
17+
const {mergeConfig} = require('../loadConfig');
18+
19+
const secondConfig /*:ConfigFn */ = previous => ({
20+
resolver: {
21+
sourceExts: ['before', ...previous.resolver.sourceExts],
22+
},
23+
});
24+
25+
const thirdConfig /*:ConfigFn */ = previous => ({
26+
resolver: {
27+
sourceExts: [...previous.resolver.sourceExts, 'after'],
28+
},
29+
});
30+
31+
module.exports = (metroDefaults /*:ConfigT*/): ConfigT =>
32+
mergeConfig(metroDefaults, secondConfig, thirdConfig);

packages/metro-config/src/__flowtests__/types-flowtest.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
import type {ConfigT, InputConfigT} from 'metro-config';
1313

14+
import {mergeConfig} from '../loadConfig';
15+
1416
declare var config: ConfigT;
1517
declare var inputConfig: InputConfigT;
1618

@@ -44,3 +46,24 @@ if (
4446
// ConfigT is completely hydrated (no errors accessing deep props)
4547
config.resolver.unstable_conditionsByPlatform['foo'];
4648
config.transformer.assetPlugins[0];
49+
50+
// A mergeConfig returns a full config only if the base is a full config
51+
mergeConfig(config, {}) as ConfigT;
52+
// $FlowExpectedError[incompatible-type]
53+
mergeConfig(inputConfig, {}) as ConfigT;
54+
55+
// And is synchronous with any number of sync arguments
56+
mergeConfig(
57+
config,
58+
() => ({}),
59+
{},
60+
() => ({}),
61+
) as ConfigT;
62+
63+
// But async if any function returns a promise
64+
mergeConfig(
65+
config,
66+
() => ({}),
67+
{},
68+
async () => ({}),
69+
).catch(() => {});

packages/metro-config/src/__tests__/loadConfig-test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,23 @@ describe('loadConfig', () => {
6666
});
6767
});
6868

69+
test('mergeConfig chains config functions', async () => {
70+
const defaultConfigOverrides = {
71+
resolver: {
72+
sourceExts: ['override'],
73+
},
74+
};
75+
const config = path.resolve(
76+
__dirname,
77+
'../__fixtures__/merged.metro.config.js',
78+
);
79+
const result = await loadConfig({config}, defaultConfigOverrides);
80+
expect(result.projectRoot).toEqual(path.dirname(config));
81+
expect(result.resolver).toMatchObject({
82+
sourceExts: ['before', 'override', 'after'],
83+
});
84+
});
85+
6986
test('can load the config from a path pointing to a directory', async () => {
7087
// We don't actually use the specified file in this test but it needs to
7188
// resolve to a real file on the file system.

packages/metro-config/src/loadConfig.js

Lines changed: 126 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -103,88 +103,139 @@ async function resolveConfig(
103103
return await loadConfigFile(configPath);
104104
}
105105

106-
function mergeConfig<T: $ReadOnly<InputConfigT>>(
107-
defaultConfig: T,
108-
...configs: Array<InputConfigT>
106+
function mergeConfigObjects<T: InputConfigT>(
107+
base: T,
108+
overrides: InputConfigT,
109109
): T {
110-
// If the file is a plain object we merge the file with the default config,
111-
// for the function we don't do this since that's the responsibility of the user
112-
return configs.reduce(
113-
(totalConfig, nextConfig) => ({
114-
...totalConfig,
115-
...nextConfig,
116-
117-
cacheStores:
118-
nextConfig.cacheStores != null
119-
? typeof nextConfig.cacheStores === 'function'
120-
? nextConfig.cacheStores(MetroCache)
121-
: nextConfig.cacheStores
122-
: totalConfig.cacheStores,
123-
124-
resolver: {
125-
...totalConfig.resolver,
126-
// $FlowFixMe[exponential-spread]
127-
...(nextConfig.resolver || {}),
128-
dependencyExtractor:
129-
nextConfig.resolver && nextConfig.resolver.dependencyExtractor != null
130-
? resolve(nextConfig.resolver.dependencyExtractor)
131-
: // $FlowFixMe[incompatible-use]
132-
totalConfig.resolver.dependencyExtractor,
133-
hasteImplModulePath:
134-
nextConfig.resolver && nextConfig.resolver.hasteImplModulePath != null
135-
? resolve(nextConfig.resolver.hasteImplModulePath)
136-
: // $FlowFixMe[incompatible-use]
137-
totalConfig.resolver.hasteImplModulePath,
138-
},
139-
serializer: {
140-
...totalConfig.serializer,
141-
// $FlowFixMe[exponential-spread]
142-
...(nextConfig.serializer || {}),
143-
},
144-
transformer: {
145-
...totalConfig.transformer,
146-
// $FlowFixMe[exponential-spread]
147-
...(nextConfig.transformer || {}),
148-
babelTransformerPath:
149-
nextConfig.transformer &&
150-
nextConfig.transformer.babelTransformerPath != null
151-
? resolve(nextConfig.transformer.babelTransformerPath)
152-
: // $FlowFixMe[incompatible-use]
153-
totalConfig.transformer.babelTransformerPath,
154-
},
155-
server: {
156-
...totalConfig.server,
110+
return {
111+
...base,
112+
...overrides,
113+
114+
cacheStores:
115+
overrides.cacheStores != null
116+
? typeof overrides.cacheStores === 'function'
117+
? overrides.cacheStores(MetroCache)
118+
: overrides.cacheStores
119+
: base.cacheStores,
120+
121+
resolver: {
122+
...base.resolver,
123+
// $FlowFixMe[exponential-spread]
124+
...(overrides.resolver || {}),
125+
dependencyExtractor:
126+
overrides.resolver && overrides.resolver.dependencyExtractor != null
127+
? resolve(overrides.resolver.dependencyExtractor)
128+
: // $FlowFixMe[incompatible-use]
129+
base.resolver.dependencyExtractor,
130+
hasteImplModulePath:
131+
overrides.resolver && overrides.resolver.hasteImplModulePath != null
132+
? resolve(overrides.resolver.hasteImplModulePath)
133+
: // $FlowFixMe[incompatible-use]
134+
base.resolver.hasteImplModulePath,
135+
},
136+
serializer: {
137+
...base.serializer,
138+
// $FlowFixMe[exponential-spread]
139+
...(overrides.serializer || {}),
140+
},
141+
transformer: {
142+
...base.transformer,
143+
// $FlowFixMe[exponential-spread]
144+
...(overrides.transformer || {}),
145+
babelTransformerPath:
146+
overrides.transformer &&
147+
overrides.transformer.babelTransformerPath != null
148+
? resolve(overrides.transformer.babelTransformerPath)
149+
: // $FlowFixMe[incompatible-use]
150+
base.transformer.babelTransformerPath,
151+
},
152+
server: {
153+
...base.server,
154+
// $FlowFixMe[exponential-spread]
155+
...(overrides.server || {}),
156+
},
157+
symbolicator: {
158+
...base.symbolicator,
159+
// $FlowFixMe[exponential-spread]
160+
...(overrides.symbolicator || {}),
161+
},
162+
watcher: {
163+
...base.watcher,
164+
// $FlowFixMe[exponential-spread]
165+
...overrides.watcher,
166+
watchman: {
157167
// $FlowFixMe[exponential-spread]
158-
...(nextConfig.server || {}),
168+
...base.watcher?.watchman,
169+
...overrides.watcher?.watchman,
159170
},
160-
symbolicator: {
161-
...totalConfig.symbolicator,
171+
healthCheck: {
162172
// $FlowFixMe[exponential-spread]
163-
...(nextConfig.symbolicator || {}),
173+
...base.watcher?.healthCheck,
174+
...overrides.watcher?.healthCheck,
164175
},
165-
watcher: {
166-
...totalConfig.watcher,
176+
unstable_autoSaveCache: {
167177
// $FlowFixMe[exponential-spread]
168-
...nextConfig.watcher,
169-
watchman: {
170-
// $FlowFixMe[exponential-spread]
171-
...totalConfig.watcher?.watchman,
172-
...nextConfig.watcher?.watchman,
173-
},
174-
healthCheck: {
175-
// $FlowFixMe[exponential-spread]
176-
...totalConfig.watcher?.healthCheck,
177-
...nextConfig.watcher?.healthCheck,
178-
},
179-
unstable_autoSaveCache: {
180-
// $FlowFixMe[exponential-spread]
181-
...totalConfig.watcher?.unstable_autoSaveCache,
182-
...nextConfig.watcher?.unstable_autoSaveCache,
183-
},
178+
...base.watcher?.unstable_autoSaveCache,
179+
...overrides.watcher?.unstable_autoSaveCache,
184180
},
185-
}),
186-
defaultConfig,
187-
);
181+
},
182+
};
183+
}
184+
185+
async function mergeConfigAsync<T: InputConfigT>(
186+
baseConfig: Promise<T>,
187+
...reversedConfigs: $ReadOnlyArray<
188+
InputConfigT | (T => InputConfigT) | (T => Promise<InputConfigT>),
189+
>
190+
): Promise<T> {
191+
let currentConfig: T = await baseConfig;
192+
for (const next of reversedConfigs) {
193+
const nextConfig: InputConfigT = await (typeof next === 'function'
194+
? next(currentConfig)
195+
: next);
196+
currentConfig = mergeConfigObjects(currentConfig, nextConfig);
197+
}
198+
return currentConfig;
199+
}
200+
201+
/**
202+
* Merge two or more partial config objects (or functions returning partial
203+
* configs) together, with arguments to the right overriding the left.
204+
*
205+
* Functions will be parsed the current config (the merge of all configs to the
206+
* left).
207+
*
208+
* Functions may be async, in which case this function will return a promise.
209+
* Otherwise it will return synchronously.
210+
*/
211+
function mergeConfig<
212+
T: InputConfigT,
213+
R: $ReadOnlyArray<
214+
| InputConfigT
215+
| ((baseConfig: T) => InputConfigT)
216+
| ((baseConfig: T) => Promise<InputConfigT>),
217+
>,
218+
>(
219+
base: T | (() => T),
220+
...configs: R
221+
): R extends $ReadOnlyArray<InputConfigT | ((baseConfig: T) => InputConfigT)>
222+
? T
223+
: Promise<T> {
224+
let currentConfig: T = typeof base === 'function' ? base() : base;
225+
// Reverse for easy popping
226+
const reversedConfigs = configs.toReversed();
227+
let next;
228+
while ((next = reversedConfigs.pop())) {
229+
const nextConfig: InputConfigT | Promise<InputConfigT> =
230+
typeof next === 'function' ? next(currentConfig) : next;
231+
if (nextConfig instanceof Promise) {
232+
// $FlowFixMe[incompatible-type] Not clear why Flow doesn't like this
233+
return mergeConfigAsync(nextConfig, reversedConfigs.toReversed());
234+
}
235+
currentConfig = mergeConfigObjects(currentConfig, nextConfig) as T;
236+
}
237+
// $FlowFixMe[incompatible-type] Not clear why Flow doesn't like this
238+
return currentConfig;
188239
}
189240

190241
async function loadMetroConfigFromDisk(

packages/metro-config/types/loadConfig.d.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,29 @@ declare function resolveConfig(
2222
filePath?: string,
2323
cwd?: string,
2424
): Promise<ResolveConfigResult>;
25-
declare function mergeConfig<T extends Readonly<InputConfigT>>(
26-
defaultConfig: T,
27-
...configs: Array<InputConfigT>
28-
): T;
25+
/**
26+
* Merge two or more partial config objects (or functions returning partial
27+
* configs) together, with arguments to the right overriding the left.
28+
*
29+
* Functions will be parsed the current config (the merge of all configs to the
30+
* left).
31+
*
32+
* Functions may be async, in which case this function will return a promise.
33+
* Otherwise it will return synchronously.
34+
*/
35+
declare function mergeConfig<
36+
T extends InputConfigT,
37+
R extends ReadonlyArray<
38+
| InputConfigT
39+
| ((baseConfig: T) => InputConfigT)
40+
| ((baseConfig: T) => Promise<InputConfigT>)
41+
>,
42+
>(
43+
base: T | (() => T),
44+
...configs: R
45+
): R extends ReadonlyArray<InputConfigT | ((baseConfig: T) => InputConfigT)>
46+
? T
47+
: Promise<T>;
2948
/**
3049
* Load the metro configuration from disk
3150
* @param {object} argv Arguments coming from the CLI, can be empty

packages/metro/src/Server/__tests__/Server-test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
ReadOnlyGraph,
1818
TransformResultDependency,
1919
} from '../../DeltaBundler/types';
20+
import type {InputConfigT} from 'metro-config';
2021

2122
import ResourceNotFoundError from '../../IncrementalBundler/ResourceNotFoundError';
2223
import CountingSet from '../../lib/CountingSet';
@@ -160,7 +161,7 @@ describe('processRequest', () => {
160161
});
161162
},
162163
},
163-
});
164+
} as InputConfigT);
164165

165166
const makeRequest = (
166167
requrl: string,

0 commit comments

Comments
 (0)