diff --git a/.github/workflows/build-templates.yml b/.github/workflows/build-templates.yml index 3559e61a7..de21ec823 100644 --- a/.github/workflows/build-templates.yml +++ b/.github/workflows/build-templates.yml @@ -54,9 +54,24 @@ jobs: type: name: library language: js + - os: macos-latest + type: + name: turbo-module + language: kotlin-objc + spm: true + - os: macos-latest + type: + name: turbo-module + language: cpp + spm: true + - os: macos-latest + type: + name: fabric-view + language: kotlin-objc + spm: true concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.os }}-${{ matrix.type.name }}-${{ matrix.type.language }} + group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.os }}-${{ matrix.type.name }}-${{ matrix.type.language }}${{ matrix.type.spm && '-spm' || '' }} cancel-in-progress: true runs-on: ${{ matrix.os }} @@ -75,7 +90,7 @@ jobs: - name: Get working directory shell: bash run: | - echo "work_dir=${{ matrix.os }}-${{ matrix.type.name }}-${{ matrix.type.language }}" >> $GITHUB_ENV + echo "work_dir=${{ matrix.os }}-${{ matrix.type.name }}-${{ matrix.type.language }}${{ matrix.type.spm && '-spm' || '' }}" >> $GITHUB_ENV - name: Create library shell: bash @@ -83,7 +98,6 @@ jobs: rm -rf ${{ env.work_dir }} # Workaround for tests failing intermittently args=( - --slug @bob/react-native-test --description test --author-name test --author-email test@test @@ -99,6 +113,17 @@ jobs: --tools release-it ) + if [[ "${{ matrix.type.spm }}" == "true" ]]; then + args+=( + --slug react-native-test + --experimental-spm + ) + else + args+=( + --slug @bob/react-native-test + ) + fi + if [[ ${{ github.event_name }} == 'schedule' ]]; then args+=( --react-native-version latest @@ -173,15 +198,27 @@ jobs: working-directory: ${{ env.work_dir }} run: yarn nitrogen + - name: Use appropriate Xcode version (SwiftPM) + if: env.ios_build == 1 && matrix.type.spm + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 + with: + xcode-version: ${{ env.XCODE_VERSION }} + + - name: Set up SwiftPM + if: env.ios_build == 1 && matrix.type.spm + working-directory: ${{ env.work_dir }} + run: | + yarn example react-native spm --deintegrate --yes + - name: Cache turborepo if: env.android_build == 1 || env.ios_build == 1 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ${{ env.work_dir }}/.turbo - key: ${{ runner.os }}-library-turborepo-${{ matrix.type.name }}-${{ matrix.type.language }}-${{ hashFiles(format('{0}/yarn.lock', env.work_dir)) }} + key: ${{ runner.os }}-library-turborepo-${{ matrix.type.name }}-${{ matrix.type.language }}${{ matrix.type.spm && '-spm' || '' }}-${{ hashFiles(format('{0}/yarn.lock', env.work_dir)) }} restore-keys: | - ${{ runner.os }}-library-turborepo-${{ matrix.type.name }}-${{ matrix.type.language }}- + ${{ runner.os }}-library-turborepo-${{ matrix.type.name }}-${{ matrix.type.language }}${{ matrix.type.spm && '-spm' || '' }}- - name: Check turborepo cache if: env.android_build == 1 || env.ios_build == 1 @@ -200,21 +237,25 @@ jobs: fi - name: Lint library + if: ${{ !matrix.type.spm }} working-directory: ${{ env.work_dir }} run: | yarn lint - name: Typecheck library + if: ${{ !matrix.type.spm }} working-directory: ${{ env.work_dir }} run: | yarn typecheck - name: Test library + if: ${{ !matrix.type.spm }} working-directory: ${{ env.work_dir }} run: | yarn test - name: Build library + if: ${{ !matrix.type.spm }} working-directory: ${{ env.work_dir }} run: | yarn prepare @@ -251,13 +292,13 @@ jobs: yarn turbo run build:android --cache-dir=".turbo" - name: Use appropriate Xcode version - if: env.ios_build == 1 && env.turbo_cache_hit_ios != 1 + if: env.ios_build == 1 && env.turbo_cache_hit_ios != 1 && !matrix.type.spm uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: ${{ env.XCODE_VERSION }} - name: Install cocoapods - if: env.ios_build == 1 && env.turbo_cache_hit_ios != 1 + if: env.ios_build == 1 && env.turbo_cache_hit_ios != 1 && !matrix.type.spm working-directory: ${{ env.work_dir }} run: | cd example @@ -271,7 +312,16 @@ jobs: run: | yarn turbo run build:ios --cache-dir=".turbo" + - name: Build example (iOS, Release) + if: env.ios_build == 1 && env.turbo_cache_hit_ios != 1 && matrix.type.spm + working-directory: ${{ env.work_dir }}/example/ios + run: | + # Release catches DEBUG/NDEBUG mismatches with the prebuilt React framework + project=$(ls -d *.xcodeproj) + xcodebuild -project "$project" -scheme "${project%.xcodeproj}" -configuration Release -sdk iphonesimulator -destination "generic/platform=iOS Simulator" build + - name: Build example (Web) + if: ${{ !matrix.type.spm }} working-directory: ${{ env.work_dir }} shell: bash run: | diff --git a/.gitignore b/.gitignore index c55006938..34e489ad8 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,6 @@ lib/ typescript/ doc_build/ *.tsbuildinfo + +# Swift Package Manager +.build/ diff --git a/docs/pages/create.md b/docs/pages/create.md index b00dcf210..fcbe0f464 100644 --- a/docs/pages/create.md +++ b/docs/pages/create.md @@ -84,6 +84,28 @@ By default, the generated library is automatically linked to the project using ` This creates a symlink to the library under `node_modules` which makes autolinking work. But if you're using a monorepo or have non-standard setup, you'll need to manually set up the package to be linked to your app based on your project setup. +## Swift Package Manager (experimental) + +To also generate a `Package.swift` for React Native's experimental [Swift Package Manager (SwiftPM) support](https://reactnative.dev/blog/2026/08/11/react-native-0.87#experimental-swift-package-manager-support-for-ios), pass `--experimental-spm`: + +```sh +npx create-react-native-library@latest awesome-library --experimental-spm +``` + +The podspec is still generated, so CocoaPods keeps working. The example app is set up for SwiftPM instead of CocoaPods. Run the following once in the root of the generated project to set it up: + +```sh +yarn example react-native spm --deintegrate +``` + +Then commit the changes to the example app's Xcode project, so that CI and fresh clones use SwiftPM. After a fresh clone, run `yarn example react-native spm` to regenerate the SwiftPM build files, since they aren't committed to the repository. + +Limitations: + +- Requires React Native 0.88 or newer. +- Nitro libraries aren't supported, since SwiftPM can't compile Swift and C++ sources in the same target. +- Expo and react-native-test-app examples can't use SwiftPM yet. + ## Writing native code Once the project is created, you can follow the official React Native docs to learn the API for writing native modules and components: diff --git a/packages/create-react-native-library/src/index.ts b/packages/create-react-native-library/src/index.ts index d3bb671bd..56354b184 100644 --- a/packages/create-react-native-library/src/index.ts +++ b/packages/create-react-native-library/src/index.ts @@ -11,7 +11,11 @@ import { alignDependencyVersionsWithExampleApp } from './exampleApp/dependencies import generateExampleApp from './exampleApp/generateExampleApp.ts'; import { printLocalLibNextSteps, printNonLocalLibNextSteps } from './inform.ts'; import { prompt } from './prompt.ts'; -import { applyTemplates, generateTemplateConfiguration } from './template.ts'; +import { + applyTemplates, + generateTemplateConfiguration, + getExperimentalSpmUnsupportedReason, +} from './template.ts'; import { assertNpxExists } from './utils/assert.ts'; import { configureTools } from './utils/configureTools.ts'; import { createMetadata } from './utils/createMetadata.ts'; @@ -67,6 +71,16 @@ async function create() { answers, }); + if (answers.experimentalSpm === true) { + const reason = getExperimentalSpmUnsupportedReason(answers); + + if (reason != null) { + console.warn( + `${kleur.yellow('⚠')} Ignoring --experimental-spm ${reason}` + ); + } + } + const folder = path.resolve(process.cwd(), answers.directory); await fs.mkdirp(folder); diff --git a/packages/create-react-native-library/src/inform.ts b/packages/create-react-native-library/src/inform.ts index bcc09b06f..f4d4cff85 100644 --- a/packages/create-react-native-library/src/inform.ts +++ b/packages/create-react-native-library/src/inform.ts @@ -18,7 +18,19 @@ export function printNonLocalLibNextSteps(config: TemplateConfiguration) { `${kleur.bold('Get started')} with the project` )}${kleur.gray(':')} - ${kleur.gray('$')} yarn + ${kleur.gray('$')} yarn${ + config.experimentalSpm + ? ` + + ${kleur.magenta( + `Set up ${kleur.bold('Swift Package Manager (SwiftPM)')} for the example app (React Native 0.88+)` + )}${kleur.gray(':')} + + ${kleur.gray('$')} yarn example react-native spm --deintegrate + + Then commit the changes, so CI and fresh clones use SwiftPM.` + : '' + } ${Object.entries(platforms) .map( ([script, { name, color }]) => ` @@ -80,9 +92,15 @@ export function printLocalLibNextSteps({ `cd ${path.relative(process.cwd(), folder)} && npx nitrogen` )} to generate the nitro code \n` : '') + - `- Run ${kleur.blue( - 'pod install --project-directory=ios' - )} to install dependencies with CocoaPods\n` + + (config.experimentalSpm + ? `- Run ${kleur.blue( + 'npx react-native spm' + )} to install dependencies with Swift Package Manager (SwiftPM), or ${kleur.blue( + 'pod install --project-directory=ios' + )} if the app uses CocoaPods\n` + : `- Run ${kleur.blue( + 'pod install --project-directory=ios' + )} to install dependencies with CocoaPods\n`) + `- Run ${kleur.blue('npx react-native run-android')} or ${kleur.blue( 'npx react-native run-ios' )} to build and run the app\n` + diff --git a/packages/create-react-native-library/src/prompt.ts b/packages/create-react-native-library/src/prompt.ts index 40a55dc1d..e097e7f6a 100644 --- a/packages/create-react-native-library/src/prompt.ts +++ b/packages/create-react-native-library/src/prompt.ts @@ -432,4 +432,11 @@ export const prompt = create(['[name]'], { 'Must be a valid version', skip: true, }, + experimentalSpm: { + type: 'confirm', + description: + 'Whether to generate an experimental Swift Package Manager (SwiftPM) manifest', + message: 'Do you want to generate a SwiftPM manifest?', + skip: true, + }, }); diff --git a/packages/create-react-native-library/src/template.ts b/packages/create-react-native-library/src/template.ts index 56f50af5e..de9510c8a 100644 --- a/packages/create-react-native-library/src/template.ts +++ b/packages/create-react-native-library/src/template.ts @@ -15,6 +15,7 @@ export type ViewConfig = 'fabric-view' | 'nitro-view' | null; // Please think at least 5 times before introducing a new config key // You can just reuse the existing ones most of the time export type TemplateConfiguration = { + experimentalSpm: boolean; versions: TemplateVersions; project: { slug: string; @@ -101,6 +102,7 @@ const CPP_FILES = path.resolve( import.meta.dirname, '../../templates/cpp-library' ); +const SPM_FILES = path.resolve(import.meta.dirname, '../../templates/spm'); const NATIVE_FILES = { module_new: path.resolve( @@ -191,6 +193,9 @@ export function generateTemplateConfiguration({ .toLowerCase()}`; return { + experimentalSpm: + answers.experimentalSpm === true && + getExperimentalSpmUnsupportedReason(answers) == null, versions, project: { slug, @@ -227,6 +232,36 @@ export function generateTemplateConfiguration({ }; } +/** + * Returns why SwiftPM can't be set up for the given answers, or `undefined` if it can + */ +export function getExperimentalSpmUnsupportedReason( + answers: Answers +): string | undefined { + if (answers.languages === 'js') { + return 'for library without native code'; + } + + if ( + getModuleConfig(answers.type) === 'nitro-modules' || + getViewConfig(answers.type) === 'nitro-view' + ) { + return "for Nitro library, since SwiftPM can't compile Swift and C++ sources in the same target"; + } + + if (answers.example === 'expo' || answers.example === 'test-app') { + return `for ${answers.example} example app, since it doesn't support SwiftPM yet`; + } + + const minor = answers.reactNativeVersion?.match(/^0\.(\d+)/)?.[1]; + + if (minor != null && Number(minor) < 88) { + return 'for React Native older than 0.88, since SwiftPM requires React Native 0.88 or newer'; + } + + return undefined; +} + function getModuleConfig(projectType: ProjectType): ModuleConfig { switch (projectType) { case 'nitro-module': @@ -261,6 +296,10 @@ export async function applyTemplates( ) { const { local } = answers; + if (config.experimentalSpm) { + await applyTemplate(config, SPM_FILES, folder); + } + if (local) { await applyTemplate(config, COMMON_LOCAL_FILES, folder); } else { diff --git a/packages/create-react-native-library/src/utils/createMetadata.ts b/packages/create-react-native-library/src/utils/createMetadata.ts index f8b8f0e18..cb2478a99 100644 --- a/packages/create-react-native-library/src/utils/createMetadata.ts +++ b/packages/create-react-native-library/src/utils/createMetadata.ts @@ -15,6 +15,7 @@ export function createMetadata(answers: Partial) { 'example', 'reactNativeVersion', 'local', + 'experimentalSpm', ]; const libraryMetadata = Object.fromEntries( diff --git a/packages/create-react-native-library/templates/common-local/$package.json b/packages/create-react-native-library/templates/common-local/$package.json index 894db6b5f..f4603c82e 100644 --- a/packages/create-react-native-library/templates/common-local/$package.json +++ b/packages/create-react-native-library/templates/common-local/$package.json @@ -21,6 +21,11 @@ <% } -%> } }, +<% } -%> +<% if (experimentalSpm) { -%> + "swiftpmConfig": { + "name": "<%- project.name -%>" + }, <% } -%> "author": "<%- author.name -%> <<%- author.email -%>> (<%- author.url -%>)", "license": "UNLICENSED", diff --git a/packages/create-react-native-library/templates/common/$.github/workflows/ci.yml b/packages/create-react-native-library/templates/common/$.github/workflows/ci.yml index a7958ad1c..a1f52b473 100644 --- a/packages/create-react-native-library/templates/common/$.github/workflows/ci.yml +++ b/packages/create-react-native-library/templates/common/$.github/workflows/ci.yml @@ -152,6 +152,17 @@ jobs: - name: Generate nitrogen code run: yarn nitrogen <% } -%> +<% if (experimentalSpm) { -%> + + - name: Use appropriate Xcode version + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 + with: + xcode-version: ${{ env.XCODE_VERSION }} + + - name: Set up SwiftPM + run: | + yarn example react-native spm --yes +<% } -%> - name: Cache turborepo for iOS uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -169,18 +180,20 @@ jobs: echo "turbo_cache_hit=1" >> $GITHUB_ENV fi +<% if (!experimentalSpm) { -%> - name: Use appropriate Xcode version if: env.turbo_cache_hit != 1 uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: ${{ env.XCODE_VERSION }} +<% } -%> <% if (example === 'expo') { -%> - name: Prebuild expo app for iOS if: env.turbo_cache_hit != 1 run: | yarn example expo prebuild --platform ios -<% } else { -%> +<% } else if (!experimentalSpm) { -%> - name: Install cocoapods if: env.turbo_cache_hit != 1 run: | diff --git a/packages/create-react-native-library/templates/common/$.gitignore b/packages/create-react-native-library/templates/common/$.gitignore index 2bae16034..88b2f9fcf 100644 --- a/packages/create-react-native-library/templates/common/$.gitignore +++ b/packages/create-react-native-library/templates/common/$.gitignore @@ -28,6 +28,13 @@ DerivedData *.xcuserstate project.xcworkspace **/.xcode.env.local +<% if (experimentalSpm) { -%> + +# Swift Package Manager +# +.build/ +.swiftpm/ +<% } -%> # Android/IJ # diff --git a/packages/create-react-native-library/templates/common/$package.json b/packages/create-react-native-library/templates/common/$package.json index 159b3d179..d897b951c 100644 --- a/packages/create-react-native-library/templates/common/$package.json +++ b/packages/create-react-native-library/templates/common/$package.json @@ -26,6 +26,9 @@ "nitro.json", <% } -%> "*.podspec", +<% if (experimentalSpm) { -%> + "Package.swift", +<% } -%> "react-native.config.js", "!ios/build", "!android/build", @@ -141,6 +144,11 @@ } <% } -%> } +<% if (experimentalSpm) { -%> + }, + "swiftpmConfig": { + "name": "<%- project.name -%>" +<% } -%> <% } -%> } } diff --git a/packages/create-react-native-library/templates/common/CONTRIBUTING.md b/packages/create-react-native-library/templates/common/CONTRIBUTING.md index 0c2d50746..0cac9f866 100644 --- a/packages/create-react-native-library/templates/common/CONTRIBUTING.md +++ b/packages/create-react-native-library/templates/common/CONTRIBUTING.md @@ -37,13 +37,25 @@ To invoke **Nitrogen**, use the following command: yarn nitrogen ``` +<% } -%> +<% if (experimentalSpm) { -%> +The example app uses [Swift Package Manager (SwiftPM)](https://reactnative.dev/blog/2026/08/11/react-native-0.87#experimental-swift-package-manager-support-for-ios) instead of CocoaPods for iOS. The SwiftPM build files are not committed to the repository, so you need to run the following after cloning the project or changing native dependencies: + +```sh +yarn example react-native spm +``` + <% } -%> The [example app](/example/) demonstrates usage of the library. You need to run it to test any changes you make. It is configured to use the local version of the library, so any changes you make to the library's source code will be reflected in the example app. Changes to the library's JavaScript code will be reflected in the example app without a rebuild, but native code changes will require a rebuild of the example app. <% if (project.native) { -%> +<% if (experimentalSpm) { -%> +If you want to use Android Studio or Xcode to edit the native code, you can open the `example/android` or `example/ios` directories respectively in those editors. To edit the Objective-C or Swift files, open `example/ios/<%- project.name -%>Example.xcodeproj` in Xcode and find the source files at `Package Dependencies > <%- project.name -%>`. +<% } else { -%> If you want to use Android Studio or Xcode to edit the native code, you can open the `example/android` or `example/ios` directories respectively in those editors. To edit the Objective-C or Swift files, open `example/ios/<%- project.name -%>Example.xcworkspace` in Xcode and find the source files at `Pods > Development Pods > <%- project.slug -%>`. +<% } -%> To edit the Java or Kotlin files, open `example/android` in Android studio and find the source files at `<%- project.identifier -%>` under `Android`. diff --git a/packages/create-react-native-library/templates/example-native-common/example/react-native.config.js b/packages/create-react-native-library/templates/example-native-common/example/react-native.config.js index 00c6117c6..433fa4db0 100644 --- a/packages/create-react-native-library/templates/example-native-common/example/react-native.config.js +++ b/packages/create-react-native-library/templates/example-native-common/example/react-native.config.js @@ -18,7 +18,7 @@ module.exports = { <% } else if (example === 'vanilla') { -%> project: { ios: { - automaticPodsInstallation: true, + automaticPodsInstallation: <%- experimentalSpm ? 'false' : 'true' %>, }, }, <% } -%> diff --git a/packages/create-react-native-library/templates/spm/Package.swift b/packages/create-react-native-library/templates/spm/Package.swift new file mode 100644 index 000000000..82902ee09 --- /dev/null +++ b/packages/create-react-native-library/templates/spm/Package.swift @@ -0,0 +1,43 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "<%- project.name -%>", + platforms: [.iOS("15.1")], + products: [ + .library(name: "<%- project.name -%>", targets: ["<%- project.name -%>"]), + ], + dependencies: [ + .package(name: "ReactNative", path: "../../../../xcframeworks"), + .package(name: "React-GeneratedCode", path: "../../../ios"), + ], + targets: [ + .target( + name: "<%- project.name -%>", + dependencies: [ + .product(name: "ReactHeaders", package: "ReactNative"), + .product(name: "ReactNativeHeaders", package: "ReactNative"), + .product(name: "ReactNativeDependenciesHeaders", package: "ReactNative"), + .product(name: "ReactAppHeaders", package: "React-GeneratedCode"), + ], + path: ".", + // SwiftPM scans the whole path for resources, even outside `sources` + // Without this, the example app in the library's repo fails with "multiple resources named ..." + exclude: ["example", "node_modules"], +<% if (project.cpp) { -%> + sources: ["ios", "cpp"], +<% } else { -%> + sources: ["ios"], +<% } -%> + publicHeadersPath: "ios", + cxxSettings: [ +<% if (project.cpp) { -%> + .headerSearchPath("cpp"), +<% } -%> + .define("DEBUG", .when(configuration: .debug)), + .define("NDEBUG", .when(configuration: .release)), + ] + ), + ], + cxxLanguageStandard: .cxx20 +) diff --git a/packages/create-react-native-library/templates/tools/turborepo/turbo.json b/packages/create-react-native-library/templates/tools/turborepo/turbo.json index 504c3ee17..04d732d9f 100644 --- a/packages/create-react-native-library/templates/tools/turborepo/turbo.json +++ b/packages/create-react-native-library/templates/tools/turborepo/turbo.json @@ -30,6 +30,7 @@ "inputs": [ "package.json", "*.podspec", + "Package.swift", "nitro.json", "ios", "cpp",