Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions .github/workflows/build-templates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -280,3 +280,106 @@ jobs:
rm -rf lib/*

yarn example build:web

build-spm:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: Let's add this to the existing build matrix instead of a separate job so it doesn't drift from the rest. It has already drifted after merging main:

  • actions/checkout is on v5.0.0 here and v7.0.1 in build.
  • setup-xcode is on v1.6.0 here and v1.7.0 in build.
  • It skips the yarn and turbo caching and the scheduled --react-native-version latest run.

The "Generate codegen native code" step can go too. The C++ example builds without it, and nothing uses ios/generated anymore.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: One more difference: this job only passes --tools eslint, while build also passes vite, jest, lefthook and release-it. Moving it into the matrix fixes this too.

env:
XCODE_VERSION: 26

strategy:
fail-fast: false
matrix:
type:
- name: turbo-module
language: kotlin-objc
- name: turbo-module
language: cpp
- name: fabric-view
language: kotlin-objc

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-spm-${{ matrix.type.name }}-${{ matrix.type.language }}
cancel-in-progress: true

runs-on: macos-latest

steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

- name: Setup
uses: ./.github/actions/setup

- name: Build package
run: |
yarn workspace create-react-native-library prepare

- name: Get working directory
shell: bash
run: |
echo "work_dir=spm-${{ matrix.type.name }}-${{ matrix.type.language }}" >> $GITHUB_ENV

- name: Create library
shell: bash
run: |
rm -rf ${{ env.work_dir }}

./packages/create-react-native-library/bin/create-react-native-library ${{ env.work_dir }} \
--slug @bob/react-native-test \

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: With a scoped slug, both naming schemes give ReactNativeTest, so this job passes even though unscoped libraries fail to build (see the comment on Package.swift). Let's use an unscoped slug here so the job catches name mismatches.

--description test \
--author-name test \
--author-email test@test \
--author-url https://test.test \
--repo-url https://test.test \
--type ${{ matrix.type.name }} \
--languages ${{ matrix.type.language }} \
--example vanilla \
--tools eslint \
--experimental-spm

- name: Install dependencies of library
working-directory: ${{ env.work_dir }}
shell: bash
run: |
touch yarn.lock # Without this Yarn will fail due to the parent directory being a Yarn workspace
yarn install --no-immutable

- name: Use local version of react-native-builder-bob
shell: bash
run: |
cd packages/react-native-builder-bob
npm pack
tgz=$(ls react-native-builder-bob-*.tgz)
cd -
cd ${{ env.work_dir }}

export YARN_CHECKSUM_BEHAVIOR=ignore

yarn add --dev ../packages/react-native-builder-bob/$tgz
yarn --cwd example add --dev ../../packages/react-native-builder-bob/$tgz

- name: Generate codegen native code
if: matrix.type.language == 'cpp'
working-directory: ${{ env.work_dir }}
run: yarn bob build --target codegen

- name: Use appropriate Xcode version
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0
with:
xcode-version: ${{ env.XCODE_VERSION }}

- name: Set up SPM
working-directory: ${{ env.work_dir }}/example
run: |
npx react-native spm --deintegrate --yes

- name: Build example (iOS, Debug)
working-directory: ${{ env.work_dir }}
run: |
yarn example build:ios

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: This job also bypasses the existing turbo setup. It builds with yarn example build:ios and has no turbo cache, so every run does a full build. Moving it to yarn turbo run build:ios needs two more changes:

  • Package.swift isn't in the build:ios inputs in the turbo.json template, so editing it keeps the same hash and turbo reuses the cached build.
  • npx react-native spm rewrites tracked files under example/ios (project.pbxproj, the scheme, Podfile, .spm-injected.json), so the hash changes after setup. The cache check needs to run after setup, or it won't match what the build saved.


- name: Build example (iOS, Release)
working-directory: ${{ env.work_dir }}/example/ios
run: |
# The CLI builds Release for a device, which requires code signing

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: Calling xcodebuild directly is fine (build-local-libraries.yml does the same without a comment). What's worth explaining is why we build Release at all, since no other job does. Release is the only build where the NDEBUG define in Package.swift matters. Without it, the Fabric view fails to link with the prebuilt React framework. Something like:

# 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: This step skips turbo completely. The generated turbo.json only has a Debug build:ios task, so there's nothing for a Release build to go through, and every run does two uncached Xcode builds. If we want to keep the Release build, the example needs a Release script and turbo.json needs a task for it.

3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,6 @@ lib/
typescript/
doc_build/
*.tsbuildinfo

# Swift Package Manager
.build/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: This is needed. A .build/ folder showed up at the repo root while working on this branch, even though nothing ran swift build there. Its marker file says SwiftPM's build system wrote it. The exact trigger isn't clear, but it's likely editor tooling picking up templates/spm/Package.swift.

14 changes: 14 additions & 0 deletions docs/pages/create.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ To create new project, run the following:
npx create-react-native-library@latest awesome-library
```

To also generate a `Package.swift` for React Native experimental [Swift Package Manager 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 SPM instead of CocoaPods: run `npx react-native spm --deintegrate` in `example` once to set it up, and `npx react-native spm` after a fresh install.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: The generated CONTRIBUTING.md still describes the CocoaPods setup. It says to open the .xcworkspace and find the sources under Pods > Development Pods, and SwiftPM creates neither. It needs SwiftPM instructions too, including running npx react-native spm after a fresh clone.


### Limitations

- Requires React Native 0.87 or newer

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: This should say 0.88, since the name fix relies on swiftpmConfig in package.json, which 0.87 doesn't read.

- 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 SPM yet.
Comment on lines +28 to +40

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: This splits the usage section, so "This will ask you a few questions..." now reads like it's about the SwiftPM command. Let's move it to its own section, like "Local library".


This will ask you a few questions about your project and generate a new project in a folder named `awesome-library`.

![Demo](../assets/create-react-native-library.svg)
Expand Down
12 changes: 11 additions & 1 deletion packages/create-react-native-library/src/inform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,17 @@ export function printNonLocalLibNextSteps(config: TemplateConfiguration) {
`${kleur.bold('Get started')} with the project`
)}${kleur.gray(':')}

${kleur.gray('$')} yarn
${kleur.gray('$')} yarn${
config.experimentalSpm && config.example === 'vanilla'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: With --example expo or --example test-app, the library still gets a Package.swift, but the example stays on CocoaPods and nothing tells the user. So the manifest gets published without being built anywhere. Let's warn about this the same way as for Nitro, or skip the manifest for these examples.

? `

${kleur.magenta(
`Set up ${kleur.bold('Swift Package Manager')} for the example app (React Native 0.87+)`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: We mix "SPM", "SwiftPM" and "Swift Package Manager" across the docs, CLI output and comments. React Native uses "Swift Package Manager (SwiftPM)" on first mention and "SwiftPM" after that, and keeps spm for the command. Let's use the same terms. This should also say 0.88+, since the name fix relies on swiftpmConfig, which 0.87 doesn't read.

)}${kleur.gray(':')}

${kleur.gray('$')} cd example && npx react-native spm --deintegrate`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: After cd example, the next step (yarn example ios) fails with Couldn't find a script named "example". We can run it through the workspace script instead, so the user stays in the root:

yarn example react-native spm --deintegrate

: ''
}
${Object.entries(platforms)
.map(
([script, { name, color }]) => `
Expand Down
7 changes: 7 additions & 0 deletions packages/create-react-native-library/src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ const isInPackage = (): boolean => {
};

export const prompt = create(['[name]'], {
experimentalSpm: {
type: 'confirm',
description: 'Whether to generate an experimental Swift package manifest',
message: 'Generate a Swift Package Manager manifest?',
default: false,
skip: () => !process.argv.includes('--experimental-spm'),
Comment on lines +146 to +151

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: Let's use skip: true like reactNativeVersion, so it's only set from the CLI and never prompted. It should also go last, or at least before reactNativeVersion, instead of first.

},
local: {
type: 'confirm',
description: 'Whether to create a local library',
Expand Down
15 changes: 15 additions & 0 deletions packages/create-react-native-library/src/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -191,6 +193,11 @@ export function generateTemplateConfiguration({
.toLowerCase()}`;

return {
experimentalSpm:
answers.experimentalSpm === true &&
languages !== 'js' &&
getModuleConfig(type) !== 'nitro-modules' &&
getViewConfig(type) !== 'nitro-view',
versions,
project: {
slug,
Expand Down Expand Up @@ -261,6 +268,14 @@ export async function applyTemplates(
) {
const { local } = answers;

if (config.experimentalSpm) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: This also runs for --local, so local libraries get a Package.swift, but nothing else is set up for SwiftPM:

  • It has the same name mismatch, and common-local/$package.json needs swiftpmConfig.name too.
  • The next steps still say to run pod install. Apps using SwiftPM need npx react-native spm instead.

Local libraries need the same setup and instructions as published ones.

await applyTemplate(config, SPM_FILES, folder);
} else if (answers.experimentalSpm === true && config.project.native) {
console.warn(
"Swift Package Manager manifest was not generated: Nitro libraries mix Swift and C++ sources, which Swift Package Manager can't compile in a single target."
);
Comment on lines +273 to +276

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: The other warnings (in index.ts and generateExampleApp.ts) have a kleur.yellow('⚠') prefix and are printed from there, not from applyTemplates. This also repeats the condition from generateTemplateConfiguration, and JS-only libraries get no warning. Let's do the check and print the warning once, next to the --react-native-version warning in index.ts.

}

if (local) {
await applyTemplate(config, COMMON_LOCAL_FILES, folder);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function createMetadata(answers: Partial<Answers>) {
'example',
'reactNativeVersion',
'local',
'experimentalSpm',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: This looks fine. The list is for answers we can derive from the generated package.json, and we can tell this one from Package.swift in files (and from swiftpmConfig after the name fix). Nothing in the repo reads this metadata either.

];

const libraryMetadata = Object.fromEntries(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ DerivedData
*.xcuserstate
project.xcworkspace
**/.xcode.env.local
<% if (experimentalSpm) { -%>

# Swift Package Manager
#
.build/
.swiftpm/
<% } -%>

