Skip to content

Commit 30d348a

Browse files
Saadnajmiclaude
andcommitted
feat: add macOS support to Swift Package Manager build system
- Add .macOS(.v14) platform to Package.swift - Create React-RCTUIKit as its own SPM module with conditional UIKit/AppKit linking - Port findMatchingHermesVersion and hermesCommitAtMergeBase from Ruby to JS - Add macOS platform and destination to prebuild CLI - Link RCTUIKit and macOS view platform headers in setup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a8c46ba commit 30d348a

5 files changed

Lines changed: 222 additions & 10 deletions

File tree

packages/react-native/Package.swift

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ let reactJsErrorHandler = RNTarget(
246246
let reactGraphicsApple = RNTarget(
247247
name: .reactGraphicsApple,
248248
path: "ReactCommon/react/renderer/graphics/platform/ios",
249-
linkedFrameworks: ["UIKit", "CoreGraphics"],
249+
linkedFrameworks: ["CoreGraphics"],
250250
dependencies: [.reactDebug, .jsi, .reactUtils, .reactNativeDependencies]
251251
)
252252

@@ -360,8 +360,8 @@ let reactCore = RNTarget(
360360
"ReactCommon/react/runtime/platform/ios", // explicit header search path to break circular dependency. RCTHost imports `RCTDefines.h` in ReactCore, ReacCore needs to import RCTHost
361361
],
362362
linkedFrameworks: ["CoreServices"],
363-
excludedPaths: ["Fabric", "Tests", "Resources", "Runtime/RCTJscInstanceFactory.mm", "I18n/strings", "CxxBridge/JSCExecutorFactory.mm", "CoreModules"],
364-
dependencies: [.reactNativeDependencies, .reactCxxReact, .reactPerfLogger, .jsi, .reactJsiExecutor, .reactUtils, .reactFeatureFlags, .reactRuntimeScheduler, .yoga, .reactJsInspector, .reactJsiTooling, .rctDeprecation, .reactCoreRCTWebsocket, .reactRCTImage, .reactTurboModuleCore, .reactRCTText, .reactRCTBlob, .reactRCTAnimation, .reactRCTNetwork, .reactFabric, .hermesPrebuilt],
363+
excludedPaths: ["Fabric", "Tests", "Resources", "Runtime/RCTJscInstanceFactory.mm", "I18n/strings", "CxxBridge/JSCExecutorFactory.mm", "CoreModules", "RCTUIKit"],
364+
dependencies: [.reactNativeDependencies, .reactCxxReact, .reactPerfLogger, .jsi, .reactJsiExecutor, .reactUtils, .reactFeatureFlags, .reactRuntimeScheduler, .yoga, .reactJsInspector, .reactJsiTooling, .rctDeprecation, .reactCoreRCTWebsocket, .reactRCTImage, .reactTurboModuleCore, .reactRCTText, .reactRCTBlob, .reactRCTAnimation, .reactRCTNetwork, .reactFabric, .hermesPrebuilt, .reactRCTUIKit],
365365
sources: [".", "Runtime/RCTHermesInstanceFactory.mm"]
366366
)
367367

