Skip to content

Commit 0034d0e

Browse files
chrfalchclaude
andcommitted
Read both export styles from a library's react-native.config.js
`defaultReadConfig` returned the module object as required, so an ESM config that only declares `export default {...}` read as a config with no `spm` block at all — while the Community CLI, which loads the same file through cosmiconfig, sees it. A library shipping that shape silently lost its `spm.name`. Unwrap the default export and let named exports win over its keys, so both styles resolve. Non-objects (a function-style config) pass through untouched. Precedence only decides the pathological shape that defines one key twice. cosmiconfig's ESM loader reads only the default export, so the two tools would disagree there; the docs say to avoid it. A config that throws while loading now warns with the path and the reason instead of being swallowed: it was already ignored, only invisibly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 54696dd commit 0034d0e

3 files changed

Lines changed: 161 additions & 3 deletions

File tree

‎packages/react-native/scripts/spm/__docs__/spm-scripts.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,21 @@ library's target can import it.
376376
This is a **library-author** surface, like the podspec dependency it replaces —
377377
apps don't normally set it.
378378

379+
### Config module format
380+
381+
`react-native.config.js` may be CommonJS or ESM, and both named and default
382+
exports are read. A key defined twice — as a named export and on the default
383+
export — resolves to the named one. Avoid that shape: the Community CLI's own
384+
loader reads only the default export, so the two tools would disagree. For
385+
maximum compatibility, prefer the one-line CommonJS form:
386+
387+
```js
388+
module.exports = {dependency: {platforms: {ios: {}}}, spm: {name: 'worklets'}};
389+
```
390+
391+
If the config fails to load, a warning names the file and the reason — the `spm`
392+
settings in it are ignored rather than silently applied.
393+
379394
## Self-managed community packages
380395

381396
A community library that ships its own `Package.swift` is referenced directly by

‎packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,14 @@
3434
*/
3535

3636
const {
37+
defaultReadConfig,
3738
expandSpmDependencies,
3839
resolveSwiftName,
3940
} = require('../expand-spm-dependencies');
4041
const {toSwiftName} = require('../spm-utils');
42+
const fs = require('node:fs');
43+
const os = require('node:os');
44+
const path = require('node:path');
4145

