diff --git a/packages/react-native-test-library/apple/TestLibraryApple.mm b/packages/react-native-test-library/apple/TestLibraryApple.mm index 2fa7f1fcefb1..bf7bb29a77a1 100644 --- a/packages/react-native-test-library/apple/TestLibraryApple.mm +++ b/packages/react-native-test-library/apple/TestLibraryApple.mm @@ -11,7 +11,7 @@ // wraps each autolinked dep as a Foo.framework under PackageFrameworks/. That // gives angle-bracket imports the standard resolution path, // matching how most React Native libraries already organize their headers. -#import +#import @implementation TestLibraryApple diff --git a/packages/react-native-test-library/apple/TestLibraryApple.podspec b/packages/react-native-test-library/apple/TestLibraryApple.podspec index 02cef6573b26..3175f4594049 100644 --- a/packages/react-native-test-library/apple/TestLibraryApple.podspec +++ b/packages/react-native-test-library/apple/TestLibraryApple.podspec @@ -19,10 +19,10 @@ Pod::Spec.new do |s| s.source_files = "*.{h,m,mm,swift}" s.requires_arc = true - # TestLibraryApple.mm imports . - # CocoaPods resolves it leniently through the shared Public headers dir, but the - # dependency edge must be declared for SwiftPM (the scaffolder wires sibling - # packages from podspec dependencies). + # TestLibraryApple.mm imports — the + # prefix the pod name gives it under CocoaPods and SwiftPM alike. CocoaPods + # resolves it leniently through the shared Public headers dir; SwiftPM needs + # the dependency edge declared (the scaffolder wires siblings from it). s.dependency "TestLibraryCommon" install_modules_dependencies(s) diff --git a/packages/react-native-test-library/apple/package.json b/packages/react-native-test-library/apple/package.json index 8dda511cbefa..593e18aefe4d 100644 --- a/packages/react-native-test-library/apple/package.json +++ b/packages/react-native-test-library/apple/package.json @@ -27,5 +27,11 @@ "peerDependencies": { "react": "*", "react-native": "1000.0.0" + }, + "swiftpmConfig": { + "name": "TestLibraryApple", + "dependencies": [ + "react-native-test-library-common" + ] } } diff --git a/packages/react-native-test-library/apple/react-native.config.js b/packages/react-native-test-library/apple/react-native.config.js index 982ed353de95..3de9a9829be7 100644 --- a/packages/react-native-test-library/apple/react-native.config.js +++ b/packages/react-native-test-library/apple/react-native.config.js @@ -16,7 +16,4 @@ module.exports = { ios: {}, }, }, - spm: { - dependencies: ['react-native-test-library-common'], - }, }; diff --git a/packages/react-native-test-library/common/package.json b/packages/react-native-test-library/common/package.json index 0a911df6a303..1611a4ffb4ac 100644 --- a/packages/react-native-test-library/common/package.json +++ b/packages/react-native-test-library/common/package.json @@ -20,5 +20,8 @@ "autolinking", "ios", "macos" - ] + ], + "swiftpmConfig": { + "name": "TestLibraryCommon" + } } diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index cfdb7f105122..a89f9f138dca 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -490,15 +490,27 @@ async function runScaffold( if (written.length > 0) { log(`Scaffolded Package.swift for ${written.length} dep(s):`); + // Silent for the outcomes that changed nothing ('already-set', 'skipped'). + const nameOutcome = (outcome /*: ?string */) /*: string */ => { + if (outcome === 'created' || outcome === 'inserted') { + return " — also recorded 'swiftpmConfig.name' in its package.json"; + } + if (outcome === 'failed') { + return " — could NOT record 'swiftpmConfig.name'; the manifest is written, the name is not"; + } + return ''; + }; for (const r of written) { - log(` • ${r.depName}`); + log(` • ${r.depName}${nameOutcome(r.swiftpmName)}`); } log(''); log( 'node_modules is NOT committed and is wiped by `npm install`. To keep\n' + 'these manifests, create and commit a patch with a tool like patch-package:\n' + ' • `npx patch-package ` for each scaffolded dep, then commit the patch.\n' + - 'Also consider asking the maintainer to ship a Package.swift upstream.\n' + + 'The patch also captures any recorded `swiftpmConfig.name`, which is what\n' + + 'lets the library be named without reading its podspec. Better still, ask the\n' + + 'maintainer to ship both upstream.\n' + 'Without a committed patch the build will hard-error again after a fresh install.', ); log(''); diff --git a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md index 3a23c378f91e..be999741f8cd 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md +++ b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md @@ -12,8 +12,8 @@ generates. See [spm-scripts.md](./spm-scripts.md) for the base tool. The documented extension points don't cover a framework: -- `spm.modules` in `react-native.config.js` is a **static** list of simple - source modules. A framework discovers its modules **dynamically** (scanning +- `swiftpmConfig.modules` in package.json is a **static** list of simple source + modules. A framework discovers its modules **dynamically** (scanning `node_modules`), generates a **module registry**, and ships mixed Swift/ObjC/C++ modules (e.g. `ExpoModulesCore`) that `spm scaffold` can't handle. @@ -32,28 +32,28 @@ stale. ## Discovery — transitive, zero app config -A dependency opts in from its **own** `react-native.config.js`, so installing -the framework is enough (mirrors how CocoaPods pulls in `use_expo_modules!` -transitively): +A dependency opts in from its **own** package.json, so installing the framework +is enough (mirrors how CocoaPods pulls in `use_expo_modules!` transitively): -```js -// node_modules/expo/react-native.config.js -module.exports = { - spm: {autolinkingPlugin: './spm/autolinking-plugin.js'}, -}; +```json +// node_modules/expo/package.json +{ + "swiftpmConfig": {"autolinkingPlugin": "./spm/autolinking-plugin.js"} +} ``` -The autolinker already walks every dependency's `react-native.config.js`; any -that declares `spm.autolinkingPlugin` is `require`d and invoked. No app-level -registration or allowlist is required. +The autolinker reads every dependency's SwiftPM settings; any that declares +`autolinkingPlugin` is `require`d and invoked. No app-level registration or +allowlist is required. The deprecated `spm.autolinkingPlugin` in +`react-native.config.js` is still read — see +[Migrating from react-native.config.js](spm-scripts.md#where-swiftpm-settings-live). -**Opt-out escape hatch.** An app can exclude a plugin from its own -`react-native.config.js`: +**Opt-out escape hatch.** An app can exclude a plugin from its own package.json: -```js -module.exports = { - spm: {denyPlugins: ['some-framework']}, // npm names to skip -}; +```json +{ + "swiftpmConfig": {"denyPlugins": ["some-framework"]} +} ``` ## The contract diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index af1c6e8a463c..4508e3660a54 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -331,49 +331,126 @@ build phase calls — a fine trade for not having to remember a command. ## Local Native Modules -Modules not discovered via autolinking can be declared in -`react-native.config.js`: +Modules not discovered via autolinking are declared in the app's package.json. +Each `path` is relative to the file that declares it — the project root for a +package.json there, the Xcode project directory for a config kept there: -```js -module.exports = { - spm: { - modules: [ +```json +{ + "swiftpmConfig": { + "modules": [ { - name: 'MyNativeModule', - path: 'ios/MyNativeModule', // relative to app root - exclude: ['*.podspec'], // optional - }, - ], - }, -}; + "name": "MyNativeModule", + "path": "ios/MyNativeModule", + "exclude": ["*.podspec"] + } + ] + } +} ``` Each entry becomes a target in `build/generated/autolinking/Package.swift`. Sources outside `build/generated/autolinking/` are automatically mirrored with file-level symlinks. +## Where SwiftPM settings live + +A package's SwiftPM settings live under `swiftpmConfig` in its **package.json**, +alongside `codegenConfig`. + +| Field | Set by | What it does | +| ------------------- | ------- | ----------------------------------------------------------- | +| `name` | library | Its SwiftPM target name, and so its header import prefix | +| `dependencies` | library | npm names of native libraries it builds against | +| `autolinkingPlugin` | library | Path to an [autolinking plugin](spm-autolinking-plugins.md) | +| `scaffold` | library | `false` opts the library out of `spm scaffold` | +| `modules` | app | [Local native modules](#local-native-modules) to build | +| `denyPlugins` | app | npm names whose autolinking plugin to skip | + +```json +{ + "swiftpmConfig": { + "name": "RNSVG", + "dependencies": ["react-native-worklets"] + } +} +``` + +A library's settings come from its own package.json. An **app's** are resolved +field by field, each from the directory holding its package.json — the JS root, +where codegen reads `codegenConfig` — falling back to the Xcode project +directory. An unrecognised field is ignored with a warning naming it, so a typo +does not pass silently. + +These settings used to live in an `spm` block in `react-native.config.js`. That +block is **deprecated** but still read, with the same field names, so nothing +breaks: move the keys across as they are, and package.json wins field by field. +`npx react-native spm scaffold` writes the `name` for you — see +[Community packages without a Package.swift](#community-packages-without-a-packageswift). + +## Library names + +An autolinked library's SwiftPM target name is also the prefix its headers are +imported under (`#import `), so it is not cosmetic: it has to be the +prefix the library's own sources and its dependents already use. + +It is resolved in this order: + +1. `swiftpmConfig.name` in the library's package.json +2. `spm.name` in `react-native.config.js` (deprecated) +3. podspec `header_dir` — `React-Core` → `React` +4. podspec `module_name` — `react-native-maps` → `ReactNativeMaps` +5. podspec name — `react-native-svg` → `RNSVG` +6. npm package name — `react-native-svg` → `ReactNativeSvg` + +Steps 3–5 are a **migration path, not the destination**. They exist so that +libraries work unchanged today, a podspec being what they already ship, and +`npx react-native spm scaffold` closes them out: it records the name it derived +as `swiftpmConfig.name` in the library's package.json, after which the podspec +is never consulted for naming again. A library that declares its own name needs +no podspec for SwiftPM at all. + +Within those three steps, `header_dir` comes first because that is what a +library sets when its import prefix differs from its pod name, then +`module_name`, what CocoaPods compiles the module as and so what Swift and +`@import` consumers write. But a `header_dir` only a **subspec** declares names +that subspec's headers, not the library, so the pod name stands +(react-native-svg is `RNSVG`, not `rnsvg`; its subspec prefix resolves through +the header search paths instead) — unless the subspec reuses the parent's block +variable (`do |s|`), which hides which scope declared what, so the podspec is +read by CocoaPods or not at all. An unreadable podspec falls through rather than +failing the build — with a warning, since a machine that can read it (one with +CocoaPods installed) may resolve a different name. A prefix Swift cannot spell +is normalized — `Some.Pod` becomes `Some_Pod`, what SwiftPM would compile it as +anyway — with a warning naming `swiftpmConfig.name`. + +Two names are refused outright: one React Native reserves (`ReactNative`, +`ReactHeaders`, `ReactNativeHeaders`, `ReactNativeDependenciesHeaders`, +`ReactAppHeaders`, `React-GeneratedCode`, `ReactCodegen`, +`ReactAppDependencyProvider`, `Autolinked`), and one another library already +took. Both are **hard errors** naming `swiftpmConfig.name` — nothing is renamed +automatically, because a name the build invented is one no `#import` in your +sources can predict. Two names must differ by more than case or punctuation to +be two targets: `worklets` and `Worklets` share a headers directory, and +`foo-bar` and `foo_bar` are one module, since SwiftPM replaces every character +C99 rejects with `_`. + ## Dependencies between libraries SwiftPM has no equivalent of a podspec's `s.dependency`, so a library that needs -another native library declares it explicitly with `spm.dependencies` in its -**own** `react-native.config.js` — a list of npm names: +another native library declares it explicitly in its **own** package.json — a +list of npm names: -```js -// react-native-reanimated/react-native.config.js -module.exports = { - dependency: {platforms: {ios: {}}}, - spm: {dependencies: ['react-native-worklets']}, -}; +```json +// react-native-reanimated/package.json +{ + "swiftpmConfig": {"dependencies": ["react-native-worklets"]} +} ``` -The autolinker starts from the directly-autolinked deps, follows each one's -`spm.dependencies` **recursively**, and dedupes the result, so a transitive -dependency is pulled into the package graph even when the app never depends on -it directly. Declared names are mapped to Swift target names, so the dependent -library's target can import it. - -This is a **library-author** surface, like the podspec dependency it replaces — -apps don't normally set it. +The autolinker follows these **recursively** from the directly-autolinked deps +and dedupes, so a transitive dependency joins the package graph even when the +app never depends on it directly. ### Config module format @@ -385,11 +462,11 @@ an async one that takes only the default export. For maximum compatibility, prefer the one-line CommonJS form: ```js -module.exports = {dependency: {platforms: {ios: {}}}, spm: {name: 'worklets'}}; +module.exports = {dependency: {platforms: {ios: {}}}}; ``` -If the config fails to load, a warning names the file and the reason — the `spm` -settings in it are ignored rather than silently applied. +If the config fails to load, a warning names the file and the reason — any +deprecated `spm` settings in it are ignored rather than silently applied. ## Self-managed community packages @@ -427,7 +504,15 @@ npx react-native spm # then inject/update as usual does not inject into the `.xcodeproj` — so on a first-time setup you still follow it with `npx react-native spm`.) -Because `node_modules/` isn't committed, persist it so it survives the next +`scaffold` also records the name it derived from the podspec as +`swiftpmConfig.name` in the library's package.json — the one step that lets the +library be named without reading a podspec at all. It never overwrites a name +the library already declares, and it says which packages it edited. Every +library it scaffolds gets a line naming the SwiftPM name it chose and where that +name came from, plus a note when a podspec's `header_dir` and `module_name` +disagree about it and only one can win. + +Because `node_modules/` isn't committed, persist both so they survive the next install: ```bash @@ -445,7 +530,9 @@ workaround keeps your app building. > A library whose sources mix Swift **and** Objective-C/C++ in one target, or > that ships neither a `Package.swift` nor a podspec, can't be scaffolded > automatically — the error says so. Opt it out via `react-native.config.js` -> (`platforms.ios = null`) or ask the maintainer for a prebuilt xcframework. +> (`platforms.ios = null`) or ask the maintainer for a prebuilt xcframework. A +> library can also opt out of scaffolding alone with +> `"swiftpmConfig": {"scaffold": false}`. ## Framework plugins (Preview) @@ -490,6 +577,7 @@ across apps; refresh it with `react-native spm update --download force`. | `spm add` fails: "no .xcodeproj found" | Create an app first (`npx @react-native-community/cli init`) or make a project in Xcode, then `spm add`. | | `spm add` fails: "multiple .xcodeproj found" | Pass `--xcodeproj ` (and `--product-name ` if multiple app targets). | | `Package.swift is missing for library ""` (exit 2) | The dep ships no SwiftPM support. `npx react-native spm scaffold`, then re-run setup; persist with `patch-package`. See [Community packages without a Package.swift](#community-packages-without-a-packageswift) | +| `SPM Swift name collision` | Two libraries resolved to one Swift name, or one took a name React Native reserves. Set `swiftpmConfig.name` in the library's package.json — see [Library names](#library-names) | | Missing headers | Re-run `react-native spm` | | "not contained in target" | Re-run setup (regenerates file-level symlinks) | | Codegen fails | Use `--skipCodegen` to iterate on other parts | @@ -596,7 +684,7 @@ _existing_ set of generated packages current; they do not create the first one. 1. Compares timestamps of staleness inputs against `build/generated/autolinking/.spm-sync-stamp`: - `package.json` — dependency declarations - - `react-native.config.js` — `spm.modules` config + - `react-native.config.js` — autolinking config - `node_modules/` directory mtime — updated by any package manager (npm, yarn, pnpm, bun); also checks parent `node_modules` for monorepo setups - a missing `build/xcframeworks/` (e.g. after a manual clean) also marks diff --git a/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js b/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js index a219daa3e868..7ee863b17dd3 100644 --- a/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js +++ b/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js @@ -34,20 +34,45 @@ describe('discoverPlugins', () => { return {name, root}; } - // readConfig fake: a dep opts in when opted[name] is truthy. + // A dep opts in through its package.json; `noConfig` stands in for the + // react-native.config.js the caller loads (absent for these cases). + const declarePlugin = dep => + fs.writeFileSync( + path.join(dep.root, 'package.json'), + JSON.stringify({ + name: dep.name, + swiftpmConfig: {autolinkingPlugin: './plugin.js'}, + }), + ); + const noConfig = () => null; + + // The deprecated home, which Expo still ships. const readConfigFor = opted => root => { const name = path.basename(root); return opted[name] ? {spm: {autolinkingPlugin: './plugin.js'}} : null; }; - it('discovers a plugin declared via react-native.config.js', () => { + it('discovers a plugin declared in package.json', () => { const dep = makeDep('expo', 'module.exports = () => ({});'); - const found = discoverPlugins([dep], readConfigFor({expo: true})); + declarePlugin(dep); + const found = discoverPlugins([dep], noConfig); expect(found).toHaveLength(1); expect(found[0].depName).toBe('expo'); expect(typeof found[0].plugin).toBe('function'); }); + it('still discovers a plugin declared the deprecated way', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const dep = makeDep('expo', 'module.exports = () => ({});'); + const found = discoverPlugins([dep], readConfigFor({expo: true})); + expect(found).toHaveLength(1); + expect(warnSpy).toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); + it('skips deps that do not declare a plugin', () => { const dep = makeDep('react-native-svg', null); expect(discoverPlugins([dep], readConfigFor({}))).toHaveLength(0); @@ -55,27 +80,31 @@ describe('discoverPlugins', () => { it('honors the app deny-list (opt-out, no allowlist needed)', () => { const dep = makeDep('expo', 'module.exports = () => ({});'); - const found = discoverPlugins([dep], readConfigFor({expo: true}), ['expo']); - expect(found).toHaveLength(0); + declarePlugin(dep); + expect(discoverPlugins([dep], noConfig, ['expo'])).toHaveLength(0); }); it('accepts default/plugin export interop', () => { const a = makeDep('a', 'module.exports.default = () => ({});'); const b = makeDep('b', 'module.exports.plugin = () => ({});'); - const found = discoverPlugins([a, b], readConfigFor({a: true, b: true})); + declarePlugin(a); + declarePlugin(b); + const found = discoverPlugins([a, b], noConfig); expect(found.map(f => f.depName).sort()).toEqual(['a', 'b']); }); it('fails closed when the plugin module is missing', () => { const dep = makeDep('expo', null); // opted in below but no plugin.js - expect(() => discoverPlugins([dep], readConfigFor({expo: true}))).toThrow( + declarePlugin(dep); + expect(() => discoverPlugins([dep], noConfig)).toThrow( /failed to load the autolinking plugin for 'expo'/, ); }); it('fails closed when the module does not export a function', () => { const dep = makeDep('expo', 'module.exports = {nope: 1};'); - expect(() => discoverPlugins([dep], readConfigFor({expo: true}))).toThrow( + declarePlugin(dep); + expect(() => discoverPlugins([dep], noConfig)).toThrow( /does not export a function/, ); }); diff --git a/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js b/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js index 1e4848930ca1..8813c6637524 100644 --- a/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js +++ b/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js @@ -36,8 +36,8 @@ const { SpmNameCollisionError, defaultReadConfig, + defaultReadPodspec, expandSpmDependencies, - isValidSwiftName, resolveSwiftName, } = require('../expand-spm-dependencies'); const { @@ -54,8 +54,12 @@ function makeReadConfig(configs /*: {[string]: ?Object} */) { Object.prototype.hasOwnProperty.call(configs, root) ? configs[root] : null; } -// The reserved map its caller builds; these cases only exercise `spm.name`. -const NONE = new Map(); +// The two config readers, from one set of fixtures. Injected so these cases stay +// pure; readSwiftpmConfig itself is covered in swiftpm-config-test.js. +function fixtures(configs /*: {[string]: ?Object} */) { + const readConfig = makeReadConfig(configs); + return {readConfig, readSwiftpmConfig: root => readConfig(root)?.spm ?? null}; +} function makeResolveDep(resolutions /*: {[string]: ?string} */) { return (name /*: string */) => @@ -64,6 +68,14 @@ function makeResolveDep(resolutions /*: {[string]: ?string} */) { : null; } +// Keyed by dep root, valued with the podspec facts the reader would return. +function makeReadPodspec(podspecs /*: {[string]: ?Object} */) { + return (root /*: string */) => + Object.prototype.hasOwnProperty.call(podspecs, root) + ? podspecs[root] + : null; +} + // --------------------------------------------------------------------------- // expandSpmDependencies // --------------------------------------------------------------------------- @@ -72,18 +84,23 @@ describe('expandSpmDependencies', () => { it('returns direct deps with auto-derived swiftName when none declare spm.dependencies', () => { const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; const result = expandSpmDependencies(direct, { - readConfig: makeReadConfig({'/a': {}}), + ...fixtures({'/a': {}}), resolveDep: makeResolveDep({}), }); expect(result).toEqual([ - {...direct[0], swiftName: toSwiftName('a'), spmDependencies: []}, + { + ...direct[0], + swiftName: toSwiftName('a'), + swiftNameSource: 'npm', + spmDependencies: [], + }, ]); }); it('pulls in one transitive dep declared by a direct dep', () => { const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; const result = expandSpmDependencies(direct, { - readConfig: makeReadConfig({ + ...fixtures({ '/apple': {spm: {dependencies: ['common']}}, '/common': {dependency: {platforms: {ios: {}}}}, }), @@ -97,7 +114,7 @@ describe('expandSpmDependencies', () => { it('recurses through a chain (A → B → C)', () => { const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; const result = expandSpmDependencies(direct, { - readConfig: makeReadConfig({ + ...fixtures({ '/a': {spm: {dependencies: ['b']}}, '/b': { dependency: {platforms: {ios: {}}}, @@ -113,7 +130,7 @@ describe('expandSpmDependencies', () => { it('handles cycles without infinite recursion (A → B → A)', () => { const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; const result = expandSpmDependencies(direct, { - readConfig: makeReadConfig({ + ...fixtures({ '/a': { dependency: {platforms: {ios: {}}}, spm: {dependencies: ['b']}, @@ -134,7 +151,7 @@ describe('expandSpmDependencies', () => { {name: 'b', root: '/b', platforms: {ios: {}}}, ]; const result = expandSpmDependencies(direct, { - readConfig: makeReadConfig({ + ...fixtures({ '/a': {spm: {dependencies: ['x']}}, '/b': {spm: {dependencies: ['x']}}, '/x': {dependency: {platforms: {ios: {}}}}, @@ -149,7 +166,7 @@ describe('expandSpmDependencies', () => { const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; expect(() => expandSpmDependencies(direct, { - readConfig: makeReadConfig({ + ...fixtures({ '/apple': {spm: {dependencies: ['ghost']}}, }), resolveDep: makeResolveDep({}), @@ -160,7 +177,7 @@ describe('expandSpmDependencies', () => { it('silently skips transitives that have no iOS native code (matches autolinkingDepToSpmTarget behavior)', () => { const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; const result = expandSpmDependencies(direct, { - readConfig: makeReadConfig({ + ...fixtures({ '/apple': {spm: {dependencies: ['js-only']}}, // js-only has no dependency.platforms.ios — pure JS package '/js-only': {}, @@ -176,7 +193,7 @@ describe('expandSpmDependencies', () => { {name: 'common', root: '/common-direct', platforms: {ios: {}}}, ]; const result = expandSpmDependencies(direct, { - readConfig: makeReadConfig({ + ...fixtures({ '/apple': {spm: {dependencies: ['common']}}, '/common-other': {dependency: {platforms: {ios: {}}}}, }), @@ -196,7 +213,7 @@ describe('expandSpmDependencies', () => { it('attaches spmDependencies: [] when the dep declares none', () => { const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; const [a] = expandSpmDependencies(direct, { - readConfig: makeReadConfig({'/a': {}}), + ...fixtures({'/a': {}}), resolveDep: makeResolveDep({}), }); expect(a.spmDependencies).toEqual([]); @@ -205,7 +222,7 @@ describe('expandSpmDependencies', () => { it('attaches spmDependencies with the declared transitive names (preserving declaration order)', () => { const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; const [apple, common] = expandSpmDependencies(direct, { - readConfig: makeReadConfig({ + ...fixtures({ '/apple': {spm: {dependencies: ['common', 'extra']}}, '/common': {dependency: {platforms: {ios: {}}}}, '/extra': {dependency: {platforms: {ios: {}}}}, @@ -219,7 +236,7 @@ describe('expandSpmDependencies', () => { it('omits JS-only transitives from spmDependencies (only iOS-native names appear)', () => { const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; const [apple] = expandSpmDependencies(direct, { - readConfig: makeReadConfig({ + ...fixtures({ '/apple': {spm: {dependencies: ['js-only', 'common']}}, '/js-only': {}, '/common': {dependency: {platforms: {ios: {}}}}, @@ -235,7 +252,7 @@ describe('expandSpmDependencies', () => { {name: 'b', root: '/b', platforms: {ios: {}}}, ]; const result = expandSpmDependencies(direct, { - readConfig: makeReadConfig({ + ...fixtures({ '/a': {spm: {dependencies: ['x']}}, '/b': {spm: {dependencies: ['x']}}, '/x': {dependency: {platforms: {ios: {}}}}, @@ -252,7 +269,7 @@ describe('expandSpmDependencies', () => { const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; let receivedFromRoot /*: ?string */ = null; expandSpmDependencies(direct, { - readConfig: makeReadConfig({ + ...fixtures({ '/apple': {spm: {dependencies: ['common']}}, '/common': {dependency: {platforms: {ios: {}}}}, }), @@ -280,7 +297,7 @@ describe('expandSpmDependencies', () => { {name: 'react-native-foo', root: '/foo', platforms: {ios: {}}}, ]; const [foo] = expandSpmDependencies(direct, { - readConfig: makeReadConfig({'/foo': {}}), + ...fixtures({'/foo': {}}), resolveDep: makeResolveDep({}), }); expect(foo.swiftName).toBe(toSwiftName('react-native-foo')); @@ -292,7 +309,7 @@ describe('expandSpmDependencies', () => { {name: 'react-native-worklets', root: '/w', platforms: {ios: {}}}, ]; const [w] = expandSpmDependencies(direct, { - readConfig: makeReadConfig({'/w': {spm: {name: 'worklets'}}}), + ...fixtures({'/w': {spm: {name: 'worklets'}}}), resolveDep: makeResolveDep({}), }); expect(w.swiftName).toBe('worklets'); @@ -303,7 +320,7 @@ describe('expandSpmDependencies', () => { {name: 'react-native-reanimated', root: '/r', platforms: {ios: {}}}, ]; const result = expandSpmDependencies(direct, { - readConfig: makeReadConfig({ + ...fixtures({ '/r': { dependency: {platforms: {ios: {}}}, spm: {name: 'reanimated', dependencies: ['react-native-worklets']}, @@ -330,7 +347,7 @@ describe('expandSpmDependencies', () => { ]; expect(() => expandSpmDependencies(direct, { - readConfig: makeReadConfig({ + ...fixtures({ '/w': {}, '/o': {spm: {name: 'ReactNativeWorklets'}}, }), @@ -346,7 +363,7 @@ describe('expandSpmDependencies', () => { ]; expect(() => expandSpmDependencies(direct, { - readConfig: makeReadConfig({ + ...fixtures({ '/w': {}, '/o': {spm: {name: 'ReactNativeWorklets'}}, }), @@ -364,7 +381,7 @@ describe('expandSpmDependencies', () => { ]; expect(() => expandSpmDependencies(direct, { - readConfig: makeReadConfig({ + ...fixtures({ '/w': {spm: {name: 'worklets'}}, '/o': {spm: {name: 'Worklets'}}, }), @@ -373,35 +390,35 @@ describe('expandSpmDependencies', () => { ).toThrow(/case/i); }); - it('rejects empty-string spm.name with a clear error citing the npm name', () => { + it('rejects an empty declared name with a clear error citing the npm name', () => { const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; expect(() => expandSpmDependencies(direct, { - readConfig: makeReadConfig({'/a': {spm: {name: ''}}}), + ...fixtures({'/a': {spm: {name: ''}}}), resolveDep: makeResolveDep({}), }), - ).toThrow(/'a' has an invalid 'spm.name'/); + ).toThrow(/'a' declares an invalid SwiftPM name/); }); - it('rejects non-string spm.name (e.g. number, object) with a clear error', () => { + it('rejects a non-string declared name (e.g. number, object) with a clear error', () => { const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; expect(() => expandSpmDependencies(direct, { - readConfig: makeReadConfig({'/a': {spm: {name: 42}}}), + ...fixtures({'/a': {spm: {name: 42}}}), resolveDep: makeResolveDep({}), }), - ).toThrow(/invalid 'spm.name'/); + ).toThrow(/declares an invalid SwiftPM name/); }); - it('rejects spm.name with disallowed characters (spaces, slashes, dots)', () => { - const resolve = name => () => resolveSwiftName('a', {spm: {name}}, NONE); - expect(resolve('foo bar')).toThrow(/invalid 'spm.name'/); - expect(resolve('foo/bar')).toThrow(/invalid 'spm.name'/); - expect(resolve('foo.bar')).toThrow(/invalid 'spm.name'/); + it('rejects a declared name with disallowed characters (spaces, slashes, dots)', () => { + const resolve = name => () => resolveSwiftName('a', {name}, null); + expect(resolve('foo bar')).toThrow(/declares an invalid SwiftPM name/); + expect(resolve('foo/bar')).toThrow(/declares an invalid SwiftPM name/); + expect(resolve('foo.bar')).toThrow(/declares an invalid SwiftPM name/); }); - it('accepts lowercase-with-hyphen and CamelCase spm.name values', () => { - const resolve = name => resolveSwiftName('a', {spm: {name}}, NONE); + it('accepts lowercase-with-hyphen and CamelCase declared names', () => { + const resolve = name => resolveSwiftName('a', {name}, null).name; expect(resolve('reanimated')).toBe('reanimated'); expect(resolve('hermes-engine')).toBe('hermes-engine'); expect(resolve('RNWorklets')).toBe('RNWorklets'); @@ -410,298 +427,325 @@ describe('expandSpmDependencies', () => { }); // --------------------------------------------------------------------------- -// Scope disambiguation: a derived name that lands on one React Native reserves. +// Podspec-derived names. A dep's Swift name is also its header prefix +// (`#import `), and the podspec is where that prefix is +// declared: `spm.name` → `header_dir` → `module_name` → podspec name → +// toSwiftName(npm name). // --------------------------------------------------------------------------- -describe('expandSpmDependencies (scope disambiguation)', () => { - function expand(direct, configs, options) { +describe('expandSpmDependencies (podspec-derived names)', () => { + function expand(direct, {configs, podspecs, ...options} = {}) { return expandSpmDependencies(direct, { - readConfig: makeReadConfig(configs), + ...fixtures(configs ?? {}), resolveDep: makeResolveDep({}), + readPodspec: makeReadPodspec(podspecs ?? {}), ...options, }); } - it('prepends the scope when the derived name is reserved', () => { - const [dep] = expand( - [{name: '@powersync/react-native', root: '/ps', platforms: {ios: {}}}], - {'/ps': {}}, - ); - expect(dep.swiftName).toBe('PowersyncReactNative'); + const dep = (name, root) => ({name, root, platforms: {ios: {}}}); + + it("prefers the podspec's header_dir over its name", () => { + const [core] = expand([dep('react-native-core-thing', '/rc')], { + podspecs: {'/rc': {name: 'React-Core', headerDir: 'React'}}, + }); + expect(core.swiftName).toBe('React'); }); - it('logs one line naming the package, the reserved name and the name it got', () => { - const log = jest.fn(); - expand( - [{name: '@powersync/react-native', root: '/ps', platforms: {ios: {}}}], - {'/ps': {}}, - {log}, - ); - expect(log).toHaveBeenCalledTimes(1); - const [line] = log.mock.calls[0]; - expect(line).toContain('@powersync/react-native'); - expect(line).toContain("'ReactNative'"); - expect(line).toContain("'PowersyncReactNative'"); + it('uses the podspec name when it declares no header_dir', () => { + const [svg] = expand([dep('react-native-svg', '/svg')], { + podspecs: {'/svg': {name: 'RNSVG', headerDir: null}}, + }); + expect(svg.swiftName).toBe('RNSVG'); + }); + + it("uses the podspec's module_name over its pod name (react-native-maps)", () => { + // `s.name = "react-native-maps"` with `s.module_name = 'ReactNativeMaps'`: + // the pod name is a legal SwiftPM target name, so nothing normalizes it — + // but every `import ReactNativeMaps` in the ecosystem is written against + // the module name. + const [maps] = expand([dep('react-native-maps', '/maps')], { + podspecs: { + '/maps': { + name: 'react-native-maps', + moduleName: 'ReactNativeMaps', + headerDir: null, + }, + }, + }); + expect(maps.swiftName).toBe('ReactNativeMaps'); + expect(maps.swiftNameSource).toBe('podspec'); }); - it('says nothing when no disambiguation happens', () => { - const log = jest.fn(); - const [dep] = expand( - [{name: '@powersync/common', root: '/c', platforms: {ios: {}}}], - {'/c': {}}, - {log}, - ); - expect(dep.swiftName).toBe('Common'); - expect(log).not.toHaveBeenCalled(); + it("prefers the podspec's header_dir over its module_name", () => { + const [core] = expand([dep('react-native-core-thing', '/rc')], { + podspecs: { + '/rc': { + name: 'React-Core', + moduleName: 'ReactCore', + headerDir: 'React', + }, + }, + }); + expect(core.swiftName).toBe('React'); }); - it('title-cases a hyphenated scope', () => { - const [dep] = expand( - [{name: '@my-org/react-native', root: '/o', platforms: {ios: {}}}], - {'/o': {}}, - ); - expect(dep.swiftName).toBe('MyOrgReactNative'); + it('takes a lowercase header_dir verbatim (worklets ships )', () => { + const [worklets] = expand([dep('react-native-worklets', '/w')], { + podspecs: {'/w': {name: 'RNWorklets', headerDir: 'worklets'}}, + }); + expect(worklets.swiftName).toBe('worklets'); }); - it('disambiguates a name that matches a reserved one only in case', () => { - // toSwiftName('@scope/reactcodegen') === 'Reactcodegen' — distinct from - // 'ReactCodegen' as a string, the same directory on a case-insensitive - // filesystem. - const [dep] = expand( - [{name: '@scope/reactcodegen', root: '/s', platforms: {ios: {}}}], - {'/s': {}}, - ); - expect(dep.swiftName).toBe('ScopeReactcodegen'); + it.each([ + ['header_dir', {name: 'React-Core', headerDir: 'React'}, 'React'], + [ + 'module_name', + {name: 'react-native-maps', moduleName: 'ReactNativeMaps'}, + 'ReactNativeMaps', + ], + ['name', {name: 'RNSVG'}, 'RNSVG'], + ])('reports %s as the podspec key the name came from', (key, facts, name) => { + // `spm scaffold` persists the winner as the library's name, so which key + // won is part of the decision it has to be able to report. + expect(resolveSwiftName('react-native-thing', null, facts)).toEqual({ + name, + source: 'podspec', + podspecKey: key, + }); }); - it('disambiguates a transitive dep too', () => { - const result = expandSpmDependencies( - [{name: 'top', root: '/top', platforms: {ios: {}}}], - { - readConfig: makeReadConfig({ - '/top': {spm: {dependencies: ['@scope/react-native']}}, - '/s': {dependency: {platforms: {ios: {}}}}, - }), - resolveDep: makeResolveDep({'@scope/react-native': '/s'}), - }, + it('reports no podspec key for a declared or guessed name', () => { + expect(resolveSwiftName('react-native-foo', {name: 'RNFoo'}, null)).toEqual( + {name: 'RNFoo', source: 'config'}, ); - expect(result.map(d => d.swiftName)).toEqual(['Top', 'ScopeReactNative']); + expect(resolveSwiftName('react-native-foo', null, null)).toEqual({ + name: 'ReactNativeFoo', + source: 'npm', + }); }); - it('disambiguates against a caller-supplied reserved name (remote identity)', () => { - const [dep] = expand( - [{name: '@acme/my-fork', root: '/f', platforms: {ios: {}}}], - {'/f': {}}, - {extraReservedNames: ['MyFork']}, - ); - expect(dep.swiftName).toBe('AcmeMyFork'); + it('carries the podspec key onto every expanded dep', () => { + const [maps] = expand([dep('react-native-maps', '/maps')], { + podspecs: { + '/maps': {name: 'react-native-maps', moduleName: 'ReactNativeMaps'}, + }, + }); + expect(maps.swiftNamePodspecKey).toBe('module_name'); }); - it("leaves an explicit 'spm.name' alone on a package that would have collided", () => { - const log = jest.fn(); - const [dep] = expand( - [{name: '@powersync/react-native', root: '/ps', platforms: {ios: {}}}], - {'/ps': {spm: {name: 'PowerSync'}}}, - {log}, - ); - expect(dep.swiftName).toBe('PowerSync'); - expect(log).not.toHaveBeenCalled(); + it('lets spm.name beat both', () => { + const [svg] = expand([dep('react-native-svg', '/svg')], { + configs: {'/svg': {spm: {name: 'MySvg'}}}, + podspecs: {'/svg': {name: 'RNSVG', headerDir: 'rnsvg'}}, + }); + expect(svg.swiftName).toBe('MySvg'); }); - it('throws when the disambiguated name is reserved as well', () => { - const run = () => - expand( - [{name: '@powersync/react-native', root: '/ps', platforms: {ios: {}}}], - {'/ps': {}}, - {extraReservedNames: ['PowersyncReactNative']}, - ); - expect(run).toThrow(SpmNameCollisionError); - expect(run).toThrow(/React Native reserves/); + it('falls back to the npm name when the dep ships no podspec (self-managed libraries)', () => { + const [svg] = expand([dep('react-native-svg', '/svg')]); + expect(svg.swiftName).toBe('ReactNativeSvg'); }); - it('gives two scoped packages that would take the same reserved name distinct names', () => { - const result = expand( - [ - {name: '@a/react-native', root: '/a', platforms: {ios: {}}}, - {name: '@b/react-native', root: '/b', platforms: {ios: {}}}, - ], - {'/a': {}, '/b': {}}, - ); - expect(result.map(d => d.swiftName)).toEqual([ - 'AReactNative', - 'BReactNative', - ]); + it('falls through to the npm name when reading the podspec throws', () => { + const [svg] = expand([dep('react-native-svg', '/svg')], { + readPodspec: () => { + throw new Error('unparseable'); + }, + }); + expect(svg.swiftName).toBe('ReactNativeSvg'); }); -}); -// --------------------------------------------------------------------------- -// Scope disambiguation across deps: two libraries deriving one name. -// --------------------------------------------------------------------------- + it('falls through when the podspec yields no name (partial parse)', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const [svg] = expand([dep('react-native-svg', '/svg')], { + podspecs: {'/svg': {name: '', headerDir: null}}, + }); + expect(svg.swiftName).toBe('ReactNativeSvg'); + } finally { + warnSpy.mockRestore(); + } + }); -describe('expandSpmDependencies (scope disambiguation across deps)', () => { - function expand(direct, configs, options) { - return expandSpmDependencies(direct, { - readConfig: makeReadConfig(configs), - resolveDep: makeResolveDep({}), - ...options, - }); - } + it('warns that a podspec it could not read makes the name machine-dependent', () => { + // The shape create-react-native-library generates: `s.name = package["name"]` + // reads as nothing without CocoaPods, so the npm name answers here and the + // pod name would answer on a machine that has `pod`. + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const [foo] = expand([dep('react-native-foo', '/foo')], { + podspecs: {'/foo': {name: '', headerDir: null}}, + }); + expect(foo.swiftName).toBe('ReactNativeFoo'); + expect(foo.swiftNameSource).toBe('npm'); + const message = warnSpy.mock.calls.map(call => call.join(' ')).join('\n'); + expect(message).toContain('react-native-foo'); + expect(message).toContain('CocoaPods'); + expect(message).toContain('swiftpmConfig.name'); + } finally { + warnSpy.mockRestore(); + } + }); - const scoped = (name, root) => ({name, root, platforms: {ios: {}}}); + it('says nothing when the dep ships no podspec at all', () => { + // Nothing is machine-dependent about a self-managed library: there is no + // podspec for another machine to read differently. + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expand([dep('react-native-svg', '/svg')]); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); - it('pulls two scoped deps apart with their scopes', () => { - const result = expand([scoped('@a/foo', '/a'), scoped('@b/foo', '/b')], { - '/a': {}, - '/b': {}, - }); - expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'BFoo']); + it('normalizes a podspec name Swift cannot spell, and says what it did', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const [svg] = expand([dep('react-native-svg', '/svg')], { + podspecs: {'/svg': {name: 'Some.Pod', headerDir: null}}, + }); + expect(svg.swiftName).toBe('Some_Pod'); + const message = warnSpy.mock.calls.map(call => call.join(' ')).join('\n'); + expect(message).toContain('react-native-svg'); + expect(message).toContain('Some.Pod'); + expect(message).toContain('Some_Pod'); + expect(message).toContain('swiftpmConfig.name'); + } finally { + warnSpy.mockRestore(); + } }); - it('logs one line per rewritten dep, naming the shared name and the new one', () => { - const log = jest.fn(); - expand( - [scoped('@a/foo', '/a'), scoped('@b/foo', '/b')], - { - '/a': {}, - '/b': {}, - }, - {log}, - ); - expect(log).toHaveBeenCalledTimes(2); - const lines = log.mock.calls.map(([line]) => line); - expect(lines[0]).toContain('@a/foo'); - expect(lines[0]).toContain("'Foo'"); - expect(lines[0]).toContain("'AFoo'"); - expect(lines[1]).toContain('@b/foo'); - expect(lines[1]).toContain("'BFoo'"); - }); - - it('leaves an unscoped member alone — it has no scope to borrow', () => { - const result = expand([scoped('@a/foo', '/a'), scoped('foo', '/f')], { - '/a': {}, - '/f': {}, - }); - expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'Foo']); + it('refuses to name a library from a header_dir Ruby has not evaluated', () => { + // Without CocoaPods the regex parser hands back the template verbatim. + // Naming from it would freeze `__s_name_Headers` into the header prefix — + // and, being podspec-derived, into the library's package.json. + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const [svg] = expand([dep('react-native-svg', '/svg')], { + podspecs: {'/svg': {name: 'RNSVG', headerDir: '#{s.name}Headers'}}, + }); + expect(svg.swiftName).toBe('ReactNativeSvg'); + expect(svg.swiftNameSource).toBe('npm'); + } finally { + warnSpy.mockRestore(); + } }); - it("leaves a member's explicit 'spm.name' alone and moves the others around it", () => { - const log = jest.fn(); - const result = expand( - [scoped('@a/foo', '/a'), scoped('@b/foo', '/b')], - {'/a': {spm: {name: 'Foo'}}, '/b': {}}, - {log}, - ); - expect(result.map(d => d.swiftName)).toEqual(['Foo', 'BFoo']); - expect(log).toHaveBeenCalledTimes(1); - expect(log.mock.calls[0][0]).toContain('@b/foo'); + it('normalizes an unspellable header_dir rather than falling back to the podspec name', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const [svg] = expand([dep('react-native-svg', '/svg')], { + podspecs: {'/svg': {name: 'RNSVG', headerDir: 'rn.svg'}}, + }); + expect(svg.swiftName).toBe('rn_svg'); + } finally { + warnSpy.mockRestore(); + } }); - it('groups case-insensitively, so a lowercase override still moves the others', () => { - const result = expand([scoped('@a/foo', '/a'), scoped('@b/foo', '/b')], { - '/a': {spm: {name: 'foo'}}, - '/b': {}, - }); - expect(result.map(d => d.swiftName)).toEqual(['foo', 'BFoo']); + it('says nothing when the podspec name needs no normalizing', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expand([dep('react-native-svg', '/svg')], { + podspecs: {'/svg': {name: 'RNSVG'}}, + }); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } }); - it('rewrites every scoped member of a three-way collision', () => { - const result = expand( - [scoped('@a/foo', '/a'), scoped('@b/foo', '/b'), scoped('@c/foo', '/c')], - {'/a': {}, '/b': {}, '/c': {}}, + it('reads the podspec autolinking.json recorded for the dep', () => { + const readPodspec = jest.fn(() => ({name: 'RNSVG'})); + expandSpmDependencies( + [ + { + name: 'react-native-svg', + root: '/svg', + platforms: {ios: {podspecPath: '/svg/apple/RNSVG.podspec'}}, + }, + ], + { + ...fixtures({}), + resolveDep: makeResolveDep({}), + readPodspec, + }, + ); + expect(readPodspec).toHaveBeenCalledWith( + '/svg', + '/svg/apple/RNSVG.podspec', ); - expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'BFoo', 'CFoo']); }); - it('rewrites the scoped members of a three-way collision and keeps the unscoped one', () => { - const result = expand( - [scoped('@a/foo', '/a'), scoped('@b/foo', '/b'), scoped('foo', '/f')], - {'/a': {}, '/b': {}, '/f': {}}, + it('names a transitive dep from its own podspec', () => { + const result = expandSpmDependencies( + [dep('react-native-reanimated', '/r')], + { + ...fixtures({ + '/r': {spm: {dependencies: ['react-native-worklets']}}, + '/w': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({'react-native-worklets': '/w'}), + readPodspec: makeReadPodspec({ + '/r': {name: 'RNReanimated', headerDir: 'reanimated'}, + '/w': {name: 'RNWorklets', headerDir: 'worklets'}, + }), + }, ); - expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'BFoo', 'Foo']); + expect(result.map(d => d.swiftName)).toEqual(['reanimated', 'worklets']); }); - it('throws when a borrowed scope lands on a third package instead of producing two of the same name', () => { - // 'a-foo' already derives 'AFoo', the name '@a/foo' borrows. + it('throws instead of correcting a podspec name React Native reserves', () => { const run = () => - expand( - [ - scoped('@a/foo', '/a'), - scoped('@b/foo', '/b'), - scoped('a-foo', '/af'), - ], - {'/a': {}, '/b': {}, '/af': {}}, - ); + expand([dep('some-lib', '/s')], { + podspecs: {'/s': {name: 'ReactHeaders'}}, + }); expect(run).toThrow(SpmNameCollisionError); - expect(run).toThrow(/both resolve to 'AFoo'/); + expect(run).toThrow( + /'some-lib' resolves to 'ReactHeaders', which React Native reserves/, + ); + expect(run).toThrow(/Set a different 'swiftpmConfig\.name'/); }); - it('throws when a borrowed scope lands on a name React Native reserves', () => { - // Both derive 'Native'; the borrow takes '@react/native' to 'ReactNative'. - const run = () => - expand([scoped('@react/native', '/r'), scoped('@other/native', '/o')], { - '/r': {}, - '/o': {}, - }); + it('throws instead of borrowing the npm scope when the derived name is reserved', () => { + const run = () => expand([dep('@powersync/react-native', '/ps')]); expect(run).toThrow(SpmNameCollisionError); expect(run).toThrow(/React Native reserves/); }); - it('still throws for two unscoped deps deriving the same name', () => { + it('throws when two deps land on the same podspec name', () => { const run = () => - expand( - [scoped('react-native-foo', '/a'), scoped('react_native_foo', '/b')], - {'/a': {}, '/b': {}}, - ); + expand([dep('@a/svg', '/a'), dep('@b/svg', '/b')], { + podspecs: {'/a': {name: 'RNSVG'}, '/b': {name: 'RNSVG'}}, + }); expect(run).toThrow(SpmNameCollisionError); - expect(run).toThrow( - /'react-native-foo' \('ReactNativeFoo'\) and 'react_native_foo' \('ReactNativeFoo'\) both resolve to 'ReactNativeFoo'\./, - ); - expect(run).toThrow(/Set a distinct 'spm\.name'/); - }); - - it('changes nothing, and says nothing, for a set with no collisions', () => { - const log = jest.fn(); - const result = expand( - [scoped('@a/foo', '/a'), scoped('@b/bar', '/b'), scoped('baz', '/c')], - {'/a': {}, '/b': {}, '/c': {}}, - {log}, - ); - expect(result.map(d => d.swiftName)).toEqual(['Foo', 'Bar', 'Baz']); - expect(log).not.toHaveBeenCalled(); - }); - - it('borrows a second time when an already-borrowed name collides, and the incumbent keeps its name', () => { - // Both land on 'AReactNative': one by borrowing, one by derivation. - const result = expand( - [scoped('@a/react-native', '/a'), scoped('a-react-native', '/b')], - {'/a': {}, '/b': {}}, - ); - expect(result.map(d => d.swiftName)).toEqual([ - 'AAReactNative', - 'AReactNative', - ]); + expect(run).toThrow(/both resolve to 'RNSVG'/); + expect(run).toThrow(/Set a distinct 'swiftpmConfig\.name'/); }); - it('disambiguates a transitive dep against a direct one', () => { - const result = expandSpmDependencies([scoped('@a/foo', '/a')], { - readConfig: makeReadConfig({ - '/a': {spm: {dependencies: ['@b/foo']}}, - '/b': {dependency: {platforms: {ios: {}}}}, - }), - resolveDep: makeResolveDep({'@b/foo': '/b'}), - }); - expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'BFoo']); + it('throws when two podspec names differ only in punctuation — SwiftPM compiles one module', () => { + const run = () => + expand([dep('@a/foo', '/a'), dep('@b/foo', '/b')], { + podspecs: {'/a': {name: 'foo-bar'}, '/b': {name: 'foo_bar'}}, + }); + expect(run).toThrow(SpmNameCollisionError); + // Both spellings the authors wrote, plus the module they share. + expect(run).toThrow(/'foo-bar'/); + expect(run).toThrow(/'foo_bar'/); + expect(run).toThrow(/module 'foo_bar'/); }); }); // --------------------------------------------------------------------------- -// Reserved React Native names — the backstop for what a scope cannot resolve. +// Reserved React Native names — terminal, whatever the name was resolved from. // --------------------------------------------------------------------------- describe('expandSpmDependencies (reserved React Native names)', () => { function expand(direct, configs, options) { return expandSpmDependencies(direct, { - readConfig: makeReadConfig(configs), + ...fixtures(configs), resolveDep: makeResolveDep({}), ...options, }); @@ -717,7 +761,7 @@ describe('expandSpmDependencies (reserved React Native names)', () => { /'react-headers' resolves to 'ReactHeaders', which React Native reserves/, ); expect(run).toThrow( - /Set a different 'spm\.name' in react-headers's react-native\.config\.js\./, + /Set a different 'swiftpmConfig\.name' in react-headers's package\.json\./, ); }); @@ -736,7 +780,7 @@ describe('expandSpmDependencies (reserved React Native names)', () => { expandSpmDependencies( [{name: 'top', root: '/top', platforms: {ios: {}}}], { - readConfig: makeReadConfig({ + ...fixtures({ '/top': {spm: {dependencies: ['react-native-headers']}}, '/rnh': {dependency: {platforms: {ios: {}}}}, }), @@ -818,6 +862,24 @@ describe('expandSpmDependencies (reserved React Native names)', () => { expect(dep.swiftName).toBe(REACT_HEADERS_TARGET_DIR); }); + it('reports a punctuation-only match against a reserved name, naming both spellings', () => { + // 'React_GeneratedCode' and RN's 'React-GeneratedCode' are distinct strings + // and distinct directories, but SwiftPM compiles them as one module. + const run = () => + expand( + [{name: 'some-lib', root: '/s', platforms: {ios: {}}}], + {'/s': {}}, + { + readPodspec: () => ({name: 'React_GeneratedCode'}), + }, + ); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow( + /'some-lib' resolves to 'React_GeneratedCode', which compiles as the same module as React Native's reserved 'React-GeneratedCode'/, + ); + expect(run).toThrow(/swiftpmConfig\.name/); + }); + it('reports a case-only match against a reserved name, naming both spellings', () => { const run = () => expand([{name: 'some-lib', root: '/s', platforms: {ios: {}}}], { @@ -827,29 +889,197 @@ describe('expandSpmDependencies (reserved React Native names)', () => { expect(run).toThrow( /'some-lib' resolves to 'reactnative', which differs from React Native's reserved 'ReactNative' only in case/, ); - expect(run).toThrow(/spm\.name/); + expect(run).toThrow(/swiftpmConfig\.name/); + }); +}); + +// --------------------------------------------------------------------------- +// swiftpmConfig — the declared home for a library's SwiftPM settings, and the +// step of the precedence that lets a library stop shipping a podspec. +// --------------------------------------------------------------------------- + +describe('expandSpmDependencies (swiftpmConfig)', () => { + let tmpRoot; + let roots = 0; + + beforeAll(() => { + tmpRoot = fs.mkdtempSync( + path.join(fs.realpathSync(os.tmpdir()), 'spm-expand-config-'), + ); + }); + + afterAll(() => { + fs.rmSync(tmpRoot, {recursive: true, force: true}); + }); + + function makeRoot(pkgJson) { + const root = path.join(tmpRoot, `pkg-${roots++}`); + fs.mkdirSync(root, {recursive: true}); + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify(pkgJson, null, 2), + ); + return root; + } + + function expand(direct, {configs, podspecs} = {}) { + return expandSpmDependencies(direct, { + readConfig: makeReadConfig(configs ?? {}), + resolveDep: makeResolveDep({}), + readPodspec: makeReadPodspec(podspecs ?? {}), + }); + } + + it('takes the name from package.json, ahead of the podspec', () => { + const root = makeRoot({ + name: 'react-native-svg', + swiftpmConfig: {name: 'MySvg'}, + }); + const [svg] = expand( + [{name: 'react-native-svg', root, platforms: {ios: {}}}], + {podspecs: {[root]: {name: 'RNSVG'}}}, + ); + expect(svg.swiftName).toBe('MySvg'); + }); + + it('records where each name came from, so the scaffolder can tell a derived name from a declared one', () => { + const declared = makeRoot({ + name: 'react-native-svg', + swiftpmConfig: {name: 'MySvg'}, + }); + const derived = makeRoot({name: 'react-native-screens'}); + const guessed = makeRoot({name: 'react-native-blur'}); + const result = expand( + [ + {name: 'react-native-svg', root: declared, platforms: {ios: {}}}, + {name: 'react-native-screens', root: derived, platforms: {ios: {}}}, + {name: 'react-native-blur', root: guessed, platforms: {ios: {}}}, + ], + {podspecs: {[derived]: {name: 'RNScreens'}}}, + ); + expect(result.map(d => d.swiftNameSource)).toEqual([ + 'config', + 'podspec', + 'npm', + ]); + }); + + it('expands dependencies declared in package.json', () => { + const root = makeRoot({ + name: 'react-native-reanimated', + swiftpmConfig: {dependencies: ['react-native-worklets']}, + }); + const result = expandSpmDependencies( + [{name: 'react-native-reanimated', root, platforms: {ios: {}}}], + { + readConfig: makeReadConfig({ + '/w': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({'react-native-worklets': '/w'}), + }, + ); + expect(result.map(d => d.name)).toEqual([ + 'react-native-reanimated', + 'react-native-worklets', + ]); + }); + + it('still honours a name in the deprecated spm block', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const root = makeRoot({name: 'react-native-svg'}); + const [svg] = expand( + [{name: 'react-native-svg', root, platforms: {ios: {}}}], + {configs: {[root]: {spm: {name: 'MySvg'}}}}, + ); + expect(svg.swiftName).toBe('MySvg'); + expect(warnSpy).toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } }); }); // --------------------------------------------------------------------------- -// isValidSwiftName — the charset rule `spm.name` enforces. +// defaultReadPodspec — only autolinking.json records a podspecPath, so deps +// synthesized from `spm.dependencies` rely on the dep-root search. // --------------------------------------------------------------------------- -describe('isValidSwiftName', () => { - it.each(['worklets', 'ReactNativeFoo', 'hermes-engine', 'react_native_foo'])( - 'accepts %j', - name => { - expect(isValidSwiftName(name)).toBe(true); - }, - ); - - it.each(['', 'foo bar', 'foo/bar', 'foo.bar', '9lives', 42, null])( - 'rejects %j', - name => { - expect(isValidSwiftName(name)).toBe(false); - }, - ); +describe('defaultReadPodspec', () => { + let root; + + beforeEach(() => { + root = fs.mkdtempSync( + path.join(fs.realpathSync(os.tmpdir()), 'spm-read-podspec-'), + ); + }); + + afterEach(() => { + fs.rmSync(root, {recursive: true, force: true}); + }); + + function writePodspec(name, body) { + fs.writeFileSync( + path.join(root, `${name}.podspec`), + ['Pod::Spec.new do |s|', ` s.name = "${name}"`, ...body, 'end', ''].join( + '\n', + ), + ); + } + + it('finds the podspec at the dep root when no path was recorded', () => { + writePodspec('RNSVG', [ + ' s.version = "1.0.0"', + ' s.header_dir = "rnsvg"', + ]); + const model = defaultReadPodspec(root, null); + expect(model?.name).toBe('RNSVG'); + expect(model?.headerDir).toBe('rnsvg'); + }); + + it('names a screens-shaped library from its pod name, not its subspec prefix', () => { + // react-native-screens and react-native-svg both declare their C++ prefix + // on a subspec; the library's ObjC headers are imported under the pod name, + // and the subspec prefix resolves through the header search paths instead. + writePodspec('RNScreens', [ + ' s.version = "4.0.0"', + ' s.subspec "common" do |ss|', + ' ss.header_mappings_dir = "common/cpp"', + ' ss.header_dir = "rnscreens"', + ' end', + ]); + const [screens] = expandSpmDependencies( + [{name: 'react-native-screens', root, platforms: {ios: {}}}], + { + readConfig: () => null, + resolveDep: makeResolveDep({}), + readPodspec: defaultReadPodspec, + }, + ); + expect(screens.swiftName).toBe('RNScreens'); + expect(screens.swiftNameSource).toBe('podspec'); + }); + + it("skips a crashed run's leftover patched copy", () => { + writePodspec('RNSVG', [' s.version = "1.0.0"']); + fs.writeFileSync( + path.join(root, '.spm-scaffold-1-Leftover.podspec'), + 'Pod::Spec.new do |s|\n s.name = "Leftover"\nend\n', + ); + expect(defaultReadPodspec(root, null)?.name).toBe('RNSVG'); + }); + + it('returns null when the dep ships no podspec', () => { + expect(defaultReadPodspec(root, null)).toBeNull(); + }); + + it('returns null when the recorded path is gone', () => { + expect( + defaultReadPodspec(root, path.join(root, 'Gone.podspec')), + ).toBeNull(); + }); }); + // defaultReadConfig // // The community CLI's own loaders disagree — sync reads named exports, async diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js index 7c1644fbc745..baa0a8fa20ba 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js @@ -25,6 +25,7 @@ const { linkHeaderTree, main, reactDescriptor, + readDenyPluginsFromConfig, reportMissingManifests, } = require('../generate-spm-autolinking'); const fs = require('node:fs'); @@ -1051,6 +1052,28 @@ describe('hasMixedLanguageSources', () => { }); }); +// Every main() case silences the [generate-spm-autolinking] logger and removes +// the temp app dirs it appends to `created`. +function useTempApps() { + const created = []; + const spies = []; + + beforeEach(() => { + for (const m of ['log', 'warn', 'error']) { + spies.push(jest.spyOn(console, m).mockImplementation(() => {})); + } + }); + + afterEach(() => { + for (const s of spies) s.mockRestore(); + spies.length = 0; + for (const d of created) fs.rmSync(d, {recursive: true, force: true}); + created.length = 0; + }); + + return {created, spies}; +} + // --------------------------------------------------------------------------- // main() — autolinking plugin host exemption // @@ -1064,27 +1087,16 @@ describe('hasMixedLanguageSources', () => { // --------------------------------------------------------------------------- describe('main() — autolinking plugin host exemption', () => { - let created = []; - let spies = []; - - beforeEach(() => { - // Silence the [generate-spm-autolinking] logger (console.log/warn/error). - for (const m of ['log', 'warn', 'error']) { - spies.push(jest.spyOn(console, m).mockImplementation(() => {})); - } - }); - - afterEach(() => { - for (const s of spies) s.mockRestore(); - spies = []; - for (const d of created) fs.rmSync(d, {recursive: true, force: true}); - created = []; - }); + const {created} = useTempApps(); // Builds a minimal app fixture whose ONLY autolinked iOS dep is `expo`, which // ships NO Package.swift. When `withPlugin` is set, expo declares an // autolinking plugin in its own react-native.config.js (transitive opt-in). - function buildFixture({withPlugin, depName = 'expo'}) { + function buildFixture({ + withPlugin, + depName = 'expo', + declareIn = 'package.json', + }) { const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-plugin-host-')); created.push(appRoot); // rnRoot only needs to exist (main() existence-checks it, then passes it @@ -1104,10 +1116,20 @@ describe('main() — autolinking plugin host exemption', () => { '// native source\n', ); if (withPlugin) { - fs.writeFileSync( - path.join(expoDir, 'react-native.config.js'), - "module.exports = { spm: { autolinkingPlugin: './spm-plugin.js' } };\n", - ); + if (declareIn === 'react-native.config.js') { + fs.writeFileSync( + path.join(expoDir, 'react-native.config.js'), + "module.exports = { spm: { autolinkingPlugin: './spm-plugin.js' } };\n", + ); + } else { + fs.writeFileSync( + path.join(expoDir, 'package.json'), + JSON.stringify({ + name: depName, + swiftpmConfig: {autolinkingPlugin: './spm-plugin.js'}, + }), + ); + } fs.writeFileSync( path.join(expoDir, 'spm-plugin.js'), 'module.exports = function () {\n' + @@ -1195,7 +1217,7 @@ describe('main() — autolinking plugin host exemption', () => { expect(run).toThrow(/'react-native-y'/); expect(run).toThrow(/'expo'/); expect(run).toThrow(/autolinking plugin/); - expect(run).toThrow(/spm\.dependencies/); + expect(run).toThrow(/declared as a SwiftPM dependency/); }); it('leaves a self-managed dependent alone — its own Package.swift declares its package references, so RN emits none', () => { @@ -1243,22 +1265,8 @@ describe('main() — autolinking plugin host exemption', () => { // of React Native's reserved names, and unique across modules and deps. // --------------------------------------------------------------------------- -describe('main() — spm.modules names', () => { - let created = []; - let spies = []; - - beforeEach(() => { - for (const m of ['log', 'warn', 'error']) { - spies.push(jest.spyOn(console, m).mockImplementation(() => {})); - } - }); - - afterEach(() => { - for (const s of spies) s.mockRestore(); - spies = []; - for (const d of created) fs.rmSync(d, {recursive: true, force: true}); - created = []; - }); +describe('main() — swiftpmConfig.modules names', () => { + const {created, spies} = useTempApps(); // App fixture whose react-native.config.js declares `spm.modules`, plus an // optional autolinked dep (for the module-vs-dep collision case). @@ -1269,17 +1277,13 @@ describe('main() — spm.modules names', () => { fs.mkdirSync(rnRoot, {recursive: true}); fs.writeFileSync( path.join(appRoot, 'package.json'), - JSON.stringify({name: 'app'}), + JSON.stringify({name: 'app', swiftpmConfig: {modules}}), ); for (const mod of modules) { const modDir = path.join(appRoot, mod.path); fs.mkdirSync(modDir, {recursive: true}); fs.writeFileSync(path.join(modDir, 'Module.mm'), '// native source\n'); } - fs.writeFileSync( - path.join(appRoot, 'react-native.config.js'), - `module.exports = ${JSON.stringify({spm: {modules}})};\n`, - ); const dependencies = {}; if (dep != null) { const depDir = path.join(appRoot, 'node_modules', dep.name); @@ -1292,7 +1296,15 @@ describe('main() — spm.modules names', () => { path.join(depDir, 'Package.swift'), '// swift-tools-version: 6.0\n', ); - dependencies[dep.name] = {root: depDir, platforms: {ios: {}}}; + const ios = {}; + if (dep.podName != null) { + ios.podspecPath = path.join(depDir, `${dep.podName}.podspec`); + fs.writeFileSync( + ios.podspecPath, + `Pod::Spec.new do |s|\n s.name = "${dep.podName}"\n s.version = "1.0.0"\nend\n`, + ); + } + dependencies[dep.name] = {root: depDir, platforms: {ios}}; } const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); fs.mkdirSync(autolinkDir, {recursive: true}); @@ -1306,11 +1318,17 @@ describe('main() — spm.modules names', () => { const run = ({appRoot, rnRoot}) => main(['--app-root', appRoot, '--react-native-root', rnRoot]); - it('accepts a normal module name', () => { + it('emits a module the app declares', () => { const app = buildApp({ modules: [{name: 'MyNativeModule', path: 'ios/MyNativeModule'}], }); - expect(() => run(app)).not.toThrow(); + run(app); + expect( + fs.readFileSync( + path.join(app.appRoot, 'build/generated/autolinking/Package.swift'), + 'utf8', + ), + ).toContain('"MyNativeModule"'); }); it('rejects a module named after a reserved React Native name', () => { @@ -1319,9 +1337,9 @@ describe('main() — spm.modules names', () => { }); expect(() => run(app)).toThrow(SpmNameCollisionError); expect(() => run(app)).toThrow( - /the 'spm.modules' entry 'ReactNative' resolves to 'ReactNative', which React Native reserves/, + /the 'swiftpmConfig.modules' entry 'ReactNative' resolves to 'ReactNative', which React Native reserves/, ); - expect(() => run(app)).toThrow(/'spm\.modules'\.$/); + expect(() => run(app)).toThrow(/'swiftpmConfig\.modules'\.$/); }); it('rejects a reserved product name in any casing', () => { @@ -1330,7 +1348,7 @@ describe('main() — spm.modules names', () => { }); expect(() => run(app)).toThrow(SpmNameCollisionError); expect(() => run(app)).toThrow( - /the 'spm\.modules' entry 'reactheaders' resolves to 'reactheaders', which differs from React Native's reserved 'ReactHeaders' only in case/, + /the 'swiftpmConfig\.modules' entry 'reactheaders' resolves to 'reactheaders', which differs from React Native's reserved 'ReactHeaders' only in case/, ); }); @@ -1338,7 +1356,9 @@ describe('main() — spm.modules names', () => { const app = buildApp({ modules: [{name: 'My Module', path: 'ios/MyNativeModule'}], }); - expect(() => run(app)).toThrow(/invalid 'spm.modules' name "My Module"/); + expect(() => run(app)).toThrow( + /invalid 'swiftpmConfig.modules' name "My Module"/, + ); }); it('rejects two modules resolving to the same name', () => { @@ -1350,7 +1370,31 @@ describe('main() — spm.modules names', () => { }); expect(() => run(app)).toThrow(SpmNameCollisionError); expect(() => run(app)).toThrow( - /the 'spm.modules' entry 'shared' differs from the existing target 'Shared' only in case/, + /the 'swiftpmConfig.modules' entry 'shared' differs from the existing target 'Shared' only in case/, + ); + }); + + it('rejects two modules whose names differ only in punctuation', () => { + const app = buildApp({ + modules: [ + {name: 'foo-bar', path: 'ios/one'}, + {name: 'foo_bar', path: 'ios/two'}, + ], + }); + expect(() => run(app)).toThrow(SpmNameCollisionError); + expect(() => run(app)).toThrow( + /the 'swiftpmConfig.modules' entry 'foo_bar' compiles as the same module as the existing target 'foo-bar'/, + ); + }); + + it('rejects a module colliding with an autolinked dep through SwiftPM normalization', () => { + const app = buildApp({ + modules: [{name: 'foo_bar', path: 'ios/MyNativeModule'}], + dep: {name: 'react-native-foo-bar', podName: 'foo-bar'}, + }); + expect(() => run(app)).toThrow(SpmNameCollisionError); + expect(() => run(app)).toThrow( + /the 'swiftpmConfig.modules' entry 'foo_bar' compiles as the same module as the existing target 'foo-bar'/, ); }); @@ -1361,9 +1405,150 @@ describe('main() — spm.modules names', () => { }); expect(() => run(app)).toThrow(SpmNameCollisionError); expect(() => run(app)).toThrow( - /the 'spm.modules' entry 'ReactNativeFoo' is already the name of another autolinked target/, + /the 'swiftpmConfig.modules' entry 'ReactNativeFoo' is already the name of another autolinked target/, ); }); + + // `appRoot` is the Xcode project dir (`ios/`) in a standard app, so the + // app's settings live one level up, with its package.json — the directory + // codegen already calls the project root. + describe('found at the project root or the Xcode dir', () => { + // A standard app: JS root with package.json, Xcode project in ios/. + function buildStandardApp({atProjectRoot, atAppRoot}) { + const projectRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'spm-config-root-')), + ); + created.push(projectRoot); + const appRoot = path.join(projectRoot, 'ios'); + const rnRoot = path.join(projectRoot, 'rn'); + fs.mkdirSync(rnRoot, {recursive: true}); + fs.mkdirSync(appRoot, {recursive: true}); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ + name: 'app', + swiftpmConfig: atProjectRoot ?? undefined, + }), + ); + // The Xcode-dir layout that predates `swiftpmConfig`: a config file next to + // the project. It has no package.json, so the project root is still above. + if (atAppRoot != null) { + fs.writeFileSync( + path.join(appRoot, 'react-native.config.js'), + `module.exports = ${JSON.stringify({spm: atAppRoot})};\n`, + ); + } + const stageModules = (root, modules) => { + for (const mod of modules ?? []) { + const modDir = path.resolve(root, mod.path); + fs.mkdirSync(modDir, {recursive: true}); + fs.writeFileSync( + path.join(modDir, 'Module.mm'), + '// native source\n', + ); + } + }; + stageModules(projectRoot, atProjectRoot?.modules); + stageModules(appRoot, atAppRoot?.modules); + const autolinkDir = path.join( + appRoot, + 'build', + 'generated', + 'autolinking', + ); + fs.mkdirSync(autolinkDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({dependencies: {}}), + ); + return {appRoot, rnRoot, autolinkDir}; + } + + const manifestOf = ({autolinkDir}) => + fs.readFileSync(path.join(autolinkDir, 'Package.swift'), 'utf8'); + + it('finds modules declared at the project root, above the Xcode dir', () => { + const app = buildStandardApp({ + atProjectRoot: {modules: [{name: 'FromProjectRoot', path: 'MyModule'}]}, + }); + main(['--app-root', app.appRoot, '--react-native-root', app.rnRoot]); + expect(manifestOf(app)).toContain('"FromProjectRoot"'); + }); + + it('still finds a config that sits in the Xcode dir, as it did before', () => { + const app = buildStandardApp({ + atAppRoot: {modules: [{name: 'FromAppRoot', path: 'MyModule'}]}, + }); + main(['--app-root', app.appRoot, '--react-native-root', app.rnRoot]); + expect(manifestOf(app)).toContain('"FromAppRoot"'); + }); + + it('prefers the project root when both declare settings', () => { + const app = buildStandardApp({ + atProjectRoot: {modules: [{name: 'FromProjectRoot', path: 'MyModule'}]}, + atAppRoot: {modules: [{name: 'FromAppRoot', path: 'Other'}]}, + }); + main(['--app-root', app.appRoot, '--react-native-root', app.rnRoot]); + const manifest = manifestOf(app); + expect(manifest).toContain('"FromProjectRoot"'); + expect(manifest).not.toContain('"FromAppRoot"'); + }); + + it('keeps a Xcode-dir field the project root does not declare', () => { + // The drop this guards: one unrelated key at the project root used to hide + // a working `modules` set in the Xcode dir. + const app = buildStandardApp({ + atProjectRoot: {denyPlugins: ['some-framework']}, + atAppRoot: {modules: [{name: 'FromAppRoot', path: 'MyModule'}]}, + }); + main(['--app-root', app.appRoot, '--react-native-root', app.rnRoot]); + expect(manifestOf(app)).toContain('"FromAppRoot"'); + }); + + it('says which field it took from the Xcode dir', () => { + const app = buildStandardApp({ + atProjectRoot: {denyPlugins: []}, + atAppRoot: {modules: [{name: 'FromAppRoot', path: 'MyModule'}]}, + }); + main(['--app-root', app.appRoot, '--react-native-root', app.rnRoot]); + const warned = spies + .flatMap(spy => spy.mock.calls) + .map(call => call.join(' ')) + .join('\n'); + expect(warned).toContain('modules'); + expect(warned).toContain(app.appRoot); + }); + + it('resolves a module path against the root that declared it', () => { + // A path written next to the JS-root package.json reads from there; one in + // the Xcode dir keeps reading from there. + const app = buildStandardApp({ + atProjectRoot: {modules: [{name: 'FromProjectRoot', path: 'ios/Deep'}]}, + }); + main(['--app-root', app.appRoot, '--react-native-root', app.rnRoot]); + // The source only mirrors if the declared path was found on disk. + expect( + fs.existsSync( + path.join(app.autolinkDir, 'packages/FromProjectRoot/root/Module.mm'), + ), + ).toBe(true); + }); + + it('reads denyPlugins from the project root too', () => { + const app = buildStandardApp({ + atProjectRoot: {denyPlugins: ['some-framework']}, + }); + expect(() => + main(['--app-root', app.appRoot, '--react-native-root', app.rnRoot]), + ).not.toThrow(); + expect(readDenyPluginsFromConfig(path.dirname(app.appRoot))).toEqual([ + 'some-framework', + ]); + expect(readDenyPluginsFromConfig(app.appRoot)).toEqual([ + 'some-framework', + ]); + }); + }); }); // --------------------------------------------------------------------------- @@ -1376,20 +1561,7 @@ describe('main() — spm.modules names', () => { // --------------------------------------------------------------------------- describe('main() — flavoredFrameworks sidecar', () => { - let created = []; - let spies = []; - - beforeEach(() => { - for (const m of ['log', 'warn', 'error']) { - spies.push(jest.spyOn(console, m).mockImplementation(() => {})); - } - }); - afterEach(() => { - for (const s of spies) s.mockRestore(); - spies = []; - for (const d of created) fs.rmSync(d, {recursive: true, force: true}); - created = []; - }); + const {created} = useTempApps(); const sidecarPath = appRoot => path.join( @@ -1511,20 +1683,7 @@ describe('main() — flavoredFrameworks sidecar', () => { // --------------------------------------------------------------------------- describe('main() — scriptPhases sidecar', () => { - let created = []; - let spies = []; - - beforeEach(() => { - for (const m of ['log', 'warn', 'error']) { - spies.push(jest.spyOn(console, m).mockImplementation(() => {})); - } - }); - afterEach(() => { - for (const s of spies) s.mockRestore(); - spies = []; - for (const d of created) fs.rmSync(d, {recursive: true, force: true}); - created = []; - }); + const {created} = useTempApps(); const sidecarPath = appRoot => path.join( @@ -1631,20 +1790,7 @@ describe('main() — scriptPhases sidecar', () => { // --------------------------------------------------------------------------- describe('main() — .spm-sync-watch-paths emission', () => { - let created = []; - let spies = []; - - beforeEach(() => { - for (const m of ['log', 'warn', 'error']) { - spies.push(jest.spyOn(console, m).mockImplementation(() => {})); - } - }); - afterEach(() => { - for (const s of spies) s.mockRestore(); - spies = []; - for (const d of created) fs.rmSync(d, {recursive: true, force: true}); - created = []; - }); + const {created} = useTempApps(); function readWatchLines(appRoot) { const contents = fs.readFileSync( @@ -1744,33 +1890,17 @@ describe('main() — .spm-sync-watch-paths emission', () => { }); // --------------------------------------------------------------------------- -// main() — scope disambiguation: the borrowed name reaching a real manifest. +// main() — the name a dep's podspec declares reaching a real manifest. // --------------------------------------------------------------------------- -describe('main() — scope disambiguation', () => { - let created = []; - let spies = []; - let logSpy; - - beforeEach(() => { - logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); - spies.push(logSpy); - for (const m of ['warn', 'error']) { - spies.push(jest.spyOn(console, m).mockImplementation(() => {})); - } - }); - - afterEach(() => { - for (const s of spies) s.mockRestore(); - spies = []; - for (const d of created) fs.rmSync(d, {recursive: true, force: true}); - created = []; - }); +describe('main() — podspec-derived names', () => { + const {created} = useTempApps(); - // Each dep ships a Package.swift, so it reaches the aggregator as self-managed. - function buildFixture(...depNames) { + // Each dep ships a Package.swift, so it reaches the aggregator as + // self-managed, plus the podspec its name is meant to come from. + function buildFixture(...deps) { const appRoot = fs.realpathSync( - fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scope-disambig-')), + fs.mkdtempSync(path.join(os.tmpdir(), 'spm-podspec-name-')), ); created.push(appRoot); const rnRoot = path.join(appRoot, 'rn'); @@ -1780,16 +1910,39 @@ describe('main() — scope disambiguation', () => { JSON.stringify({name: 'app'}), ); const dependencies = {}; - for (const depName of depNames) { - const depDir = path.join(appRoot, 'node_modules', ...depName.split('/')); + for (const {npmName, podName, headerDir, swiftpmConfig} of deps) { + const depDir = path.join(appRoot, 'node_modules', ...npmName.split('/')); fs.mkdirSync(path.join(depDir, 'ios'), {recursive: true}); + if (swiftpmConfig != null) { + fs.writeFileSync( + path.join(depDir, 'package.json'), + JSON.stringify({name: npmName, swiftpmConfig}), + ); + } fs.writeFileSync( path.join(depDir, 'Package.swift'), '// swift-tools-version:6.0\n// hand-authored\n', ); fs.writeFileSync(path.join(depDir, 'ios', 'Lib.h'), '// header\n'); fs.writeFileSync(path.join(depDir, 'ios', 'Lib.mm'), '// src\n'); - dependencies[depName] = {root: depDir, platforms: {ios: {}}}; + const ios = {}; + if (podName != null) { + const podspecPath = path.join(depDir, `${podName}.podspec`); + fs.writeFileSync( + podspecPath, + [ + 'Pod::Spec.new do |s|', + ` s.name = "${podName}"`, + ' s.version = "1.0.0"', + ...(headerDir != null ? [` s.header_dir = "${headerDir}"`] : []), + ' s.source_files = "ios/**/*.{h,m,mm}"', + 'end', + '', + ].join('\n'), + ); + ios.podspecPath = podspecPath; + } + dependencies[npmName] = {root: depDir, platforms: {ios}}; } const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); fs.mkdirSync(autolinkDir, {recursive: true}); @@ -1800,77 +1953,64 @@ describe('main() — scope disambiguation', () => { return {appRoot, rnRoot}; } - it('emits the disambiguated name as the package ref, the product ref and the header slice', () => { - const {appRoot, rnRoot} = buildFixture('@powersync/react-native'); + function run(appRoot, rnRoot) { main(['--app-root', appRoot, '--react-native-root', rnRoot]); - const outDir = path.join(appRoot, 'build/generated/autolinking'); - const pkg = fs.readFileSync(path.join(outDir, 'Package.swift'), 'utf8'); - expect(pkg).toContain( - '.package(name: "PowersyncReactNative", path: "libs/PowersyncReactNative")', - ); - expect(pkg).toContain( - '.product(name: "PowersyncReactNative", package: "PowersyncReactNative")', - ); - // Nothing is referenced under the name the derivation would have taken. - expect(pkg).not.toContain('"ReactNative", path: "libs/'); - expect(pkg).not.toContain('package: "ReactNative"'); + return { + outDir, + manifest: fs.readFileSync(path.join(outDir, 'Package.swift'), 'utf8'), + }; + } - // So `#import ` resolves for consumers. - expect( - fs.existsSync( - path.join(outDir, 'headers/PowersyncReactNative/ios/Lib.h'), - ), - ).toBe(true); - expect(fs.existsSync(path.join(outDir, 'libs/PowersyncReactNative'))).toBe( - true, + it('emits every step of the precedence as the package ref, the product ref and the header slice', () => { + const {appRoot, rnRoot} = buildFixture( + {npmName: 'react-native-svg', podName: 'RNSVG'}, + { + npmName: 'react-native-reanimated', + podName: 'RNReanimated', + headerDir: 'reanimated', + }, + { + npmName: 'react-native-svg-fork', + podName: 'RNSVGFork', + swiftpmConfig: {name: 'MySvg'}, + }, + {npmName: 'react-native-screens'}, ); - expect(fs.existsSync(path.join(outDir, 'headers/ReactNative'))).toBe(false); - }); - - it('tells the developer which name it took and why', () => { - const {appRoot, rnRoot} = buildFixture('@powersync/react-native'); - main(['--app-root', appRoot, '--react-native-root', rnRoot]); - - const line = logSpy.mock.calls - .map(call => call.join(' ')) - .find(l => l.includes('PowersyncReactNative')); - expect(line).toBeDefined(); - expect(line).toContain('@powersync/react-native'); - expect(line).toContain("'ReactNative'"); - }); - - it('still rejects an unscoped dep deriving a reserved name — it has no scope to borrow', () => { - const {appRoot, rnRoot} = buildFixture('react-headers'); - expect(() => - main(['--app-root', appRoot, '--react-native-root', rnRoot]), - ).toThrow(SpmNameCollisionError); - }); + const {outDir, manifest} = run(appRoot, rnRoot); - it('emits both names of a dep-vs-dep collision as package refs, product refs and header slices', () => { - const {appRoot, rnRoot} = buildFixture('@a/foo', '@b/foo'); - main(['--app-root', appRoot, '--react-native-root', rnRoot]); - - const outDir = path.join(appRoot, 'build/generated/autolinking'); - const pkg = fs.readFileSync(path.join(outDir, 'Package.swift'), 'utf8'); - for (const name of ['AFoo', 'BFoo']) { - expect(pkg).toContain(`.package(name: "${name}", path: "libs/${name}")`); - expect(pkg).toContain(`.product(name: "${name}", package: "${name}")`); + // swiftpmConfig.name, header_dir, the podspec name, the npm name. + for (const name of ['MySvg', 'reanimated', 'RNSVG', 'ReactNativeScreens']) { + expect(manifest).toContain( + `.package(name: "${name}", path: "libs/${name}")`, + ); + expect(manifest).toContain( + `.product(name: "${name}", package: "${name}")`, + ); + // So `#import ` resolves for consumers. expect( fs.existsSync(path.join(outDir, `headers/${name}/ios/Lib.h`)), ).toBe(true); + expect(fs.existsSync(path.join(outDir, `libs/${name}`))).toBe(true); + } + + // Nothing is referenced under a name an earlier step outranked. + for (const outranked of [ + 'RNSVGFork', + 'RNReanimated', + 'ReactNativeSvg', + 'ReactNativeReanimated', + ]) { + expect(manifest).not.toContain(outranked); + expect(fs.existsSync(path.join(outDir, `headers/${outranked}`))).toBe( + false, + ); } - expect(pkg).not.toContain('"Foo", path: "libs/'); - expect(pkg).not.toContain('package: "Foo"'); - expect(fs.existsSync(path.join(outDir, 'headers/Foo'))).toBe(false); }); - it('still rejects a collision the scopes cannot resolve', () => { - // 'a-foo' already derives 'AFoo', the name '@a/foo' borrows. - const {appRoot, rnRoot} = buildFixture('@a/foo', '@b/foo', 'a-foo'); - expect(() => - main(['--app-root', appRoot, '--react-native-root', rnRoot]), - ).toThrow(SpmNameCollisionError); + it('refuses an unscoped dep deriving a reserved name', () => { + const {appRoot, rnRoot} = buildFixture({npmName: 'react-headers'}); + expect(() => run(appRoot, rnRoot)).toThrow(SpmNameCollisionError); }); }); diff --git a/packages/react-native/scripts/spm/__tests__/in-repo-library-names-test.js b/packages/react-native/scripts/spm/__tests__/in-repo-library-names-test.js new file mode 100644 index 000000000000..8027c5087055 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/in-repo-library-names-test.js @@ -0,0 +1,157 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +/** + * The in-repo libraries the SwiftPM autolinker names, checked against the + * includes their sources hand-write. + * + * A library's Swift name is its header import prefix, so renaming one breaks + * every `#import ` written against the old name. rn-tester + * depends on both fixtures, so its SwiftPM job does catch that — as an Xcode + * build failure on a macOS runner. This catches it in `test_js` instead, and + * says which prefix went stale and what the library is called now. + */ + +const { + defaultReadPodspec, + resolveSwiftName, +} = require('../expand-spm-dependencies'); +const {toSwiftName} = require('../spm-utils'); +const {readSwiftpmConfig, writeSwiftpmName} = require('../swiftpm-config'); +const fs = require('node:fs'); +const path = require('node:path'); + +const REPO_ROOT = path.resolve(__dirname, '../../../../..'); + +const LIBRARIES = [ + { + npmName: 'react-native-test-library-apple', + dir: 'packages/react-native-test-library/apple', + }, + { + npmName: 'react-native-test-library-common', + dir: 'packages/react-native-test-library/common', + }, +]; + +const SOURCE_SUFFIXES = ['.h', '.m', '.mm', '.c', '.cpp', '.swift']; + +// What the autolinker resolves, exactly as it does it. +function resolve(library) { + const root = path.join(REPO_ROOT, library.dir); + return resolveSwiftName( + library.npmName, + readSwiftpmConfig(root, null), + defaultReadPodspec(root, null), + ); +} + +// The same, ignoring the declared name — what the podspec on its own says. +function resolveFromPodspec(library) { + const root = path.join(REPO_ROOT, library.dir); + return resolveSwiftName( + library.npmName, + null, + defaultReadPodspec(root, null), + ); +} + +function sourceFiles(dir) { + return fs + .readdirSync(path.join(REPO_ROOT, dir), {recursive: true}) + .map(entry => path.join(dir, String(entry))) + .filter(file => SOURCE_SUFFIXES.includes(path.extname(file))) + .filter(file => fs.statSync(path.join(REPO_ROOT, file)).isFile()); +} + +// Every `#import ` in the libraries' own sources. +function angleIncludes() { + const found = []; + for (const library of LIBRARIES) { + for (const file of sourceFiles(library.dir)) { + const source = fs.readFileSync(path.join(REPO_ROOT, file), 'utf8'); + const re = /^\s*#(?:import|include)\s+<([^/>]+)\/[^>]+>/gm; + let match; + while ((match = re.exec(source)) != null) { + found.push({file, prefix: match[1]}); + } + } + } + return found; +} + +describe('in-repo SwiftPM library names', () => { + const resolved = new Map( + LIBRARIES.map(library => [library.npmName, resolve(library).name]), + ); + + it('names each library from its declared name, not its npm package name', () => { + expect(resolved.get('react-native-test-library-common')).toBe( + 'TestLibraryCommon', + ); + expect(resolved.get('react-native-test-library-apple')).toBe( + 'TestLibraryApple', + ); + }); + + it('declares each name in package.json, so `spm scaffold` writes nothing', () => { + // scaffold records a podspec-derived name in the library's package.json. + // These two are tracked files, so a name they do not already declare makes + // every scaffold run — CI's included — dirty the checkout. + for (const library of LIBRARIES) { + const root = path.join(REPO_ROOT, library.dir); + expect(resolve(library).source).toBe('config'); + expect( + writeSwiftpmName(root, resolved.get(library.npmName), {dryRun: true}), + ).toBe('already-set'); + } + }); + + it('declares the same name its podspec derives, so CocoaPods agrees', () => { + for (const library of LIBRARIES) { + expect(resolveFromPodspec(library).name).toBe( + resolved.get(library.npmName), + ); + } + }); + + it('imports each sibling under the name the autolinker resolves for it', () => { + // Any prefix that names an in-repo library — under a name it no longer has — + // is a build failure waiting for whichever app links these fixtures. + const stale = new Map(); + for (const library of LIBRARIES) { + const name = resolved.get(library.npmName); + for (const alias of [toSwiftName(library.npmName), library.npmName]) { + if (alias !== name) { + stale.set(alias, {name, npmName: library.npmName}); + } + } + } + + const offenders = angleIncludes() + .filter(include => stale.has(include.prefix)) + .map( + include => + `${include.file} imports <${include.prefix}/…>, but '${stale.get(include.prefix).npmName}' resolves to '${stale.get(include.prefix).name}'`, + ); + expect(offenders).toEqual([]); + }); + + it('wires the one cross-library import in these fixtures', () => { + const prefixes = angleIncludes() + .filter(include => include.file.endsWith('TestLibraryApple.mm')) + .map(include => include.prefix); + expect(prefixes).toContain( + resolved.get('react-native-test-library-common'), + ); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/read-podspec-test.js b/packages/react-native/scripts/spm/__tests__/read-podspec-test.js index acbad0d06681..ff748458903d 100644 --- a/packages/react-native/scripts/spm/__tests__/read-podspec-test.js +++ b/packages/react-native/scripts/spm/__tests__/read-podspec-test.js @@ -10,7 +10,12 @@ 'use strict'; -const {flattenSubspecs, readPodspec, regexPodspec} = require('../read-podspec'); +const { + flattenSubspecs, + readPodspec, + readPodspecNames, + regexPodspec, +} = require('../read-podspec'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); @@ -51,6 +56,33 @@ Pod::Spec.new do |s| end `; +const SCREENS_LIKE_PODSPEC = ` +Pod::Spec.new do |s| + s.name = "RNScreens" + s.version = "4.0.0" + s.source_files = ["ios/**/*.{h,m,mm}"] + + s.subspec "common" do |ss| + ss.source_files = ["common/cpp/**/*.{cpp,h}"] + ss.header_mappings_dir = "common/cpp" + ss.header_dir = "rnscreens" + end +end +`; + +const SVG_LIKE_PODSPEC = ` +Pod::Spec.new do |s| + s.name = "RNSVG" + s.version = "15.0.0" + s.source_files = "apple/**/*.{h,m,mm}" + + s.subspec "common" do |ss| + ss.source_files = "common/cpp/**/*.{cpp,h}" + ss.header_dir = "rnsvg" + end +end +`; + const REANIMATED_LIKE_PODSPEC = ` Pod::Spec.new do |s| s.name = "RNReanimated" @@ -69,6 +101,50 @@ Pod::Spec.new do |s| end `; +const MAPS_LIKE_PODSPEC = ` +Pod::Spec.new do |s| + s.name = "react-native-maps" + s.module_name = 'ReactNativeMaps' + s.version = "1.20.0" + s.source_files = "ios/**/*.{h,m,mm}" +end +`; + +// A subspec that reuses the parent's block variable instead of taking its own: +// inside the block, `s` is the CHILD, so a regex anchored on the receiver +// spelling reads the child's fields as the library's. +const SHADOWING_SUBSPEC_PODSPEC = ` +Pod::Spec.new do |s| + s.name = "ParentPod" + s.module_name = "ParentModule" + s.version = "1.0.0" + s.source_files = "ios/**/*.{h,m,mm}" + + s.subspec "common" do |s| + s.source_files = "common/cpp/**/*.{cpp,h}" + s.header_mappings_dir = "common/cpp" + s.header_dir = "child_prefix" + end +end +`; + +// The same library with the subspec taking its own variable — the unambiguous +// shape, which must keep naming the parent. +const OWN_VARIABLE_SUBSPEC_PODSPEC = ` +Pod::Spec.new do |s| + s.name = "ParentPod" + s.module_name = "ParentModule" + s.version = "1.0.0" + s.source_files = "ios/**/*.{h,m,mm}" + + s.subspec "common" do |ss| + ss.source_files = "common/cpp/**/*.{cpp,h}" + ss.header_mappings_dir = "common/cpp" + ss.header_dir = "child_prefix" + end +end +`; + const HEADER_SEARCH_PATHS_PODSPEC = ` Pod::Spec.new do |s| s.name = "react-native-thing" @@ -95,6 +171,153 @@ function writeFixture(name, content) { // --------------------------------------------------------------------------- describe('regexPodspec', () => { + it('reads past comments: a commented-out field never wins, a `#` in a string is not one', () => { + const {file, dir} = writeFixture( + 'commented.podspec', + [ + 'Pod::Spec.new do |s|', + ' s.name = "RNSVG" # the pod everyone imports', + ' s.summary = "# not a comment"', + ' # s.header_dir = "OldPrefix"', + ' s.header_dir = "rnsvg"', + ' # s.header_mappings_dir = "Old/Mappings"', + 'end', + '', + ].join('\n'), + ); + try { + const raw = regexPodspec(file); + expect(raw.name).toBe('RNSVG'); + expect(raw.header_dir).toBe('rnsvg'); + // Commented out with no live counterpart: absent, not "Old/Mappings". + expect(raw.header_mappings_dir).toBeNull(); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it.each([ + ['RNScreens', () => SCREENS_LIKE_PODSPEC], + ['RNSVG', () => SVG_LIKE_PODSPEC], + ])( + 'reads no spec-level header_dir when only a subspec declares one (%s)', + (podName, source) => { + const {file, dir} = writeFixture(`${podName}.podspec`, source()); + try { + const raw = regexPodspec(file); + expect(raw.name).toBe(podName); + // `ss.header_dir` belongs to that subspec, not to the library. + expect(raw.header_dir).toBeNull(); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }, + ); + + it('reports no identity fields when a subspec rebinds the spec variable', () => { + const {file, dir} = writeFixture( + 'shadowing.podspec', + SHADOWING_SUBSPEC_PODSPEC, + ); + try { + const raw = regexPodspec(file); + // The child's `header_dir` is what a receiver-anchored regex would read + // as the library's own — and the parent's literal fields are no more + // trustworthy, since the same shadowing hides which scope declared them. + expect(raw.header_dir).toBeNull(); + expect(raw.module_name).toBeNull(); + expect(raw.name).toBeNull(); + // The fields subspecs are MEANT to contribute to still merge. + expect(raw.source_files).toEqual(['ios/**/*.{h,m,mm}']); + expect(raw.header_mappings_dir).toBe('common/cpp'); + expect(raw.__warnings__.join('\n')).toMatch( + /subspec rebinds the spec variable/, + ); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('leaves the flattened model without an identity to name the library by', () => { + // The pod-unavailable path: these three fields are what the name resolver + // reads, so nulling them is what makes it fall back to the npm name. + const {file, dir} = writeFixture( + 'shadowing.podspec', + SHADOWING_SUBSPEC_PODSPEC, + ); + try { + const model = flattenSubspecs(regexPodspec(file)); + expect(model.name).toBe(''); + expect(model.moduleName).toBeNull(); + expect(model.headerDir).toBeNull(); + expect(model.sourceFiles).toEqual(['ios/**/*.{h,m,mm}']); + expect(model.headerMappingsDirs).toEqual(['common/cpp']); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('reads the parent identity when a subspec takes its own variable', () => { + const {file, dir} = writeFixture( + 'own-variable.podspec', + OWN_VARIABLE_SUBSPEC_PODSPEC, + ); + try { + const raw = regexPodspec(file); + expect(raw.name).toBe('ParentPod'); + expect(raw.module_name).toBe('ParentModule'); + expect(raw.header_dir).toBeNull(); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it("still reads a subspec's header_mappings_dir, which feeds the search paths", () => { + const {file, dir} = writeFixture('RNScreens.podspec', SCREENS_LIKE_PODSPEC); + try { + const raw = regexPodspec(file); + expect(raw.header_mappings_dir).toBe('common/cpp'); + expect(raw.source_files).toEqual(['ios/**/*.{h,m,mm}']); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('reads a spec-level header_dir even when a subspec declares its own', () => { + const {file, dir} = writeFixture( + 'core.podspec', + [ + 'Pod::Spec.new do |s|', + ' s.name = "React-Core"', + ' s.header_dir = "React"', + ' s.subspec "cxx" do |ss|', + ' ss.header_dir = "reactcxx"', + ' end', + 'end', + '', + ].join('\n'), + ); + try { + expect(regexPodspec(file).header_dir).toBe('React'); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('reads a spec-level field written on the block-argument line', () => { + const {file, dir} = writeFixture( + 'oneline.podspec', + 'Pod::Spec.new do |spec| spec.name = "OneLine"\n spec.header_dir = "oneline"\nend\n', + ); + try { + const raw = regexPodspec(file); + expect(raw.name).toBe('OneLine'); + expect(raw.header_dir).toBe('oneline'); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + it('extracts name, version, source_files, dependency from a real-world simple podspec', () => { const {file, dir} = writeFixture( 'react-native-safe-area-context.podspec', @@ -460,6 +683,251 @@ describe('flattenSubspecs', () => { // regex parser kicks in transparently. // --------------------------------------------------------------------------- +describe('flattenSubspecs (spec-level identity)', () => { + it("does not take a subspec's header_dir as the library's", () => { + const model = flattenSubspecs({ + name: 'RNScreens', + version: '4.0.0', + subspecs: [ + { + name: 'RNScreens/common', + header_dir: 'rnscreens', + header_mappings_dir: 'common/cpp', + source_files: ['common/cpp/**/*.{cpp,h}'], + }, + ], + }); + expect(model.name).toBe('RNScreens'); + expect(model.headerDir).toBeNull(); + // Still merged: these drive the header search paths, not the target name. + expect(model.headerMappingsDirs).toEqual(['common/cpp']); + expect(model.sourceFiles).toEqual(['common/cpp/**/*.{cpp,h}']); + }); + + it("keeps the spec's own module_name, not a subspec's", () => { + const model = flattenSubspecs({ + name: 'react-native-maps', + version: '1.20.0', + module_name: 'ReactNativeMaps', + subspecs: [{name: 'react-native-maps/cxx', module_name: 'MapsCxx'}], + }); + expect(model.moduleName).toBe('ReactNativeMaps'); + }); + + it('reports a missing module_name as null', () => { + const model = flattenSubspecs({name: 'RNSVG', version: '15.0.0'}); + expect(model.moduleName).toBeNull(); + }); + + it('keeps a spec-level header_dir', () => { + const model = flattenSubspecs({ + name: 'React-Core', + version: '1000.0.0', + header_dir: 'React', + subspecs: [{name: 'React-Core/cxx', header_dir: 'reactcxx'}], + }); + expect(model.headerDir).toBe('React'); + }); +}); + +describe('readPodspecNames', () => { + it('reads the pod name, and no header_dir the subspecs kept to themselves', () => { + const {file, dir} = writeFixture( + 'reanimated.podspec', + REANIMATED_LIKE_PODSPEC, + ); + try { + expect(readPodspecNames(file)).toEqual({ + name: 'RNReanimated', + moduleName: null, + headerDir: null, + }); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it.each([ + ['RNScreens', () => SCREENS_LIKE_PODSPEC], + ['RNSVG', () => SVG_LIKE_PODSPEC], + ])( + 'names %s from its pod name when only a subspec declares a header_dir', + (podName, source) => { + const {file, dir} = writeFixture(`${podName}.podspec`, source()); + try { + expect(readPodspecNames(file)).toEqual({ + name: podName, + moduleName: null, + headerDir: null, + }); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }, + ); + + it('declines when a subspec rebinds the spec variable', () => { + // `do |s|` puts the child's `header_dir` where the parent's would be, and a + // regex over flat text cannot see the block scope — so `pod ipc spec`, which + // evaluates the real Ruby, has to answer instead. + const {file, dir} = writeFixture( + 'shadowing.podspec', + SHADOWING_SUBSPEC_PODSPEC, + ); + try { + expect(readPodspecNames(file)).toBeNull(); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('names the parent when a subspec takes its own variable', () => { + const {file, dir} = writeFixture( + 'own-variable.podspec', + OWN_VARIABLE_SUBSPEC_PODSPEC, + ); + try { + expect(readPodspecNames(file)).toEqual({ + name: 'ParentPod', + moduleName: 'ParentModule', + headerDir: null, + }); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('reports a missing header_dir as null', () => { + const {file, dir} = writeFixture('simple.podspec', SIMPLE_LIB_PODSPEC); + try { + expect(readPodspecNames(file)).toEqual({ + name: 'react-native-foo', + moduleName: null, + headerDir: null, + }); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('reads a module_name the pod name alone would lose (react-native-maps)', () => { + // Without this field the fast path answers with the dashed pod name and + // `pod ipc spec` never runs, so nothing downstream can see `module_name`. + const {file, dir} = writeFixture('rnmaps.podspec', MAPS_LIKE_PODSPEC); + try { + expect(readPodspecNames(file)).toEqual({ + name: 'react-native-maps', + moduleName: 'ReactNativeMaps', + headerDir: null, + }); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it.each([ + ['interpolation', 's.module_name = "#{package[\'name\']}"'], + ['a Ruby call', 's.module_name = File.basename(__dir__)'], + ])( + 'reports nothing when the podspec computes its module_name with %s', + (_label, declaration) => { + // Same trap as a computed header_dir: the pod name is literal, but naming + // from it would ignore the module_name only Ruby can produce. + const {file, dir} = writeFixture( + 'computed-module-name.podspec', + [ + 'Pod::Spec.new do |s|', + ' s.name = "react-native-maps"', + ` ${declaration}`, + 'end', + '', + ].join('\n'), + ); + try { + expect(readPodspecNames(file)).toBeNull(); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }, + ); + + it('reports nothing when the podspec computes its header_dir in Ruby', () => { + // The literal name must not satisfy the fast path: `header_dir` outranks it, + // so resolving without it would pick the wrong prefix — and the scaffolder + // would then write that wrong name into the library's package.json. + const {file, dir} = writeFixture( + 'computed-header-dir.podspec', + [ + 'Pod::Spec.new do |s|', + ' s.name = "RNSVG"', + ' s.version = "1.0"', + ' s.header_dir = "#{s.name}Headers"', + 'end', + '', + ].join('\n'), + ); + try { + expect(readPodspecNames(file)).toBeNull(); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('reports nothing when header_dir is computed by a Ruby call', () => { + const {file, dir} = writeFixture( + 'ruby-header-dir.podspec', + [ + 'Pod::Spec.new do |s|', + ' s.name = "RNSVG"', + ' s.header_dir = File.basename(__dir__)', + 'end', + '', + ].join('\n'), + ); + try { + expect(readPodspecNames(file)).toBeNull(); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('treats an interpolated name as unparsed', () => { + const {file, dir} = writeFixture( + 'interpolated-name.podspec', + [ + 'Pod::Spec.new do |s|', + ' s.name = "#{package[\'name\']}"', + ' s.version = "1.0"', + 'end', + '', + ].join('\n'), + ); + try { + expect(readPodspecNames(file)).toBeNull(); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('reports nothing when the podspec computes its name in Ruby', () => { + const {file, dir} = writeFixture( + 'interpolated.podspec', + [ + 'Pod::Spec.new do |s|', + ' s.name = package["name"]', + ' s.version = "1.0"', + 'end', + '', + ].join('\n'), + ); + try { + expect(readPodspecNames(file)).toBeNull(); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); +}); + describe('readPodspec', () => { it('throws a clear error when the file does not exist', () => { expect(() => readPodspec('/no/such/file.podspec')).toThrow( diff --git a/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js index c7c402afdf36..fe73914b3884 100644 --- a/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js +++ b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js @@ -56,6 +56,7 @@ function autolinkedDep(overrides = {}) { return { name: 'react-native-foo', root: '/fake/node_modules/react-native-foo', + swiftName: 'ReactNativeFoo', platforms: { ios: { podspecPath: @@ -72,19 +73,29 @@ function autolinkedDep(overrides = {}) { // --------------------------------------------------------------------------- describe('translatePodspecToSpmTarget', () => { - it('always uses toSwiftName(npm-name) as the SPM target name — header_dir does NOT change the target name', () => { - // The autolinker registers every autolinked dep under toSwiftName(npmName) - // in its aggregator. The scaffolded Package.swift's product MUST match - // that or SPM resolution fails on the .product(name:, package:) lookup. - // header_dir flows through headerSearchPaths instead. + it('uses the name the autolinker resolved, never one derived here', () => { + // The autolinker registers every dep in its aggregator under the name it + // resolved; the scaffolded product MUST match or SPM resolution fails on + // the .product(name:, package:) lookup. const model = podspec({ headerDir: 'react/renderer/components/safeareacontext', }); const spec = translatePodspecToSpmTarget( model, - autolinkedDep({name: 'react-native-safe-area-context'}), + autolinkedDep({ + name: 'react-native-safe-area-context', + swiftName: 'react-native-safe-area-context', + }), + ); + expect(spec.swiftName).toBe('react-native-safe-area-context'); + }); + + it('fails loudly for a dep whose name was never resolved', () => { + const dep = autolinkedDep(); + delete dep.swiftName; + expect(() => translatePodspecToSpmTarget(podspec(), dep)).toThrow( + /expandSpmDependencies/, ); - expect(spec.swiftName).toBe('ReactNativeSafeAreaContext'); }); it('adds dirname(header_mappings_dir) as a header search path so namespaced includes resolve (reanimated/worklets pattern)', () => { @@ -294,22 +305,16 @@ describe('translatePodspecToSpmTarget', () => { } }); - it('still uses toSwiftName(npm-name) even when header_dir is a plain identifier (matches autolinker registration)', () => { + it("ignores the model's own header_dir — the resolved name already accounts for it", () => { const model = podspec({headerDir: 'reanimated'}); const spec = translatePodspecToSpmTarget( model, - autolinkedDep({name: 'react-native-reanimated'}), - ); - expect(spec.swiftName).toBe('ReactNativeReanimated'); - }); - - it('falls back cleanly when header_dir is absent', () => { - const model = podspec({headerDir: null}); - const spec = translatePodspecToSpmTarget( - model, - autolinkedDep({name: 'react-native-foo-bar'}), + autolinkedDep({ + name: 'react-native-reanimated', + swiftName: 'reanimated', + }), ); - expect(spec.swiftName).toBe('ReactNativeFooBar'); + expect(spec.swiftName).toBe('reanimated'); }); it('substitutes $(PODS_TARGET_SRCROOT) in HEADER_SEARCH_PATHS with the target-relative form', () => { @@ -623,7 +628,10 @@ describe('emitScaffoldedPackageSwift', () => { it('emits sibling .package(path: "../") + .product entries for sibling RN deps', () => { const out = emitScaffoldedPackageSwift( - baseSpec({siblingNames: ['react-native-worklets']}), + baseSpec({ + siblingNames: ['react-native-worklets'], + siblingSwiftNames: {'react-native-worklets': 'ReactNativeWorklets'}, + }), ); // Path uses the libs/ symlink name (where the autolinker places // the sibling), NOT the npm name — `../react-native-worklets` would be @@ -636,6 +644,16 @@ describe('emitScaffoldedPackageSwift', () => { ); }); + it('fails loudly for a sibling whose name was never resolved', () => { + // Deriving one here would write a name nothing in the package graph + // matches into a manifest that outlives the run. + expect(() => + emitScaffoldedPackageSwift( + baseSpec({siblingNames: ['react-native-worklets']}), + ), + ).toThrow(/expandSpmDependencies/); + }); + it('emits the sibling override name for both the package and the product', () => { const out = emitScaffoldedPackageSwift( baseSpec({ @@ -784,6 +802,7 @@ end return { name: 'react-native-foo', root: depRoot, + swiftName: 'ReactNativeFoo', platforms: {ios: {}}, ...overrides, }; @@ -1005,16 +1024,259 @@ end expect(fs.existsSync(path.join(depRoot, 'Package.swift'))).toBe(false); }); - it("honors a dep's spm: { scaffold: false } opt-out in its react-native.config.js", () => { + it("honors a dep's swiftpmConfig.scaffold = false opt-out", () => { makePodspec(); fs.writeFileSync( - path.join(depRoot, 'react-native.config.js'), - 'module.exports = { spm: { scaffold: false } };', + path.join(depRoot, 'package.json'), + JSON.stringify({ + name: 'react-native-foo', + swiftpmConfig: {scaffold: false}, + }), ); const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); expect(result.status).toBe('skipped-opt-out'); }); + it('still honors the deprecated spm.scaffold = false opt-out', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + makePodspec(); + fs.writeFileSync( + path.join(depRoot, 'react-native.config.js'), + 'module.exports = { spm: { scaffold: false } };', + ); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-opt-out'); + expect(warnSpy).toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); + + // The migration step: a name derived from the podspec is recorded in the + // library's package.json, so the next run needs no podspec to find it. + describe('swiftpmConfig.name', () => { + const pkgJsonPath = () => path.join(depRoot, 'package.json'); + const writePkgJson = pkg => + fs.writeFileSync(pkgJsonPath(), JSON.stringify(pkg, null, 2) + '\n'); + const readPkgJson = () => + JSON.parse(fs.readFileSync(pkgJsonPath(), 'utf8')); + const podspecNamed = overrides => + makeDep({swiftName: 'RNFoo', swiftNameSource: 'podspec', ...overrides}); + + it('records a podspec-derived name in the package.json', () => { + makePodspec(); + writePkgJson({name: 'react-native-foo', version: '1.0.0'}); + const result = scaffoldPackageSwiftForDep(podspecNamed(), makeCtx()); + expect(result.status).toBe('written'); + expect(result.swiftpmName).toBe('created'); + expect(readPkgJson().swiftpmConfig).toEqual({name: 'RNFoo'}); + }); + + // Only a derived name is recorded: a guess would freeze into someone's + // config, and a declared one is already where it belongs. + it.each([ + ['guessed from the npm name', 'npm', {name: 'react-native-foo'}], + [ + 'declared by the library', + 'config', + {name: 'react-native-foo', swiftpmConfig: {name: 'RNFoo'}}, + ], + ])( + 'leaves the package.json alone when the name was %s', + (_, source, pkg) => { + makePodspec(); + writePkgJson(pkg); + const result = scaffoldPackageSwiftForDep( + makeDep({swiftName: 'RNFoo', swiftNameSource: source}), + makeCtx(), + ); + expect(result.swiftpmName).toBeUndefined(); + expect(readPkgJson().swiftpmConfig).toEqual(pkg.swiftpmConfig); + }, + ); + + it('writes nothing on a dry run', () => { + makePodspec(); + writePkgJson({name: 'react-native-foo'}); + const before = fs.readFileSync(pkgJsonPath(), 'utf8'); + scaffoldPackageSwiftForDep(podspecNamed(), makeCtx({dryRun: true})); + expect(fs.readFileSync(pkgJsonPath(), 'utf8')).toBe(before); + }); + + it('keeps the manifest when recording the name fails, and reports the failure', () => { + makePodspec(); + writePkgJson({name: 'react-native-foo'}); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const realWriteFileSync = fs.writeFileSync; + const writeSpy = jest + .spyOn(fs, 'writeFileSync') + .mockImplementation((file, ...rest) => { + if (String(file).includes('package.json')) { + throw new Error('EACCES: permission denied'); + } + return realWriteFileSync(file, ...rest); + }); + let result; + try { + result = scaffoldPackageSwiftForDep(podspecNamed(), makeCtx()); + } finally { + writeSpy.mockRestore(); + warnSpy.mockRestore(); + } + // What is reported matches what is on disk: manifest yes, name no. + expect(result.status).toBe('written'); + expect(result.swiftpmName).toBe('failed'); + expect(fs.existsSync(path.join(depRoot, 'Package.swift'))).toBe(true); + expect(readPkgJson().swiftpmConfig).toBeUndefined(); + }); + + it('does not touch a package.json when the manifest was not written', () => { + writePkgJson({name: 'react-native-foo'}); + const result = scaffoldPackageSwiftForDep(podspecNamed(), makeCtx()); + expect(result.status).toBe('skipped-no-podspec'); + expect(readPkgJson().swiftpmConfig).toBeUndefined(); + }); + }); + + // The name is the header import prefix, and scaffold persists a derived one + // into the library's package.json — so the run has to say what it picked. + describe('name report', () => { + let logSpy; + let warnSpy; + + beforeEach(() => { + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + warnSpy.mockRestore(); + }); + + const output = spy => spy.mock.calls.map(call => call.join(' ')).join('\n'); + + // A podspec whose identity fields are the test's subject. + function writePodspec(...identityLines) { + fs.writeFileSync( + path.join(depRoot, 'react-native-foo.podspec'), + [ + 'Pod::Spec.new do |s|', + ' s.name = "react-native-foo"', + ' s.version = "1.0"', + ' s.source_files = "ios/**/*.{h,m,mm}"', + ...identityLines.map(line => ` ${line}`), + 'end', + '', + ].join('\n'), + ); + } + + it.each([ + [ + 'a podspec header_dir', + { + swiftName: 'BareKit', + swiftNameSource: 'podspec', + swiftNamePodspecKey: 'header_dir', + }, + "'header_dir'", + ], + [ + 'a podspec module_name', + { + swiftName: 'ReactNativeFoo', + swiftNameSource: 'podspec', + swiftNamePodspecKey: 'module_name', + }, + "'module_name'", + ], + [ + 'the pod name', + { + swiftName: 'RNFoo', + swiftNameSource: 'podspec', + swiftNamePodspecKey: 'name', + }, + 'pod name', + ], + [ + 'a declared swiftpmConfig.name', + {swiftName: 'RNFoo', swiftNameSource: 'config'}, + "'swiftpmConfig.name'", + ], + [ + 'the npm package name', + {swiftName: 'ReactNativeFoo', swiftNameSource: 'npm'}, + 'npm package name', + ], + ])('reports a name that came from %s', (_label, depOverrides, origin) => { + writePodspec('s.header_dir = "BareKit"'); + const result = scaffoldPackageSwiftForDep( + makeDep(depOverrides), + makeCtx(), + ); + expect(result.status).toBe('written'); + const reported = output(logSpy); + expect(reported).toContain('react-native-foo'); + expect(reported).toContain(`'${depOverrides.swiftName}'`); + expect(reported).toContain(origin); + }); + + it('reports the name it chose on a dry run too', () => { + writePodspec('s.header_dir = "BareKit"'); + scaffoldPackageSwiftForDep( + makeDep({ + swiftName: 'BareKit', + swiftNameSource: 'podspec', + swiftNamePodspecKey: 'header_dir', + }), + makeCtx({dryRun: true}), + ); + expect(output(logSpy)).toContain("'BareKit'"); + }); + + // react-native-bare-kit's shape: two namespaces declared, one used. + it('notes the module_name it dropped when the podspec declares both', () => { + writePodspec( + 's.header_dir = "BareKit"', + 's.module_name = "react_native_bare_kit"', + ); + scaffoldPackageSwiftForDep( + makeDep({ + swiftName: 'BareKit', + swiftNameSource: 'podspec', + swiftNamePodspecKey: 'header_dir', + }), + makeCtx(), + ); + const noted = output(warnSpy); + expect(noted).toContain("'BareKit'"); + expect(noted).toContain("'react_native_bare_kit'"); + expect(noted).toContain("'swiftpmConfig.name'"); + }); + + it.each([ + ['only a header_dir is declared', ['s.header_dir = "BareKit"']], + [ + 'the two agree', + ['s.header_dir = "BareKit"', 's.module_name = "BareKit"'], + ], + ])('says nothing about a dropped namespace when %s', (_label, lines) => { + writePodspec(...lines); + scaffoldPackageSwiftForDep( + makeDep({ + swiftName: 'BareKit', + swiftNameSource: 'podspec', + swiftNamePodspecKey: 'header_dir', + }), + makeCtx(), + ); + expect(output(warnSpy)).not.toContain('module_name'); + }); + }); + it('returns skipped-is-react-native for `react-native` itself (handled by the xcframework path)', () => { const result = scaffoldPackageSwiftForDep( makeDep({name: 'react-native'}), @@ -1087,14 +1349,17 @@ describe('scaffoldAll', () => { } it('propagates a Swift name collision instead of scaffolding anyway, plugin or not', () => { - // 'react-headers' derives the reserved 'ReactHeaders', with no scope to - // borrow. Degrading to the direct deps would scaffold manifests SPM rejects - // later, and a plugin buys no exemption — `spm scaffold` has no plugin code. + // 'react-headers' derives the reserved 'ReactHeaders'. Degrading to the + // direct deps would scaffold manifests SPM rejects later, and a plugin buys + // no exemption — `spm scaffold` has no plugin code. const depRoot = path.join(appRoot, 'node_modules', 'react-headers'); fs.mkdirSync(depRoot, {recursive: true}); fs.writeFileSync( - path.join(depRoot, 'react-native.config.js'), - "module.exports = {spm: {autolinkingPlugin: './spm-plugin.js'}};\n", + path.join(depRoot, 'package.json'), + JSON.stringify({ + name: 'react-headers', + swiftpmConfig: {autolinkingPlugin: './spm-plugin.js'}, + }), ); writeAutolinkingJson({ 'react-headers': {root: depRoot, platforms: {ios: {}}}, @@ -1123,8 +1388,52 @@ describe('scaffoldAll', () => { }); expect(results.map(r => r.depName)).toEqual(['react-native-a']); expect(logSpy.mock.calls.map(call => call.join(' ')).join('\n')).toMatch( - /Transitive spm\.dependencies expansion failed/, + /Transitive dependency expansion failed/, + ); + } finally { + logSpy.mockRestore(); + } + }); + + it('names a degraded dep from its podspec, so the manifest it writes still matches the autolinker', () => { + const depRoot = path.join(appRoot, 'node_modules', 'react-native-svg'); + fs.mkdirSync(path.join(depRoot, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(depRoot, 'ios', 'Lib.mm'), '// src\n'); + fs.writeFileSync( + path.join(depRoot, 'package.json'), + JSON.stringify({ + name: 'react-native-svg', + swiftpmConfig: {dependencies: ['ghost-dep-that-is-not-installed']}, + }), + ); + fs.writeFileSync( + path.join(depRoot, 'RNSVG.podspec'), + [ + 'Pod::Spec.new do |s|', + ' s.name = "RNSVG"', + ' s.version = "1.0.0"', + ' s.source_files = "ios/**/*.{h,m,mm}"', + 'end', + '', + ].join('\n'), + ); + writeAutolinkingJson({ + 'react-native-svg': {root: depRoot, platforms: {ios: {}}}, + }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + try { + const [result] = scaffoldAll({ + appRoot, + projectRoot: appRoot, + reactNativeRoot: appRoot, + }); + expect(result.status).toBe('written'); + const manifest = fs.readFileSync( + path.join(depRoot, 'Package.swift'), + 'utf8', ); + expect(manifest).toContain('name: "RNSVG"'); + expect(manifest).not.toContain('ReactNativeSvg'); } finally { logSpy.mockRestore(); } @@ -1266,6 +1575,7 @@ describe('scaffoldPackageSwiftForDep — version-based regen', () => { return { name: 'react-native-foo', root: depRoot, + swiftName: 'ReactNativeFoo', platforms: {ios: {}}, }; } diff --git a/packages/react-native/scripts/spm/__tests__/spm-utils-test.js b/packages/react-native/scripts/spm/__tests__/spm-utils-test.js index dc3d7a8e071b..d11f372a947d 100644 --- a/packages/react-native/scripts/spm/__tests__/spm-utils-test.js +++ b/packages/react-native/scripts/spm/__tests__/spm-utils-test.js @@ -27,6 +27,7 @@ const { defaultCacheDir, displayPath, isPublishableVersion, + isValidSwiftName, makeLogger, readPackageJson, remotePackageConfig, @@ -34,6 +35,8 @@ const { resolveReactNativeRoot, runCodegenAndInstallTemplate, sharedCacheDir, + swiftNameKey, + toC99Name, toSwiftName, } = require('../spm-utils'); const fs = require('node:fs'); @@ -57,6 +60,58 @@ describe('toSwiftName', () => { }); }); +// --------------------------------------------------------------------------- +// isValidSwiftName — the charset rule `spm.name` enforces. +// --------------------------------------------------------------------------- + +describe('isValidSwiftName', () => { + it.each(['worklets', 'ReactNativeFoo', 'hermes-engine', 'react_native_foo'])( + 'accepts %j', + name => { + expect(isValidSwiftName(name)).toBe(true); + }, + ); + + it.each(['', 'foo bar', 'foo/bar', 'foo.bar', '9lives', 42, null])( + 'rejects %j', + name => { + expect(isValidSwiftName(name)).toBe(false); + }, + ); +}); + +// --------------------------------------------------------------------------- +// toC99Name / swiftNameKey — what SwiftPM compiles a target name as, and the +// key two target names have to differ in to be two targets. +// --------------------------------------------------------------------------- + +describe('toC99Name', () => { + it.each([ + ['RNSVG', 'RNSVG'], + ['react-native-svg', 'react_native_svg'], + ['react_native_svg', 'react_native_svg'], + ['Some.Pod', 'Some_Pod'], + ['React-Core', 'React_Core'], + ['3d-lib', '_3d_lib'], + ])('toC99Name(%j) => %j', (input, expected) => { + expect(toC99Name(input)).toBe(expected); + }); +}); + +describe('swiftNameKey', () => { + it('collapses names that differ only in punctuation', () => { + expect(swiftNameKey('foo-bar')).toBe(swiftNameKey('foo_bar')); + }); + + it('collapses names that differ only in case', () => { + expect(swiftNameKey('worklets')).toBe(swiftNameKey('Worklets')); + }); + + it('keeps genuinely different names apart', () => { + expect(swiftNameKey('RNSVG')).not.toBe(swiftNameKey('RNScreens')); + }); +}); + // --------------------------------------------------------------------------- // Reserved Swift names — the one list the manifests and the guard both use // --------------------------------------------------------------------------- diff --git a/packages/react-native/scripts/spm/__tests__/swiftpm-config-test.js b/packages/react-native/scripts/spm/__tests__/swiftpm-config-test.js new file mode 100644 index 000000000000..b514a8adda43 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/swiftpm-config-test.js @@ -0,0 +1,414 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +/** + * `swiftpmConfig` in package.json is the declared home for a package's SwiftPM + * settings. The `spm` block in react-native.config.js is the deprecated one: + * still read, still honoured, and warned about once per file. + */ + +const {readSwiftpmConfig, writeSwiftpmName} = require('../swiftpm-config'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +let tmpRoot; +let roots = 0; + +beforeAll(() => { + tmpRoot = fs.mkdtempSync( + path.join(fs.realpathSync(os.tmpdir()), 'spm-swiftpm-config-'), + ); +}); + +afterAll(() => { + fs.rmSync(tmpRoot, {recursive: true, force: true}); +}); + +// A fresh root per case: the deprecation warning is remembered per config file. +function makeRoot(pkgJson) { + const root = path.join(tmpRoot, `pkg-${roots++}`); + fs.mkdirSync(root, {recursive: true}); + if (pkgJson != null) { + fs.writeFileSync( + path.join(root, 'package.json'), + typeof pkgJson === 'string' ? pkgJson : JSON.stringify(pkgJson, null, 2), + ); + } + return root; +} + +describe('readSwiftpmConfig', () => { + it.each([ + ['name', 'RNSVG'], + ['dependencies', ['react-native-worklets']], + ['autolinkingPlugin', './spm-plugin.js'], + ['scaffold', false], + ['modules', [{name: 'MyModule', path: 'ios/MyModule'}]], + ['denyPlugins', ['some-framework']], + ])('reads swiftpmConfig.%s from package.json', (field, value) => { + const root = makeRoot({name: 'lib', swiftpmConfig: {[field]: value}}); + expect(readSwiftpmConfig(root, null)?.[field]).toEqual(value); + }); + + it.each([ + ['no swiftpmConfig at all', {name: 'lib'}], + ['no package.json', null], + [ + 'a swiftpmConfig that is not an object', + {name: 'lib', swiftpmConfig: 'nope'}, + ], + // An array's indices are not fields; treating it as one would report + // "unknown keys (0, 1)" and let the writer spread indices into it. + ['an array', {name: 'lib', swiftpmConfig: ['oops']}], + ])('reads nothing from %s', (_label, pkgJson) => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect(readSwiftpmConfig(makeRoot(pkgJson), null)).toBeNull(); + const message = warnSpy.mock.calls.map(call => call.join(' ')).join('\n'); + expect(message).not.toMatch(/\bfields React Native does not know\b/); + } finally { + warnSpy.mockRestore(); + } + }); + + it('falls back to the package.json-free config when package.json is malformed', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const root = makeRoot('{ not json'); + expect(readSwiftpmConfig(root, {spm: {name: 'RNSVG'}})?.name).toBe( + 'RNSVG', + ); + } finally { + warnSpy.mockRestore(); + } + }); + + it('still honours the deprecated spm block', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const root = makeRoot({name: 'lib'}); + const config = readSwiftpmConfig(root, { + spm: {name: 'RNSVG', dependencies: ['react-native-worklets']}, + }); + expect(config?.name).toBe('RNSVG'); + expect(config?.dependencies).toEqual(['react-native-worklets']); + } finally { + warnSpy.mockRestore(); + } + }); + + it('lets swiftpmConfig win field by field, keeping the fields it does not set', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const root = makeRoot({name: 'lib', swiftpmConfig: {name: 'RNSVG'}}); + const config = readSwiftpmConfig(root, { + spm: {name: 'OldName', dependencies: ['react-native-worklets']}, + }); + expect(config?.name).toBe('RNSVG'); + expect(config?.dependencies).toEqual(['react-native-worklets']); + } finally { + warnSpy.mockRestore(); + } + }); + + it('warns once per config file, naming the fields and the new home', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const root = makeRoot({name: 'lib'}); + const rnConfig = {spm: {name: 'RNSVG', dependencies: ['other']}}; + readSwiftpmConfig(root, rnConfig); + readSwiftpmConfig(root, rnConfig); + expect(warnSpy).toHaveBeenCalledTimes(1); + const message = warnSpy.mock.calls[0].join(' '); + expect(message).toContain(path.join(root, 'react-native.config.js')); + expect(message).toContain('name'); + expect(message).toContain('dependencies'); + expect(message).toContain('swiftpmConfig'); + expect(message).toContain('package.json'); + } finally { + warnSpy.mockRestore(); + } + }); + + it('warns per config file, not per package sharing a shape', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + readSwiftpmConfig(makeRoot({name: 'a'}), {spm: {name: 'A'}}); + readSwiftpmConfig(makeRoot({name: 'b'}), {spm: {name: 'B'}}); + expect(warnSpy).toHaveBeenCalledTimes(2); + } finally { + warnSpy.mockRestore(); + } + }); + + it('says nothing when only the new location is used', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const root = makeRoot({name: 'lib', swiftpmConfig: {name: 'RNSVG'}}); + expect( + readSwiftpmConfig(root, {dependency: {platforms: {ios: {}}}}), + ).toEqual({name: 'RNSVG'}); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); + + it('says nothing about an spm block with no SPM fields in it', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + readSwiftpmConfig(makeRoot({name: 'lib'}), {spm: {}}); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); +}); + +describe('readSwiftpmConfig (unknown keys)', () => { + it('warns once per package.json, naming the keys it does not know', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const root = makeRoot({ + name: 'lib', + swiftpmConfig: {name: 'RNSVG', dependancies: [], modulez: []}, + }); + readSwiftpmConfig(root, null); + readSwiftpmConfig(root, null); + expect(warnSpy).toHaveBeenCalledTimes(1); + const message = warnSpy.mock.calls[0].join(' '); + expect(message).toContain(path.join(root, 'package.json')); + expect(message).toContain('dependancies'); + expect(message).toContain('modulez'); + expect(message).not.toContain("'name'"); + } finally { + warnSpy.mockRestore(); + } + }); + + it('still reads the keys it does know', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const root = makeRoot({ + name: 'lib', + swiftpmConfig: {name: 'RNSVG', dependancies: []}, + }); + expect(readSwiftpmConfig(root, null)?.name).toBe('RNSVG'); + } finally { + warnSpy.mockRestore(); + } + }); + + it('says nothing when every key is known', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const root = makeRoot({ + name: 'lib', + swiftpmConfig: {name: 'RNSVG', dependencies: [], scaffold: false}, + }); + readSwiftpmConfig(root, null); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); + + it('says nothing about unknown keys in the deprecated block, which is going away', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const root = makeRoot({name: 'lib'}); + readSwiftpmConfig(root, {spm: {name: 'RNSVG', dependancies: []}}); + const message = warnSpy.mock.calls.map(call => call.join(' ')).join('\n'); + expect(message).not.toContain('dependancies'); + } finally { + warnSpy.mockRestore(); + } + }); +}); + +describe('readSwiftpmConfig (malformed swiftpmConfig)', () => { + it('falls back to the deprecated block when swiftpmConfig is an array', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const root = makeRoot({name: 'lib', swiftpmConfig: ['oops']}); + expect(readSwiftpmConfig(root, {spm: {name: 'RNSVG'}})?.name).toBe( + 'RNSVG', + ); + } finally { + warnSpy.mockRestore(); + } + }); +}); + +describe('writeSwiftpmName', () => { + const read = root => + JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + + it('creates swiftpmConfig when the package has none', () => { + const root = makeRoot({name: 'react-native-svg', version: '1.0.0'}); + expect(writeSwiftpmName(root, 'RNSVG')).toBe('created'); + expect(read(root).swiftpmConfig).toEqual({name: 'RNSVG'}); + }); + + it('inserts the name into an existing swiftpmConfig', () => { + const root = makeRoot({ + name: 'react-native-svg', + swiftpmConfig: {dependencies: ['react-native-worklets']}, + }); + expect(writeSwiftpmName(root, 'RNSVG')).toBe('inserted'); + expect(read(root).swiftpmConfig).toEqual({ + dependencies: ['react-native-worklets'], + name: 'RNSVG', + }); + }); + + it('is a no-op when the same name is already there', () => { + const root = makeRoot({name: 'lib', swiftpmConfig: {name: 'RNSVG'}}); + const before = fs.readFileSync(path.join(root, 'package.json'), 'utf8'); + expect(writeSwiftpmName(root, 'RNSVG')).toBe('already-set'); + expect(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).toBe( + before, + ); + }); + + it("leaves a different name alone and says so — the author's choice wins", () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const root = makeRoot({name: 'lib', swiftpmConfig: {name: 'MySvg'}}); + expect(writeSwiftpmName(root, 'RNSVG')).toBe('skipped'); + expect(read(root).swiftpmConfig.name).toBe('MySvg'); + const message = warnSpy.mock.calls.map(call => call.join(' ')).join('\n'); + expect(message).toContain('MySvg'); + expect(message).toContain('RNSVG'); + } finally { + warnSpy.mockRestore(); + } + }); + + it('refuses a name Swift cannot spell, without touching the file', () => { + const root = makeRoot({name: 'lib'}); + const before = fs.readFileSync(path.join(root, 'package.json'), 'utf8'); + expect(writeSwiftpmName(root, 'Some.Pod')).toBe('skipped'); + expect(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).toBe( + before, + ); + }); + + it('skips a package with no package.json', () => { + expect(writeSwiftpmName(makeRoot(null), 'RNSVG')).toBe('skipped'); + }); + + it('never rewrites a swiftpmConfig that is not a settings object', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const root = makeRoot({name: 'lib', swiftpmConfig: ['oops']}); + const before = fs.readFileSync(path.join(root, 'package.json'), 'utf8'); + expect(writeSwiftpmName(root, 'RNSVG')).toBe('skipped'); + expect(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).toBe( + before, + ); + expect( + warnSpy.mock.calls.map(call => call.join(' ')).join('\n'), + ).toContain('swiftpmConfig'); + } finally { + warnSpy.mockRestore(); + } + }); + + it('writes nothing on a dry run', () => { + const root = makeRoot({name: 'lib'}); + const before = fs.readFileSync(path.join(root, 'package.json'), 'utf8'); + expect(writeSwiftpmName(root, 'RNSVG', {dryRun: true})).toBe('created'); + expect(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).toBe( + before, + ); + }); + + it.each([ + ['two spaces', '{\n "name": "lib"\n}\n', ' '], + ['four spaces', '{\n "name": "lib"\n}\n', ' '], + ['tabs', '{\n\t"name": "lib"\n}\n', '\t'], + ])('preserves %s of indentation', (_label, source, indent) => { + const root = makeRoot(source); + writeSwiftpmName(root, 'RNSVG'); + const written = fs.readFileSync(path.join(root, 'package.json'), 'utf8'); + expect(written).toContain(`\n${indent}"swiftpmConfig": {`); + expect(written).toContain(`\n${indent}${indent}"name": "RNSVG"`); + expect(written.endsWith('\n')).toBe(true); + }); + + it('keeps the existing keys in the order the author wrote them', () => { + const root = makeRoot({name: 'lib', version: '1.0.0', main: 'index.js'}); + writeSwiftpmName(root, 'RNSVG'); + expect(Object.keys(read(root))).toEqual([ + 'name', + 'version', + 'main', + 'swiftpmConfig', + ]); + }); + + it('reports a failed write instead of an outcome it did not achieve', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const root = makeRoot({name: 'lib', version: '1.0.0'}); + const before = fs.readFileSync(path.join(root, 'package.json'), 'utf8'); + const writeSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => { + throw new Error('ENOSPC: no space left on device'); + }); + try { + expect(writeSwiftpmName(root, 'RNSVG')).toBe('failed'); + } finally { + writeSpy.mockRestore(); + warnSpy.mockRestore(); + } + expect(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).toBe( + before, + ); + // No half-written file left behind. + expect(fs.readdirSync(root)).toEqual(['package.json']); + }); + + it('replaces the file atomically, so a reader never sees a partial one', () => { + const root = makeRoot({name: 'lib', version: '1.0.0'}); + const pkgPath = path.join(root, 'package.json'); + const renames = []; + const renameSpy = jest + .spyOn(fs, 'renameSync') + .mockImplementation((from, to) => { + renames.push([from, to]); + // Whatever was staged must already be complete JSON. + expect(JSON.parse(fs.readFileSync(from, 'utf8')).swiftpmConfig).toEqual( + { + name: 'RNSVG', + }, + ); + fs.rmSync(from); + }); + try { + writeSwiftpmName(root, 'RNSVG'); + } finally { + renameSpy.mockRestore(); + } + expect(renames).toHaveLength(1); + expect(renames[0][1]).toBe(pkgPath); + expect(renames[0][0]).not.toBe(pkgPath); + }); + + it('leaves a file with no trailing newline without one', () => { + const root = makeRoot('{"name": "lib"}'); + writeSwiftpmName(root, 'RNSVG'); + expect( + fs.readFileSync(path.join(root, 'package.json'), 'utf8').endsWith('\n'), + ).toBe(false); + }); +}); diff --git a/packages/react-native/scripts/spm/autolinking-plugins.js b/packages/react-native/scripts/spm/autolinking-plugins.js index f3777c230e08..1063267e0d5c 100644 --- a/packages/react-native/scripts/spm/autolinking-plugins.js +++ b/packages/react-native/scripts/spm/autolinking-plugins.js @@ -85,6 +85,7 @@ */ const {isValidScriptPhaseId, isValidScriptPhaseName} = require('./spm-utils'); +const {readSwiftpmConfig} = require('./swiftpm-config'); const path = require('node:path'); /*:: import type { @@ -98,8 +99,8 @@ const path = require('node:path'); /** * Discover plugins declared by autolinked deps. `readConfig(root)` returns the - * dep's parsed react-native.config.js (or null). `denyList` is the app's - * `spm.denyPlugins` (npm names to skip). Fail-closed: a declared-but-missing + * dep's parsed react-native.config.js (or null), which is one of the two homes + * the plugin may be declared in. `denyList` is the app's `denyPlugins`. Fail-closed: a declared-but-missing * or unloadable plugin throws, naming the dep — a framework silently dropping * its modules is worse than a loud stop. */ @@ -114,15 +115,17 @@ function discoverPlugins( if (denied.has(dep.name)) { continue; } - const config = readConfig(dep.root); // $FlowFixMe[incompatible-use] config has a dynamic shape - const rel = config?.spm?.autolinkingPlugin; + const rel = readSwiftpmConfig( + dep.root, + readConfig(dep.root), + )?.autolinkingPlugin; if (rel == null) { continue; } if (typeof rel !== 'string' || rel.length === 0) { throw new Error( - `react-native spm: '${dep.name}' declares an invalid spm.autolinkingPlugin ` + + `react-native spm: '${dep.name}' declares an invalid 'swiftpmConfig.autolinkingPlugin' ` + `(expected a module path string).`, ); } diff --git a/packages/react-native/scripts/spm/expand-spm-dependencies.js b/packages/react-native/scripts/spm/expand-spm-dependencies.js index 5e769745af69..872450e7494c 100644 --- a/packages/react-native/scripts/spm/expand-spm-dependencies.js +++ b/packages/react-native/scripts/spm/expand-spm-dependencies.js @@ -10,53 +10,86 @@ 'use strict'; -const {RESERVED_SWIFT_NAMES, makeLogger, toSwiftName} = require('./spm-utils'); +const { + findPodspecs, + readPodspecCached, + readPodspecNames, +} = require('./read-podspec'); +const { + RESERVED_SWIFT_NAMES, + isValidSwiftName, + makeLogger, + swiftNameKey, + toC99Name, + toSwiftName, +} = require('./spm-utils'); +const {readSwiftpmConfig, stringList} = require('./swiftpm-config'); const fs = require('node:fs'); const path = require('node:path'); const {warn} = makeLogger('expand-spm-dependencies'); /** - * expand-spm-dependencies.js — Resolves transitive native deps declared via - * `spm.dependencies` in a library's react-native.config.js. + * expand-spm-dependencies.js — Resolves transitive native deps a library + * declares in its `swiftpmConfig`. * * SPM has no equivalent of CocoaPods' podspec `s.dependency`, so library * authors declare the same relationships explicitly: * - * // react-native-reanimated/react-native.config.js - * module.exports = { - * dependency: { platforms: { ios: {} } }, - * spm: { dependencies: ['react-native-worklets'] }, - * }; + * // react-native-reanimated/package.json + * { "swiftpmConfig": { "dependencies": ["react-native-worklets"] } } * * This module reads the directly-autolinked deps (from autolinking.json), - * follows each one's spm.dependencies recursively, and returns the deduped + * follows each one's declared dependencies recursively, and returns the deduped * list with autolinking-shaped entries so the downstream pipeline can convert * each to an SPM target without further branching. * - * I/O is injected (readConfig, resolveDep, log) so the logic stays pure and - * testable. + * It also resolves each dep's Swift target name — see resolveSwiftName. + * + * I/O is injected (readConfig, resolveDep, readPodspec) so the logic stays pure + * and testable. */ /*:: import type {AutolinkedDep} from './spm-types'; +import type {SwiftpmConfig} from './swiftpm-config'; // react-native.config.js entries have a user-defined shape, so we use an // inexact object type and access properties dynamically. type RnConfig = {...}; type ReadConfig = (root: string) => ?RnConfig; type ResolveDep = (name: string, fromRoot: string) => ?string; -type Log = (message: string) => void; -// Keyed by lower case, valued with the canonical spelling: two names differing -// only in case are not distinct enough for the build to keep the two apart. +// Where a resolved name came from: what the library declared, what its podspec +// says, or the npm-name guess. `podspecKey` narrows a podspec-sourced name to +// the field it was read from — `header_dir` and `module_name` can name the same +// library differently, and only one of them wins. +type PodspecNameKey = 'header_dir' | 'module_name' | 'name'; +type ResolvedSwiftName = { + name: string, + source: 'config' | 'podspec' | 'npm', + podspecKey?: PodspecNameKey, +}; +// The podspec fields that name a library. A PodspecModel satisfies it. +type PodspecFacts = { + readonly name?: ?string, + readonly moduleName?: ?string, + readonly headerDir?: ?string, + ... +}; +type ReadPodspec = (root: string, podspecPath: ?string) => ?PodspecFacts; +type ReadSwiftpmConfig = (root: string, rnConfig: ?RnConfig) => ?SwiftpmConfig; +// Keyed by swiftNameKey, valued with the canonical spelling: a name that only +// differs from a reserved one in case or punctuation is not distinct enough for +// the build to keep the two apart. type ReservedNames = ReadonlyMap; type Options = { readConfig: ReadConfig, resolveDep: ResolveDep, + readPodspec?: ?ReadPodspec, + readSwiftpmConfig?: ?ReadSwiftpmConfig, // Names to reserve alongside RESERVED_SWIFT_NAMES, supplied by the caller // (remote mode relabels the RN package) since this module reads no config. extraReservedNames?: ?ReadonlyArray, - log?: ?Log, }; */ @@ -71,74 +104,133 @@ class SpmNameCollisionError extends Error { } } -// The charset `spm.name` must satisfy — permissive on purpose, since it has to -// admit header-dir style (lowercase with hyphens) as well as Swift identifiers. -// Shared with the app's own `spm.modules` names. -function isValidSwiftName(name /*: unknown */) /*: boolean */ { - return typeof name === 'string' && /^[A-Za-z_][A-Za-z0-9_-]*$/.test(name); -} - function reservedSwiftNames( extraReservedNames /*: ?ReadonlyArray */, ) /*: ReservedNames */ { return new Map( [...RESERVED_SWIFT_NAMES, ...(extraReservedNames ?? [])].map(name => [ - name.toLowerCase(), + swiftNameKey(name), name, ]), ); } -// The scope-borrowed form of a name: `@powersync/react-native`'s `ReactNative` -// becomes `PowersyncReactNative`. -function scopeBorrowedName( +// The prefix a podspec publishes its headers under, and the field it came from +// — or null when the podspec declares none. A prefix Swift cannot spell is +// normalized rather than abandoned: reverting to the npm name would silently +// produce a prefix nothing imports. +function podspecSwiftName( npmName /*: string */, - swiftName /*: string */, -) /*: ?string */ { - const scope = /^@([^/]+)\//.exec(npmName)?.[1]; - return scope == null ? null : `${toSwiftName(scope)}${swiftName}`; + podspec /*: ?PodspecFacts */, +) /*: ?{name: string, key: PodspecNameKey} */ { + // CocoaPods' own order is `module_name` first (specification.rb), but our + // single name is the ObjC include prefix as well as the Swift module: a + // declared `header_dir` IS the prefix a library's consumers write, so it keeps + // winning. `module_name` comes next, being what Swift and `@import` consumers + // spell, and the pod name — often dashed — is the last resort. + const candidates /*: ReadonlyArray<[PodspecNameKey, ?string]> */ = [ + ['header_dir', podspec?.headerDir], + ['module_name', podspec?.moduleName], + ['name', podspec?.name], + ]; + for (const [key, candidate] of candidates) { + if (typeof candidate !== 'string' || candidate.length === 0) { + continue; + } + // Ruby the evaluator never ran: what the prefix would be is unknowable, so + // the npm name is the honest answer — and, not being derived, it is not + // recorded in the library's package.json. + if (candidate.includes('#{')) { + return null; + } + if (isValidSwiftName(candidate)) { + return {name: candidate, key}; + } + const normalized = toC99Name(candidate); + warn( + `'${npmName}' declares the podspec prefix '${candidate}', which is not a valid Swift target name; using '${normalized}'. ` + + `Set 'swiftpmConfig.name' in ${npmName}'s package.json to choose the prefix yourself.`, + ); + return {name: normalized, key}; + } + return null; } -// The Swift target name for one dep, judged in isolation. `spm.name` is for -// libraries whose import prefix differs from the derived name: -// `react-native-worklets` ships headers as `` (podspec -// `s.header_dir`), so its target is `worklets`, not `ReactNativeWorklets`. A -// derived name that lands on a reserved one borrows the npm scope instead. +/** + * The Swift target name for one dep, which is also the prefix its headers are + * imported under (`#import `), and where it came from. The name + * a library declares wins; the podspec is the transitional source of truth + * behind it — `react-native-svg` publishes `RNSVG`, not `ReactNativeSvg` — in + * the order `header_dir` → `module_name` → pod name. The npm name is the last + * resort, for a library that ships a `Package.swift` and no podspec. + * + * The source is what tells the scaffolder which names are safe to write into a + * library's package.json: a derived one, never a guessed one. + */ function resolveSwiftName( npmName /*: string */, - config /*: ?RnConfig */, - reserved /*: ReservedNames */, - log /*:: ?: ?Log */, -) /*: string */ { - // $FlowFixMe[prop-missing] config has dynamic shape - const override = config?.spm?.name; - if (override != null) { - if (typeof override !== 'string' || override.length === 0) { + spmConfig /*: ?SwiftpmConfig */, + podspec /*: ?PodspecFacts */, +) /*: ResolvedSwiftName */ { + const declared = spmConfig?.name; + if (declared != null) { + if (typeof declared !== 'string' || declared.length === 0) { throw new Error( - `react-native autolinking: '${npmName}' has an invalid 'spm.name' override: expected a non-empty string, got ${JSON.stringify(override)}.`, + `react-native autolinking: '${npmName}' declares an invalid SwiftPM name: expected a non-empty string, got ${String(declared)}. Set 'swiftpmConfig.name' in its package.json.`, ); } - if (!isValidSwiftName(override)) { + if (!isValidSwiftName(declared)) { throw new Error( - `react-native autolinking: '${npmName}' has an invalid 'spm.name' override '${override}': must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.`, + `react-native autolinking: '${npmName}' declares an invalid SwiftPM name '${declared}': must start with a letter or underscore and contain only letters, digits, underscores, or hyphens. Set 'swiftpmConfig.name' in its package.json.`, ); } - return override; + return {name: declared, source: 'config'}; } - const derived = toSwiftName(npmName); - if (!reserved.has(derived.toLowerCase())) { - return derived; + const fromPodspec = podspecSwiftName(npmName, podspec); + if (fromPodspec != null) { + return { + name: fromPodspec.name, + source: 'podspec', + podspecKey: fromPodspec.key, + }; } - const disambiguated = scopeBorrowedName(npmName, derived); - if (disambiguated == null || reserved.has(disambiguated.toLowerCase())) { - return derived; + const fromNpm = toSwiftName(npmName); + if (podspec != null) { + warn( + `'${npmName}' ships a podspec React Native could not read a name from (CocoaPods may not be installed), so it is named '${fromNpm}' after its npm package instead. ` + + `A machine that can read the podspec may resolve a different name — set 'swiftpmConfig.name' in ${npmName}'s package.json to settle it everywhere.`, + ); } - log?.( - `'${npmName}' would take React Native's reserved name '${derived}', so its npm scope is prepended: '${disambiguated}'. ` + - `Set 'spm.name' in ${npmName}'s react-native.config.js to choose the name yourself.`, - ); - return disambiguated; + return {name: fromNpm, source: 'npm'}; +} + +function collisionDiagnosis( + existing /*: string */, + swiftName /*: string */, +) /*: string */ { + if (existing === swiftName) { + return `both resolve to '${swiftName}'.`; + } + if (existing.toLowerCase() === swiftName.toLowerCase()) { + return `differ only in case, which collides on case-insensitive filesystems.`; + } + return `both compile as the module '${toC99Name(swiftName)}' — SwiftPM replaces every character C99 rejects.`; +} + +// Vaguer about the clash than the dep-vs-dep message on purpose: this set spans +// package identities and product names, which collide differently. +function reservedDiagnosis( + swiftName /*: string */, + reservedName /*: string */, +) /*: string */ { + if (reservedName === swiftName) { + return `which React Native reserves for its own SPM package and products.`; + } + if (reservedName.toLowerCase() === swiftName.toLowerCase()) { + return `which differs from React Native's reserved '${reservedName}' only in case — not distinct enough for the build to keep the two apart.`; + } + return `which compiles as the same module as React Native's reserved '${reservedName}'.`; } function assertNameNotReserved( @@ -146,24 +238,21 @@ function assertNameNotReserved( reserved /*: ReservedNames */, labels /*: {label: string, remedy: string} */, ) /*: void */ { - const reservedName = reserved.get(swiftName.toLowerCase()); + const reservedName = reserved.get(swiftNameKey(swiftName)); if (reservedName == null) { return; } - // Vaguer about the case clash than the dep-vs-dep message on purpose: this - // set spans package identities and product names, which collide differently. throw new SpmNameCollisionError( `react-native autolinking: SPM Swift name collision: ${labels.label} resolves to '${swiftName}', ` + - (reservedName === swiftName - ? `which React Native reserves for its own SPM package and products.` - : `which differs from React Native's reserved '${reservedName}' only in case — not distinct enough for the build to keep the two apart.`) + + reservedDiagnosis(swiftName, reservedName) + ` ${labels.remedy}`, ); } /** * Throws when `swiftName` is one React Native's own manifests use. `remedy` is - * the fix: a library sets `spm.name`, an app renames its `spm.modules` entry. + * the fix: a library sets `swiftpmConfig.name`, an app renames its + * `swiftpmConfig.modules` entry. */ function assertSwiftNameNotReserved( swiftName /*: string */, @@ -194,83 +283,36 @@ function assertNoReservedSwiftNames( } assertNameNotReserved(swiftName, reserved, { label: `'${dep.name}'`, - remedy: `Set a different 'spm.name' in ${dep.name}'s react-native.config.js.`, + remedy: `Set a different 'swiftpmConfig.name' in ${dep.name}'s package.json.`, }); } } -// Pulls apart deps that resolved to the same name by borrowing their npm scopes. -// Every scoped member of a colliding group moves: there is no non-arbitrary -// winner to keep. Exactly one pass — retrying would trade a diagnosable error -// for a name nobody can predict. -function disambiguateSharedSwiftNames( - deps /*: ReadonlyArray */, - autoNamed /*: ReadonlySet */, - log /*: ?Log */, -) /*: void */ { - const groups /*: Map> */ = - new Map(); - for (const dep of deps) { - const swiftName = dep.swiftName; - if (swiftName == null) { - continue; - } - const key = swiftName.toLowerCase(); - const group = groups.get(key); - if (group == null) { - groups.set(key, [{dep, swiftName}]); - } else { - group.push({dep, swiftName}); - } - } - - for (const group of groups.values()) { - if (group.length < 2) { - continue; - } - for (const {dep, swiftName} of group) { - // A name we derived can borrow a second time (`AAReactNative`); the - // member whose name we did not derive is the incumbent and keeps it. - if (!autoNamed.has(dep.name)) { - continue; - } - const borrowed = scopeBorrowedName(dep.name, swiftName); - if (borrowed == null) { - continue; - } - const others = group - .filter(other => other.dep !== dep) - .map(other => `'${other.dep.name}'`) - .join(', '); - log?.( - `'${dep.name}' would share the name '${swiftName}' with ${others}, so its npm scope is prepended: '${borrowed}'. ` + - `Set 'spm.name' in ${dep.name}'s react-native.config.js to choose the name yourself.`, - ); - dep.swiftName = borrowed; - } - } -} - function expandSpmDependencies( directDeps /*: Array */, options /*: Options */, ) /*: Array */ { - const {readConfig, resolveDep, extraReservedNames, log} = options; + const {readConfig, resolveDep, readPodspec, extraReservedNames} = options; + const spmConfigOf = options.readSwiftpmConfig ?? readSwiftpmConfig; const reserved = reservedSwiftNames(extraReservedNames); const byName /*: Map */ = new Map(); for (const dep of directDeps) { byName.set(dep.name, {...dep, spmDependencies: []}); } - const autoNamed /*: Set */ = new Set(); - const resolveName = ( - npmName /*: string */, - config /*: ?RnConfig */, - ) /*: string */ => { - // $FlowFixMe[prop-missing] config has dynamic shape - if (config?.spm?.name == null) { - autoNamed.add(npmName); + // A podspec that cannot be read leaves the name to the next precedence step, + // rather than failing a build over a file only CocoaPods needs. + const podspecFor = ( + root /*: string */, + podspecPath /*: ?string */, + ) /*: ?PodspecFacts */ => { + if (readPodspec == null) { + return null; + } + try { + return readPodspec(root, podspecPath); + } catch { + return null; } - return resolveSwiftName(npmName, config, reserved, log); }; const queue /*: Array */ = directDeps.map(d => d.name); @@ -283,14 +325,18 @@ function expandSpmDependencies( if (current == null) { continue; } - const config = readConfig(current.root); - // Resolve swiftName lazily from the same config read we already need for - // spm.dependencies — saves a duplicate readConfig call per direct dep. + const spmConfig = spmConfigOf(current.root, readConfig(current.root)); if (current.swiftName == null) { - current.swiftName = resolveName(currentName, config); + const resolved = resolveSwiftName( + currentName, + spmConfig, + podspecFor(current.root, current.platforms.ios.podspecPath), + ); + current.swiftName = resolved.name; + current.swiftNameSource = resolved.source; + current.swiftNamePodspecKey = resolved.podspecKey; } - // $FlowFixMe[prop-missing] config has dynamic shape - const transitives /*: Array */ = config?.spm?.dependencies ?? []; + const transitives = stringList(spmConfig?.dependencies); const currentSpmDeps /*: Array */ = []; for (const transitiveName of transitives) { @@ -298,7 +344,7 @@ function expandSpmDependencies( const transitiveRoot = resolveDep(transitiveName, current.root); if (transitiveRoot == null) { throw new Error( - `react-native autolinking: '${currentName}' declares an unresolvable spm.dependency '${transitiveName}'. Ensure '${transitiveName}' is installed and visible via Node module resolution from ${current.root}.`, + `react-native autolinking: '${currentName}' declares an unresolvable SwiftPM dependency '${transitiveName}'. Ensure '${transitiveName}' is installed and visible via Node module resolution from ${current.root}.`, ); } @@ -312,11 +358,18 @@ function expandSpmDependencies( continue; } + const resolved = resolveSwiftName( + transitiveName, + spmConfigOf(transitiveRoot, transitiveConfig), + podspecFor(transitiveRoot, iosPlatform.podspecPath), + ); byName.set(transitiveName, { name: transitiveName, root: transitiveRoot, platforms: {ios: iosPlatform}, - swiftName: resolveName(transitiveName, transitiveConfig), + swiftName: resolved.name, + swiftNameSource: resolved.source, + swiftNamePodspecKey: resolved.podspecKey, spmDependencies: [], }); queue.push(transitiveName); @@ -328,37 +381,26 @@ function expandSpmDependencies( const allDeps /*: Array */ = Array.from(byName.values()); - disambiguateSharedSwiftNames(allDeps, autoNamed, log); - - // Both checks below validate the FINAL set, after that pass: a borrowed scope - // can land on a reserved name, or on one another dep already holds. assertNoReservedSwiftNames(allDeps, reserved); - // Collision check: two deps mapping to the same Swift name (whether via - // override or auto-derivation) would clobber each other in the synth - // package layout and the centralized headers tree. Surface it now with a - // clear message instead of letting SPM emit a confusing duplicate-target - // error later. - // Key case-INSENSITIVELY: resolveSwiftName permits lowercase ('worklets') - // while toSwiftName produces TitleCase ('Worklets') — an exact-equality check - // passes but the two still collide as directories on the default - // case-insensitive macOS filesystem (synth package layout + headers tree). + // Collision check: two deps mapping to the same Swift name would clobber each + // other in the synth package layout and the centralized headers tree. Surface + // it now with a clear message instead of letting SPM emit a confusing + // duplicate-target error later. swiftNameKey is what makes two names two + // targets — punctuation collapses into one module, case into one directory. const seen /*: Map */ = new Map(); for (const dep of allDeps) { const swiftName = dep.swiftName; if (swiftName == null) { continue; } - const key = swiftName.toLowerCase(); + const key = swiftNameKey(swiftName); const existing = seen.get(key); if (existing != null) { - const same = existing.swiftName === swiftName; throw new SpmNameCollisionError( `react-native autolinking: SPM Swift name collision: '${existing.name}' ('${existing.swiftName}') and '${dep.name}' ('${swiftName}') ` + - (same - ? `both resolve to '${swiftName}'.` - : `differ only in case, which collides on case-insensitive filesystems.`) + - ` Set a distinct 'spm.name' in one of their react-native.config.js files.`, + collisionDiagnosis(existing.swiftName, swiftName) + + ` Set a distinct 'swiftpmConfig.name' in one of their package.json files.`, ); } seen.set(key, {name: dep.name, swiftName}); @@ -424,12 +466,31 @@ function defaultResolveDep( } } +function defaultReadPodspec( + root /*: string */, + podspecPath /*: ?string */, +) /*: ?PodspecFacts */ { + // Deps synthesized from declared dependencies carry no podspecPath — only + // autolinking.json records one — so the dep root is searched as well. + const found = podspecPath ?? findPodspecs(root)[0]; + if (found == null) { + return null; + } + try { + // Ruby-computed fields need the real evaluator; everything else is spared + // the `pod ipc spec` spawn. + return readPodspecNames(found) ?? readPodspecCached(found); + } catch { + return null; + } +} + module.exports = { SpmNameCollisionError, assertSwiftNameNotReserved, expandSpmDependencies, - isValidSwiftName, resolveSwiftName, defaultReadConfig, + defaultReadPodspec, defaultResolveDep, }; diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking.js b/packages/react-native/scripts/spm/generate-spm-autolinking.js index 4f84ea3f7e4d..ecb91f8d64aa 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -10,7 +10,8 @@ 'use strict'; -/*:: import type { +/*:: import type {SwiftpmConfig} from './swiftpm-config'; +import type { AggregatorInput, AutolinkedDep, AutolinkingArgs, @@ -62,11 +63,11 @@ const { SpmNameCollisionError, assertSwiftNameNotReserved, defaultReadConfig, + defaultReadPodspec, defaultResolveDep, expandSpmDependencies, - isValidSwiftName, } = require('./expand-spm-dependencies'); -const {readPodspec} = require('./read-podspec'); +const {findPodspecs, readPodspecCached} = require('./read-podspec'); const { AUTOLINKED_PACKAGE_NAME, REACT_CODEGEN_PACKAGE_NAME, @@ -74,10 +75,14 @@ const { REACT_NATIVE_PACKAGE_NAME, REACT_NATIVE_PRODUCTS, RemoteVersionError, + displayPath, findProjectRoot, + isValidSwiftName, makeLogger, remotePackageConfig, + swiftNameKey, } = require('./spm-utils'); +const {readSwiftpmConfig, stringList} = require('./swiftpm-config'); const fs = require('node:fs'); const path = require('node:path'); const yargs = require('yargs'); @@ -233,92 +238,116 @@ function readAutolinkingJson( } /** - * Attempts to read react-native.config.js to find spm.modules entries. - * These are extra modules not discoverable via autolinking.json. + * One of the app's settings, and the root that declared it. Looked up field by + * field so a project-root `swiftpmConfig` cannot hide a field the Xcode dir + * still declares: the directory holding the app's package.json wins (where + * codegen reads `codegenConfig` from), and `appRoot` — the Xcode project dir, + * the settings' old home — fills the gaps. + */ +function appConfigField( + appRoot /*: string */, + field /*: 'modules' | 'denyPlugins' */, +) /*: ?{root: string, value: unknown} */ { + const projectRoot = findProjectRoot(appRoot); + const roots = projectRoot === appRoot ? [appRoot] : [projectRoot, appRoot]; + for (const root of roots) { + const value = readSwiftpmConfig(root, defaultReadConfig(root))?.[field]; + if (value === undefined) { + continue; + } + if (root !== projectRoot) { + warn( + `Read '${field}' from ${displayPath(root)}. Move it to 'swiftpmConfig' in ${displayPath(path.join(projectRoot, 'package.json'))} — an app's SwiftPM settings belong with its package.json.`, + ); + } + return {root, value}; + } + return null; +} + +/** + * The app's own native modules — the ones autolinking.json cannot discover — + * with the root that declared them, since each `path` is relative to the file + * it is written in: * - * Expected structure in react-native.config.js: - * module.exports = { - * ... - * spm: { - * modules: [ + * // /package.json + * "swiftpmConfig": { + * "modules": [ * { - * name: "MyNativeModule", - * path: "ios/MyNativeModule", // relative to appRoot - * exclude: ["*.js", "*.podspec"], // optional + * "name": "MyNativeModule", + * "path": "ios/MyNativeModule", + * "exclude": ["*.js", "*.podspec"] * } * ] * } - * } */ function readSpmModulesFromConfig( appRoot /*: string */, -) /*: Array */ { - const configPath = path.join(appRoot, 'react-native.config.js'); - if (!fs.existsSync(configPath)) { - return []; +) /*: {modules: Array, root: string} */ { + const declared = appConfigField(appRoot, 'modules'); + if (declared == null || !Array.isArray(declared.value)) { + return {modules: [], root: appRoot}; } - try { - // $FlowFixMe[unsupported-syntax] dynamic require by computed path - const config = require(configPath); - return config.spm?.modules ?? []; - } catch (e) { - // Config might use Ruby interop or other patterns – skip - return []; + // Entries stay unvalidated here: assertSpmModuleName checks each name, and + // the emission loop reads the rest defensively. + // $FlowFixMe[incompatible-type] user-authored entries + const modules /*: ReadonlyArray */ = declared.value; + return {modules: [...modules], root: declared.root}; +} + +function moduleClashDiagnosis( + moduleName /*: string */, + clash /*: string */, +) /*: string */ { + if (clash === moduleName) { + return `is already the name of another autolinked target.`; } + if (clash.toLowerCase() === moduleName.toLowerCase()) { + return `differs from the existing target '${clash}' only in case, which collides on case-insensitive filesystems.`; + } + return `compiles as the same module as the existing target '${clash}'.`; } /** * Validates one app-local `spm.modules` name against the same rules a library's * `spm.name` gets: a usable Swift identifier, not a name React Native reserves, * and not one already taken by another module or an autolinked dep. - * `taken` maps lower-cased name → the name as written. + * `taken` is keyed by swiftNameKey, valued with the name as written. */ function assertSpmModuleName( name /*: unknown */, taken /*: Map */, ) /*: void */ { const remedy = - "Rename it in this app's react-native.config.js 'spm.modules'."; + "Rename it in this app's package.json 'swiftpmConfig.modules'."; if (typeof name !== 'string' || !isValidSwiftName(name)) { throw new Error( - `react-native autolinking: invalid 'spm.modules' name ${JSON.stringify(name) ?? 'undefined'}: must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.`, + `react-native autolinking: invalid 'swiftpmConfig.modules' name ${JSON.stringify(name) ?? 'undefined'}: must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.`, ); } const moduleName = name; assertSwiftNameNotReserved(moduleName, { - label: `the 'spm.modules' entry '${moduleName}'`, + label: `the 'swiftpmConfig.modules' entry '${moduleName}'`, remedy, extraReservedNames: reservedNamesForRun(), }); - const clash = taken.get(moduleName.toLowerCase()); + const clash = taken.get(swiftNameKey(moduleName)); if (clash != null) { throw new SpmNameCollisionError( - `react-native autolinking: SPM Swift name collision: the 'spm.modules' entry '${moduleName}' ` + - (clash === moduleName - ? `is already the name of another autolinked target.` - : `differs from the existing target '${clash}' only in case, which collides on case-insensitive filesystems.`) + + `react-native autolinking: SPM Swift name collision: the 'swiftpmConfig.modules' entry '${moduleName}' ` + + moduleClashDiagnosis(moduleName, clash) + ` ${remedy}`, ); } } /** - * Reads the app's `spm.denyPlugins` — npm names of autolinking plugins to - * skip. The escape hatch for the transitive plugin discovery (an app opts a - * framework's plugin OUT); no allowlist is required. + * The app's `denyPlugins` — npm names of autolinking plugins to skip. The + * escape hatch for transitive plugin discovery (an app opts a framework's + * plugin OUT); no allowlist is required. */ function readDenyPluginsFromConfig(appRoot /*: string */) /*: Array */ { - const configPath = path.join(appRoot, 'react-native.config.js'); - if (!fs.existsSync(configPath)) { - return []; - } - try { - // $FlowFixMe[unsupported-syntax] dynamic require by computed path - const config = require(configPath); - return config.spm?.denyPlugins ?? []; - } catch (e) { - return []; - } + return stringList(appConfigField(appRoot, 'denyPlugins')?.value); } /** @@ -433,21 +462,9 @@ function findSelfManagedPackageDir(absSource /*: string */) /*: ?string */ { * the scaffolder translates the podspec into a Package.swift. */ function hasPodspec(absSource /*: string */) /*: boolean */ { - for (const sub of ['', 'ios']) { - const dir = sub === '' ? absSource : path.join(absSource, sub); - try { - if ( - fs - .readdirSync(dir) - .some(e => e.endsWith('.podspec') && !e.startsWith('.spm-scaffold-')) - ) { - return true; - } - } catch { - // dir does not exist; try the next candidate - } - } - return false; + return [absSource, path.join(absSource, 'ios')].some( + dir => findPodspecs(dir).length > 0, + ); } /** @@ -778,9 +795,9 @@ function expandSpmSourceGlobs( * Returns null if the dependency doesn't have iOS support. * * `swiftNameByNpm` maps each autolinked dep's npm name to its resolved Swift - * name (populated by expandSpmDependencies, honoring the dep's `spm.name` - * config and scope disambiguation). Every name this function emits comes from - * there — see requireSwiftName. + * name (populated by expandSpmDependencies from the dep's podspec and + * `spm.name`). Every name this function emits comes from there — see + * requireSwiftName. */ /** * Read the dep's podspec (if any) and extract its declared @@ -799,24 +816,12 @@ function expandSpmSourceGlobs( function extractPodspecHeaderSearchPaths( sourceDir /*: string */, ) /*: Array */ { - let podspecPath /*: ?string */ = null; - try { - const entries = fs.readdirSync(sourceDir); - // Skip a crashed run's leftover `.spm-scaffold--.podspec` copy. - const candidate = entries.find( - e => e.endsWith('.podspec') && !e.startsWith('.spm-scaffold-'), - ); - if (candidate != null) { - podspecPath = path.join(sourceDir, candidate); - } - } catch { - return []; - } + const podspecPath /*: ?string */ = findPodspecs(sourceDir)[0]; if (podspecPath == null) return []; let model; try { - model = readPodspec(podspecPath); + model = readPodspecCached(podspecPath); } catch { return []; } @@ -1312,8 +1317,8 @@ function main(argv /*:: ?: Array */) /*: void */ { const allDeps = expandSpmDependencies(directDeps, { readConfig: defaultReadConfig, resolveDep: defaultResolveDep, + readPodspec: defaultReadPodspec, extraReservedNames: reservedNamesForRun(), - log, }); // Map every autolinked npm name to its resolved Swift name so transitive @@ -1380,7 +1385,7 @@ function main(argv /*:: ?: Array */) /*: void */ { const dependents = pluginHostDependents.get(dep.name); if (dependents != null) { throw new Error( - `react-native autolinking: '${dep.name}' ships an SPM autolinking plugin, which owns its native contribution — so React Native does not build it as a sibling target for anything to depend on. It is declared in 'spm.dependencies' by ${dependents.map(name => `'${name}'`).join(', ')}. Remove it there; nothing is lost. Its plugin links its products into the app and resolves its own ecosystem's dependencies, so a library that builds against it does not declare it here.`, + `react-native autolinking: '${dep.name}' ships an SPM autolinking plugin, which owns its native contribution — so React Native does not build it as a sibling target for anything to depend on. It is declared as a SwiftPM dependency by ${dependents.map(name => `'${name}'`).join(', ')}. Remove it there; nothing is lost. Its plugin links its products into the app and resolves its own ecosystem's dependencies, so a library that builds against it does not declare it here.`, ); } log( @@ -1414,17 +1419,20 @@ function main(argv /*:: ?: Array */) /*: void */ { // If the module declares `sources: [glob, ...]` (CocoaPods-style), expand // the globs now relative to its dir and attach the file list to the target // so the emission loop below renders `sources: [...]` literally. - const configModules = readSpmModulesFromConfig(appRoot); + const {modules: configModules, root: configModulesRoot} = + readSpmModulesFromConfig(appRoot); // Module names land in the manifest exactly as written, so they get the same // checks a dep's Swift name gets. Seeded with the dep target names already // emitted so a module can't shadow an autolinked library either. const takenSwiftNames /*: Map */ = new Map( - entries.map(entry => [entry.target.name.toLowerCase(), entry.target.name]), + entries.map(entry => [swiftNameKey(entry.target.name), entry.target.name]), ); for (const mod of configModules) { assertSpmModuleName(mod.name, takenSwiftNames); - takenSwiftNames.set(mod.name.toLowerCase(), mod.name); - const absPath = path.resolve(appRoot, mod.path); + takenSwiftNames.set(swiftNameKey(mod.name), mod.name); + // Relative to the package.json (or config file) that declared it, so a + // path reads correctly from where it is written. + const absPath = path.resolve(configModulesRoot, mod.path); const relPath = path.relative(outputDir, absPath); const userSources = Array.isArray(mod.sources) && mod.sources.length > 0 @@ -1999,6 +2007,7 @@ module.exports = { findSelfManagedPackageDir, hasPodspec, hasMixedLanguageSources, + readDenyPluginsFromConfig, MissingManifestError, reportMissingManifests, AUTOGEN_MARKER, diff --git a/packages/react-native/scripts/spm/read-podspec.js b/packages/react-native/scripts/spm/read-podspec.js index 0b9a75010f8c..8fcf04bbf658 100644 --- a/packages/react-native/scripts/spm/read-podspec.js +++ b/packages/react-native/scripts/spm/read-podspec.js @@ -170,6 +170,27 @@ function cleanupPatchedPodspec(patchedPath /*: ?string */) /*: void */ { // Regex fallback // --------------------------------------------------------------------------- +// Comment-only lines are dropped so a commented-out `# s.header_dir` cannot win +// over the live one. A trailing `#` and a `#` inside a string survive: only the +// first non-whitespace character counts. +function readPodspecSource(podspecPath /*: string */) /*: string */ { + return fs + .readFileSync(podspecPath, 'utf8') + .split('\n') + .filter(line => !/^\s*#/.test(line)) + .join('\n'); +} + +// A subspec that re-binds a receiver the identity regexes trust: inside +// `s.subspec "common" do |s|`, `s` is the CHILD, and no regex over flat text +// can see that. Any other block variable (`do |ss|`) is unambiguous. +const SHADOWING_SUBSPEC_RE = + /\.subspec\s+["'][^"']*["']\s+do\s*\|\s*(?:s|spec)\s*\|/; + +function hasShadowingSubspec(source /*: string */) /*: boolean */ { + return SHADOWING_SUBSPEC_RE.test(source); +} + /** * Best-effort Ruby podspec parser. Extracts the literal-string and * literal-array fields most RN libs use. Skips subspec blocks, Ruby helper @@ -178,16 +199,39 @@ function cleanupPatchedPodspec(patchedPath /*: ?string */) /*: void */ { * user. Always returns a RawSpec; pure-JS, no Ruby dep required. */ function regexPodspec(podspecPath /*: string */) /*: RawSpec */ { - const content = fs.readFileSync(podspecPath, 'utf8'); + const content = readPodspecSource(podspecPath); const warnings /*: Array */ = []; - // Matches: s. = "value" or s. = 'value' + // Matches: s. = "value" or s. = 'value' — at ANY scope, + // so a subspec's value counts. Right for the fields flattenSubspecs merges + // (source globs, header mappings); wrong for the fields that identify the + // spec, which use getSpecStringField. function getStringField(name /*: string */) /*: string | null */ { const re = new RegExp(`(?:s|spec)\\.${name}\\s*=\\s*["']([^"']+)["']`); const m = content.match(re); return m ? m[1] : null; } + // The same, but only where the receiver starts the statement — so a subspec's + // block variable (`ss.header_dir`, the shape react-native-screens and + // react-native-svg ship) cannot answer for the spec itself. + function getSpecStringField(name /*: string */) /*: string | null */ { + const re = new RegExp( + `(?:^|[;|])\\s*(?:s|spec)\\.${name}\\s*=\\s*["']([^"']+)["']`, + 'm', + ); + const m = content.match(re); + return m ? m[1] : null; + } + + // The fields that identify the spec, and only when the receiver spelling + // still says which scope declared them: a subspec re-binding `s` puts its own + // `header_dir` exactly where the library's would be. + const shadowedIdentity = hasShadowingSubspec(content); + function getIdentityField(name /*: string */) /*: string | null */ { + return shadowedIdentity ? null : getSpecStringField(name); + } + // Matches: // s. = ["a", "b"] (array) // s. = "single" (single value treated as 1-element array) @@ -289,6 +333,11 @@ function regexPodspec(podspecPath /*: string */) /*: RawSpec */ { } // Surface known unparseable constructs so the caller can warn the user. + if (shadowedIdentity) { + warnings.push( + "A subspec rebinds the spec variable (`do |s|`), so its `name`, `module_name` and `header_dir` cannot be told from the library's own — those are left unread. Install CocoaPods (`gem install cocoapods`) to enable full `pod ipc spec` parsing.", + ); + } if (/(?:s|spec)\.subspec\s+["']/.test(content)) { warnings.push( 'Subspecs detected — regex parser only extracts top-level fields. Install CocoaPods (`gem install cocoapods`) to enable full `pod ipc spec` parsing.', @@ -306,14 +355,15 @@ function regexPodspec(podspecPath /*: string */) /*: RawSpec */ { } return { - name: getStringField('name'), - version: getStringField('version'), + name: getIdentityField('name'), + module_name: getIdentityField('module_name'), + version: getSpecStringField('version'), source_files: getArrayField('source_files'), public_header_files: getArrayField('public_header_files'), private_header_files: getArrayField('private_header_files'), exclude_files: getArrayField('exclude_files'), header_mappings_dir: getStringField('header_mappings_dir'), - header_dir: getStringField('header_dir'), + header_dir: getIdentityField('header_dir'), frameworks: getFrameworks(false), weak_frameworks: getFrameworks(true), libraries: getArrayField('libraries'), @@ -432,6 +482,14 @@ function flattenSubspecs(rawSpec /*: RawSpec */) /*: PodspecModel */ { return Array.from(new Set(out)); } + // Only the spec's own value: a subspec's `header_dir` names that subspec's + // headers, and several subspecs can disagree. + function specStringField(key /*: string */) /*: string | null */ { + // $FlowFixMe[incompatible-use] dynamic shape + const value = rawSpec[key]; + return typeof value === 'string' && value.length > 0 ? value : null; + } + function mergeStringField(key /*: string */) /*: string | null */ { for (const layer of layers) { // $FlowFixMe[incompatible-use] layer narrowed from `mixed`; runtime-validated below @@ -620,8 +678,8 @@ function flattenSubspecs(rawSpec /*: RawSpec */) /*: PodspecModel */ { : true; // RN ecosystem default return { - name: mergeStringField('name') ?? '', - version: mergeStringField('version') ?? '', + name: specStringField('name') ?? '', + version: specStringField('version') ?? '', sourceFiles: mergeArrayField('source_files'), publicHeaderFiles: mergeArrayField('public_header_files'), privateHeaderFiles: mergeArrayField('private_header_files'), @@ -634,7 +692,8 @@ function flattenSubspecs(rawSpec /*: RawSpec */) /*: PodspecModel */ { // resolve from the physical tree — CocoaPods does this via the // header_mappings_dir copy step, which SPM has no equivalent for. headerMappingsDirs: mergeArrayField('header_mappings_dir'), - headerDir: mergeStringField('header_dir'), + headerDir: specStringField('header_dir'), + moduleName: specStringField('module_name'), frameworks: mergeArrayField('frameworks'), weakFrameworks: mergeArrayField('weak_frameworks'), libraries: mergeArrayField('libraries'), @@ -687,8 +746,97 @@ function readPodspec(podspecPath /*: string */) /*: PodspecModel */ { return model; } +const podspecModels /*: Map */ = new Map(); + +/** + * readPodspec memoized on the resolved path. `pod ipc spec` costs a process + * spawn, and one autolinking run reads the same podspec from several sites + * (name resolution, header search paths, scaffolding). The model is shared, so + * callers must treat it as read-only. + */ +function readPodspecCached(podspecPath /*: string */) /*: PodspecModel */ { + const key = path.resolve(podspecPath); + const cached = podspecModels.get(key); + if (cached != null) { + return cached; + } + const model = readPodspec(podspecPath); + podspecModels.set(key, model); + return model; +} + +/** + * The three fields that name a library, read WITHOUT `pod ipc spec`'s process + * spawn — name resolution runs for every dep, including self-managed ones that + * need nothing else from the podspec. + * + * Null when the answer cannot be trusted without Ruby: an interpolated value, + * or a spec-level `header_dir`/`module_name` the regex could not read. Both + * outrank the pod name, so a literal name alone is not enough — resolving + * without them would pick the wrong prefix. A value only a subspec declares is + * not the library's, so it neither answers nor defers — unless that subspec + * re-binds `s` (or `spec`), which makes every field's scope unreadable. + */ +function readPodspecNames( + podspecPath /*: string */, +) /*: ?{name: ?string, moduleName: ?string, headerDir: ?string} */ { + const raw = regexPodspec(podspecPath); + const field = (key /*: string */) /*: ?string */ => { + // $FlowFixMe[prop-missing] RawSpec is dynamically shaped + const value = raw[key]; + return typeof value === 'string' && + value.length > 0 && + !value.includes('#{') + ? value + : null; + }; + const source = readPodspecSource(podspecPath); + if (hasShadowingSubspec(source)) { + return null; + } + const declaredUnread = ( + key /*: string */, + value /*: ?string */, + ) /*: boolean */ => + value == null && + new RegExp(`(?:^|[;|])\\s*(?:s|spec)\\.${key}\\s*=`, 'm').test(source); + + const name = field('name'); + const moduleName = field('module_name'); + const headerDir = field('header_dir'); + if ( + declaredUnread('header_dir', headerDir) || + declaredUnread('module_name', moduleName) + ) { + return null; + } + return name == null && moduleName == null && headerDir == null + ? null + : {name, moduleName, headerDir}; +} + +/** + * Every podspec in `dir`, sorted so two of them name a target the same way on + * every machine (readdir order is unspecified). Skips a crashed run's leftover + * `.spm-scaffold--.podspec` copy. + */ +function findPodspecs(dir /*: string */) /*: Array */ { + try { + return fs + .readdirSync(dir) + .filter(e => e.endsWith('.podspec') && !e.startsWith('.spm-scaffold-')) + .sort() + .map(e => path.join(dir, e)); + } catch { + return []; + } +} + module.exports = { + findPodspecs, readPodspec, + readPodspecCached, + readPodspecNames, runPodIpcSpec, regexPodspec, flattenSubspecs, diff --git a/packages/react-native/scripts/spm/scaffold-package-swift.js b/packages/react-native/scripts/spm/scaffold-package-swift.js index b6b4242d83b3..5c0e3f8325af 100644 --- a/packages/react-native/scripts/spm/scaffold-package-swift.js +++ b/packages/react-native/scripts/spm/scaffold-package-swift.js @@ -38,11 +38,13 @@ import type { const { SpmNameCollisionError, defaultReadConfig, + defaultReadPodspec, defaultResolveDep, expandSpmDependencies, + resolveSwiftName, } = require('./expand-spm-dependencies'); const {expandSpmSourceGlobs} = require('./generate-spm-autolinking'); -const {readPodspec} = require('./read-podspec'); +const {findPodspecs, readPodspecCached} = require('./read-podspec'); const { REACT_CODEGEN_PACKAGE_NAME, REACT_CODEGEN_PRODUCTS, @@ -51,12 +53,12 @@ const { SCAFFOLDER_MARKER, makeLogger, remotePackageConfig, - toSwiftName, } = require('./spm-utils'); +const {readSwiftpmConfig, writeSwiftpmName} = require('./swiftpm-config'); const fs = require('node:fs'); const path = require('node:path'); -const {log} = makeLogger('scaffold-package-swift'); +const {log, warn} = makeLogger('scaffold-package-swift'); // SCAFFOLDER_MARKER lives in spm-utils.js (shared, no import cycle). It must NOT // contain the autolinker's AUTOGEN_MARKER ('// AUTO-GENERATED by @@ -246,22 +248,16 @@ function translatePodspecToSpmTarget( ) /*: SpmScaffoldSpec */ { const warnings = [...model.warnings]; - // Swift target name: whatever the autolinker resolved for this dep — its - // `spm.name` override when set, else toSwiftName(npm-name). The autolinker - // registers the dep under that name in its aggregator (and in any sibling - // spm.dependencies refs), so the scaffolded Package.swift's product/library - // name must match it exactly — otherwise SPM resolution fails with a name - // mismatch on `.product(name: "X", package: "X")`. - // - // The podspec's `header_dir` is captured separately: when it changes the - // include surface (e.g. `` instead of ``), - // header resolution already works via the -I flags from headerSearchPaths - // (path-style includes like safe-area-context's - // `` resolve through - // `-I common/cpp/`). Module-style includes that NEED the target name to - // match (e.g. reanimated's `` via SwiftPM's auto-generated - // module map) are what `spm.name` is for. - const swiftName = dep.swiftName ?? toSwiftName(dep.name); + // Whatever the autolinker resolved (see resolveSwiftName). It registers the + // dep under that name in its aggregator and in any sibling spm.dependencies + // refs, so the scaffolded product/library name must match it exactly — + // otherwise SPM fails to resolve `.product(name: "X", package: "X")`. + const swiftName = dep.swiftName; + if (swiftName == null) { + throw new Error( + `react-native spm scaffold: no resolved Swift name for '${dep.name}'. expandSpmDependencies must resolve every dep's name before a manifest is written.`, + ); + } // Header search paths — substitute Xcode build-setting tokens against the // dep root. Anything we can't substitute is dropped + warned (avoids @@ -658,8 +654,7 @@ function emitScaffoldedPackageSwift( // Dependencies block. Always declares ReactNative if any React-core dep // was in the podspec; sibling RN deps come from autolinking's existing - // .package(path: ...) graph — we reference them by toSwiftName(npmName) - // since that's what the autolinker registers them under. + // .package(path: ...) graph, under the names the autolinker resolved. const packageDeps /*: Array */ = []; const targetDeps /*: Array */ = []; if (spec.coreReactNative) { @@ -704,8 +699,12 @@ function emitScaffoldedPackageSwift( } } for (const siblingName of spec.siblingNames) { - const swiftSibling = - spec.siblingSwiftNames?.[siblingName] ?? toSwiftName(siblingName); + const swiftSibling = spec.siblingSwiftNames?.[siblingName]; + if (swiftSibling == null) { + throw new Error( + `react-native spm scaffold: no resolved Swift name for the sibling '${siblingName}'. expandSpmDependencies must resolve every dep's name before a manifest is written.`, + ); + } // The autolinker references each self-managed (scaffolded) dep through a // `libs/` symlink, and SPM resolves a manifest's relative // package paths against that symlink location — so a sibling lives at @@ -784,6 +783,61 @@ ${packageDepsBlock} targets: [ `; } +const PODSPEC_KEY_ORIGINS = { + header_dir: "its podspec's 'header_dir'", + module_name: "its podspec's 'module_name'", + name: 'its pod name', +}; + +// Null when the caller resolved the name without recording where it came from, +// since the alternative is inventing an origin. +function swiftNameOrigin(dep /*: AutolinkedDep */) /*: ?string */ { + switch (dep.swiftNameSource) { + case 'config': + return "its declared 'swiftpmConfig.name'"; + case 'podspec': + return PODSPEC_KEY_ORIGINS[dep.swiftNamePodspecKey ?? 'name']; + case 'npm': + return 'its npm package name'; + default: + return null; + } +} + +/** + * Says which SwiftPM name the library got and where that name came from. The + * name is the prefix every `#import ` against the library has to + * spell, and a podspec-derived one is written into the library's package.json, + * so it is not a decision to make silently. + */ +function reportSwiftName( + dep /*: AutolinkedDep */, + swiftName /*: string */, + model /*: PodspecModel */, +) /*: void */ { + const origin = swiftNameOrigin(dep); + if (origin == null) { + return; + } + log(`${dep.name}: SwiftPM name '${swiftName}' (from ${origin}).`); + // Two declared namespaces, one name: CocoaPods compiles the module as + // `module_name` while consumers import through `header_dir`, so a library + // that declares both loses one of them here. + const {headerDir, moduleName} = model; + if ( + dep.swiftNamePodspecKey === 'header_dir' && + headerDir != null && + moduleName != null && + headerDir !== moduleName + ) { + warn( + `${dep.name}'s podspec declares both a 'header_dir' ('${headerDir}') and a 'module_name' ('${moduleName}'): ` + + `'${swiftName}' is the SwiftPM name, and '${moduleName}' is not used. ` + + `Set 'swiftpmConfig.name' in its package.json to override that choice.`, + ); + } +} + // --------------------------------------------------------------------------- // Per-dep orchestrator // --------------------------------------------------------------------------- @@ -806,7 +860,7 @@ type ScaffoldContext = { // `s.dependency` names (e.g. "RNWorklets") wire to the right sibling. podToNpm?: Map, // npm-name → resolved Swift name over all autolinked deps, so sibling - // references honor each sibling's `spm.name`. + // references honor each sibling's declared name. swiftNameByNpm?: Map, remote: ?{url: string, version: string, identity: string}, }; @@ -839,14 +893,14 @@ function scaffoldPackageSwiftForDep( }; } - // Dep can opt out via its own react-native.config.js. - const cfg = defaultReadConfig(dep.root); - // $FlowFixMe[prop-missing] config has dynamic shape - if (cfg != null && cfg.spm != null && cfg.spm.scaffold === false) { + // Dep can opt out in its own package.json. + if ( + readSwiftpmConfig(dep.root, defaultReadConfig(dep.root))?.scaffold === false + ) { return { depName, status: 'skipped-opt-out', - reason: 'react-native.config.js sets spm.scaffold = false.', + reason: "the library sets 'swiftpmConfig.scaffold' to false.", }; } @@ -925,25 +979,10 @@ function scaffoldPackageSwiftForDep( } } - // Find a podspec to read. autolinking.json may have provided podspecPath; - // otherwise glob for *.podspec in dep root. + // autolinking.json may have provided podspecPath; otherwise search dep.root. // $FlowFixMe[prop-missing] dynamic shape from autolinking.json - let podspecPath /*: ?string */ = dep.platforms.ios.podspecPath ?? null; - if (podspecPath == null) { - try { - const entries = fs.readdirSync(dep.root); - // Skip a crashed run's leftover patched copy (read-podspec.js stages it - // as `.spm-scaffold--.podspec` next to the original). - const candidate = entries.find( - e => e.endsWith('.podspec') && !e.startsWith('.spm-scaffold-'), - ); - if (candidate != null) { - podspecPath = path.join(dep.root, candidate); - } - } catch { - // dep.root may not exist; treat as no-podspec - } - } + const podspecPath /*: ?string */ = + dep.platforms.ios.podspecPath ?? findPodspecs(dep.root)[0]; if (podspecPath == null || !fs.existsSync(podspecPath)) { return { depName, @@ -954,7 +993,7 @@ function scaffoldPackageSwiftForDep( let model; try { - model = readPodspec(podspecPath); + model = readPodspecCached(podspecPath); } catch (e) { return { depName, @@ -1064,12 +1103,23 @@ function scaffoldPackageSwiftForDep( } } + reportSwiftName(dep, spec.swiftName, model); + + // Migration: record a name we derived from the podspec, so the library can + // drop the podspec. A declared name is already where it belongs, and a name + // guessed from the npm name would freeze a guess into someone's config. + const swiftpmName = + dep.swiftNameSource === 'podspec' + ? writeSwiftpmName(dep.root, spec.swiftName, {dryRun: ctx.dryRun}) + : undefined; + return { depName, status: 'written', packageSwiftPath: pkgSwiftPath, warnings: spec.warnings, previouslyExisted, + swiftpmName, }; } @@ -1158,17 +1208,31 @@ function scaffoldAll( allDeps = expandSpmDependencies(directDeps, { readConfig: defaultReadConfig, resolveDep: defaultResolveDep, + readPodspec: defaultReadPodspec, extraReservedNames: remote != null ? [remote.identity] : undefined, - log, }); } catch (e) { if (e instanceof SpmNameCollisionError) { throw e; } // A transitive-resolution failure shouldn't abort the whole scaffold pass; - // fall back to the direct deps so at least those get manifests. - log(`Transitive spm.dependencies expansion failed: ${e.message}`); - allDeps = directDeps; + // fall back to the direct deps so at least those get manifests. They are + // named the same way the autolinker names them — a manifest written under + // any other name outlives this error on disk and then fails to resolve. + log(`Transitive dependency expansion failed: ${e.message}`); + allDeps = directDeps.map(dep => { + const resolved = resolveSwiftName( + dep.name, + readSwiftpmConfig(dep.root, defaultReadConfig(dep.root)), + defaultReadPodspec(dep.root, dep.platforms.ios.podspecPath), + ); + return { + ...dep, + swiftName: resolved.name, + swiftNameSource: resolved.source, + swiftNamePodspecKey: resolved.podspecKey, + }; + }); } // Index every autolinked dep's podspec name → its npm name, so a dep that @@ -1187,14 +1251,8 @@ function scaffoldAll( // discover the podspec at the dep root so podspec-name sibling deps // (e.g. `s.dependency "TestLibraryCommon"`) still wire to their npm // package. - try { - for (const f of fs.readdirSync(dep.root)) { - if (f.endsWith('.podspec') && !f.startsWith('.spm-scaffold-')) { - podToNpm.set(path.basename(f, '.podspec'), dep.name); - } - } - } catch { - // unreadable dep root — sibling wiring falls back to the warning path + for (const found of findPodspecs(dep.root)) { + podToNpm.set(path.basename(found, '.podspec'), dep.name); } } } diff --git a/packages/react-native/scripts/spm/spm-types.js b/packages/react-native/scripts/spm/spm-types.js index 849fecff5637..dcda8d3addd7 100644 --- a/packages/react-native/scripts/spm/spm-types.js +++ b/packages/react-native/scripts/spm/spm-types.js @@ -9,6 +9,7 @@ */ /*:: +import type {SwiftpmNameOutcome} from './swiftpm-config'; export type SetupArgs = { action: 'add' | 'update' | 'deinit' | 'sync' | 'codegen' | 'download' | 'scaffold' | null, version: string | null, @@ -143,13 +144,19 @@ export type AutolinkedDep = { name: string, root: string, platforms: {ios: AutolinkingIosPlatform, ...}, - // Resolved Swift target / module / headers-subdir name. Defaults to - // toSwiftName(name) and is overridden by the dep's react-native.config.js - // `spm.name`. Populated by expandSpmDependencies — always present after - // expansion; optional in the type so caller-side construction stays simple. + // Resolved Swift target / module / headers-subdir name — the name the library + // declares in `swiftpmConfig`, else its podspec's (see resolveSwiftName). + // Populated by expandSpmDependencies — always present after expansion; + // optional in the type so caller-side construction stays simple. swiftName?: string, - // Populated by expandSpmDependencies from each dep's - // react-native.config.js `spm.dependencies` array. + // Where swiftName came from. Only a podspec-derived name is safe for the + // scaffolder to record in the library's package.json. + swiftNameSource?: 'config' | 'podspec' | 'npm', + // Which podspec field named it ('podspec' source only), so `spm scaffold` can + // report the choice it made between `header_dir` and `module_name`. + swiftNamePodspecKey?: 'header_dir' | 'module_name' | 'name', + // Populated by expandSpmDependencies from each dep's declared + // `dependencies` array. spmDependencies?: Array, ... }; @@ -422,6 +429,10 @@ export type PodspecModel = { // physical source tree (SPM has no header_mappings_dir copy step). headerMappingsDirs: Array, headerDir: ?string, + // What CocoaPods compiles the module as, and so what Swift and `@import` + // consumers spell (`s.module_name`). Defaults to the pod name in CocoaPods; + // null here when the podspec does not declare it. + moduleName: ?string, frameworks: Array, weakFrameworks: Array, libraries: Array, @@ -462,8 +473,7 @@ export type PodspecModel = { // Decouples podspec reading from SPM-specific shaping so each side can be // tested in isolation. export type SpmScaffoldSpec = { - // Swift target / module name. Default: toSwiftName(podspec.name); overridden - // by `header_dir` when present. + // Swift target / module name, as resolved by expandSpmDependencies. swiftName: string, // Source file paths relative to the dep root, ready for `sources: [...]` // emission after the `root/` wrapper-dir prefix is applied at emit time. @@ -522,6 +532,9 @@ export type ScaffoldResult = // changed, --force, etc.); false on first-time scaffolds. The CLI // orchestrator prompts only for first-time scaffolds. previouslyExisted: boolean, + // What became of `swiftpmConfig.name` in the library's package.json. + // Absent when the name was not podspec-derived, so nothing was attempted. + swiftpmName?: SwiftpmNameOutcome, } | { depName: string, diff --git a/packages/react-native/scripts/spm/spm-utils.js b/packages/react-native/scripts/spm/spm-utils.js index b916e3dfe921..fc5313623e71 100644 --- a/packages/react-native/scripts/spm/spm-utils.js +++ b/packages/react-native/scripts/spm/spm-utils.js @@ -171,6 +171,29 @@ function toSwiftName(name /*: string */) /*: string */ { .join(''); } +// The charset a declared SwiftPM name must satisfy — permissive on purpose, +// since it has to admit header-dir style (lowercase with hyphens) as well as +// Swift identifiers. +function isValidSwiftName(name /*: unknown */) /*: boolean */ { + return typeof name === 'string' && /^[A-Za-z_][A-Za-z0-9_-]*$/.test(name); +} + +/** + * SwiftPM's `c99name` — the identifier a target's module is compiled under, + * with every character C99 rejects replaced by `_`. `react-native-svg` and + * `react_native_svg` share one, so they cannot both be targets. + */ +function toC99Name(name /*: string */) /*: string */ { + const mangled = name.replace(/[^A-Za-z0-9_]/g, '_'); + return /^[0-9]/.test(mangled) ? `_${mangled}` : mangled; +} + +// Collision key for a Swift target name: two names that share one collide, as a +// module (punctuation) or as a headers directory (case). +function swiftNameKey(name /*: string */) /*: string */ { + return toC99Name(name).toLowerCase(); +} + /** * Derive a default app name from the raw package name and source path. * Prefers the source directory name when it's meaningful (e.g. "RNTester"), @@ -743,6 +766,9 @@ module.exports = { sharedCacheDir, defaultCacheDir, toSwiftName, + toC99Name, + swiftNameKey, + isValidSwiftName, deriveAppName, readPackageJson, findProjectRoot, diff --git a/packages/react-native/scripts/spm/swiftpm-config.js b/packages/react-native/scripts/spm/swiftpm-config.js new file mode 100644 index 000000000000..109fa3bde38c --- /dev/null +++ b/packages/react-native/scripts/spm/swiftpm-config.js @@ -0,0 +1,245 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +const {isValidSwiftName, makeLogger} = require('./spm-utils'); +const fs = require('node:fs'); +const path = require('node:path'); + +const {log, warn} = makeLogger('swiftpm-config'); + +/** + * swiftpm-config.js — one reader for a package's SwiftPM settings. + * + * They live in `swiftpmConfig` in the package's package.json: + * + * { "swiftpmConfig": { "name": "RNSVG" } } + * + * A library owns `name`, `dependencies`, `autolinkingPlugin` and `scaffold`; + * an app owns `modules` and `denyPlugins`. The `spm` block in + * react-native.config.js is the deprecated spelling of the same fields — still + * read so nothing breaks mid-migration, but package.json wins field by field. + */ + +/*:: +export type SwiftpmNameOutcome = + | 'created' + | 'inserted' + | 'already-set' + | 'skipped' + | 'failed'; +// User-authored, so values stay unknown and are validated where consumed. +export type SwiftpmConfig = { + readonly name?: unknown, + readonly dependencies?: unknown, + readonly autolinkingPlugin?: unknown, + readonly scaffold?: unknown, + readonly modules?: unknown, + readonly denyPlugins?: unknown, + ... +}; +type RnConfig = {...}; +*/ + +const SWIFTPM_FIELDS /*: ReadonlyArray */ = [ + 'name', + 'dependencies', + 'autolinkingPlugin', + 'scaffold', + 'modules', + 'denyPlugins', +]; + +// Config file paths already warned about, by kind of warning. +const deprecationsReported /*: Set */ = new Set(); +const unknownKeysReported /*: Set */ = new Set(); + +function readPackageJson( + root /*: string */, +) /*: ?{readonly [string]: unknown} */ { + const pkgPath = path.join(root, 'package.json'); + if (!fs.existsSync(pkgPath)) { + return null; + } + try { + const parsed = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + return parsed != null && typeof parsed === 'object' ? parsed : null; + } catch (e) { + warn( + `Failed to read ${pkgPath}: ${e.message}. 'swiftpmConfig' is ignored.`, + ); + return null; + } +} + +// A settings object, or null for anything else the author may have written +// there — an array included, since its indices are not fields. +function objectField( + source /*: ?{readonly [string]: unknown} */, + key /*: string */, +) /*: ?SwiftpmConfig */ { + const value = source?.[key]; + return value != null && typeof value === 'object' && !Array.isArray(value) + ? value + : null; +} + +function declaredFields( + config /*: ?{readonly [string]: unknown} */, +) /*: Array */ { + return config == null + ? [] + : SWIFTPM_FIELDS.filter(field => config[field] !== undefined); +} + +function stringList(value /*: unknown */) /*: Array */ { + return Array.isArray(value) ? value.filter(v => typeof v === 'string') : []; +} + +function reportDeprecation( + root /*: string */, + fields /*: Array */, +) /*: void */ { + const configPath = path.join(root, 'react-native.config.js'); + if (deprecationsReported.has(configPath)) { + return; + } + deprecationsReported.add(configPath); + warn( + `${configPath} declares SwiftPM settings in the deprecated 'spm' block (${fields.join(', ')}). ` + + `Move them to 'swiftpmConfig' in the package's package.json — the 'spm' block still works, but will stop being read.`, + ); +} + +// A misspelled field would otherwise do nothing at all. Unknown keys are only +// reported, never rejected: a library may declare a field a newer React Native +// knows and this one does not. +function reportUnknownKeys( + root /*: string */, + config /*: SwiftpmConfig */, +) /*: void */ { + const pkgPath = path.join(root, 'package.json'); + const unknown = Object.keys(config).filter( + key => !SWIFTPM_FIELDS.includes(key), + ); + if (unknown.length === 0 || unknownKeysReported.has(pkgPath)) { + return; + } + unknownKeysReported.add(pkgPath); + warn( + `${pkgPath} declares 'swiftpmConfig' fields React Native does not know (${unknown.join(', ')}). ` + + `They are ignored — check the spelling against ${SWIFTPM_FIELDS.join(', ')}.`, + ); +} + +/** + * The SwiftPM settings that apply to the package at `root`: `swiftpmConfig` + * from its package.json over the deprecated `spm` block of its already-loaded + * react-native.config.js. Null when the package declares neither. + */ +function readSwiftpmConfig( + root /*: string */, + rnConfig /*: ?RnConfig */, +) /*: ?SwiftpmConfig */ { + const fromPackageJson = objectField(readPackageJson(root), 'swiftpmConfig'); + if (fromPackageJson != null) { + reportUnknownKeys(root, fromPackageJson); + } + const deprecated = objectField(rnConfig, 'spm'); + const deprecatedFields = declaredFields(deprecated); + if (deprecatedFields.length > 0) { + reportDeprecation(root, deprecatedFields); + } + if (fromPackageJson == null) { + return deprecatedFields.length > 0 ? deprecated : null; + } + return {...deprecated, ...fromPackageJson}; +} + +// The file's own indentation, so writing a field back does not reformat it. +function detectIndent(source /*: string */) /*: string */ { + return /\n([ \t]+)"/.exec(source)?.[1] ?? ' '; +} + +/** + * Records `swiftName` as `swiftpmConfig.name` in the package's package.json — + * the scaffolder's migration step, which is what lets a library stop deriving + * its SwiftPM name from a podspec. A name the author already chose is never + * overwritten. + */ +function writeSwiftpmName( + root /*: string */, + swiftName /*: string */, + options /*:: ?: {dryRun?: boolean} */, +) /*: SwiftpmNameOutcome */ { + if (!isValidSwiftName(swiftName)) { + return 'skipped'; + } + const pkgPath = path.join(root, 'package.json'); + let source; + let pkg; + try { + source = fs.readFileSync(pkgPath, 'utf8'); + pkg = JSON.parse(source); + } catch { + return 'skipped'; + } + if (pkg == null || typeof pkg !== 'object') { + return 'skipped'; + } + const existing = objectField(pkg, 'swiftpmConfig'); + if (existing == null && pkg.swiftpmConfig !== undefined) { + warn( + `${pkgPath} has a 'swiftpmConfig' that is not an object; leaving it alone. ` + + `React Native resolved the name '${swiftName}' from the podspec.`, + ); + return 'skipped'; + } + const declaredName = existing?.name; + if (declaredName === swiftName) { + return 'already-set'; + } + if (declaredName != null) { + warn( + `${pkgPath} already sets 'swiftpmConfig.name' to '${String(declaredName)}'; leaving it. ` + + `React Native resolved '${swiftName}' from the podspec — set them to the same value to silence this.`, + ); + return 'skipped'; + } + const outcome = existing == null ? 'created' : 'inserted'; + pkg.swiftpmConfig = {...existing, name: swiftName}; + if (options?.dryRun === true) { + log(`Would set 'swiftpmConfig.name' to '${swiftName}' in ${pkgPath}`); + return outcome; + } + const serialized = JSON.stringify(pkg, null, detectIndent(source)); + const content = source.endsWith('\n') ? `${serialized}\n` : serialized; + // Staged and renamed, so an interrupted or out-of-space write cannot leave a + // third-party package.json truncated. + const tmpPath = `${pkgPath}.${process.pid}.tmp`; + try { + fs.writeFileSync(tmpPath, content, 'utf8'); + fs.renameSync(tmpPath, pkgPath); + } catch (e) { + fs.rmSync(tmpPath, {force: true}); + warn( + `Could not record 'swiftpmConfig.name' in ${pkgPath}: ${e.message}. The manifest is written; the name is not.`, + ); + return 'failed'; + } + return outcome; +} + +module.exports = { + readSwiftpmConfig, + stringList, + writeSwiftpmName, +}; diff --git a/packages/rn-tester/package.json b/packages/rn-tester/package.json index 16c3e22529f5..0373d7f79395 100644 --- a/packages/rn-tester/package.json +++ b/packages/rn-tester/package.json @@ -61,5 +61,32 @@ "commander": "^12.0.0", "listr2": "^6.4.1", "rxjs": "npm:@react-native-community/rxjs@6.5.4-custom" + }, + "swiftpmConfig": { + "modules": [ + { + "name": "ReactCommonSamples", + "path": "../react-native/ReactCommon/react/nativemodule/samples/platform/ios" + }, + { + "name": "ReactRCTPushNotification", + "path": "../react-native/Libraries/PushNotificationIOS", + "exclude": [ + "React-RCTPushNotification.podspec" + ] + }, + { + "name": "ScreenshotManager", + "path": "NativeModuleExample" + }, + { + "name": "MyNativeView", + "path": "NativeComponentExample/ios" + }, + { + "name": "NativeCxxModuleExample", + "path": "NativeCxxModuleExample" + } + ] } } diff --git a/packages/rn-tester/react-native.config.js b/packages/rn-tester/react-native.config.js index 7d9058c4686d..7b54b0d7d5cc 100644 --- a/packages/rn-tester/react-native.config.js +++ b/packages/rn-tester/react-native.config.js @@ -29,31 +29,4 @@ module.exports = { packageName: 'com.facebook.react.uiapp', }, }, - // SPM-only: local native modules not discoverable via autolinking.json. - // These are pods added directly in Podfile for rn-tester examples. - spm: { - modules: [ - { - name: 'ReactCommonSamples', - path: '../react-native/ReactCommon/react/nativemodule/samples/platform/ios', - }, - { - name: 'ReactRCTPushNotification', - path: '../react-native/Libraries/PushNotificationIOS', - exclude: ['React-RCTPushNotification.podspec'], - }, - { - name: 'ScreenshotManager', - path: 'NativeModuleExample', - }, - { - name: 'MyNativeView', - path: 'NativeComponentExample/ios', - }, - { - name: 'NativeCxxModuleExample', - path: 'NativeCxxModuleExample', - }, - ], - }, };