From e347c802e29e629c7799cb0db31ab56eda79f156 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Apr 2026 11:38:59 -0700 Subject: [PATCH 1/6] fix(ci): align Resolve Hermes with HermesV1 / hermesvm.xcframework Package.swift and ios-prebuild/hermes.js already reference hermesvm.xcframework (the HermesV1 product introduced in RN 0.83), but the Resolve Hermes job still recomposed hermes.xcframework and resolved Hermes commits on the legacy `main` branch. With the upstream build scripts now unconditionally producing hermesvm, this left the SPM build path internally inconsistent. - microsoft-hermes.js: resolve commits on Hermes `static_h` branch; add `hermesV1Tag()` helper that reads sdks/.hermesv1version - resolve-hermes.mts: recompose hermesvm.xcframework / hermesvm.framework - microsoft-resolve-hermes.yml: bump cache key (hermes-v1- -> hermesv1-engine-) so stale V0-shaped caches aren't reused as V1 artifacts Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/scripts/resolve-hermes.mts | 15 ++++---- .../workflows/microsoft-resolve-hermes.yml | 4 +- .../scripts/ios-prebuild/microsoft-hermes.js | 37 ++++++++++++++++--- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/.github/scripts/resolve-hermes.mts b/.github/scripts/resolve-hermes.mts index 8d6289573593..bfd3446d1b30 100644 --- a/.github/scripts/resolve-hermes.mts +++ b/.github/scripts/resolve-hermes.mts @@ -125,8 +125,9 @@ async function downloadUpstreamHermesTarball( * Extracts an upstream Hermes tarball and recomposes the xcframework to include * the macOS slice, if needed. * - * Upstream tarballs ship a universal xcframework (iOS, simulator, catalyst, - * tvOS, visionOS) plus a standalone macosx/hermes.framework. This function + * As of RN 0.83, the Hermes binary product is `hermesvm` (HermesV1). Upstream + * tarballs ship a universal `hermesvm.xcframework` (iOS, simulator, catalyst, + * tvOS, visionOS) plus a standalone `macosx/hermesvm.framework`. This function * merges the standalone macOS framework into the universal xcframework using * `xcodebuild -create-xcframework`. * @@ -147,7 +148,7 @@ async function recomposeHermesXcframework( await $`tar -xzf ${tarballPath} -C ${destroot} --strip-components=2`; const frameworksDir = path.join(destroot, 'Library', 'Frameworks'); - const xcfwPath = path.join(frameworksDir, 'universal', 'hermes.xcframework'); + const xcfwPath = path.join(frameworksDir, 'universal', 'hermesvm.xcframework'); echo('Upstream tarball contents:'); await $`ls -la ${frameworksDir}`; @@ -167,16 +168,16 @@ async function recomposeHermesXcframework( } // Check for standalone macOS framework - const standaloneMacFw = path.join(frameworksDir, 'macosx', 'hermes.framework'); + const standaloneMacFw = path.join(frameworksDir, 'macosx', 'hermesvm.framework'); if (!fs.existsSync(standaloneMacFw)) { - echo('ERROR: Upstream tarball missing macosx/hermes.framework'); + echo('ERROR: Upstream tarball missing macosx/hermesvm.framework'); return false; } // Collect existing frameworks from inside the universal xcframework const frameworkArgs: string[] = []; for (const entry of xcfwContents) { - const fwPath = path.join(xcfwPath, entry, 'hermes.framework'); + const fwPath = path.join(xcfwPath, entry, 'hermesvm.framework'); if (fs.existsSync(fwPath) && fs.statSync(fwPath).isDirectory()) { echo(`Found slice: ${fwPath}`); frameworkArgs.push('-framework', fwPath); @@ -188,7 +189,7 @@ async function recomposeHermesXcframework( frameworkArgs.push('-framework', standaloneMacFw); // Build new xcframework at a temp path (frameworks reference paths inside the old xcfw) - const xcfwNew = path.join(frameworksDir, 'universal', 'hermes-new.xcframework'); + const xcfwNew = path.join(frameworksDir, 'universal', 'hermesvm-new.xcframework'); const sliceCount = frameworkArgs.filter(f => f !== '-framework').length; echo(`Creating new universal xcframework with ${sliceCount} slices...`); await $`xcodebuild -create-xcframework ${frameworkArgs} -output ${xcfwNew} -allow-internal-distribution`; diff --git a/.github/workflows/microsoft-resolve-hermes.yml b/.github/workflows/microsoft-resolve-hermes.yml index 78f590715ddc..2c6cb2a05fcb 100644 --- a/.github/workflows/microsoft-resolve-hermes.yml +++ b/.github/workflows/microsoft-resolve-hermes.yml @@ -79,7 +79,7 @@ jobs: id: cache uses: actions/cache/restore@v4 with: - key: hermes-v1-${{ steps.resolve.outputs.hermes-commit }}-Debug + key: hermesv1-engine-${{ steps.resolve.outputs.hermes-commit }}-Debug path: hermes-destroot - name: Upload cached Hermes artifacts @@ -249,7 +249,7 @@ jobs: - name: Save Hermes cache uses: actions/cache/save@v4 with: - key: hermes-v1-${{ needs.resolve-hermes.outputs.hermes-commit }}-Debug + key: hermesv1-engine-${{ needs.resolve-hermes.outputs.hermes-commit }}-Debug path: hermes/destroot - name: Upload Hermes artifacts diff --git a/packages/react-native/scripts/ios-prebuild/microsoft-hermes.js b/packages/react-native/scripts/ios-prebuild/microsoft-hermes.js index 9bdeec72d8fc..f882d749cc05 100644 --- a/packages/react-native/scripts/ios-prebuild/microsoft-hermes.js +++ b/packages/react-native/scripts/ios-prebuild/microsoft-hermes.js @@ -56,6 +56,29 @@ function findMatchingHermesVersion( return null; } +/** + * Reads the pinned HermesV1 tag from sdks/.hermesv1version. + * + * Returns a value like 'hermes-v250829098.0.2', which can be used as a git ref + * when checking out facebook/hermes for from-source builds. Returns null if the + * file is missing or empty. + */ +function hermesV1Tag() /*: ?string */ { + const tagFile = path.resolve( + __dirname, + '..', + '..', + 'sdks', + '.hermesv1version', + ); + try { + const tag = fs.readFileSync(tagFile, 'utf8').trim(); + return tag.length > 0 ? tag : null; + } catch (_) { + return null; + } +} + /** * Finds the Hermes commit at the merge base with facebook/react-native. * Used on the main branch (1000.0.0) where no prebuilt artifacts exist. @@ -64,11 +87,15 @@ function findMatchingHermesVersion( * the latest Hermes commit because Hermes and JSI don't always guarantee backwards compatibility. * Instead, we take the commit hash of Hermes at the time of the merge base with facebook/react-native. * + * Hermes ships HermesV1 on the `static_h` branch as of RN 0.83, and the macOS fork's + * SPM build path consumes hermesvm — so we resolve against `static_h`, not `main`. + * * This is the JavaScript equivalent of the Ruby `hermes_commit_at_merge_base` * in sdks/hermes-engine/hermes-utils.rb. */ function hermesCommitAtMergeBase() /*: {| commit: string, timestamp: string |} */ { const HERMES_GITHUB_URL = 'https://github.com/facebook/hermes.git'; + const HERMES_BRANCH = 'static_h'; // Fetch upstream react-native macosLog('Fetching facebook/react-native to find merge base...'); @@ -110,21 +137,20 @@ function hermesCommitAtMergeBase() /*: {| commit: string, timestamp: string |} * const hermesGitDir = path.join(tmpDir, 'hermes.git'); try { - // Explicitly use Hermes 'main' branch since the default branch changed to 'static_h' (Hermes V1) execSync( - `git clone -q --bare --filter=blob:none --single-branch --branch main ${HERMES_GITHUB_URL} "${hermesGitDir}"`, + `git clone -q --bare --filter=blob:none --single-branch --branch ${HERMES_BRANCH} ${HERMES_GITHUB_URL} "${hermesGitDir}"`, {stdio: 'pipe', timeout: 120000}, ); - // Find the Hermes commit at the time of the merge base on branch 'main' + // Find the Hermes commit at the time of the merge base on the HermesV1 branch const commit = execSync( - `git --git-dir="${hermesGitDir}" rev-list -1 --before="${timestamp}" refs/heads/main`, + `git --git-dir="${hermesGitDir}" rev-list -1 --before="${timestamp}" refs/heads/${HERMES_BRANCH}`, {encoding: 'utf8'}, ).trim(); if (!commit) { abort( - `[Hermes] Unable to find the Hermes commit hash at time ${timestamp} on branch 'main'.`, + `[Hermes] Unable to find the Hermes commit hash at time ${timestamp} on branch '${HERMES_BRANCH}'.`, ); } @@ -196,6 +222,7 @@ function abort(message /*: string */) { module.exports = { findMatchingHermesVersion, hermesCommitAtMergeBase, + hermesV1Tag, findVersionAtMergeBase, getLatestStableVersionFromNPM, }; From 46b8d38f722c09c86bf5d2383dd67fe7a4079147 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Apr 2026 11:41:46 -0700 Subject: [PATCH 2/6] fix(ci): trigger PR gate on 0.83-merge base branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR-gate workflow's `branches` filter only matched `main`, `*-stable`, and `release/*`, so PRs targeting `0.83-merge` skipped every required check — including the very Resolve-Hermes / Prebuild-macOS-Core jobs we want to exercise on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/microsoft-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/microsoft-pr.yml b/.github/workflows/microsoft-pr.yml index ee3a01e5dd01..f408a1b0c547 100644 --- a/.github/workflows/microsoft-pr.yml +++ b/.github/workflows/microsoft-pr.yml @@ -3,7 +3,7 @@ name: PR on: pull_request: types: [opened, synchronize, edited] - branches: [ "main", "*-stable", "release/*" ] + branches: [ "main", "*-stable", "release/*", "0.83-merge" ] concurrency: # Ensure single build of a pull request. `main` should not be affected. From e7269a0c79f572a982e677813a741403af3bbc89 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Apr 2026 14:19:06 -0700 Subject: [PATCH 3/6] fix(ci): pass CMAKE_BUILD_TYPE when building host hermesc for V1 HermesV1's static_h CMakeLists requires CMAKE_BUILD_TYPE to be set; the upstream `build_host_hermesc` helper in build-apple-framework.sh doesn't pass it, so the from-source fallback in Resolve Hermes failed at the hermesc step with "Please set CMAKE_BUILD_TYPE". Invoke cmake directly from the workflow with -DCMAKE_BUILD_TYPE=Release (the host compiler is always built Release for perf, matching configure_apple_framework's behavior in Debug builds of the apple frameworks). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/microsoft-resolve-hermes.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/microsoft-resolve-hermes.yml b/.github/workflows/microsoft-resolve-hermes.yml index 2c6cb2a05fcb..780f52cadae9 100644 --- a/.github/workflows/microsoft-resolve-hermes.yml +++ b/.github/workflows/microsoft-resolve-hermes.yml @@ -118,6 +118,8 @@ jobs: ref: ${{ needs.resolve-hermes.outputs.hermes-commit }} path: hermes + # HermesV1's CMakeLists requires CMAKE_BUILD_TYPE explicitly. The upstream + # `build_host_hermesc` helper doesn't set it, so we invoke cmake directly here. - name: Build hermesc working-directory: hermes env: @@ -125,8 +127,8 @@ jobs: JSI_PATH: ${{ github.workspace }}/hermes/API/jsi MAC_DEPLOYMENT_TARGET: '14.0' run: | - source $GITHUB_WORKSPACE/packages/react-native/sdks/hermes-engine/utils/build-apple-framework.sh - build_host_hermesc + cmake -S . -B build_host_hermesc -DJSI_DIR="$JSI_PATH" -DCMAKE_BUILD_TYPE=Release + cmake --build ./build_host_hermesc --target hermesc -j "$(sysctl -n hw.ncpu)" - name: Upload hermesc artifact uses: actions/upload-artifact@v4 From 6512c887ed400a72d70a3078224d42075910455e Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Apr 2026 14:32:20 -0700 Subject: [PATCH 4/6] fix(hermes): tolerate missing legacy inspector headers under HermesV1 HermesV1 (static_h) replaces the legacy API/hermes/inspector/ headers with API/hermes/cdp/, so the unconditional cp commands in build_apple_framework and prepare_dest_root_for_ci fail with 'No such file or directory' when building from V1 sources. Guard each copy with compgen so the copy is skipped cleanly when the source directory is empty. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../utils/build-apple-framework.sh | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/react-native/sdks/hermes-engine/utils/build-apple-framework.sh b/packages/react-native/sdks/hermes-engine/utils/build-apple-framework.sh index dbb363bd6870..bc62858e6dd9 100755 --- a/packages/react-native/sdks/hermes-engine/utils/build-apple-framework.sh +++ b/packages/react-native/sdks/hermes-engine/utils/build-apple-framework.sh @@ -163,11 +163,17 @@ function build_apple_framework { mkdir -p destroot/include/hermes/cdp cp API/hermes/cdp/*.h destroot/include/hermes/cdp - mkdir -p destroot/include/hermes/inspector - cp API/hermes/inspector/*.h destroot/include/hermes/inspector + # [macOS] HermesV1 (static_h) drops the legacy inspector headers in favour of + # API/hermes/cdp/. Skip the copy when the source directory has no headers. + if compgen -G "API/hermes/inspector/*.h" > /dev/null; then + mkdir -p destroot/include/hermes/inspector + cp API/hermes/inspector/*.h destroot/include/hermes/inspector + fi - mkdir -p destroot/include/hermes/inspector/chrome - cp API/hermes/inspector/chrome/*.h destroot/include/hermes/inspector/chrome + if compgen -G "API/hermes/inspector/chrome/*.h" > /dev/null; then + mkdir -p destroot/include/hermes/inspector/chrome + cp API/hermes/inspector/chrome/*.h destroot/include/hermes/inspector/chrome + fi mkdir -p destroot/include/jsi cp "$JSI_PATH"/jsi/*.h destroot/include/jsi @@ -193,11 +199,17 @@ function prepare_dest_root_for_ci { mkdir -p destroot/include/hermes/cdp cp API/hermes/cdp/*.h destroot/include/hermes/cdp - mkdir -p destroot/include/hermes/inspector - cp API/hermes/inspector/*.h destroot/include/hermes/inspector + # [macOS] HermesV1 (static_h) drops the legacy inspector headers in favour of + # API/hermes/cdp/. Skip the copy when the source directory has no headers. + if compgen -G "API/hermes/inspector/*.h" > /dev/null; then + mkdir -p destroot/include/hermes/inspector + cp API/hermes/inspector/*.h destroot/include/hermes/inspector + fi - mkdir -p destroot/include/hermes/inspector/chrome - cp API/hermes/inspector/chrome/*.h destroot/include/hermes/inspector/chrome + if compgen -G "API/hermes/inspector/chrome/*.h" > /dev/null; then + mkdir -p destroot/include/hermes/inspector/chrome + cp API/hermes/inspector/chrome/*.h destroot/include/hermes/inspector/chrome + fi mkdir -p destroot/include/jsi cp "$JSI_PATH"/jsi/*.h destroot/include/jsi From f712a73bd0228e3cf8dab574eeeb24a804b90a42 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Apr 2026 14:58:56 -0700 Subject: [PATCH 5/6] fix(spm): define HERMES_V1_ENABLED so V1-aware guards take effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Package.swift links against hermesvm.xcframework (HermesV1) but never defines HERMES_V1_ENABLED, so headers like ReactCommon/hermes/inspector-modern/chrome/Registration.h still try to #include — a legacy header V1 dropped. Set HERMES_V1_ENABLED=1 in the shared cxxSettings (alongside USE_HERMES); this matches what cocoapods/utils.rb does when RCT_HERMES_V1_ENABLED=1. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/react-native/Package.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/react-native/Package.swift b/packages/react-native/Package.swift index f6eefe500acd..ba1d02178ef6 100644 --- a/packages/react-native/Package.swift +++ b/packages/react-native/Package.swift @@ -945,6 +945,11 @@ extension Target { .define("DEBUG", .when(configuration: .debug)), .define("NDEBUG", .when(configuration: .release)), .define("USE_HERMES", to: "1"), + // [macOS] The SPM build links against hermesvm.xcframework (HermesV1). + // Several headers (e.g. ReactCommon/hermes/inspector-modern/chrome/Registration.h) + // gate legacy inspector code on `!defined(HERMES_V1_ENABLED)`, matching what the + // CocoaPods path sets via cocoapods/utils.rb when RCT_HERMES_V1_ENABLED=1. + .define("HERMES_V1_ENABLED", to: "1"), ] + defines + cxxCommonHeaderPaths return .target( From 44245c8d3f32efc58102ebd3cc2794b4adec98a9 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Apr 2026 21:08:38 -0700 Subject: [PATCH 6/6] fix(macos): use platform aliases for UIView/UIColor in RCTViewComponentView The post-merge SPM macOS build failed with `unknown type name 'UIView'` in iOS-style code introduced upstream (effectiveContentView and the SwiftUI-based filter path), and with duplicate-declaration errors for focus/blur/handleCommand/becomeFirstResponder/resignFirstResponder because the macOS section already provides macOS-specific versions. - Swap raw UIView */UIColor * for RCTPlatformView */RCTUIColor * (compatibility aliases that map to UIView/UIColor on iOS and NSView/NSColor on macOS). - Wrap the iOS-only canBecomeFirstResponder + focus-related responder methods in `#if !TARGET_OS_OSX` so they don't collide with the macOS implementations and don't break AppKit's responder model (NSView uses acceptsFirstResponder). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../View/RCTViewComponentView.mm | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index 17945ce64e40..73068815def1 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -807,7 +807,7 @@ - (RCTPlatformView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event // BOOL isPointInside = [self pointInside:point withEvent:event]; - UIView *currentContainerView = self.currentContainerView; + RCTPlatformView *currentContainerView = self.currentContainerView; // [macOS] BOOL clipsToBounds = false; @@ -1045,19 +1045,19 @@ - (BOOL)styleWouldClipOverflowInk // `blur` applied, we need to wrap it in a SwiftUI view to render the effect. // In this case, `effectiveContentView` will be the content view inside the // SwiftUI wrapper. -- (UIView *)effectiveContentView +- (RCTPlatformView *)effectiveContentView // [macOS] { if (!ReactNativeFeatureFlags::enableSwiftUIBasedFilters()) { return self; } - UIView *effectiveContentView = self; + RCTPlatformView *effectiveContentView = self; // [macOS] if (self.styleNeedsSwiftUIContainer) { if (_swiftUIWrapper == nullptr) { _swiftUIWrapper = [RCTSwiftUIContainerViewWrapper new]; - UIView *swiftUIContentView = [[UIView alloc] init]; - for (UIView *subview = nullptr in self.subviews) { + RCTPlatformView *swiftUIContentView = [[RCTPlatformView alloc] init]; // [macOS] + for (RCTPlatformView *subview = nullptr in self.subviews) { // [macOS] [swiftUIContentView addSubview:subview]; } swiftUIContentView.clipsToBounds = self.clipsToBounds; @@ -1074,8 +1074,8 @@ - (UIView *)effectiveContentView effectiveContentView = _swiftUIWrapper.contentView; } else { if (_swiftUIWrapper != nullptr) { - UIView *swiftUIContentView = _swiftUIWrapper.contentView; - for (UIView *subview = nullptr in swiftUIContentView.subviews) { + RCTPlatformView *swiftUIContentView = _swiftUIWrapper.contentView; // [macOS] + for (RCTPlatformView *subview = nullptr in swiftUIContentView.subviews) { // [macOS] [self addSubview:subview]; } self.clipsToBounds = swiftUIContentView.clipsToBounds; @@ -1096,7 +1096,7 @@ - (UIView *)effectiveContentView // the view and is not affected by clipping. - (RCTUIView *)currentContainerView // [macOS] { - UIView *effectiveContentView = self.effectiveContentView; + RCTPlatformView *effectiveContentView = self.effectiveContentView; // [macOS] if (_useCustomContainerView) { if (!_containerView) { @@ -1357,7 +1357,7 @@ - (void)invalidateLayer if (primitive.type == FilterType::DropShadow) { if (_swiftUIWrapper != nullptr && std::holds_alternative(primitive.parameters)) { const auto &dropShadowParams = std::get(primitive.parameters); - UIColor *shadowColor = RCTUIColorFromSharedColor(dropShadowParams.color); + RCTUIColor *shadowColor = RCTUIColorFromSharedColor(dropShadowParams.color); // [macOS] [_swiftUIWrapper updateDropShadow:@(dropShadowParams.standardDeviation) x:@(dropShadowParams.offsetX) y:@(dropShadowParams.offsetY) @@ -2409,7 +2409,7 @@ - (BOOL)styleNeedsSwiftUIContainer return NO; } -- (void)transferVisualPropertiesFromView:(UIView *)sourceView toView:(UIView *)destinationView +- (void)transferVisualPropertiesFromView:(RCTPlatformView *)sourceView toView:(RCTPlatformView *)destinationView // [macOS] { // shadow destinationView.layer.shadowColor = sourceView.layer.shadowColor; @@ -2457,6 +2457,12 @@ - (void)transferVisualPropertiesFromView:(UIView *)sourceView toView:(UIView *)d } } +#if !TARGET_OS_OSX // [macOS] +// macOS provides its own implementations of these methods inside the +// `#if TARGET_OS_OSX` block above (see ~line 1846 onwards), so the iOS +// definitions must be guarded to avoid duplicate-declaration errors and +// to keep AppKit's responder model intact (NSView uses +// `acceptsFirstResponder` rather than `canBecomeFirstResponder`). - (BOOL)canBecomeFirstResponder { return YES; @@ -2512,6 +2518,7 @@ - (BOOL)resignFirstResponder return YES; } +#endif // [macOS] @end