4246
function makeReadConfig(configs /*: {[string]: ?Object} */) {
4347
return (root /*: string */) =>
@@ -391,3 +395,116 @@ describe('expandSpmDependencies', () => {
391395
);
392396
});
393397
});
398+
399+
// ---------------------------------------------------------------------------
400+
// defaultReadConfig
401+
//
402+
// The community CLI reads the same react-native.config.js through cosmiconfig,
403+
// which prefers the default export — so an ESM config that only sets
404+
// `export default` must not be invisible to us.
405+
// ---------------------------------------------------------------------------
406+
407+
describe('defaultReadConfig', () => {
408+
let tmpRoot;
409+
410+
beforeAll(() => {
411+
tmpRoot = fs.mkdtempSync(
412+
path.join(fs.realpathSync(os.tmpdir()), 'spm-read-config-'),
413+
);
414+
});
415+
416+
afterAll(() => {
417+
fs.rmSync(tmpRoot, {recursive: true, force: true});
418+
});
419+
420+
function writeConfig(name, source, esm = false) {
421+
const root = path.join(tmpRoot, name);
422+
fs.mkdirSync(root, {recursive: true});
423+
fs.writeFileSync(
424+
path.join(root, 'package.json'),
425+
JSON.stringify(esm ? {name, type: 'module'} : {name}),
426+
);
427+
fs.writeFileSync(path.join(root, 'react-native.config.js'), source);
428+
return root;
429+
}
430+
431+
it('returns null when the library ships no config', () => {
432+
const root = path.join(tmpRoot, 'no-config');
433+
fs.mkdirSync(root, {recursive: true});
434+
expect(defaultReadConfig(root)).toBeNull();
435+
});
436+
437+
it('reads a CommonJS config', () => {
438+
const root = writeConfig('cjs', "module.exports = {spm: {name: 'Cjs'}};\n");
439+
expect(defaultReadConfig(root).spm.name).toBe('Cjs');
440+
});
441+
442+
it('unwraps an ESM config that only has a default export', () => {
443+
const root = writeConfig(
444+
'esm-default',
445+
"export default {spm: {name: 'EsmDefault'}};\n",
446+
true,
447+
);
448+
expect(defaultReadConfig(root).spm.name).toBe('EsmDefault');
449+
});
450+
451+
it('reads an ESM config that only has named exports', () => {
452+
const root = writeConfig(
453+
'esm-named',
454+
"export const spm = {name: 'EsmNamed'};\n",
455+
true,
456+
);
457+
expect(defaultReadConfig(root).spm.name).toBe('EsmNamed');
458+
});
459+
460+
it('prefers the named export when a config ships both (the PowerSync shape)', () => {
461+
const root = writeConfig(
462+
'esm-both',
463+
"export const spm = {name: 'Named'};\n" +
464+
"export default {spm: {name: 'Default'}, dependency: {platforms: {ios: {}}}};\n",
465+
true,
466+
);
467+
const config = defaultReadConfig(root);
468+
expect(config.spm.name).toBe('Named');
469+
// Only the merge satisfies this: `dependency` exists on the default export
470+
// alone, so reading the module raw would miss it.
471+
expect(config.dependency.platforms.ios).toEqual({});
472+
});
473+
474+
it('keeps sibling keys of the default export (dependency.platforms.ios)', () => {
475+
const root = writeConfig(
476+
'esm-siblings',
477+
"export default {dependency: {platforms: {ios: {}}}, spm: {name: 'Siblings'}};\n",
478+
true,
479+
);
480+
const config = defaultReadConfig(root);
481+
expect(config.dependency.platforms.ios).toEqual({});
482+
expect(config.spm.name).toBe('Siblings');
483+
});
484+
485+
it('passes a function-style config through unchanged (module.exports = () => ({...}))', () => {
486+
const root = writeConfig(
487+
'fn-style',
488+
"module.exports = () => ({spm: {name: 'FnStyle'}});\n",
489+
);
490+
const config = defaultReadConfig(root);
491+
expect(typeof config).toBe('function');
492+
expect(config().spm.name).toBe('FnStyle');
493+
});
494+
495+
it('warns with the config path and the reason when the config fails to load, and returns null', () => {
496+
const root = writeConfig(
497+
'broken',
498+
"require('a-dev-dependency-that-is-not-installed');\n",
499+
);
500+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
501+
try {
502+
expect(defaultReadConfig(root)).toBeNull();
503+
const message = warnSpy.mock.calls.map(call => call.join(' ')).join('\n');
504+
expect(message).toContain(path.join(root, 'react-native.config.js'));
505+
expect(message).toContain('a-dev-dependency-that-is-not-installed');
506+
} finally {
507+
warnSpy.mockRestore();
508+
}
509+
});
510+
});

‎packages/react-native/scripts/spm/expand-spm-dependencies.js‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010

1111
'use strict';
1212

13-
const {toSwiftName} = require('./spm-utils');
13+
const {makeLogger, toSwiftName} = require('./spm-utils');
1414
const fs = require('node:fs');
1515
const path = require('node:path');
1616

17+
const {warn} = makeLogger('expand-spm-dependencies');
18+
1719
/**
1820
* expand-spm-dependencies.js — Resolves transitive native deps declared via
1921
* `spm.dependencies` in a library's react-native.config.js.
@@ -188,8 +190,32 @@ function defaultReadConfig(root /*: string */) /*: ?RnConfig */ {
188190
}
189191
try {
190192
// $FlowFixMe[unsupported-syntax]
191-
return require(configPath);
192-
} catch {
193+
const mod = require(configPath);
194+
// The community CLI reads this same file through cosmiconfig, which
195+
// prefers the default export — so an ESM config declaring only
196+
// `export default` must not read as empty here. Named exports win over
197+
// the default object's keys, and every sibling key is preserved
198+
// (`dependency.platforms.ios` is read from this result too).
199+
// A function-style config (`module.exports = () => ({...})`) and other
200+
// non-objects pass through untouched — there is no default export to
201+
// unwrap, and nulling them would hide a config that used to be read.
202+
if (mod == null || typeof mod !== 'object') {
203+
return mod;
204+
}
205+
const dflt = mod.default;
206+
if (dflt == null || typeof dflt !== 'object') {
207+
return mod;
208+
}
209+
const {default: _unused, ...named} = mod;
210+
return {...dflt, ...named};
211+
} catch (e) {
212+
// A config can fail to load for reasons unrelated to SPM (it may import a
213+
// devDependency absent in a consumer install), so this stays a warning —
214+
// but a silent null turns a dropped `spm` block into a link error much
215+
// later.
216+
warn(
217+
`Failed to load ${configPath}: ${e.message}. Any 'spm' settings in it are ignored.`,
218+
);
193219
return null;
194220
}
195221
}

0 commit comments

Comments
 (0)