@@ -376,7 +376,6 @@ let reactFabric = RNTarget(
376376
"components/view/tests",
377377
"components/view/platform/android",
378378
"components/view/platform/windows",
379-
"components/view/platform/macos",
380379
"components/scrollview/tests",
381380
"components/scrollview/platform/android",
382381
"mounting/tests",
@@ -420,16 +419,13 @@ let reactFabricComponents = RNTarget(
420419
"components/modal/platform/cxx",
421420
"components/view/platform/android",
422421
"components/view/platform/windows",
423-
"components/view/platform/macos",
424422
"components/textinput/platform/android",
425423
"components/text/platform/android",
426-
"components/textinput/platform/macos",
427424
"components/text/tests",
428425
"textlayoutmanager/tests",
429426
"textlayoutmanager/platform/android",
430427
"textlayoutmanager/platform/cxx",
431428
"textlayoutmanager/platform/windows",
432-
"textlayoutmanager/platform/macos",
433429
"conponents/rncore", // this was the old folder where RN Core Components were generated. If you ran codegen in the past, you might have some files in it that might make the build fail.
434430
],
435431
dependencies: [.reactNativeDependencies, .reactCore, .reactJsiExecutor, .reactTurboModuleCore, .jsi, .logger, .reactDebug, .reactFeatureFlags, .reactUtils, .reactRuntimeScheduler, .reactCxxReact, .yoga, .reactRendererDebug, .reactGraphics, .reactFabric, .reactTurboModuleBridging],
@@ -524,6 +520,16 @@ let reactSettings = RNTarget(
524520
dependencies: [.reactTurboModuleCore, .yoga]
525521
)
526522

523+
// [macOS
524+
/// React-RCTUIKit.podspec
525+
/// UIKit/AppKit compatibility layer for React Native macOS.
526+
let reactRCTUIKit = RNTarget(
527+
name: .reactRCTUIKit,
528+
path: "React/RCTUIKit",
529+
excludedPaths: ["README.md"]
530+
)
531+
// macOS]
532+
527533
// MARK: Target list
528534
let targets = [
529535
reactDebug,
@@ -581,13 +587,14 @@ let targets = [
581587
reactAppDelegate,
582588
reactSettings,
583589
reactRuntimeExecutor,
590+
reactRCTUIKit, // [macOS]
584591
]
585592

586593
// MARK: Package object
587594

588595
let package = Package(
589596
name: react,
590-
platforms: [.iOS(.v15), .macCatalyst(SupportedPlatform.MacCatalystVersion.v13)],
597+
platforms: [.iOS(.v15), .macOS(.v14), .macCatalyst(SupportedPlatform.MacCatalystVersion.v13)],
591598
products: [
592599
.library(
593600
name: react,
@@ -753,6 +760,7 @@ extension String {
753760
static let reactNativeModuleDom = "React-domnativemodule"
754761
static let reactAppDelegate = "React-RCTAppDelegate"
755762
static let reactSettings = "React-RCTSettings"
763+
static let reactRCTUIKit = "React-RCTUIKit" // [macOS]
756764
}
757765

758766
func relativeSearchPath(_ depth: Int, _ path: String) -> String {
@@ -792,6 +800,14 @@ extension Target {
792800
.define("USE_HERMES", to: "1"),
793801
] + defines + cxxCommonHeaderPaths
794802

803+
// [macOS] Platform-specific framework linking for targets that need UIKit (iOS/visionOS) vs AppKit (macOS)
804+
var conditionalLinkerSettings: [LinkerSetting] = linkerSettings
805+
if name == "React-graphics-Apple" || name == "React-RCTUIKit" {
806+
conditionalLinkerSettings.append(.linkedFramework("UIKit", .when(platforms: [.iOS, .visionOS])))
807+
conditionalLinkerSettings.append(.linkedFramework("AppKit", .when(platforms: [.macOS])))
808+
}
809+
// macOS]
810+
795811
return .target(
796812
name: name,
797813
dependencies: dependencies,
@@ -800,7 +816,7 @@ extension Target {
800816
sources: sources,
801817
publicHeadersPath: publicHeadersPath,
802818
cxxSettings: cxxSettings,
803-
linkerSettings: linkerSettings
819+
linkerSettings: conditionalLinkerSettings
804820
)
805821
}
806822
}

packages/react-native/scripts/ios-prebuild/cli.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {BuildFlavor, Destination, Platform} from './types';
1717
const platforms /*: $ReadOnlyArray<Platform> */ = [
1818
'ios',
1919
'ios-simulator',
20+
'macos', // [macOS]
2021
'mac-catalyst',
2122
];
2223

@@ -25,6 +26,7 @@ const platforms /*: $ReadOnlyArray<Platform> */ = [
2526
const platformToDestination /*: $ReadOnly<{|[Platform]: Destination|}> */ = {
2627
ios: 'iOS',
2728
'ios-simulator': 'iOS Simulator',
29+
'macos': 'macOS', // [macOS]
2830
'mac-catalyst': 'macOS,variant=Mac Catalyst',
2931
};
3032

packages/react-native/scripts/ios-prebuild/hermes.js

Lines changed: 182 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
const {computeNightlyTarballURL, createLogger} = require('./utils');
1212
const {execSync} = require('child_process');
1313
const fs = require('fs');
14+
const os = require('os');
1415
const path = require('path');
1516
const stream = require('stream');
1617
const {promisify} = require('util');
@@ -22,6 +23,124 @@ const hermesLog = createLogger('Hermes');
2223
import type {BuildFlavor, Destination, Platform} from './types';
2324
*/
2425

26+
// [macOS
27+
/**
28+
* For react-native-macos stable branches, maps the macOS package version
29+
* to the upstream react-native version using peerDependencies.
30+
* Returns null for version 1000.0.0 (main branch dev version).
31+
*
32+
* This is the JavaScript equivalent of the Ruby `findMatchingHermesVersion`
33+
* in sdks/hermes-engine/hermes-utils.rb.
34+
*/
35+
function findMatchingHermesVersion(
36+
packageJsonPath /*: string */,
37+
) /*: ?string */ {
38+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
39+
40+
if (pkg.version === '1000.0.0') {
41+
hermesLog(
42+
'Main branch detected (1000.0.0), no matching upstream Hermes version',
43+
);
44+
return null;
45+
}
46+
47+
if (pkg.peerDependencies && pkg.peerDependencies['react-native']) {
48+
const upstreamVersion = pkg.peerDependencies['react-native'];
49+
hermesLog(
50+
`Mapped macOS version ${pkg.version} to upstream RN version: ${upstreamVersion}`,
51+
);
52+
return upstreamVersion;
53+
}
54+
55+
hermesLog(
56+
'No matching Hermes version found in peerDependencies. Defaulting to package version.',
57+
);
58+
return null;
59+
}
60+
61+
/**
62+
* Finds the Hermes commit at the merge base with facebook/react-native.
63+
* Used on the main branch (1000.0.0) where no prebuilt artifacts exist.
64+
*
65+
* Since react-native-macos lags slightly behind facebook/react-native, we can't always use
66+
* the latest Hermes commit because Hermes and JSI don't always guarantee backwards compatibility.
67+
* Instead, we take the commit hash of Hermes at the time of the merge base with facebook/react-native.
68+
*
69+
* This is the JavaScript equivalent of the Ruby `hermes_commit_at_merge_base`
70+
* in sdks/hermes-engine/hermes-utils.rb.
71+
*/
72+
function hermesCommitAtMergeBase() /*: {| commit: string, timestamp: string |} */ {
73+
const HERMES_GITHUB_URL = 'https://github.com/facebook/hermes.git';
74+
75+
// Fetch upstream react-native
76+
hermesLog('Fetching facebook/react-native to find merge base...');
77+
try {
78+
execSync('git fetch -q https://github.com/facebook/react-native.git', {
79+
stdio: 'pipe',
80+
});
81+
} catch (e) {
82+
abort(
83+
'[Hermes] Failed to fetch facebook/react-native into the local repository.',
84+
);
85+
}
86+
87+
// Find merge base between our HEAD and upstream's HEAD
88+
const mergeBase = execSync('git merge-base FETCH_HEAD HEAD', {
89+
encoding: 'utf8',
90+
}).trim();
91+
if (!mergeBase) {
92+
abort(
93+
"[Hermes] Unable to find the merge base between our HEAD and upstream's HEAD.",
94+
);
95+
}
96+
97+
// Get timestamp of merge base
98+
const timestamp = execSync(`git show -s --format=%ci ${mergeBase}`, {
99+
encoding: 'utf8',
100+
}).trim();
101+
if (!timestamp) {
102+
abort(
103+
`[Hermes] Unable to extract the timestamp for the merge base (${mergeBase}).`,
104+
);
105+
}
106+
107+
// Clone Hermes bare (minimal) into a temp directory and find the commit
108+
hermesLog(
109+
`Merge base timestamp: ${timestamp}. Cloning Hermes to find matching commit...`,
110+
);
111+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-'));
112+
const hermesGitDir = path.join(tmpDir, 'hermes.git');
113+
114+
try {
115+
// Explicitly use Hermes 'main' branch since the default branch changed to 'static_h' (Hermes V1)
116+
execSync(
117+
`git clone -q --bare --filter=blob:none --single-branch --branch main ${HERMES_GITHUB_URL} "${hermesGitDir}"`,
118+
{stdio: 'pipe', timeout: 120000},
119+
);
120+
121+
// Find the Hermes commit at the time of the merge base on branch 'main'
122+
const commit = execSync(
123+
`git --git-dir="${hermesGitDir}" rev-list -1 --before="${timestamp}" refs/heads/main`,
124+
{encoding: 'utf8'},
125+
).trim();
126+
127+
if (!commit) {
128+
abort(
129+
`[Hermes] Unable to find the Hermes commit hash at time ${timestamp} on branch 'main'.`,
130+
);
131+
}
132+
133+
hermesLog(
134+
`Using Hermes commit from the merge base with facebook/react-native: ${commit} (timestamp: ${timestamp})`,
135+
);
136+
return {commit, timestamp};
137+
} finally {
138+
// Clean up temp directory
139+
fs.rmSync(tmpDir, {recursive: true, force: true});
140+
}
141+
}
142+
// macOS]
143+
25144
/**
26145
* Downloads hermes artifacts from the specified version and build type. If you want to specify a specific
27146
* version of hermes, use the HERMES_VERSION environment variable. The path to the artifacts will be inside
@@ -56,6 +175,22 @@ async function prepareHermesArtifactsAsync(
56175
// Resolve the version from the environment variable or use the default version
57176
let resolvedVersion = process.env.HERMES_VERSION ?? version;
58177

178+
// [macOS] Map macOS version to upstream RN version for artifact lookup.
179+
// If no mapped version is found (main branch / 1000.0.0), allowBuildFromSource
180+
// enables the fallback to hermesCommitAtMergeBase() when no prebuilt artifacts exist.
181+
let allowBuildFromSource = false;
182+
if (!process.env.HERMES_VERSION) {
183+
const packageJsonPath = path.resolve(__dirname, '..', '..', 'package.json');
184+
const mappedVersion = findMatchingHermesVersion(packageJsonPath);
185+
if (mappedVersion != null) {
186+
hermesLog(`Using mapped upstream version for Hermes lookup: ${mappedVersion}`);
187+
resolvedVersion = mappedVersion;
188+
} else {
189+
allowBuildFromSource = true;
190+
}
191+
}
192+
// macOS]
193+
59194
if (resolvedVersion === 'nightly') {
60195
hermesLog('Using latest nightly tarball');
61196
const hermesVersion = await getNightlyVersionFromNPM();
@@ -74,7 +209,7 @@ async function prepareHermesArtifactsAsync(
74209
return artifactsPath;
75210
}
76211

77-
const sourceType = await hermesSourceType(resolvedVersion, buildType);
212+
const sourceType = await hermesSourceType(resolvedVersion, buildType, allowBuildFromSource);
78213
localPath = await resolveSourceFromSourceType(
79214
sourceType,
80215
resolvedVersion,
@@ -124,12 +259,14 @@ type HermesEngineSourceType =
124259
| 'local_prebuilt_tarball'
125260
| 'download_prebuild_tarball'
126261
| 'download_prebuilt_nightly_tarball'
262+
| 'build_from_hermes_commit'
127263
*/
128264

129265
const HermesEngineSourceTypes = {
130266
LOCAL_PREBUILT_TARBALL: 'local_prebuilt_tarball',
131267
DOWNLOAD_PREBUILD_TARBALL: 'download_prebuild_tarball',
132268
DOWNLOAD_PREBUILT_NIGHTLY_TARBALL: 'download_prebuilt_nightly_tarball',
269+
BUILD_FROM_HERMES_COMMIT: 'build_from_hermes_commit', // [macOS]
133270
} /*:: as const */;
134271

135272
/**
@@ -221,10 +358,16 @@ async function hermesArtifactExists(
221358

222359
/**
223360
* Determines the source type for Hermes based on availability
361+
*
362+
* @param version - The resolved version string
363+
* @param buildType - Debug or Release
364+
* @param allowBuildFromSource - If true (macOS main branch), fall back to BUILD_FROM_HERMES_COMMIT
365+
* when no prebuilt artifacts exist. If false, fall back to nightly download (original behavior).
224366
*/
225367
async function hermesSourceType(
226368
version /*: string */,
227369
buildType /*: BuildFlavor */,
370+
allowBuildFromSource /*: boolean */ = false,
228371
) /*: Promise<HermesEngineSourceType> */ {
229372
if (hermesEngineTarballEnvvarDefined()) {
230373
hermesLog('Using local prebuild tarball');
@@ -244,6 +387,16 @@ async function hermesSourceType(
244387
return HermesEngineSourceTypes.DOWNLOAD_PREBUILT_NIGHTLY_TARBALL;
245388
}
246389

390+
// [macOS] When on the macOS main branch (no mapped version, no explicit HERMES_VERSION),
391+
// fall back to resolving the Hermes commit at the merge base with facebook/react-native.
392+
if (allowBuildFromSource) {
393+
hermesLog(
394+
'No prebuilt Hermes artifact found. Will attempt to resolve from merge base with facebook/react-native.',
395+
);
396+
return HermesEngineSourceTypes.BUILD_FROM_HERMES_COMMIT;
397+
}
398+
// macOS]
399+
247400
hermesLog(
248401
'Using download prebuild nightly tarball - this is a fallback and might not work.',
249402
);
@@ -263,6 +416,8 @@ async function resolveSourceFromSourceType(
263416
return downloadPrebuildTarball(version, buildType, artifactsPath);
264417
case HermesEngineSourceTypes.DOWNLOAD_PREBUILT_NIGHTLY_TARBALL:
265418
return downloadPrebuiltNightlyTarball(version, buildType, artifactsPath);
419+
case HermesEngineSourceTypes.BUILD_FROM_HERMES_COMMIT: // [macOS]
420+
return buildFromHermesCommit(version, buildType, artifactsPath);
266421
default:
267422
abort(
268423
`[Hermes] Unsupported or invalid source type provided: ${sourceType}`,
@@ -369,11 +524,37 @@ async function downloadHermesTarball(
369524
return destPath;
370525
}
371526

527+
// [macOS
528+
/**
529+
* Handles the case where no prebuilt Hermes artifacts are available.
530+
* Determines the Hermes commit at the merge base with facebook/react-native
531+
* and provides actionable guidance for building Hermes.
532+
*/
533+
async function buildFromHermesCommit(
534+
version /*: string */,
535+
buildType /*: BuildFlavor */,
536+
artifactsPath /*: string */,
537+
) /*: Promise<string> */ {
538+
const {commit, timestamp} = hermesCommitAtMergeBase();
539+
abort(
540+
`[Hermes] No prebuilt Hermes artifacts available for version "${version}".\n` +
541+
`Hermes commit at merge base with facebook/react-native: ${commit} (timestamp: ${timestamp})\n` +
542+
`To resolve, either:\n` +
543+
` 1. Set HERMES_ENGINE_TARBALL_PATH to a local Hermes tarball path\n` +
544+
` 2. Set HERMES_VERSION to an upstream RN version with published artifacts (e.g., HERMES_VERSION=nightly)\n` +
545+
` 3. Build Hermes from commit ${commit} and provide the tarball path via HERMES_ENGINE_TARBALL_PATH`,
546+
);
547+
return ''; // unreachable
548+
}
549+
// macOS]
550+
372551
function abort(message /*: string */) {
373552
hermesLog(message, 'error');
374553
throw new Error(message);
375554
}
376555

377556
module.exports = {
378557
prepareHermesArtifactsAsync,
558+
findMatchingHermesVersion, // [macOS]
559+
hermesCommitAtMergeBase, // [macOS]
379560
};

0 commit comments

Comments
 (0)