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 49e91fdd640..f018a8a8b51 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 @@ -34,16 +34,25 @@ */ const { + SpmNameCollisionError, expandSpmDependencies, + isValidSwiftName, resolveSwiftName, } = require('../expand-spm-dependencies'); -const {toSwiftName} = require('../spm-utils'); +const { + REACT_HEADERS_TARGET_DIR, + RESERVED_SWIFT_NAMES, + toSwiftName, +} = require('../spm-utils'); function makeReadConfig(configs /*: {[string]: ?Object} */) { return (root /*: string */) => 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(); + function makeResolveDep(resolutions /*: {[string]: ?string} */) { return (name /*: string */) => Object.prototype.hasOwnProperty.call(resolutions, name) @@ -326,6 +335,22 @@ describe('expandSpmDependencies', () => { ).toThrow(/ReactNativeWorklets/); }); + it('throws SpmNameCollisionError on a dep-vs-dep collision too', () => { + const direct = [ + {name: 'react-native-worklets', root: '/w', platforms: {ios: {}}}, + {name: 'other-package', root: '/o', platforms: {ios: {}}}, + ]; + expect(() => + expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/w': {}, + '/o': {spm: {name: 'ReactNativeWorklets'}}, + }), + resolveDep: makeResolveDep({}), + }), + ).toThrow(SpmNameCollisionError); + }); + it('throws on a CASE-INSENSITIVE swiftName collision (worklets vs Worklets)', () => { // Distinct as exact strings, but collide as directories on the default // case-insensitive macOS filesystem. @@ -365,29 +390,459 @@ describe('expandSpmDependencies', () => { }); it('rejects spm.name with disallowed characters (spaces, slashes, dots)', () => { - expect(() => resolveSwiftName('a', {spm: {name: 'foo bar'}})).toThrow( - /invalid 'spm.name'/, + 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('accepts lowercase-with-hyphen and CamelCase spm.name values', () => { + const resolve = name => resolveSwiftName('a', {spm: {name}}, NONE); + expect(resolve('reanimated')).toBe('reanimated'); + expect(resolve('hermes-engine')).toBe('hermes-engine'); + expect(resolve('RNWorklets')).toBe('RNWorklets'); + expect(resolve('react_native_foo')).toBe('react_native_foo'); + }); +}); + +// --------------------------------------------------------------------------- +// Scope disambiguation: a derived name that lands on one React Native reserves. +// --------------------------------------------------------------------------- + +describe('expandSpmDependencies (scope disambiguation)', () => { + function expand(direct, configs, options) { + return expandSpmDependencies(direct, { + readConfig: makeReadConfig(configs), + resolveDep: makeResolveDep({}), + ...options, + }); + } + + it('prepends the scope when the derived name is reserved', () => { + const [dep] = expand( + [{name: '@powersync/react-native', root: '/ps', platforms: {ios: {}}}], + {'/ps': {}}, ); - expect(() => resolveSwiftName('a', {spm: {name: 'foo/bar'}})).toThrow( - /invalid 'spm.name'/, + expect(dep.swiftName).toBe('PowersyncReactNative'); + }); + + 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(() => resolveSwiftName('a', {spm: {name: 'foo.bar'}})).toThrow( - /invalid 'spm.name'/, + 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('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('accepts lowercase-with-hyphen and CamelCase spm.name values', () => { - expect(resolveSwiftName('a', {spm: {name: 'reanimated'}})).toBe( - 'reanimated', + 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('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('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'}), + }, + ); + expect(result.map(d => d.swiftName)).toEqual(['Top', 'ScopeReactNative']); + }); + + 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(resolveSwiftName('a', {spm: {name: 'hermes-engine'}})).toBe( - 'hermes-engine', + expect(dep.swiftName).toBe('AcmeMyFork'); + }); + + 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(resolveSwiftName('a', {spm: {name: 'RNWorklets'}})).toBe( - 'RNWorklets', + expect(dep.swiftName).toBe('PowerSync'); + expect(log).not.toHaveBeenCalled(); + }); + + 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('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(resolveSwiftName('a', {spm: {name: 'react_native_foo'}})).toBe( - 'react_native_foo', + expect(result.map(d => d.swiftName)).toEqual([ + 'AReactNative', + 'BReactNative', + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Scope disambiguation across deps: two libraries deriving one name. +// --------------------------------------------------------------------------- + +describe('expandSpmDependencies (scope disambiguation across deps)', () => { + function expand(direct, configs, options) { + return expandSpmDependencies(direct, { + readConfig: makeReadConfig(configs), + resolveDep: makeResolveDep({}), + ...options, + }); + } + + const scoped = (name, root) => ({name, root, platforms: {ios: {}}}); + + 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('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("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('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('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': {}}, ); + 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': {}}, + ); + expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'BFoo', 'Foo']); + }); + + 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. + const run = () => + expand( + [ + scoped('@a/foo', '/a'), + scoped('@b/foo', '/b'), + scoped('a-foo', '/af'), + ], + {'/a': {}, '/b': {}, '/af': {}}, + ); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow(/both resolve to 'AFoo'/); + }); + + 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': {}, + }); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow(/React Native reserves/); + }); + + it('still throws for two unscoped deps deriving the same name', () => { + const run = () => + expand( + [scoped('react-native-foo', '/a'), scoped('react_native_foo', '/b')], + {'/a': {}, '/b': {}}, + ); + 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', + ]); + }); + + 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']); + }); +}); + +// --------------------------------------------------------------------------- +// Reserved React Native names — the backstop for what a scope cannot resolve. +// --------------------------------------------------------------------------- + +describe('expandSpmDependencies (reserved React Native names)', () => { + function expand(direct, configs, options) { + return expandSpmDependencies(direct, { + readConfig: makeReadConfig(configs), + resolveDep: makeResolveDep({}), + ...options, + }); + } + + it('throws when an unscoped dep auto-derives a reserved product name', () => { + const run = () => + expand([{name: 'react-headers', root: '/rh', platforms: {ios: {}}}], { + '/rh': {}, + }); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow( + /'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\./, + ); + }); + + it('throws when an explicit spm.name override lands on a reserved name', () => { + expect(() => + expand([{name: 'some-lib', root: '/s', platforms: {ios: {}}}], { + '/s': {spm: {name: 'ReactAppHeaders'}}, + }), + ).toThrow( + /'some-lib' resolves to 'ReactAppHeaders', which React Native reserves/, + ); + }); + + it('throws when a transitive dep lands on a reserved name', () => { + expect(() => + expandSpmDependencies( + [{name: 'top', root: '/top', platforms: {ios: {}}}], + { + readConfig: makeReadConfig({ + '/top': {spm: {dependencies: ['react-native-headers']}}, + '/rnh': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({'react-native-headers': '/rnh'}), + }, + ), + ).toThrow( + /'react-native-headers' resolves to 'ReactNativeHeaders', which React Native reserves/, + ); + }); + + it('reserves the caller-supplied extraReservedNames (remote package identity)', () => { + const direct = [{name: 'my-fork', root: '/f', platforms: {ios: {}}}]; + expect(() => + expand(direct, {'/f': {}}, {extraReservedNames: ['MyFork']}), + ).toThrow(/'my-fork' resolves to 'MyFork', which React Native reserves/); + }); + + it('accepts that same name when no extraReservedNames are supplied', () => { + const [dep] = expand( + [{name: 'my-fork', root: '/f', platforms: {ios: {}}}], + { + '/f': {}, + }, + ); + expect(dep.swiftName).toBe('MyFork'); + }); + + it('leaves a non-colliding dep untouched', () => { + const [dep] = expand( + [{name: 'react-native-worklets', root: '/w', platforms: {ios: {}}}], + {'/w': {spm: {name: 'worklets'}}}, + {extraReservedNames: ['SomeRemoteIdentity']}, + ); + expect(dep.swiftName).toBe('worklets'); + }); + + it('reports the reserved-name diagnosis in preference to the dep-vs-dep one', () => { + // Both unscoped deps derive 'ReactNative', so neither can borrow a scope. + expect(() => + expand( + [ + {name: 'react-native', root: '/a', platforms: {ios: {}}}, + {name: 'react_native', root: '/b', platforms: {ios: {}}}, + ], + {'/a': {}, '/b': {}}, + ), + ).toThrow(/React Native reserves/); + }); + + it('rejects every name in RESERVED_SWIFT_NAMES', () => { + expect(RESERVED_SWIFT_NAMES.length).toBeGreaterThan(0); + for (const reserved of RESERVED_SWIFT_NAMES) { + expect(() => + expand([{name: 'some-lib', root: '/s', platforms: {ios: {}}}], { + '/s': {spm: {name: reserved}}, + }), + ).toThrow(/React Native reserves/); + } + }); + + it('rejects the autolinking aggregator package name', () => { + expect(() => + expand([{name: 'autolinked', root: '/a', platforms: {ios: {}}}], { + '/a': {}, + }), + ).toThrow( + /'autolinked' resolves to 'Autolinked', which React Native reserves/, + ); + }); + + it('accepts the React headers TARGET dir name — it is not a package or product, so nothing collides', () => { + const [dep] = expand( + [{name: 'some-lib', root: '/s', platforms: {ios: {}}}], + { + '/s': {spm: {name: REACT_HEADERS_TARGET_DIR}}, + }, + ); + expect(dep.swiftName).toBe(REACT_HEADERS_TARGET_DIR); + }); + + it('reports a case-only match against a reserved name, naming both spellings', () => { + const run = () => + expand([{name: 'some-lib', root: '/s', platforms: {ios: {}}}], { + '/s': {spm: {name: 'reactnative'}}, + }); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow( + /'some-lib' resolves to 'reactnative', which differs from React Native's reserved 'ReactNative' only in case/, + ); + expect(run).toThrow(/spm\.name/); + }); +}); + +// --------------------------------------------------------------------------- +// 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); + }, + ); }); 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 39939562e15..ea32e11dbc0 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 @@ -10,9 +10,11 @@ 'use strict'; +const {SpmNameCollisionError} = require('../expand-spm-dependencies'); const { AUTOGEN_MARKER, MissingManifestError, + autolinkingDepToSpmTarget, collectSpmSources, expandSpmSourceGlobs, findSelfManagedPackageDir, @@ -1082,7 +1084,7 @@ describe('main() — autolinking plugin host exemption', () => { // 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}) { + function buildFixture({withPlugin, depName = 'expo'}) { 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 @@ -1095,7 +1097,7 @@ describe('main() — autolinking plugin host exemption', () => { JSON.stringify({name: 'app'}), ); // The plugin-host dep: native sources present, but NO Package.swift. - const expoDir = path.join(appRoot, 'node_modules', 'expo'); + const expoDir = path.join(appRoot, 'node_modules', ...depName.split('/')); fs.mkdirSync(path.join(expoDir, 'ios'), {recursive: true}); fs.writeFileSync( path.join(expoDir, 'ios', 'Expo.mm'), @@ -1121,7 +1123,9 @@ describe('main() — autolinking plugin host exemption', () => { fs.writeFileSync( path.join(autolinkDir, 'autolinking.json'), JSON.stringify({ - dependencies: {expo: {root: expoDir, platforms: {ios: {}}}}, + dependencies: { + [depName]: {root: expoDir, platforms: {ios: {}}}, + }, }), ); return {appRoot, rnRoot}; @@ -1154,6 +1158,25 @@ describe('main() — autolinking plugin host exemption', () => { main(['--app-root', appRoot, '--react-native-root', rnRoot]), ).toThrow(MissingManifestError); }); + + // `spm scaffold` has no plugin knowledge, so exempting the autolinker alone + // left the two commands disagreeing about the same dep. + it.each([[true], [false]])( + 'rejects a dep deriving a reserved name whether or not it ships a plugin (withPlugin=%s)', + withPlugin => { + // 'react-headers' derives the reserved 'ReactHeaders', with no scope. + const {appRoot, rnRoot} = buildFixture({ + withPlugin, + depName: 'react-headers', + }); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(SpmNameCollisionError); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(/'react-headers'.*React Native reserves/s); + }, + ); }); // --------------------------------------------------------------------------- @@ -1532,3 +1555,219 @@ describe('main() — .spm-sync-watch-paths emission', () => { expect([...lines].sort()).toEqual(lines); }); }); + +// --------------------------------------------------------------------------- +// main() — scope disambiguation: the borrowed name 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 = []; + }); + + // Each dep ships a Package.swift, so it reaches the aggregator as self-managed. + function buildFixture(...depNames) { + const appRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scope-disambig-')), + ); + created.push(appRoot); + const rnRoot = path.join(appRoot, 'rn'); + fs.mkdirSync(rnRoot, {recursive: true}); + fs.writeFileSync( + path.join(appRoot, 'package.json'), + JSON.stringify({name: 'app'}), + ); + const dependencies = {}; + for (const depName of depNames) { + const depDir = path.join(appRoot, 'node_modules', ...depName.split('/')); + fs.mkdirSync(path.join(depDir, 'ios'), {recursive: true}); + 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 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}; + } + + it('emits the disambiguated name as the package ref, the product ref and the header slice', () => { + const {appRoot, rnRoot} = buildFixture('@powersync/react-native'); + 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"'); + + // 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, + ); + 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); + }); + + 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}")`); + expect( + fs.existsSync(path.join(outDir, `headers/${name}/ios/Lib.h`)), + ).toBe(true); + } + 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); + }); +}); + +// --------------------------------------------------------------------------- +// autolinkingDepToSpmTarget — resolved names only, never re-derived ones. +// --------------------------------------------------------------------------- + +describe('autolinkingDepToSpmTarget', () => { + const dep = (extra = {}) => ({ + name: '@powersync/react-native', + root: '/dep', + platforms: {ios: {sourceDir: '/dep/ios'}}, + ...extra, + }); + + it('carries a resolved sibling name into the emitted sibling refs', () => { + const target = autolinkingDepToSpmTarget( + 'react-native-consumer', + { + name: 'react-native-consumer', + root: '/consumer', + platforms: {ios: {sourceDir: '/consumer/ios'}}, + swiftName: 'ReactNativeConsumer', + spmDependencies: ['@powersync/react-native'], + }, + '/out', + new Map([['@powersync/react-native', 'PowersyncReactNative']]), + ); + const manifest = generateSynthPackageSwift({ + swiftName: target.name, + spmDependencies: (target.spmTargetDependencies ?? []).map(swiftName => ({ + swiftName, + })), + hasReactDep: false, + targetPath: '.', + }); + expect(manifest).toContain( + '.package(name: "PowersyncReactNative", path: "../PowersyncReactNative")', + ); + expect(manifest).toContain( + '.product(name: "PowersyncReactNative", package: "PowersyncReactNative")', + ); + expect(manifest).not.toContain('ReactNative", package: "ReactNative"'); + }); + + it('fails loudly instead of re-deriving a dep with no resolved name', () => { + expect(() => + autolinkingDepToSpmTarget( + '@powersync/react-native', + dep(), + '/out', + new Map(), + ), + ).toThrow(/expandSpmDependencies/); + }); + + it('fails loudly instead of re-deriving an unmapped spm.dependency', () => { + expect(() => + autolinkingDepToSpmTarget( + 'react-native-consumer', + { + name: 'react-native-consumer', + root: '/consumer', + platforms: {ios: {sourceDir: '/consumer/ios'}}, + swiftName: 'ReactNativeConsumer', + spmDependencies: ['@powersync/react-native'], + }, + '/out', + new Map(), + ), + ).toThrow(/@powersync\/react-native/); + expect(() => + autolinkingDepToSpmTarget( + 'react-native-consumer', + { + name: 'react-native-consumer', + root: '/consumer', + platforms: {ios: {sourceDir: '/consumer/ios'}}, + swiftName: 'ReactNativeConsumer', + spmDependencies: ['@powersync/react-native'], + }, + '/out', + new Map(), + ), + ).toThrow(/expandSpmDependencies/); + }); +}); 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 e0e72b3c4b3..88c40e68ddc 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 @@ -18,6 +18,7 @@ const { scaffoldPackageSwiftForDep, translatePodspecToSpmTarget, } = require('../scaffold-package-swift'); +const {RemoteVersionError} = require('../spm-utils'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); @@ -1017,6 +1018,130 @@ describe('scaffoldAll', () => { 'skipped-no-podspec', ); }); + + function writeAutolinkingJson(dependencies) { + const autolinkingDir = path.join(appRoot, 'build/generated/autolinking'); + fs.mkdirSync(autolinkingDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkingDir, 'autolinking.json'), + JSON.stringify({dependencies}), + ); + } + + 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. + 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", + ); + writeAutolinkingJson({ + 'react-headers': {root: depRoot, platforms: {ios: {}}}, + }); + expect(() => + scaffoldAll({appRoot, projectRoot: appRoot, reactNativeRoot: appRoot}), + ).toThrow(/React Native reserves/); + }); + + it('still falls back to the direct deps when a transitive dep cannot be resolved', () => { + const depRoot = path.join(appRoot, 'node_modules', 'react-native-a'); + fs.mkdirSync(depRoot, {recursive: true}); + fs.writeFileSync( + path.join(depRoot, 'react-native.config.js'), + "module.exports = {spm: {dependencies: ['ghost-dep-that-is-not-installed']}};\n", + ); + writeAutolinkingJson({ + 'react-native-a': {root: depRoot, platforms: {ios: {}}}, + }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + try { + const results = scaffoldAll({ + appRoot, + projectRoot: appRoot, + reactNativeRoot: appRoot, + }); + 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/, + ); + } finally { + logSpy.mockRestore(); + } + }); + + it('emits the remote package reference for every dep it scaffolds', () => { + const depRoot = path.join(appRoot, 'node_modules', 'react-native-foo'); + fs.mkdirSync(path.join(depRoot, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(depRoot, 'ios', 'Foo.mm'), '// native\n'); + fs.writeFileSync( + path.join(depRoot, 'react-native-foo.podspec'), + 'Pod::Spec.new do |s|\n' + + ' s.name = "react-native-foo"\n' + + ' s.version = "1.0"\n' + + ' s.source_files = "ios/**/*.{h,m,mm}"\n' + + ' s.dependency "React-Core"\n' + + 'end\n', + ); + writeAutolinkingJson({ + 'react-native-foo': {root: depRoot, platforms: {ios: {}}}, + }); + const prevUrl = process.env.RN_SPM_REMOTE_URL; + const prevVersion = process.env.RN_SPM_REMOTE_VERSION; + process.env.RN_SPM_REMOTE_URL = 'https://example.com/rn.git'; + process.env.RN_SPM_REMOTE_VERSION = '9.9.9'; + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + try { + const results = scaffoldAll({ + appRoot, + projectRoot: appRoot, + reactNativeRoot: appRoot, + }); + expect(results.map(r => r.status)).toEqual(['written']); + const manifest = fs.readFileSync( + path.join(depRoot, 'Package.swift'), + 'utf8', + ); + expect(manifest).toContain( + '.package(url: "https://example.com/rn.git", exact: "9.9.9")', + ); + expect(manifest).not.toContain('.package(name: "ReactNative"'); + } finally { + logSpy.mockRestore(); + if (prevUrl == null) delete process.env.RN_SPM_REMOTE_URL; + else process.env.RN_SPM_REMOTE_URL = prevUrl; + if (prevVersion == null) delete process.env.RN_SPM_REMOTE_VERSION; + else process.env.RN_SPM_REMOTE_VERSION = prevVersion; + } + }); + + it('propagates a RemoteVersionError from the remote package config', () => { + writeAutolinkingJson({ + 'react-native-a': {root: '/no/such/a', platforms: {ios: {}}}, + }); + const prevUrl = process.env.RN_SPM_REMOTE_URL; + const prevVersion = process.env.RN_SPM_REMOTE_VERSION; + process.env.RN_SPM_REMOTE_URL = 'https://example.com/react-native-spm.git'; + delete process.env.RN_SPM_REMOTE_VERSION; + try { + // No react-native under the temp appRoot, so no version resolves — the + // author must see that, not have it degraded into "expansion failed". + expect(() => + scaffoldAll({appRoot, projectRoot: appRoot, reactNativeRoot: appRoot}), + ).toThrow(RemoteVersionError); + } finally { + if (prevUrl == null) { + delete process.env.RN_SPM_REMOTE_URL; + } else { + process.env.RN_SPM_REMOTE_URL = prevUrl; + } + if (prevVersion != null) { + process.env.RN_SPM_REMOTE_VERSION = prevVersion; + } + } + }); }); // --------------------------------------------------------------------------- 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 f50bf4c2f9d..dc3d7a8e071 100644 --- a/packages/react-native/scripts/spm/__tests__/spm-utils-test.js +++ b/packages/react-native/scripts/spm/__tests__/spm-utils-test.js @@ -22,6 +22,7 @@ const { REACT_NATIVE_UMBRELLA_PRODUCT, REACT_NATIVE_XCFRAMEWORK_PRODUCTS, RemoteVersionError, + RESERVED_SWIFT_NAMES, buildPerAppHeaderTree, defaultCacheDir, displayPath, @@ -57,11 +58,10 @@ describe('toSwiftName', () => { }); // --------------------------------------------------------------------------- -// Name constants — the single list every generated manifest derives its -// package and product names from +// Reserved Swift names — the one list the manifests and the guard both use // --------------------------------------------------------------------------- -describe('name constants', () => { +describe('reserved Swift names', () => { it('names the React Native package and the per-app codegen package', () => { expect(REACT_NATIVE_PACKAGE_NAME).toBe('ReactNative'); expect(REACT_CODEGEN_PACKAGE_NAME).toBe('React-GeneratedCode'); @@ -97,11 +97,42 @@ describe('name constants', () => { expect(REACT_HEADERS_TARGET_DIR).toBe('ReactHeadersTarget'); }); + // The one test that pins the literal strings: every other check compares + // constants to constants, so this is what would catch a rename. + it('reserves exactly the names React Native puts in a manifest', () => { + expect([...RESERVED_SWIFT_NAMES].sort()).toEqual([ + 'Autolinked', + 'React-GeneratedCode', + 'ReactAppDependencyProvider', + 'ReactAppHeaders', + 'ReactCodegen', + 'ReactHeaders', + 'ReactNative', + 'ReactNativeDependenciesHeaders', + 'ReactNativeHeaders', + ]); + }); + + it('holds names that real generated manifests actually use', () => { + const { + generateXCFrameworksPackageSwift, + } = require('../generate-spm-package'); + const manifest = generateXCFrameworksPackageSwift(); + for (const name of [REACT_NATIVE_PACKAGE_NAME, ...REACT_NATIVE_PRODUCTS]) { + expect(manifest).toContain(`"${name}"`); + } + }); + + it('does NOT reserve the headers target dir — target names only have to be unique within their own package', () => { + expect(RESERVED_SWIFT_NAMES).not.toContain(REACT_HEADERS_TARGET_DIR); + }); + it('freezes the lists so no caller can mutate the shared source of truth', () => { for (const list of [ REACT_NATIVE_PRODUCTS, REACT_CODEGEN_PRODUCTS, REACT_CODEGEN_APP_PRODUCTS, + RESERVED_SWIFT_NAMES, ]) { expect(Array.isArray(list)).toBe(true); expect(Object.isFrozen(list)).toBe(true); diff --git a/packages/react-native/scripts/spm/expand-spm-dependencies.js b/packages/react-native/scripts/spm/expand-spm-dependencies.js index 4b529c08afa..dead1f9bb11 100644 --- a/packages/react-native/scripts/spm/expand-spm-dependencies.js +++ b/packages/react-native/scripts/spm/expand-spm-dependencies.js @@ -10,7 +10,7 @@ 'use strict'; -const {toSwiftName} = require('./spm-utils'); +const {RESERVED_SWIFT_NAMES, toSwiftName} = require('./spm-utils'); const fs = require('node:fs'); const path = require('node:path'); @@ -32,7 +32,7 @@ const path = require('node:path'); * 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) so the logic stays pure and + * I/O is injected (readConfig, resolveDep, log) so the logic stays pure and * testable. */ @@ -44,52 +44,231 @@ import type {AutolinkedDep} from './spm-types'; 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. +type ReservedNames = ReadonlyMap; type Options = { readConfig: ReadConfig, resolveDep: ResolveDep, + // 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, }; */ -// Validates and returns the Swift target name for a dep. Falls back to -// toSwiftName(npmName) when no override is set. The override is the dep's -// `react-native.config.js` `spm.name`, intended for libraries whose import -// prefix differs from the auto-derived name (e.g. `react-native-worklets` -// publishes headers under `` via the podspec `s.header_dir`, -// so the SPM target name should be `worklets`, not `ReactNativeWorklets`). +/** + * A misconfiguration rather than a resolution failure: scaffoldAll degrades past + * a transitive dep it cannot find, but must still surface this. + */ +class SpmNameCollisionError extends Error { + constructor(message /*: string */) { + super(message); + this.name = 'SpmNameCollisionError'; + } +} + +// 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. +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(), + name, + ]), + ); +} + +// The scope-borrowed form of a name: `@powersync/react-native`'s `ReactNative` +// becomes `PowersyncReactNative`. +function scopeBorrowedName( + npmName /*: string */, + swiftName /*: string */, +) /*: ?string */ { + const scope = /^@([^/]+)\//.exec(npmName)?.[1]; + return scope == null ? null : `${toSwiftName(scope)}${swiftName}`; +} + +// 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. 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) { - return toSwiftName(npmName); + if (override != null) { + if (typeof override !== 'string' || override.length === 0) { + throw new Error( + `react-native autolinking: '${npmName}' has an invalid 'spm.name' override: expected a non-empty string, got ${JSON.stringify(override)}.`, + ); + } + if (!isValidSwiftName(override)) { + 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.`, + ); + } + return override; + } + + const derived = toSwiftName(npmName); + if (!reserved.has(derived.toLowerCase())) { + return derived; + } + const disambiguated = scopeBorrowedName(npmName, derived); + if (disambiguated == null || reserved.has(disambiguated.toLowerCase())) { + return derived; + } + 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; +} + +function assertNameNotReserved( + swiftName /*: string */, + reserved /*: ReservedNames */, + labels /*: {label: string, remedy: string} */, +) /*: void */ { + const reservedName = reserved.get(swiftName.toLowerCase()); + 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.`) + + ` ${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. + */ +function assertSwiftNameNotReserved( + swiftName /*: string */, + options /*: { + label: string, + remedy: string, + extraReservedNames?: ?ReadonlyArray, + } */, +) /*: void */ { + const {label, remedy, extraReservedNames} = options; + assertNameNotReserved(swiftName, reservedSwiftNames(extraReservedNames), { + label, + remedy, + }); +} + +// Reserved-name backstop over the resolved set. Unconditional: a plugin-shipping +// library is checked like any other, so `spm scaffold` — which knows nothing +// about plugins — cannot disagree with the autolinker about the same dep. +function assertNoReservedSwiftNames( + deps /*: ReadonlyArray */, + reserved /*: ReservedNames */, +) /*: void */ { + for (const dep of deps) { + const swiftName = dep.swiftName; + if (swiftName == null) { + continue; + } + assertNameNotReserved(swiftName, reserved, { + label: `'${dep.name}'`, + remedy: `Set a different 'spm.name' in ${dep.name}'s react-native.config.js.`, + }); } - if (typeof override !== 'string' || override.length === 0) { - throw new Error( - `react-native autolinking: '${npmName}' has an invalid 'spm.name' override: expected a non-empty string, got ${JSON.stringify(override)}.`, - ); +} + +// 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}); + } } - // Accept Swift-identifier style (TitleCase / snake_case) and header-dir - // style (lowercase, optional hyphens). Reject whitespace, slashes, and - // other characters that would break SPM target / module identifiers. - if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(override)) { - 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.`, - ); + + 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; + } } - return override; } function expandSpmDependencies( directDeps /*: Array */, options /*: Options */, ) /*: Array */ { - const {readConfig, resolveDep} = options; + const {readConfig, resolveDep, extraReservedNames, log} = options; + 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); + } + return resolveSwiftName(npmName, config, reserved, log); + }; const queue /*: Array */ = directDeps.map(d => d.name); while (queue.length > 0) { @@ -105,7 +284,7 @@ function expandSpmDependencies( // Resolve swiftName lazily from the same config read we already need for // spm.dependencies — saves a duplicate readConfig call per direct dep. if (current.swiftName == null) { - current.swiftName = resolveSwiftName(currentName, config); + current.swiftName = resolveName(currentName, config); } // $FlowFixMe[prop-missing] config has dynamic shape const transitives /*: Array */ = config?.spm?.dependencies ?? []; @@ -134,7 +313,7 @@ function expandSpmDependencies( name: transitiveName, root: transitiveRoot, platforms: {ios: iosPlatform}, - swiftName: resolveSwiftName(transitiveName, transitiveConfig), + swiftName: resolveName(transitiveName, transitiveConfig), spmDependencies: [], }); queue.push(transitiveName); @@ -144,6 +323,14 @@ function expandSpmDependencies( current.spmDependencies = currentSpmDeps; } + 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 @@ -154,7 +341,7 @@ function expandSpmDependencies( // passes but the two still collide as directories on the default // case-insensitive macOS filesystem (synth package layout + headers tree). const seen /*: Map */ = new Map(); - for (const dep of byName.values()) { + for (const dep of allDeps) { const swiftName = dep.swiftName; if (swiftName == null) { continue; @@ -163,7 +350,7 @@ function expandSpmDependencies( const existing = seen.get(key); if (existing != null) { const same = existing.swiftName === swiftName; - throw new Error( + throw new SpmNameCollisionError( `react-native autolinking: SPM Swift name collision: '${existing.name}' ('${existing.swiftName}') and '${dep.name}' ('${swiftName}') ` + (same ? `both resolve to '${swiftName}'.` @@ -174,7 +361,7 @@ function expandSpmDependencies( seen.set(key, {name: dep.name, swiftName}); } - return Array.from(byName.values()); + return allDeps; } // --------------------------------------------------------------------------- @@ -209,7 +396,10 @@ function defaultResolveDep( } module.exports = { + SpmNameCollisionError, + assertSwiftNameNotReserved, expandSpmDependencies, + isValidSwiftName, resolveSwiftName, defaultReadConfig, defaultResolveDep, diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking.js b/packages/react-native/scripts/spm/generate-spm-autolinking.js index cf2932731a4..3cdee1d2645 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -74,7 +74,6 @@ const { findProjectRoot, makeLogger, remotePackageConfig, - toSwiftName, } = require('./spm-utils'); const fs = require('node:fs'); const path = require('node:path'); @@ -97,6 +96,11 @@ let remoteCfg /*: ?{url: string, version: string, identity: string} */ = null; function reactNativePackageLabel() /*: string */ { return remoteCfg != null ? remoteCfg.identity : REACT_NATIVE_PACKAGE_NAME; } +// In remote mode the RN package is labelled with the remote identity, so that +// name is reserved for this run too. +function reservedNamesForRun() /*: ?Array */ { + return remoteCfg != null ? [remoteCfg.identity] : undefined; +} function reactNativePackageDecl(localDecl /*: string */) /*: string */ { return remoteCfg != null ? `.package(url: "${remoteCfg.url}", exact: "${remoteCfg.version}")` @@ -737,9 +741,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, possibly overridden via the dep's - * `spm.name` config). Optional for backwards compatibility with callers that - * don't have the map; falls back to `toSwiftName(name)` per entry. + * name (populated by expandSpmDependencies, honoring the dep's `spm.name` + * config and scope disambiguation). Every name this function emits comes from + * there — see requireSwiftName. */ /** * Read the dep's podspec (if any) and extract its declared @@ -796,11 +800,27 @@ function extractPodspecHeaderSearchPaths( return out; } +/** + * The Swift name expandSpmDependencies resolved for `npmName`, or a hard error: + * re-deriving one here would emit a reference nothing in the graph matches. + */ +function requireSwiftName( + npmName /*: string */, + resolved /*: ?string */, +) /*: string */ { + if (resolved == null) { + throw new Error( + `react-native autolinking: no resolved Swift name for '${npmName}'. expandSpmDependencies must resolve every autolinked dep's name before SPM targets are generated.`, + ); + } + return resolved; +} + function autolinkingDepToSpmTarget( depName /*: string */, dep /*: AutolinkedDep */, outputDir /*: string */, - swiftNameByNpm /*: ?Map */, + swiftNameByNpm /*: Map */, ) /*: SpmTarget | null */ { const iosPlatform = dep.platforms.ios; const sourceDir = iosPlatform.sourceDir ?? dep.root; @@ -813,10 +833,7 @@ function autolinkingDepToSpmTarget( // same convention the spmModule branch in main() follows. const relSourcePath = path.relative(outputDir, sourceDir); - // Prefer the resolved Swift name (which honors `spm.name` overrides set in - // the dep's react-native.config.js). Fall back to toSwiftName(depName) when - // the caller didn't run expandSpmDependencies. - const targetName = dep.swiftName ?? toSwiftName(depName); + const targetName = requireSwiftName(depName, dep.swiftName); // No exclude inference — main()'s emission loop emits `sources:` (an // explicit allowlist). User-supplied excludes still work. @@ -826,13 +843,11 @@ function autolinkingDepToSpmTarget( const resources = privacyManifest != null ? [privacyManifest] : undefined; // Map declared spm.dependencies (npm names) to Swift target names so the - // synth's .product(...) deps list reaches the consuming target. Each - // transitive npm name's Swift name comes from the map (honoring overrides); - // toSwiftName fallback handles entries the map doesn't know about. + // synth's .product(...) deps list reaches the consuming target. const spmDeps /*: Array */ = dep.spmDependencies ?? []; const spmTargetDependencies = spmDeps.length > 0 - ? spmDeps.map(n => swiftNameByNpm?.get(n) ?? toSwiftName(n)) + ? spmDeps.map(n => requireSwiftName(n, swiftNameByNpm.get(n))) : undefined; const headerSearchPaths = extractPodspecHeaderSearchPaths(sourceDir); @@ -1260,11 +1275,13 @@ function main(argv /*:: ?: Array */) /*: void */ { const allDeps = expandSpmDependencies(directDeps, { readConfig: defaultReadConfig, resolveDep: defaultResolveDep, + extraReservedNames: reservedNamesForRun(), + log, }); - // Map every autolinked npm name to its resolved Swift name (post-override) - // so transitive references inside autolinkingDepToSpmTarget find the right - // target identifier — not just the auto-derived toSwiftName. + // Map every autolinked npm name to its resolved Swift name so transitive + // references inside autolinkingDepToSpmTarget find the right target + // identifier. const swiftNameByNpm /*: Map */ = new Map(); for (const dep of allDeps) { if (dep.swiftName != null) { @@ -1890,6 +1907,7 @@ if (require.main === module) { module.exports = { main, + autolinkingDepToSpmTarget, generateAutolinkedPackageSwift, generateSynthPackageSwift, reactDescriptor, diff --git a/packages/react-native/scripts/spm/scaffold-package-swift.js b/packages/react-native/scripts/spm/scaffold-package-swift.js index ce3103ebedd..09c740bd54a 100644 --- a/packages/react-native/scripts/spm/scaffold-package-swift.js +++ b/packages/react-native/scripts/spm/scaffold-package-swift.js @@ -36,6 +36,7 @@ import type { */ const { + SpmNameCollisionError, defaultReadConfig, defaultResolveDep, expandSpmDependencies, @@ -792,6 +793,7 @@ type ScaffoldContext = { // podspec-name → npm-name index over all autolinked deps, so pod-style // `s.dependency` names (e.g. "RNWorklets") wire to the right sibling. podToNpm?: Map, + remote: ?{url: string, version: string, identity: string}, }; */ @@ -1004,7 +1006,7 @@ function scaffoldPackageSwiftForDep( .join('/'); const content = emitScaffoldedPackageSwift(spec, { cacheSlotLabel: ctx.cacheSlotLabel, - remote: remotePackageConfig(ctx.appRoot), + remote: ctx.remote, codegenPackageDir: relFromManifest('build', 'generated', 'ios'), localXcfwPackageDir: relFromManifest('build', 'xcframeworks'), }); @@ -1131,13 +1133,22 @@ function scaffoldAll( directDeps.push({name, root, platforms: {ios: iosPlatform}}); } + // Outside the try: a malformed remote config (RemoteVersionError) is a + // misconfiguration to surface, not an expansion failure to degrade past. + const remote = remotePackageConfig(appRoot); + let allDeps /*: Array */ = []; try { allDeps = expandSpmDependencies(directDeps, { readConfig: defaultReadConfig, resolveDep: defaultResolveDep, + 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}`); @@ -1180,6 +1191,7 @@ function scaffoldAll( dryRun: opts.dryRun === true, cacheSlotLabel: opts.cacheSlotLabel ?? null, podToNpm, + remote, }; const skipSet /*: Set */ = new Set(opts.skipDeps ?? []); diff --git a/packages/react-native/scripts/spm/spm-utils.js b/packages/react-native/scripts/spm/spm-utils.js index c974b12b01f..b916e3dfe92 100644 --- a/packages/react-native/scripts/spm/spm-utils.js +++ b/packages/react-native/scripts/spm/spm-utils.js @@ -14,12 +14,13 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -// The package and product names React Native's own generated manifests use, -// in one place so the emitters in this directory cannot drift apart from each -// other. +// The package and product names React Native's own generated manifests use. A +// dependency whose Swift name lands on one of them surfaces as a duplicate-name +// error from inside SPM's resolution, far from the react-native.config.js that +// caused it, so the autolinker rejects the collision up front instead. // -// Adding a React Native SPM product also touches, depending on what the -// product is: +// These cover the emitters in this directory and the collision guard. Adding a +// React Native SPM product also touches, depending on what the product is: // - scripts/codegen/templates/Package.swift.spm-template — if the per-app // codegen package must depend on it (a static Swift file, patched by // string replacement, not generated from these constants) @@ -62,6 +63,17 @@ const REACT_CODEGEN_APP_PRODUCTS /*: ReadonlyArray */ = Object.freeze([ 'ReactAppDependencyProvider', ]); const REACT_HEADERS_TARGET_DIR /*: string */ = 'ReactHeadersTarget'; +// Target names only have to be unique within their own package, so +// REACT_HEADERS_TARGET_DIR is deliberately absent: a dependency named after it +// collides with nothing. +const RESERVED_SWIFT_NAMES /*: ReadonlyArray */ = Object.freeze([ + REACT_NATIVE_PACKAGE_NAME, + REACT_CODEGEN_PACKAGE_NAME, + AUTOLINKED_PACKAGE_NAME, + ...REACT_NATIVE_PRODUCTS, + ...REACT_CODEGEN_PRODUCTS, + ...REACT_CODEGEN_APP_PRODUCTS, +]); /** * Creates a logger trio {log, warn, die} that prefixes messages with [name]. @@ -725,6 +737,7 @@ module.exports = { REACT_CODEGEN_PRODUCTS, REACT_CODEGEN_APP_PRODUCTS, REACT_HEADERS_TARGET_DIR, + RESERVED_SWIFT_NAMES, makeLogger, displayPath, sharedCacheDir,