Skip to content

Commit ed76640

Browse files
chrfalchmeta-codesync[bot]
authored andcommitted
Read both export styles from a library's react-native.config.js (#58034)
Summary: When saving settings in `react-native.config.js`, we should support all styles of exported data: ```js module.exports = { spm: { name: 'worklets' } }; // old style export const spm = { name: 'worklets' }; // new style, with a label export default { spm: { name: 'worklets' } }; // new style, no label — "just this" ``` We only read the two first in the SwiftPM pipeline, silently ignoring anything written with `export default`. This PR fixes this by adding a catch that tries to decode the default export. Fixed after community feedback here: powersync-ja/powersync-js#1076 ## Changelog: [IOS] [FIXED] - SwiftPM pipeline now reads react-native.config.js with default exports correctly Pull Request resolved: #58034 Test Plan: ✅ Added and ran unit tests. Reviewed By: christophpurrer Differential Revision: D116935856 Pulled By: cipolleschi fbshipit-source-id: 7ebf7af3b3aaee2ffb506f94a15fabc244a4db30
1 parent 7de7b77 commit ed76640

3 files changed

Lines changed: 160 additions & 3 deletions

File tree

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,22 @@ 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 anyway: the Community CLI
384+
has two loaders that disagree about it, a sync one that sees named exports and
385+
an async one that takes only the default export. For maximum compatibility,
386+
prefer the one-line CommonJS form:
387+
388+
```js
389+
module.exports = {dependency: {platforms: {ios: {}}}, spm: {name: 'worklets'}};
390+
```
391+
392+
If the config fails to load, a warning names the file and the reason — the `spm`
393+
settings in it are ignored rather than silently applied.
394+
379395
## Self-managed community packages
380396

381397
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: 113 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,112 @@ describe('expandSpmDependencies', () => {
391395
);
392396
});
393397
});
398+
399+
// ---------------------------------------------------------------------------
400+
// defaultReadConfig
401+
//
402+
// The community CLI's own loaders disagree — sync reads named exports, async
403+
// reads the default one — so a config that sets only `export default` must not
404+
// be invisible here. Fixtures are transpiled by babel, so they present the
405+
// `__esModule`/`default` interop shape; a Node namespace object from
406+
// `require(ESM)` has no `__esModule` but exposes `.default` alongside
407+
// enumerable named keys the same way, which is what the merge reads.
408+
// ---------------------------------------------------------------------------
409+
410+
describe('defaultReadConfig', () => {
411+
let tmpRoot;
412+
413+
beforeAll(() => {
414+
tmpRoot = fs.mkdtempSync(
415+
path.join(fs.realpathSync(os.tmpdir()), 'spm-read-config-'),
416+
);
417+
});
418+
419+
afterAll(() => {
420+
fs.rmSync(tmpRoot, {recursive: true, force: true});
421+
});
422+
423+
function writeConfig(name, source) {
424+
const root = path.join(tmpRoot, name);
425+
fs.mkdirSync(root, {recursive: true});
426+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({name}));
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+
);
447+
expect(defaultReadConfig(root).spm.name).toBe('EsmDefault');
448+
});
449+
450+
it('reads an ESM config that only has named exports', () => {
451+
const root = writeConfig(
452+
'esm-named',
453+
"export const spm = {name: 'EsmNamed'};\n",
454+
);
455+
expect(defaultReadConfig(root).spm.name).toBe('EsmNamed');
456+
});
457+
458+
it('prefers the named export when a config ships both (the PowerSync shape)', () => {
459+
const root = writeConfig(
460+
'esm-both',
461+
"export const spm = {name: 'Named'};\n" +
462+
"export default {spm: {name: 'Default'}, dependency: {platforms: {ios: {}}}};\n",
463+
);
464+
const config = defaultReadConfig(root);
465+
expect(config.spm.name).toBe('Named');
466+
// Only the merge satisfies this: `dependency` exists on the default export
467+
// alone, so reading the module raw would miss it.
468+
expect(config.dependency.platforms.ios).toEqual({});
469+
});
470+
471+
it('keeps sibling keys of the default export (dependency.platforms.ios)', () => {
472+
const root = writeConfig(
473+
'esm-siblings',
474+
"export default {dependency: {platforms: {ios: {}}}, spm: {name: 'Siblings'}};\n",
475+
);
476+
const config = defaultReadConfig(root);
477+
expect(config.dependency.platforms.ios).toEqual({});
478+
expect(config.spm.name).toBe('Siblings');
479+
});
480+
481+
it('passes a function-style config through unchanged (module.exports = () => ({...}))', () => {
482+
const root = writeConfig(
483+
'fn-style',
484+
"module.exports = () => ({spm: {name: 'FnStyle'}});\n",
485+
);
486+
const config = defaultReadConfig(root);
487+
expect(typeof config).toBe('function');
488+
expect(config().spm.name).toBe('FnStyle');
489+
});
490+
491+
it('warns with the config path and the reason when the config fails to load, and returns null', () => {
492+
const root = writeConfig(
493+
'broken',
494+
"require('a-dev-dependency-that-is-not-installed');\n",
495+
);
496+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
497+
try {
498+
expect(defaultReadConfig(root)).toBeNull();
499+
const message = warnSpy.mock.calls.map(call => call.join(' ')).join('\n');
500+
expect(message).toContain(path.join(root, 'react-native.config.js'));
501+
expect(message).toContain('a-dev-dependency-that-is-not-installed');
502+
} finally {
503+
warnSpy.mockRestore();
504+
}
505+
});
506+
});

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

Lines changed: 31 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,34 @@ function defaultReadConfig(root /*: string */) /*: ?RnConfig */ {
188190
}
189191
try {
190192
// $FlowFixMe[unsupported-syntax]
191-
return require(configPath);
192-
} catch {
193+
const mod = require(configPath);
194+
// Read both export styles, because the community CLI's two loaders
195+
// disagree with each other: its sync path (`loadConfig`) requires the
196+
// module and sees named exports at top level, its async path
197+
// (`loadConfigAsync`) takes the default export only. Merging covers both,
198+
// with named exports winning — the shape the sync path already resolves.
199+
// Every sibling key of the default export is preserved
200+
// (`dependency.platforms.ios` is read from this result too).
201+
// A function-style config (`module.exports = () => ({...})`) and other
202+
// non-objects pass through untouched — there is no default export to
203+
// unwrap, and nulling them would hide a config that used to be read.
204+
if (mod == null || typeof mod !== 'object') {
205+
return mod;
206+
}
207+
const dflt = mod.default;
208+
if (dflt == null || typeof dflt !== 'object') {
209+
return mod;
210+
}
211+
const {default: _unused, ...named} = mod;
212+
return {...dflt, ...named};
213+
} catch (e) {
214+
// A config can fail to load for reasons unrelated to SPM (it may import a
215+
// devDependency absent in a consumer install), so this stays a warning —
216+
// but a silent null turns a dropped `spm` block into a link error much
217+
// later.
218+
warn(
219+
`Failed to load ${configPath}: ${e.message}. Any 'spm' settings in it are ignored.`,
220+
);
193221
return null;
194222
}
195223
}

0 commit comments

Comments
 (0)