From d74e70f4c97d7b9a1770944a82a209cc8888be6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Rolka?= Date: Fri, 26 Jun 2026 16:53:01 +0200 Subject: [PATCH 1/5] fix(iOS): hide react symbols from swift --- ios/RNSBarButtonItem.h | 4 ++++ ios/RNSFullWindowOverlay.h | 6 ++++++ ios/RNSScreen.h | 8 ++++++++ ios/RNSScreenContainer.h | 6 ++++++ ios/RNSScreenContentWrapper.h | 6 ++++++ ios/RNSScreenFooter.h | 6 ++++++ ios/RNSScreenNavigationContainer.h | 2 ++ ios/RNSScreenStack.h | 4 ++++ ios/RNSScreenStackHeaderConfig.h | 6 ++++++ ios/RNSScreenStackHeaderSubview.h | 6 ++++++ ios/RNSSearchBar.h | 8 ++++++-- ios/events/RNSHeaderHeightChangeEvent.h | 7 ++++++- ios/events/RNSScreenViewEvent.h | 7 ++++++- ios/helpers/image/RNSImageLoadingHelper.h | 4 ++++ ios/tabs/RCTConvert+RNSTabs.h | 3 +++ ios/tabs/RNSTabBarAppearanceCoordinator.h | 4 ++++ ios/tabs/host/RNSTabsHostComponentView.h | 4 ++++ ios/tabs/screen/RNSTabsScreenComponentView.h | 6 ++++++ ios/utils/RCTSurfaceTouchHandler+RNSUtility.h | 4 ++++ ios/utils/extensions/RCTImageSource+AccessHiddenMembers.h | 3 +++ 20 files changed, 100 insertions(+), 4 deletions(-) diff --git a/ios/RNSBarButtonItem.h b/ios/RNSBarButtonItem.h index ea5325ea8d..04c0608db2 100644 --- a/ios/RNSBarButtonItem.h +++ b/ios/RNSBarButtonItem.h @@ -1,6 +1,8 @@ #pragma once +#if defined(__cplusplus) #import +#endif //defined(__cplusplus) #import typedef void (^RNSBarButtonItemAction)(NSString *buttonId); @@ -8,9 +10,11 @@ typedef void (^RNSBarButtonMenuItemAction)(NSString *menuId); @interface RNSBarButtonItem : UIBarButtonItem +#if defined(__cplusplus) - (instancetype)initWithConfig:(NSDictionary *)dict action:(RNSBarButtonItemAction)action menuAction:(RNSBarButtonMenuItemAction)menuAction imageLoader:(RCTImageLoader *)imageLoader; +#endif //defined(__cplusplus) @end diff --git a/ios/RNSFullWindowOverlay.h b/ios/RNSFullWindowOverlay.h index e0c770a982..e1853cb82d 100644 --- a/ios/RNSFullWindowOverlay.h +++ b/ios/RNSFullWindowOverlay.h @@ -1,13 +1,19 @@ #pragma once +#if defined(__cplusplus) #import +#endif // defined(__cplusplus) #import "RNSReactBaseView.h" #if defined(__cplusplus) namespace react = facebook::react; #endif // __cplusplus +#if defined(__cplusplus) @interface RNSFullWindowOverlayManager : RCTViewManager +#else +@interface RNSFullWindowOverlayManager : NSObject +#endif // __cplusplus @end diff --git a/ios/RNSScreen.h b/ios/RNSScreen.h index 931c35cd5a..b1f482c10b 100644 --- a/ios/RNSScreen.h +++ b/ios/RNSScreen.h @@ -1,7 +1,9 @@ #pragma once +#if defined(__cplusplus) #import #import +#endif // __cplusplus #import "RNSEnums.h" #import "RNSSafeAreaProviding.h" @@ -22,6 +24,7 @@ namespace react = facebook::react; NS_ASSUME_NONNULL_BEGIN +#if defined(__cplusplus) @interface RCTConvert (RNSScreen) #if !TARGET_OS_TV @@ -31,6 +34,7 @@ NS_ASSUME_NONNULL_BEGIN #endif @end +#endif @class RNSScreenView; @@ -172,7 +176,11 @@ NS_ASSUME_NONNULL_BEGIN - (UIViewController *)parentViewController; @end +#if defined(__cplusplus) @interface RNSScreenManager : RCTViewManager +#else +@interface RNSScreenManager : NSObject +#endif // __cplusplus @end diff --git a/ios/RNSScreenContainer.h b/ios/RNSScreenContainer.h index e29f56aabe..5364873665 100644 --- a/ios/RNSScreenContainer.h +++ b/ios/RNSScreenContainer.h @@ -1,6 +1,8 @@ #pragma once +#if defined(__cplusplus) #import +#endif // __cplusplus #import "RNSReactBaseView.h" NS_ASSUME_NONNULL_BEGIN @@ -22,7 +24,11 @@ NS_ASSUME_NONNULL_BEGIN @end +#if defined(__cplusplus) @interface RNSScreenContainerManager : RCTViewManager +#else +@interface RNSScreenContainerManager : NSObject +#endif // __cplusplus @end diff --git a/ios/RNSScreenContentWrapper.h b/ios/RNSScreenContentWrapper.h index adb63eab0f..ffe052c9d8 100644 --- a/ios/RNSScreenContentWrapper.h +++ b/ios/RNSScreenContentWrapper.h @@ -1,6 +1,8 @@ #pragma once +#if defined(__cplusplus) #import +#endif // __cplusplus #import #import "RNSDefines.h" #import "RNSReactBaseView.h" @@ -43,7 +45,11 @@ typedef struct { @end +#if defined(__cplusplus) @interface RNSScreenContentWrapperManager : RCTViewManager +#else +@interface RNSScreenContentWrapperManager : NSObject +#endif // __cplusplus @end diff --git a/ios/RNSScreenFooter.h b/ios/RNSScreenFooter.h index 7a71ffc67f..261c7592aa 100644 --- a/ios/RNSScreenFooter.h +++ b/ios/RNSScreenFooter.h @@ -1,6 +1,8 @@ #pragma once +#if defined(__cplusplus) #import +#endif // __cplusplus #import #import "RNSReactBaseView.h" @@ -18,7 +20,11 @@ typedef void (^OnLayoutCallback)(CGRect frame); @end +#if defined(__cplusplus) @interface RNSScreenFooterManager : RCTViewManager +#else +@interface RNSScreenFooterManager : NSObject +#endif // __cplusplus @end diff --git a/ios/RNSScreenNavigationContainer.h b/ios/RNSScreenNavigationContainer.h index 2979dae3d4..195c8485a5 100644 --- a/ios/RNSScreenNavigationContainer.h +++ b/ios/RNSScreenNavigationContainer.h @@ -1,6 +1,8 @@ #pragma once +#if defined(__cplusplus) #import +#endif // __cplusplus #import "RNSScreenContainer.h" #import "RNSScreenStack.h" diff --git a/ios/RNSScreenStack.h b/ios/RNSScreenStack.h index 41b17bab4d..9ae11c30f6 100644 --- a/ios/RNSScreenStack.h +++ b/ios/RNSScreenStack.h @@ -47,7 +47,11 @@ NS_ASSUME_NONNULL_BEGIN @end +#if defined(__cplusplus) @interface RNSScreenStackManager : RCTViewManager +#else +@interface RNSScreenStackManager : NSObject +#endif // __cplusplus @end diff --git a/ios/RNSScreenStackHeaderConfig.h b/ios/RNSScreenStackHeaderConfig.h index 0b8fa60a24..e27d5ce77a 100644 --- a/ios/RNSScreenStackHeaderConfig.h +++ b/ios/RNSScreenStackHeaderConfig.h @@ -1,6 +1,8 @@ #pragma once +#if defined(__cplusplus) #import +#endif // __cplusplus #import "RNSReactBaseView.h" #import "RNSScreen.h" #import "RNSScreenStackHeaderSubview.h" @@ -107,6 +109,10 @@ NS_ASSUME_NONNULL_END #pragma mark - View Manager +#if defined(__cplusplus) @interface RNSScreenStackHeaderConfigManager : RCTViewManager +#else +@interface RNSScreenStackHeaderConfigManager : NSObject +#endif // __cplusplus @end diff --git a/ios/RNSScreenStackHeaderSubview.h b/ios/RNSScreenStackHeaderSubview.h index 864483f0d0..7a0ae71dd6 100644 --- a/ios/RNSScreenStackHeaderSubview.h +++ b/ios/RNSScreenStackHeaderSubview.h @@ -1,7 +1,9 @@ #pragma once +#if defined(__cplusplus) #import #import +#endif // __cplusplus #import "RNSEnums.h" #import "RNSReactBaseView.h" @@ -46,7 +48,11 @@ NS_ASSUME_NONNULL_BEGIN @end +#if defined(__cplusplus) @interface RNSScreenStackHeaderSubviewManager : RCTViewManager +#else +@interface RNSScreenStackHeaderSubviewManager : NSObject +#endif // __cplusplus @property (nonatomic) RNSScreenStackHeaderSubviewType type; diff --git a/ios/RNSSearchBar.h b/ios/RNSSearchBar.h index efdfbb09fc..e7c5c760e8 100644 --- a/ios/RNSSearchBar.h +++ b/ios/RNSSearchBar.h @@ -5,10 +5,10 @@ #if defined(__cplusplus) #import #import -#endif - #import #import +#endif + #import "RNSDefines.h" #import "RNSEnums.h" @@ -32,6 +32,10 @@ @end +#if defined(__cplusplus) @interface RNSSearchBarManager : RCTViewManager +#else +@interface RNSSearchBarManager : NSObject +#endif @end diff --git a/ios/events/RNSHeaderHeightChangeEvent.h b/ios/events/RNSHeaderHeightChangeEvent.h index ad3d88397e..ab7f8fca45 100644 --- a/ios/events/RNSHeaderHeightChangeEvent.h +++ b/ios/events/RNSHeaderHeightChangeEvent.h @@ -1,8 +1,13 @@ #pragma once +#if defined(__cplusplus) #import +#endif // defined(__cplusplus) -@interface RNSHeaderHeightChangeEvent : NSObject +@interface RNSHeaderHeightChangeEvent : NSObject +#if defined(__cplusplus) + +#endif // defined(__cplusplus) - (instancetype)initWithEventName:(NSString *)eventName reactTag:(NSNumber *)reactTag headerHeight:(double)headerHeight; diff --git a/ios/events/RNSScreenViewEvent.h b/ios/events/RNSScreenViewEvent.h index 12f56358bd..7d06ad9f6b 100644 --- a/ios/events/RNSScreenViewEvent.h +++ b/ios/events/RNSScreenViewEvent.h @@ -1,8 +1,13 @@ #pragma once +#if defined(__cplusplus) #import +#endif // defined(__cplusplus) -@interface RNSScreenViewEvent : NSObject +@interface RNSScreenViewEvent : NSObject +#if defined(__cplusplus) + +#endif // defined(__cplusplus) - (instancetype)initWithEventName:(NSString *)eventName reactTag:(NSNumber *)reactTag diff --git a/ios/helpers/image/RNSImageLoadingHelper.h b/ios/helpers/image/RNSImageLoadingHelper.h index b0ef5b0ba4..317fd5618e 100644 --- a/ios/helpers/image/RNSImageLoadingHelper.h +++ b/ios/helpers/image/RNSImageLoadingHelper.h @@ -1,5 +1,7 @@ #pragma once +#if defined(__cplusplus) + #import #import @@ -26,3 +28,5 @@ completionBlock:(void (^_Nonnull)(UIImage *_Nullable image))imageLoadingCompletionBlock; @end + +#endif //defined(__cplusplus) diff --git a/ios/tabs/RCTConvert+RNSTabs.h b/ios/tabs/RCTConvert+RNSTabs.h index 6473ec80b3..6d99c85d04 100644 --- a/ios/tabs/RCTConvert+RNSTabs.h +++ b/ios/tabs/RCTConvert+RNSTabs.h @@ -1,5 +1,6 @@ #pragma once +#if defined(__cplusplus) #import #import @@ -12,3 +13,5 @@ NS_ASSUME_NONNULL_BEGIN @end NS_ASSUME_NONNULL_END + +#endif // defined(__cplusplus) diff --git a/ios/tabs/RNSTabBarAppearanceCoordinator.h b/ios/tabs/RNSTabBarAppearanceCoordinator.h index 2125b5a6c1..9ec3391c2b 100644 --- a/ios/tabs/RNSTabBarAppearanceCoordinator.h +++ b/ios/tabs/RNSTabBarAppearanceCoordinator.h @@ -7,7 +7,9 @@ NS_ASSUME_NONNULL_BEGIN +#if defined (__cplusplus) @class RCTImageLoader; +#endif // defined(__cplusplus) /** * Responsible for creating & applying appearance to the tab bar. @@ -17,6 +19,7 @@ NS_ASSUME_NONNULL_BEGIN */ @interface RNSTabBarAppearanceCoordinator : NSObject +#if defined (__cplusplus) /** * Applies the tab bar appearance props to the tab bar and respective tab bar items, basing on information contained in * provided params. @@ -28,6 +31,7 @@ NS_ASSUME_NONNULL_BEGIN withHostComponentView:(nullable RNSTabsHostComponentView *)hostComponentView tabScreenControllers:(nullable NSArray *)tabScreenCtrls imageLoader:(nullable RCTImageLoader *)imageLoader; +#endif // defined(__cplusplus) /** * Configures UITabBarAppearance object using appearance props provided in the param. diff --git a/ios/tabs/host/RNSTabsHostComponentView.h b/ios/tabs/host/RNSTabsHostComponentView.h index a8f8df559f..d1a328f5de 100644 --- a/ios/tabs/host/RNSTabsHostComponentView.h +++ b/ios/tabs/host/RNSTabsHostComponentView.h @@ -11,7 +11,9 @@ NS_ASSUME_NONNULL_BEGIN +#if defined(__cplusplus) @class RCTImageLoader; +#endif // defined(__cplusplus) /** * Component view. Lifecycle is managed by React Native. @@ -72,7 +74,9 @@ NS_ASSUME_NONNULL_BEGIN @interface RNSTabsHostComponentView () +#if defined(__cplusplus) - (nullable RCTImageLoader *)reactImageLoader; +#endif // defined(__cplusplus) @end diff --git a/ios/tabs/screen/RNSTabsScreenComponentView.h b/ios/tabs/screen/RNSTabsScreenComponentView.h index 38805843e6..a367f84b81 100644 --- a/ios/tabs/screen/RNSTabsScreenComponentView.h +++ b/ios/tabs/screen/RNSTabsScreenComponentView.h @@ -1,6 +1,8 @@ #pragma once +#if defined(__cplusplus) #import +#endif // defined(__cplusplus) #import "RNSEnums.h" #import "RNSReactBaseView.h" #import "RNSSafeAreaProviding.h" @@ -45,10 +47,14 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly) RNSTabsIconType iconType; +#if defined(__cplusplus) @property (nonatomic, strong, readonly, nullable) RCTImageSource *iconImageSource; +#endif // defined(__cplusplus) @property (nonatomic, strong, readonly, nullable) NSString *iconResourceName; +#if defined(__cplusplus) @property (nonatomic, strong, readonly, nullable) RCTImageSource *selectedIconImageSource; +#endif // defined(__cplusplus) @property (nonatomic, strong, readonly, nullable) NSString *selectedIconResourceName; @property (nonatomic, strong, readonly, nullable) UITabBarAppearance *standardAppearance; diff --git a/ios/utils/RCTSurfaceTouchHandler+RNSUtility.h b/ios/utils/RCTSurfaceTouchHandler+RNSUtility.h index c747c9de64..d7fba55c0e 100644 --- a/ios/utils/RCTSurfaceTouchHandler+RNSUtility.h +++ b/ios/utils/RCTSurfaceTouchHandler+RNSUtility.h @@ -1,5 +1,7 @@ #pragma once +#if defined(__cplusplus) + #import NS_ASSUME_NONNULL_BEGIN @@ -11,3 +13,5 @@ NS_ASSUME_NONNULL_BEGIN @end NS_ASSUME_NONNULL_END + +#endif // defined(__cplusplus) diff --git a/ios/utils/extensions/RCTImageSource+AccessHiddenMembers.h b/ios/utils/extensions/RCTImageSource+AccessHiddenMembers.h index 6d4ad928d3..7b958e1524 100644 --- a/ios/utils/extensions/RCTImageSource+AccessHiddenMembers.h +++ b/ios/utils/extensions/RCTImageSource+AccessHiddenMembers.h @@ -1,5 +1,6 @@ #pragma once +#if defined(__cplusplus) // This field should exist in extension in `RCTImageSource.m` @interface RCTImageSource (AccessHiddenMembers) @@ -7,3 +8,5 @@ @property (nonatomic, assign) BOOL packagerAsset; @end + +#endif // defined(__cplusplus) From 369bd26141ad9686747e8c10c71f072b6e0dc153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Rolka?= Date: Mon, 29 Jun 2026 11:41:10 +0200 Subject: [PATCH 2/5] create check job for swift symbols --- .github/workflows/ios-build-test-fabric.yml | 6 + package.json | 1 + ...check-react-symbols-not-leaked-to-swift.sh | 128 ++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100755 scripts/ios/check-react-symbols-not-leaked-to-swift.sh diff --git a/.github/workflows/ios-build-test-fabric.yml b/.github/workflows/ios-build-test-fabric.yml index 8cff50f2a8..f0ebf4b91d 100644 --- a/.github/workflows/ios-build-test-fabric.yml +++ b/.github/workflows/ios-build-test-fabric.yml @@ -74,3 +74,9 @@ jobs: - name: Build app working-directory: ${{ env.WORKING_DIRECTORY }} run: npx react-native run-ios --no-packager --simulator="iPhone 17" + + # Verify that the build above did not leak any React (React-Core / Fabric) + # symbols into the RNScreens Clang module consumed by Swift. See the script + # header for the rationale and detection details. + - name: Check React symbols are not leaked to Swift + run: ./scripts/ios/check-react-symbols-not-leaked-to-swift.sh diff --git a/package.json b/package.json index d13ac28394..5c7968bd7c 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "submodules": "git submodule update --init --recursive && (cd react-navigation && yarn && yarn build && cd ../)", "check-types": "tsc --noEmit", + "check-ios-swift-react-leak": "sh scripts/ios/check-react-symbols-not-leaked-to-swift.sh", "start": "react-native start", "test:unit": "jest --passWithNoTests", "format-js": "prettier --write --list-different './{src,FabricExample,apps}/**/*.{js,ts,tsx}'", diff --git a/scripts/ios/check-react-symbols-not-leaked-to-swift.sh b/scripts/ios/check-react-symbols-not-leaked-to-swift.sh new file mode 100755 index 0000000000..f8140538d0 --- /dev/null +++ b/scripts/ios/check-react-symbols-not-leaked-to-swift.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# +# check-react-symbols-not-leaked-to-swift.sh +# +# Guards the invariant established by the "hide react symbols from swift" fix: +# the public Objective-C headers of the `RNScreens` module must NOT expose any +# React (React-Core / Fabric / ReactCommon ...) symbols to Swift. +# +# Why this matters +# ---------------- +# Swift consumes `RNScreens` through its Clang module. The Clang importer parses +# the public Objective-C headers in *Objective-C* mode (without `__cplusplus`). +# If a public header `#import`s a React header (or derives from a React type) +# outside of an `#if defined(__cplusplus)` guard, that React header is baked +# into the precompiled `RNScreens` Clang module (`.pcm`) and leaks into every +# Swift translation unit that does `import RNScreens`. +# +# How the check works +# ------------------- +# After the app is built, Xcode emits a precompiled module for `RNScreens` under +# `.../SwiftExplicitPrecompiledModules/RNScreens-*.pcm`. We dump its inputs with +# `clang -module-file-info` and assert that every input header is either: +# * a system / SDK header (UIKit, Foundation, the SDK itself), or +# * one of the module's own public headers (incl. RNS-owned `RCT*` categories +# such as `RCTConvert+RNSTabs.h`). +# Any input that lives outside the module's own header directory (e.g. a +# React-Core header like `RCTViewManager.h` or `RCTBridge.h`) is a leak and +# fails the check. +# +# Usage +# ----- +# scripts/ios/check-react-symbols-not-leaked-to-swift.sh [SEARCH_ROOT] +# +# SEARCH_ROOT defaults to ~/Library/Developer/Xcode/DerivedData. Pass a custom +# DerivedData path when the build uses one (e.g. `-derivedDataPath`). + +set -euo pipefail + +MODULE_NAME="RNScreens" +SEARCH_ROOT="${1:-$HOME/Library/Developer/Xcode/DerivedData}" + +if [[ ! -d "$SEARCH_ROOT" ]]; then + echo "error: search root not found: $SEARCH_ROOT" >&2 + echo " Build the app first so that the ${MODULE_NAME} Clang module is generated." >&2 + exit 1 +fi + +# Collect every precompiled module for RNScreens (there may be several, e.g. one +# per architecture / configuration). All of them must be clean. +pcms=() +while IFS= read -r pcm; do + pcms+=("$pcm") +done < <(find "$SEARCH_ROOT" \ + -path '*/SwiftExplicitPrecompiledModules/*' \ + -name "${MODULE_NAME}-*.pcm" 2>/dev/null | sort -u) + +if [[ ${#pcms[@]} -eq 0 ]]; then + echo "error: no ${MODULE_NAME}-*.pcm found under ${SEARCH_ROOT}." >&2 + echo " The Swift Clang module for ${MODULE_NAME} was not generated -" >&2 + echo " make sure the iOS app was built (with Swift) before running this check." >&2 + exit 1 +fi + +# Returns true (0) for headers that are allowed to back the module: +# * the module's own public headers (under the dir holding its module map), and +# * system / SDK headers. +is_allowed() { + local file="$1" module_dir="$2" + [[ -n "$module_dir" && "$file" == "$module_dir/"* ]] && return 0 + case "$file" in + *.platform/*|*.sdk/*|/Applications/Xcode*.app/*) return 0 ;; + esac + return 1 +} + +leak_found=0 + +for pcm in "${pcms[@]}"; do + echo "==> Inspecting $(basename "$pcm")" + + inputs=$(xcrun clang -module-file-info "$pcm" 2>/dev/null \ + | sed -n 's/^[[:space:]]*Input file:[[:space:]]*//p' \ + | sed 's/[[:space:]]*\[System\]$//') + + if [[ -z "$inputs" ]]; then + echo "error: could not read input files from $pcm" >&2 + exit 1 + fi + + # The module's own public headers live alongside its module map. + module_dir=$(printf '%s\n' "$inputs" | grep -E '/'"${MODULE_NAME}"'\.modulemap$' | head -n1 | xargs -I{} dirname {} 2>/dev/null || true) + if [[ -z "$module_dir" ]]; then + module_dir=$(printf '%s\n' "$inputs" | grep -E '/'"${MODULE_NAME}"'-umbrella\.h$' | head -n1 | xargs -I{} dirname {} 2>/dev/null || true) + fi + + leaks=() + while IFS= read -r file; do + [[ -z "$file" ]] && continue + if ! is_allowed "$file" "$module_dir"; then + leaks+=("$file") + fi + done <<< "$inputs" + + if [[ ${#leaks[@]} -gt 0 ]]; then + leak_found=1 + echo " ✗ React/foreign headers leaked into the ${MODULE_NAME} Swift module:" >&2 + for f in "${leaks[@]}"; do + echo " - $f" >&2 + done + else + echo " ✓ no foreign headers leaked" + fi +done + +if [[ $leak_found -ne 0 ]]; then + cat >&2 < Date: Mon, 29 Jun 2026 12:08:08 +0200 Subject: [PATCH 3/5] fix(iOS): hide react symbols from swift in gamma --- ios/gamma/split/RNSSplitHostComponentViewManager.h | 6 ++++++ ios/gamma/split/RNSSplitScreenComponentViewManager.h | 6 ++++++ ios/gamma/stack/host/RNSStackHostComponentViewManager.h | 6 ++++++ ios/gamma/stack/screen/RNSStackScreenComponentViewManager.h | 6 ++++++ 4 files changed, 24 insertions(+) diff --git a/ios/gamma/split/RNSSplitHostComponentViewManager.h b/ios/gamma/split/RNSSplitHostComponentViewManager.h index 54cd37668d..3ff8781a94 100644 --- a/ios/gamma/split/RNSSplitHostComponentViewManager.h +++ b/ios/gamma/split/RNSSplitHostComponentViewManager.h @@ -1,10 +1,16 @@ #pragma once +#if defined(__cplusplus) #import +#endif // __cplusplus NS_ASSUME_NONNULL_BEGIN +#if defined(__cplusplus) @interface RNSSplitHostComponentViewManager : RCTViewManager +#else +@interface RNSSplitHostComponentViewManager : NSObject +#endif // __cplusplus @end diff --git a/ios/gamma/split/RNSSplitScreenComponentViewManager.h b/ios/gamma/split/RNSSplitScreenComponentViewManager.h index 1c7ff74a15..16df4fa1a1 100644 --- a/ios/gamma/split/RNSSplitScreenComponentViewManager.h +++ b/ios/gamma/split/RNSSplitScreenComponentViewManager.h @@ -1,10 +1,16 @@ #pragma once +#if defined(__cplusplus) #import +#endif // __cplusplus NS_ASSUME_NONNULL_BEGIN +#if defined(__cplusplus) @interface RNSSplitScreenComponentViewManager : RCTViewManager +#else +@interface RNSSplitScreenComponentViewManager : NSObject +#endif @end diff --git a/ios/gamma/stack/host/RNSStackHostComponentViewManager.h b/ios/gamma/stack/host/RNSStackHostComponentViewManager.h index 17e1ae2d0a..a76988439a 100644 --- a/ios/gamma/stack/host/RNSStackHostComponentViewManager.h +++ b/ios/gamma/stack/host/RNSStackHostComponentViewManager.h @@ -1,10 +1,16 @@ #pragma once +#if defined(__cplusplus) #import +#endif // __cplusplus NS_ASSUME_NONNULL_BEGIN +#if defined(__cplusplus) @interface RNSStackHostComponentViewManager : RCTViewManager +#else +@interface RNSStackHostComponentViewManager : NSObject +#endif @end diff --git a/ios/gamma/stack/screen/RNSStackScreenComponentViewManager.h b/ios/gamma/stack/screen/RNSStackScreenComponentViewManager.h index c52edc9828..43acae760e 100644 --- a/ios/gamma/stack/screen/RNSStackScreenComponentViewManager.h +++ b/ios/gamma/stack/screen/RNSStackScreenComponentViewManager.h @@ -1,10 +1,16 @@ #pragma once +#if defined(__cplusplus) #import +#endif // __cplusplus NS_ASSUME_NONNULL_BEGIN +#if defined(__cplusplus) @interface RNSStackScreenComponentViewManager : RCTViewManager +#else +@interface RNSStackScreenComponentViewManager : NSObject +#endif @end From 17d63a5b9c5814d49d9bc8f7e313bfca71f11649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Rolka?= Date: Tue, 30 Jun 2026 10:27:26 +0200 Subject: [PATCH 4/5] change verification script to js --- .github/workflows/ios-build-test-fabric.yml | 2 +- package.json | 2 +- ...check-react-symbols-not-leaked-to-swift.js | 207 ++++++++++++++++++ ...check-react-symbols-not-leaked-to-swift.sh | 128 ----------- 4 files changed, 209 insertions(+), 130 deletions(-) create mode 100644 scripts/ios/check-react-symbols-not-leaked-to-swift.js delete mode 100755 scripts/ios/check-react-symbols-not-leaked-to-swift.sh diff --git a/.github/workflows/ios-build-test-fabric.yml b/.github/workflows/ios-build-test-fabric.yml index f0ebf4b91d..31117b9ba1 100644 --- a/.github/workflows/ios-build-test-fabric.yml +++ b/.github/workflows/ios-build-test-fabric.yml @@ -79,4 +79,4 @@ jobs: # symbols into the RNScreens Clang module consumed by Swift. See the script # header for the rationale and detection details. - name: Check React symbols are not leaked to Swift - run: ./scripts/ios/check-react-symbols-not-leaked-to-swift.sh + run: node ./scripts/ios/check-react-symbols-not-leaked-to-swift.js diff --git a/package.json b/package.json index 5c7968bd7c..e5c572ab8a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "submodules": "git submodule update --init --recursive && (cd react-navigation && yarn && yarn build && cd ../)", "check-types": "tsc --noEmit", - "check-ios-swift-react-leak": "sh scripts/ios/check-react-symbols-not-leaked-to-swift.sh", + "check-ios-swift-react-leak": "node scripts/ios/check-react-symbols-not-leaked-to-swift.js", "start": "react-native start", "test:unit": "jest --passWithNoTests", "format-js": "prettier --write --list-different './{src,FabricExample,apps}/**/*.{js,ts,tsx}'", diff --git a/scripts/ios/check-react-symbols-not-leaked-to-swift.js b/scripts/ios/check-react-symbols-not-leaked-to-swift.js new file mode 100644 index 0000000000..b00dc22627 --- /dev/null +++ b/scripts/ios/check-react-symbols-not-leaked-to-swift.js @@ -0,0 +1,207 @@ +#!/usr/bin/env node +// +// check-react-symbols-not-leaked-to-swift.js +// +// Guards the invariant established by the "hide react symbols from swift" fix: +// the public Objective-C headers of the `RNScreens` module must NOT expose any +// React (React-Core / Fabric / ReactCommon ...) symbols to Swift. +// +// Why this matters +// ---------------- +// Swift consumes `RNScreens` through its Clang module. The Clang importer parses +// the public Objective-C headers in *Objective-C* mode (without `__cplusplus`). +// If a public header `#import`s a React header (or derives from a React type) +// outside of an `#if defined(__cplusplus)` guard, that React header is baked +// into the precompiled `RNScreens` Clang module (`.pcm`) and leaks into every +// Swift translation unit that does `import RNScreens`. +// +// How the check works +// ------------------- +// After the app is built, Xcode emits a precompiled module for `RNScreens` under +// `.../SwiftExplicitPrecompiledModules/RNScreens-*.pcm`. We dump its inputs with +// `clang -module-file-info` and assert that every input header is either: +// * a system / SDK header (UIKit, Foundation, the SDK itself), or +// * one of the module's own public headers (incl. RNS-owned `RCT*` categories +// such as `RCTConvert+RNSTabs.h`). +// Any input that lives outside the module's own header directory (e.g. a +// React-Core header like `RCTViewManager.h` or `RCTBridge.h`) is a leak and +// fails the check. +// +// Usage +// ----- +// node scripts/ios/check-react-symbols-not-leaked-to-swift.js [SEARCH_ROOT] +// +// SEARCH_ROOT defaults to ~/Library/Developer/Xcode/DerivedData. Pass a custom +// DerivedData path when the build uses one (e.g. `-derivedDataPath`). + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const MODULE_NAME = 'RNScreens'; +const SEARCH_ROOT = + process.argv[2] || + path.join(os.homedir(), 'Library', 'Developer', 'Xcode', 'DerivedData'); + +function fail(...lines) { + console.error(lines.join('\n')); + process.exit(1); +} + +if (!isDirectory(SEARCH_ROOT)) { + fail( + `error: search root not found: ${SEARCH_ROOT}`, + ` Build the app first so that the ${MODULE_NAME} Clang module is generated.` + ); +} + +// Collect every precompiled module for RNScreens (there may be several, e.g. one +// per architecture / configuration). All of them must be clean. +const pcms = findPcms(SEARCH_ROOT).sort(); + +if (pcms.length === 0) { + fail( + `error: no ${MODULE_NAME}-*.pcm found under ${SEARCH_ROOT}.`, + ` The Swift Clang module for ${MODULE_NAME} was not generated -`, + ` make sure the iOS app was built (with Swift) before running this check.` + ); +} + +let leakFound = false; + +for (const pcm of pcms) { + console.log(`==> Inspecting ${path.basename(pcm)}`); + + const inputs = readModuleInputs(pcm); + + if (inputs.length === 0) { + fail(`error: could not read input files from ${pcm}`); + } + + // The module's own public headers live alongside its module map. + const moduleDir = resolveModuleDir(inputs); + + const leaks = inputs.filter((file) => !isAllowed(file, moduleDir)); + + if (leaks.length > 0) { + leakFound = true; + console.error( + ` ✗ React/foreign headers leaked into the ${MODULE_NAME} Swift module:` + ); + for (const file of leaks) { + console.error(` - ${file}`); + } + } else { + console.log(' ✓ no foreign headers leaked'); + } +} + +if (leakFound) { + fail( + '', + `React symbols are leaking into the ${MODULE_NAME} Swift module.`, + '', + 'A public Objective-C header imports a React header (or derives from a React', + "type) outside of an '#if defined(__cplusplus)' guard. Wrap the React import and", + 'the React-dependent declaration in such a guard so the symbol stays invisible to', + "Swift's (Objective-C) Clang importer. See the headers under ios/ for the pattern." + ); +} + +console.log(`All ${MODULE_NAME} Swift modules are free of leaked React symbols.`); + +// --------------------------------------------------------------------------- + +function isDirectory(p) { + try { + return fs.statSync(p).isDirectory(); + } catch { + return false; + } +} + +// Recursively find `*/SwiftExplicitPrecompiledModules/*/RNScreens-*.pcm`. +function findPcms(root) { + const pcmName = new RegExp(`^${MODULE_NAME}-.*\\.pcm$`); + const results = []; + + const walk = (dir) => { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; // unreadable directory - skip, mirroring `find`'s 2>/dev/null + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if ( + entry.isFile() && + pcmName.test(entry.name) && + full.includes(`${path.sep}SwiftExplicitPrecompiledModules${path.sep}`) + ) { + results.push(full); + } + } + }; + + walk(root); + // Dedupe (a path can only appear once from a single walk, but keep parity + // with the original `sort -u`). + return [...new Set(results)]; +} + +// Dump the precompiled module's inputs and return the list of header paths. +function readModuleInputs(pcm) { + let output; + try { + output = execFileSync('xcrun', ['clang', '-module-file-info', pcm], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + return []; + } + + return output + .split('\n') + .map((line) => { + const match = line.match(/^\s*Input file:\s*(.*)$/); + if (!match) { + return null; + } + // Strip a trailing " [System]" marker. + return match[1].replace(/\s*\[System\]$/, ''); + }) + .filter((file) => file !== null && file.length > 0); +} + +// The module map (or its umbrella header) sits in the module's own header dir. +function resolveModuleDir(inputs) { + const moduleMap = inputs.find((file) => + new RegExp(`/${MODULE_NAME}\\.modulemap$`).test(file) + ); + if (moduleMap) { + return path.dirname(moduleMap); + } + const umbrella = inputs.find((file) => + new RegExp(`/${MODULE_NAME}-umbrella\\.h$`).test(file) + ); + return umbrella ? path.dirname(umbrella) : ''; +} + +// Headers allowed to back the module: +// * the module's own public headers (under the dir holding its module map), and +// * system / SDK headers. +function isAllowed(file, moduleDir) { + if (moduleDir && file.startsWith(`${moduleDir}/`)) { + return true; + } + return ( + /\.platform\//.test(file) || + /\.sdk\//.test(file) || + /\/Applications\/Xcode[^/]*\.app\//.test(file) + ); +} diff --git a/scripts/ios/check-react-symbols-not-leaked-to-swift.sh b/scripts/ios/check-react-symbols-not-leaked-to-swift.sh deleted file mode 100755 index f8140538d0..0000000000 --- a/scripts/ios/check-react-symbols-not-leaked-to-swift.sh +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env bash -# -# check-react-symbols-not-leaked-to-swift.sh -# -# Guards the invariant established by the "hide react symbols from swift" fix: -# the public Objective-C headers of the `RNScreens` module must NOT expose any -# React (React-Core / Fabric / ReactCommon ...) symbols to Swift. -# -# Why this matters -# ---------------- -# Swift consumes `RNScreens` through its Clang module. The Clang importer parses -# the public Objective-C headers in *Objective-C* mode (without `__cplusplus`). -# If a public header `#import`s a React header (or derives from a React type) -# outside of an `#if defined(__cplusplus)` guard, that React header is baked -# into the precompiled `RNScreens` Clang module (`.pcm`) and leaks into every -# Swift translation unit that does `import RNScreens`. -# -# How the check works -# ------------------- -# After the app is built, Xcode emits a precompiled module for `RNScreens` under -# `.../SwiftExplicitPrecompiledModules/RNScreens-*.pcm`. We dump its inputs with -# `clang -module-file-info` and assert that every input header is either: -# * a system / SDK header (UIKit, Foundation, the SDK itself), or -# * one of the module's own public headers (incl. RNS-owned `RCT*` categories -# such as `RCTConvert+RNSTabs.h`). -# Any input that lives outside the module's own header directory (e.g. a -# React-Core header like `RCTViewManager.h` or `RCTBridge.h`) is a leak and -# fails the check. -# -# Usage -# ----- -# scripts/ios/check-react-symbols-not-leaked-to-swift.sh [SEARCH_ROOT] -# -# SEARCH_ROOT defaults to ~/Library/Developer/Xcode/DerivedData. Pass a custom -# DerivedData path when the build uses one (e.g. `-derivedDataPath`). - -set -euo pipefail - -MODULE_NAME="RNScreens" -SEARCH_ROOT="${1:-$HOME/Library/Developer/Xcode/DerivedData}" - -if [[ ! -d "$SEARCH_ROOT" ]]; then - echo "error: search root not found: $SEARCH_ROOT" >&2 - echo " Build the app first so that the ${MODULE_NAME} Clang module is generated." >&2 - exit 1 -fi - -# Collect every precompiled module for RNScreens (there may be several, e.g. one -# per architecture / configuration). All of them must be clean. -pcms=() -while IFS= read -r pcm; do - pcms+=("$pcm") -done < <(find "$SEARCH_ROOT" \ - -path '*/SwiftExplicitPrecompiledModules/*' \ - -name "${MODULE_NAME}-*.pcm" 2>/dev/null | sort -u) - -if [[ ${#pcms[@]} -eq 0 ]]; then - echo "error: no ${MODULE_NAME}-*.pcm found under ${SEARCH_ROOT}." >&2 - echo " The Swift Clang module for ${MODULE_NAME} was not generated -" >&2 - echo " make sure the iOS app was built (with Swift) before running this check." >&2 - exit 1 -fi - -# Returns true (0) for headers that are allowed to back the module: -# * the module's own public headers (under the dir holding its module map), and -# * system / SDK headers. -is_allowed() { - local file="$1" module_dir="$2" - [[ -n "$module_dir" && "$file" == "$module_dir/"* ]] && return 0 - case "$file" in - *.platform/*|*.sdk/*|/Applications/Xcode*.app/*) return 0 ;; - esac - return 1 -} - -leak_found=0 - -for pcm in "${pcms[@]}"; do - echo "==> Inspecting $(basename "$pcm")" - - inputs=$(xcrun clang -module-file-info "$pcm" 2>/dev/null \ - | sed -n 's/^[[:space:]]*Input file:[[:space:]]*//p' \ - | sed 's/[[:space:]]*\[System\]$//') - - if [[ -z "$inputs" ]]; then - echo "error: could not read input files from $pcm" >&2 - exit 1 - fi - - # The module's own public headers live alongside its module map. - module_dir=$(printf '%s\n' "$inputs" | grep -E '/'"${MODULE_NAME}"'\.modulemap$' | head -n1 | xargs -I{} dirname {} 2>/dev/null || true) - if [[ -z "$module_dir" ]]; then - module_dir=$(printf '%s\n' "$inputs" | grep -E '/'"${MODULE_NAME}"'-umbrella\.h$' | head -n1 | xargs -I{} dirname {} 2>/dev/null || true) - fi - - leaks=() - while IFS= read -r file; do - [[ -z "$file" ]] && continue - if ! is_allowed "$file" "$module_dir"; then - leaks+=("$file") - fi - done <<< "$inputs" - - if [[ ${#leaks[@]} -gt 0 ]]; then - leak_found=1 - echo " ✗ React/foreign headers leaked into the ${MODULE_NAME} Swift module:" >&2 - for f in "${leaks[@]}"; do - echo " - $f" >&2 - done - else - echo " ✓ no foreign headers leaked" - fi -done - -if [[ $leak_found -ne 0 ]]; then - cat >&2 < Date: Wed, 1 Jul 2026 12:34:16 +0200 Subject: [PATCH 5/5] fix script to pass on CI runners --- ...check-react-symbols-not-leaked-to-swift.js | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/scripts/ios/check-react-symbols-not-leaked-to-swift.js b/scripts/ios/check-react-symbols-not-leaked-to-swift.js index b00dc22627..478297981f 100644 --- a/scripts/ios/check-react-symbols-not-leaked-to-swift.js +++ b/scripts/ios/check-react-symbols-not-leaked-to-swift.js @@ -121,9 +121,20 @@ function isDirectory(p) { } } -// Recursively find `*/SwiftExplicitPrecompiledModules/*/RNScreens-*.pcm`. +// Recursively find every `RNScreens-*.pcm`. The precompiled module can land in +// different directories depending on the toolchain / build mode: +// * `SwiftExplicitPrecompiledModules/` - Swift's explicit-module build +// * `ExplicitPrecompiledModules/` - Clang's explicit-module build +// (what Xcode 26 emits from the CLI) +// * `ModuleCache.noindex/` - implicit module cache +// We accept all of them and let the caller decide which are relevant. function findPcms(root) { const pcmName = new RegExp(`^${MODULE_NAME}-.*\\.pcm$`); + const dirMatchers = [ + 'SwiftExplicitPrecompiledModules', + 'ExplicitPrecompiledModules', + 'ModuleCache.noindex', + ].map((name) => `${path.sep}${name}${path.sep}`); const results = []; const walk = (dir) => { @@ -137,12 +148,10 @@ function findPcms(root) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { walk(full); - } else if ( - entry.isFile() && - pcmName.test(entry.name) && - full.includes(`${path.sep}SwiftExplicitPrecompiledModules${path.sep}`) - ) { - results.push(full); + } else if (entry.isFile() && pcmName.test(entry.name)) { + if (dirMatchers.some((needle) => full.includes(needle))) { + results.push(full); + } } } };