Skip to content

Commit 28ac322

Browse files
Saadnajmiclaude
andauthored
feat(0.81): build React Native macOS with Swift Package Manager (#2916)
## Summary - Backports SPM macOS support from #2815 (main branch) to 0.81-stable - Key adaptation: RCTUIKit is part of React-Core on 0.81 (not a separate module), so `platformLinkerSettings` for UIKit/AppKit go directly on the `reactCore` target - Adds macOS/visionOS platform support, Hermes CI pipeline, and macOS view platform conditionals ## Changes from main branch PR - No separate `reactRCTUIKit` target — UIKit/AppKit compat is inside React-Core - `platformLinkerSettings` on `reactCore` instead of `reactRCTUIKit` - No `RCTUIKit` header link in setup.js (not needed since it's part of React-Core) - `ENTERPRISE_REPOSITORY` env var preserved in `getTarballUrl` (0.81-specific) ## Test plan - [ ] CI: resolve-hermes job passes - [ ] CI: build-hermesc job passes - [ ] CI: all hermes slice builds pass (iphoneos, iphonesimulator, catalyst, macosx, xros) - [ ] CI: assemble-hermes produces xcframework - [ ] CI: SPM ios build passes - [ ] CI: SPM macos build passes - [ ] CI: SPM visionos build passes - [ ] Local: `node scripts/prebuild-ios -s -f Debug` completes setup - [ ] Local: `node scripts/prebuild-ios -b -f Debug -p macos` builds for macOS 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c212544 commit 28ac322

13 files changed

Lines changed: 715 additions & 15 deletions

File tree

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
name: Build SwiftPM
2+
3+
on:
4+
workflow_call:
5+
6+
jobs:
7+
resolve-hermes:
8+
name: "Resolve Hermes"
9+
runs-on: macos-15
10+
timeout-minutes: 10
11+
outputs:
12+
hermes-commit: ${{ steps.resolve.outputs.hermes-commit }}
13+
cache-hit: ${{ steps.cache.outputs.cache-hit }}
14+
steps:
15+
- uses: actions/checkout@v4
16+
with:
17+
filter: blob:none
18+
fetch-depth: 0
19+
20+
- name: Setup Xcode
21+
run: sudo xcode-select --switch /Applications/Xcode_16.2.app
22+
23+
- name: Set up Node.js
24+
uses: actions/setup-node@v4.4.0
25+
with:
26+
node-version: '22'
27+
cache: yarn
28+
registry-url: https://registry.npmjs.org
29+
30+
- name: Install npm dependencies
31+
run: yarn install
32+
33+
- name: Resolve Hermes commit at merge base
34+
id: resolve
35+
working-directory: packages/react-native
36+
run: |
37+
COMMIT=$(node -e "const {hermesCommitAtMergeBase} = require('./scripts/ios-prebuild/macosVersionResolver'); console.log(hermesCommitAtMergeBase().commit);" 2>&1 | grep -E '^[0-9a-f]{40}$')
38+
echo "hermes-commit=$COMMIT" >> "$GITHUB_OUTPUT"
39+
echo "Resolved Hermes commit: $COMMIT"
40+
41+
- name: Restore Hermes cache
42+
id: cache
43+
uses: actions/cache/restore@v4
44+
with:
45+
key: hermes-v1-${{ steps.resolve.outputs.hermes-commit }}-Debug
46+
path: hermes-destroot
47+
48+
- name: Upload cached Hermes artifacts
49+
if: steps.cache.outputs.cache-hit == 'true'
50+
uses: actions/upload-artifact@v4
51+
with:
52+
name: hermes-artifacts
53+
path: hermes-destroot
54+
retention-days: 30
55+
56+
build-hermesc:
57+
name: "Build hermesc"
58+
if: ${{ needs.resolve-hermes.outputs.cache-hit != 'true' }}
59+
needs: resolve-hermes
60+
runs-on: macos-15
61+
timeout-minutes: 30
62+
steps:
63+
- uses: actions/checkout@v4
64+
with:
65+
filter: blob:none
66+
67+
- name: Setup Xcode
68+
run: sudo xcode-select --switch /Applications/Xcode_16.2.app
69+
70+
- name: Clone Hermes
71+
uses: actions/checkout@v4
72+
with:
73+
repository: facebook/hermes
74+
ref: ${{ needs.resolve-hermes.outputs.hermes-commit }}
75+
path: hermes
76+
77+
- name: Build hermesc
78+
working-directory: hermes
79+
env:
80+
HERMES_PATH: ${{ github.workspace }}/hermes
81+
JSI_PATH: ${{ github.workspace }}/hermes/API/jsi
82+
MAC_DEPLOYMENT_TARGET: '14.0'
83+
run: |
84+
source $GITHUB_WORKSPACE/packages/react-native/sdks/hermes-engine/utils/build-apple-framework.sh
85+
build_host_hermesc
86+
87+
- name: Upload hermesc artifact
88+
uses: actions/upload-artifact@v4
89+
with:
90+
name: hermesc
91+
path: hermes/build_host_hermesc
92+
retention-days: 30
93+
94+
build-hermes-slice:
95+
name: "Hermes ${{ matrix.slice }}"
96+
if: ${{ needs.resolve-hermes.outputs.cache-hit != 'true' }}
97+
needs: [resolve-hermes, build-hermesc]
98+
runs-on: macos-15
99+
timeout-minutes: 45
100+
strategy:
101+
fail-fast: false
102+
matrix:
103+
slice: [iphoneos, iphonesimulator, macosx, xros, xrsimulator]
104+
steps:
105+
- uses: actions/checkout@v4
106+
with:
107+
filter: blob:none
108+
109+
- name: Setup Xcode
110+
run: sudo xcode-select --switch /Applications/Xcode_16.2.app
111+
112+
- name: Download visionOS SDK
113+
if: ${{ matrix.slice == 'xros' || matrix.slice == 'xrsimulator' }}
114+
run: |
115+
sudo xcodebuild -runFirstLaunch
116+
sudo xcrun simctl list
117+
sudo xcodebuild -downloadPlatform visionOS
118+
sudo xcodebuild -runFirstLaunch
119+
120+
- name: Clone Hermes
121+
uses: actions/checkout@v4
122+
with:
123+
repository: facebook/hermes
124+
ref: ${{ needs.resolve-hermes.outputs.hermes-commit }}
125+
path: hermes
126+
127+
- name: Download hermesc
128+
uses: actions/download-artifact@v4
129+
with:
130+
name: hermesc
131+
path: hermes/build_host_hermesc
132+
133+
- name: Restore hermesc permissions
134+
run: chmod +x ${{ github.workspace }}/hermes/build_host_hermesc/bin/hermesc
135+
136+
- name: Build Hermes slice (${{ matrix.slice }})
137+
working-directory: hermes
138+
env:
139+
BUILD_TYPE: Debug
140+
HERMES_PATH: ${{ github.workspace }}/hermes
141+
JSI_PATH: ${{ github.workspace }}/hermes/API/jsi
142+
IOS_DEPLOYMENT_TARGET: '15.1'
143+
MAC_DEPLOYMENT_TARGET: '14.0'
144+
XROS_DEPLOYMENT_TARGET: '1.0'
145+
RELEASE_VERSION: '1000.0.0'
146+
run: |
147+
bash $GITHUB_WORKSPACE/packages/react-native/sdks/hermes-engine/utils/build-ios-framework.sh "${{ matrix.slice }}"
148+
149+
- name: Upload slice artifact
150+
uses: actions/upload-artifact@v4
151+
with:
152+
name: hermes-slice-${{ matrix.slice }}
153+
path: hermes/destroot
154+
retention-days: 30
155+
156+
assemble-hermes:
157+
name: "Assemble Hermes xcframework"
158+
if: ${{ needs.resolve-hermes.outputs.cache-hit != 'true' }}
159+
needs: [resolve-hermes, build-hermes-slice]
160+
runs-on: macos-15
161+
timeout-minutes: 15
162+
steps:
163+
- uses: actions/checkout@v4
164+
with:
165+
filter: blob:none
166+
167+
- name: Download all slice artifacts
168+
uses: actions/download-artifact@v4
169+
with:
170+
pattern: hermes-slice-*
171+
path: /tmp/slices
172+
173+
- name: Assemble destroot from slices
174+
run: |
175+
mkdir -p ${{ github.workspace }}/hermes/destroot/Library/Frameworks
176+
for slice_dir in /tmp/slices/hermes-slice-*; do
177+
slice_name=$(basename "$slice_dir" | sed 's/hermes-slice-//')
178+
echo "Copying slice: $slice_name"
179+
cp -R "$slice_dir/Library/Frameworks/$slice_name" ${{ github.workspace }}/hermes/destroot/Library/Frameworks/
180+
# Copy include and bin directories (identical across slices, only need one copy)
181+
if [ -d "$slice_dir/include" ] && [ ! -d ${{ github.workspace }}/hermes/destroot/include ]; then
182+
cp -R "$slice_dir/include" ${{ github.workspace }}/hermes/destroot/
183+
fi
184+
if [ -d "$slice_dir/bin" ]; then
185+
cp -R "$slice_dir/bin" ${{ github.workspace }}/hermes/destroot/
186+
fi
187+
done
188+
echo "Assembled destroot contents:"
189+
ls -la ${{ github.workspace }}/hermes/destroot/Library/Frameworks/
190+
191+
- name: Create universal xcframework
192+
working-directory: hermes
193+
env:
194+
HERMES_PATH: ${{ github.workspace }}/hermes
195+
run: |
196+
source $GITHUB_WORKSPACE/packages/react-native/sdks/hermes-engine/utils/build-apple-framework.sh
197+
create_universal_framework "iphoneos" "iphonesimulator" "macosx" "xros" "xrsimulator"
198+
199+
- name: Save Hermes cache
200+
uses: actions/cache/save@v4
201+
with:
202+
key: hermes-v1-${{ needs.resolve-hermes.outputs.hermes-commit }}-Debug
203+
path: hermes/destroot
204+
205+
- name: Upload Hermes artifacts
206+
uses: actions/upload-artifact@v4
207+
with:
208+
name: hermes-artifacts
209+
path: hermes/destroot
210+
retention-days: 30
211+
212+
build-spm:
213+
name: "SPM ${{ matrix.platform }}"
214+
needs: [resolve-hermes, assemble-hermes]
215+
# Run when upstream jobs succeeded or were skipped (cache hit)
216+
if: ${{ always() && !cancelled() && !failure() }}
217+
runs-on: macos-26
218+
timeout-minutes: 60
219+
strategy:
220+
fail-fast: false
221+
matrix:
222+
platform: [ios, macos, visionos]
223+
steps:
224+
- uses: actions/checkout@v4
225+
with:
226+
filter: blob:none
227+
fetch-depth: 0
228+
229+
- name: Setup toolchain
230+
uses: ./.github/actions/microsoft-setup-toolchain
231+
with:
232+
node-version: '22'
233+
platform: ${{ matrix.platform }}
234+
235+
- name: Install npm dependencies
236+
run: yarn install
237+
238+
- name: Download Hermes artifacts
239+
uses: actions/download-artifact@v4
240+
with:
241+
name: hermes-artifacts
242+
path: packages/react-native/.build/artifacts/hermes/destroot
243+
244+
- name: Create Hermes version marker
245+
working-directory: packages/react-native
246+
run: |
247+
VERSION=$(node -p "require('./package.json').version")
248+
echo "${VERSION}-Debug" > .build/artifacts/hermes/version.txt
249+
250+
- name: Setup SPM workspace (using prebuilt Hermes)
251+
working-directory: packages/react-native
252+
run: node scripts/ios-prebuild.js -s -f Debug
253+
254+
- name: Build SPM (${{ matrix.platform }})
255+
working-directory: packages/react-native
256+
run: node scripts/ios-prebuild.js -b -f Debug -p ${{ matrix.platform }}

.github/workflows/microsoft-pr.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,11 @@ jobs:
132132
permissions: {}
133133
uses: ./.github/workflows/microsoft-build-rntester.yml
134134

135+
build-spm:
136+
name: "Build SPM"
137+
permissions: {}
138+
uses: ./.github/workflows/microsoft-build-spm.yml
139+
135140
test-react-native-macos-init:
136141
name: "Test react-native-macos init"
137142
permissions: {}
@@ -156,6 +161,7 @@ jobs:
156161
- yarn-constraints
157162
- javascript-tests
158163
- build-rntester
164+
- build-spm
159165
- test-react-native-macos-init
160166
# - react-native-test-app-integration
161167
steps:

packages/react-native/Package.swift

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,12 @@ let reactJsErrorHandler = RNTarget(
249249
let reactGraphicsApple = RNTarget(
250250
name: .reactGraphicsApple,
251251
path: "ReactCommon/react/renderer/graphics/platform/ios",
252-
linkedFrameworks: ["UIKit", "CoreGraphics"],
252+
linkedFrameworks: ["CoreGraphics"],
253+
// [macOS] UIKit/AppKit linked conditionally for cross-compilation
254+
platformLinkerSettings: [
255+
.linkedFramework("UIKit", .when(platforms: [.iOS, .visionOS])),
256+
.linkedFramework("AppKit", .when(platforms: [.macOS])),
257+
],
253258
dependencies: [.reactDebug, .jsi, .reactUtils, .reactNativeDependencies]
254259
)
255260

@@ -363,12 +368,26 @@ let reactCore = RNTarget(
363368
"ReactCommon/react/runtime/platform/ios", // explicit header search path to break circular dependency. RCTHost imports `RCTDefines.h` in ReactCore, ReacCore needs to import RCTHost
364369
],
365370
linkedFrameworks: ["CoreServices"],
371+
// [macOS]
372+
platformLinkerSettings: [
373+
.linkedFramework("UIKit", .when(platforms: [.iOS, .visionOS])),
374+
.linkedFramework("AppKit", .when(platforms: [.macOS])),
375+
],
366376
excludedPaths: ["Fabric", "Tests", "Resources", "Runtime/RCTJscInstanceFactory.mm", "I18n/strings", "CxxBridge/JSCExecutorFactory.mm", "CoreModules"],
367377
dependencies: [.reactNativeDependencies, .reactCxxReact, .reactPerfLogger, .jsi, .reactJsiExecutor, .reactUtils, .reactFeatureFlags, .reactRuntimeScheduler, .yoga, .reactJsInspector, .reactJsiTooling, .rctDeprecation, .reactCoreRCTWebsocket, .reactRCTImage, .reactTurboModuleCore, .reactRCTText, .reactRCTBlob, .reactRCTAnimation, .reactRCTNetwork, .reactFabric, .hermesPrebuilt],
368378
sources: [".", "Runtime/RCTHermesInstanceFactory.mm"]
369379
)
370380

371381
/// React-Fabric.podspec
382+
// [macOS
383+
#if os(macOS)
384+
let reactFabricViewPlatformSources = ["components/view/platform/macos"]
385+
let reactFabricViewPlatformExcludes = ["components/view/platform/cxx"]
386+
#else
387+
let reactFabricViewPlatformExcludes = ["components/view/platform/macos"]
388+
let reactFabricViewPlatformSources = ["components/view/platform/cxx"]
389+
#endif
390+
// macOS]
372391
let reactFabric = RNTarget(
373392
name: .reactFabric,
374393
path: "ReactCommon/react/renderer",
@@ -379,7 +398,7 @@ let reactFabric = RNTarget(
379398
"components/view/tests",
380399
"components/view/platform/android",
381400
"components/view/platform/windows",
382-
"components/view/platform/macos",
401+
// "components/view/platform/macos", // [macOS]
383402
"components/scrollview/tests",
384403
"components/scrollview/platform/android",
385404
"mounting/tests",
@@ -402,9 +421,9 @@ let reactFabric = RNTarget(
402421
"components/unimplementedview",
403422
"components/virtualview",
404423
"components/root/tests",
405-
],
424+
] + reactFabricViewPlatformExcludes, // [macOS]
406425
dependencies: [.reactNativeDependencies, .reactJsiExecutor, .rctTypesafety, .reactTurboModuleCore, .jsi, .logger, .reactDebug, .reactFeatureFlags, .reactUtils, .reactRuntimeScheduler, .reactCxxReact, .reactRendererDebug, .reactGraphics, .yoga],
407-
sources: ["animations", "attributedstring", "core", "componentregistry", "componentregistry/native", "components/root", "components/view", "components/view/platform/cxx", "components/scrollview", "components/scrollview/platform/cxx", "components/legacyviewmanagerinterop", "dom", "scheduler", "mounting", "observers/events", "telemetry", "consistency", "leakchecker", "uimanager", "uimanager/consistency"]
426+
sources: ["animations", "attributedstring", "core", "componentregistry", "componentregistry/native", "components/root", "components/view", "components/scrollview", "components/scrollview/platform/cxx", "components/legacyviewmanagerinterop", "dom", "scheduler", "mounting", "observers/events", "telemetry", "consistency", "leakchecker", "uimanager", "uimanager/consistency"] + reactFabricViewPlatformSources // [macOS]
408427
)
409428

410429
/// React-RCTFabric.podspec
@@ -424,7 +443,7 @@ let reactFabricComponents = RNTarget(
424443
"components/view/platform/android",
425444
"components/view/platform/windows",
426445
"components/view/platform/macos",
427-
"components/switch/iosswitch/react/renderer/components/switch/MacOSSwitchShadowNode.mm",
446+
// [macOS] Both switch files included; TARGET_OS_OSX guards select the correct one.
428447
"components/textinput/platform/android",
429448
"components/text/platform/android",
430449
"components/textinput/platform/macos",
@@ -591,7 +610,7 @@ let targets = [
591610

592611
let package = Package(
593612
name: react,
594-
platforms: [.iOS(.v15), .macCatalyst(SupportedPlatform.MacCatalystVersion.v13)],
613+
platforms: [.iOS(.v15), .macOS(.v14) /* [macOS] */, .macCatalyst(SupportedPlatform.MacCatalystVersion.v13)],
595614
products: [
596615
.library(
597616
name: react,
@@ -632,14 +651,16 @@ class BinaryTarget: BaseTarget {
632651

633652
class RNTarget: BaseTarget {
634653
let linkedFrameworks: [String]
654+
let platformLinkerSettings: [LinkerSetting] // [macOS]
635655
let excludedPaths: [String]
636656
let dependencies: [String]
637657
let sources: [String]?
638658
let publicHeadersPath: String?
639659
let defines: [CXXSetting]
640660

641-
init(name: String, path: String, searchPaths: [String] = [], linkedFrameworks: [String] = [], excludedPaths: [String] = [], dependencies: [String] = [], sources: [String]? = nil, publicHeadersPath: String? = ".", defines: [CXXSetting] = []) {
661+
init(name: String, path: String, searchPaths: [String] = [], linkedFrameworks: [String] = [], platformLinkerSettings: [LinkerSetting] = [], excludedPaths: [String] = [], dependencies: [String] = [], sources: [String]? = nil, publicHeadersPath: String? = ".", defines: [CXXSetting] = []) {
642662
self.linkedFrameworks = linkedFrameworks
663+
self.platformLinkerSettings = platformLinkerSettings
643664
self.excludedPaths = excludedPaths
644665
self.dependencies = dependencies
645666
self.sources = sources
@@ -675,7 +696,7 @@ class RNTarget: BaseTarget {
675696
override func target(targets: [BaseTarget]) -> Target {
676697
let searchPaths: [String] = self.headerSearchPaths(targets: targets)
677698

678-
let linkerSettings = self.linkedFrameworks.reduce([]) { $0 + [LinkerSetting.linkedFramework($1)] }
699+
let linkerSettings = self.linkedFrameworks.reduce([]) { $0 + [LinkerSetting.linkedFramework($1)] } + self.platformLinkerSettings // [macOS]
679700

680701
return Target.reactNativeTarget(
681702
name: self.name,

packages/react-native/ReactCommon/react/renderer/components/switch/iosswitch/react/renderer/components/switch/IOSSwitchShadowNode.mm

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8+
#include <TargetConditionals.h> // [macOS]
9+
#if !TARGET_OS_OSX // [macOS]
10+
811
#import <React/RCTUtils.h>
912
#import <UIKit/UIKit.h>
1013
#include "AppleSwitchShadowNode.h"
@@ -26,3 +29,5 @@
2629
}
2730

2831
} // namespace facebook::react
32+
33+
#endif // !TARGET_OS_OSX [macOS]

0 commit comments

Comments
 (0)