# Android/IJ
#
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
"nitro.json",
<% } -%>
"*.podspec",
<% if (experimentalSpm) { -%>
"Package.swift",
<% } -%>
"react-native.config.js",
"!ios/build",
"!android/build",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ module.exports = {
<% } else if (example === 'vanilla') { -%>
project: {
ios: {
automaticPodsInstallation: true,
automaticPodsInstallation: <%- experimentalSpm ? 'false' : 'true' %>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: Once the example is moved to SwiftPM, the generated CI and turbo config still assume CocoaPods:

  • ci.yml runs pod install, but after --deintegrate the Podfile doesn't parse anymore (React Native 0.88.0 removes use_react_native!( and leaves its arguments behind).
  • A fresh checkout needs npx react-native spm before building, since everything it generates is gitignored (docs). React Native suggests a postinstall script for this.
  • turbo.json lists *.podspec in the build:ios inputs but not Package.swift, so changes to the manifest don't invalidate the cache.

The CI and turbo templates need to handle the SwiftPM case too.

},
},
<% } -%>
Expand Down
53 changes: 53 additions & 0 deletions packages/create-react-native-library/templates/spm/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// swift-tools-version: 6.0
<%
// Must match the name React Native's SPM autolinker derives from the package name
const spmName = project.slug
.replace(/^@[^/]+\//, '')
.split(/[^a-zA-Z0-9]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
Comment on lines +3 to +9

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: This name doesn't match what React Native 0.88 expects. The SwiftPM name now comes from swiftpmConfig.name, then the podspec name, and only then the npm name (docs). Our podspec uses project.name, so for an unscoped package like react-native-awesome-module, the app looks for AwesomeModule while we declare ReactNativeAwesomeModule. It fails in both the example app and a standalone 0.88 app with the packed library:

product 'AwesomeModule' required by package 'autolinking' target 'AutolinkedAggregate' not found in package 'AwesomeModule'.

We can use project.name here and add "swiftpmConfig": { "name": "<%- project.name -%>" } to package.json. With that, both apps build and run. Then we don't need to compute the name in the template either.

-%>
import PackageDescription

let package = Package(
name: "<%- spmName -%>",
platforms: [.iOS(.v15)],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: The podspec uses min_ios_version_supported (15.1), and React Native's scaffolded manifests use .iOS("15.1"). Let's use the same version here.

products: [
.library(name: "<%- spmName -%>", targets: ["<%- spmName -%>"]),
],
dependencies: [
.package(name: "ReactNative", path: "../../../../xcframeworks"),
.package(name: "React-GeneratedCode", path: "../../../ios"),
Comment on lines +20 to +21

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: React Native's contract for libraries that ship their own Package.swift says the manifest shouldn't know anything about the app's layout. These paths only resolve through the app's build/generated/autolinking/libs/<Name> symlink. In remote mode (RN_SPM_REMOTE_URL), React Native comes from a package URL, so xcframeworks won't exist at all.

The contract asks for two things:

  • Depend on the React Native package directly instead of paths into the app.
  • Ship the codegen output (includesGeneratedCode: true) instead of depending on the app's React-GeneratedCode.

One catch is that the docs only show the remote form for the first one, and the second one goes against #819, where we stopped shipping generated code. It's worth asking in RFC 0994 how libraries should reference React Native in local mode.

],
targets: [
.target(
name: "<%- spmName -%>",
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",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: With publicHeadersPath: "ios", every header under ios is public, but the podspec marks them private with private_header_files. React Native's scaffold does the same thing, so it may be needed for SwiftPM. If so, it's worth a short comment on why it differs from the podspec.

cxxSettings: [
<% if (project.cpp) { -%>
.headerSearchPath("cpp"),
.headerSearchPath("ios/generated/ReactCodegen"),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via Claude: ios/generated/ReactCodegen doesn't exist since #819, and the spec header comes from the app's ReactAppHeaders now. The C++ example builds without it, so we can remove this line.

<% } -%>
.define("DEBUG", .when(configuration: .debug)),
.define("NDEBUG", .when(configuration: .release)),
]
),
],
cxxLanguageStandard: .cxx20
)
Loading