From f1b5447b9445e9c7b09c349f7a2c94f7240df0ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Trzci=C5=84ski?= Date: Wed, 6 May 2026 17:19:25 +0200 Subject: [PATCH 01/12] feat(ios): support bundled JS in debug builds --- .changeset/tender-poems-rhyme.md | 5 + .github/workflows/ci.yml | 19 ++++ .gitignore | 1 + apps/RNApp/ios/Podfile.lock | 14 +-- .../RNApp/ios/RNApp.xcodeproj/project.pbxproj | 2 +- .../react-native-brownfield/objective-c.mdx | 1 + .../react-native-brownfield/swift.mdx | 1 + docs/docs/docs/getting-started/expo.mdx | 2 + docs/docs/docs/getting-started/ios.mdx | 8 ++ .../ReactBrownfield.podspec | 1 + .../ios/BrownfieldBundleURLResolver.swift | 26 +++++ .../ios/ExpoHostRuntime.swift | 36 ++++-- .../react-native-brownfield/ios/Package.swift | 48 ++++++++ .../ios/ReactNativeBrownfield.swift | 14 +++ .../ios/ReactNativeHostRuntime.swift | 37 ++++-- .../BrownfieldBundleURLResolverTests.swift | 105 ++++++++++++++++++ 16 files changed, 292 insertions(+), 28 deletions(-) create mode 100644 .changeset/tender-poems-rhyme.md create mode 100644 packages/react-native-brownfield/ios/BrownfieldBundleURLResolver.swift create mode 100644 packages/react-native-brownfield/ios/Package.swift create mode 100644 packages/react-native-brownfield/ios/Tests/BrownfieldBundleURLResolverTests.swift diff --git a/.changeset/tender-poems-rhyme.md b/.changeset/tender-poems-rhyme.md new file mode 100644 index 00000000..3c92820a --- /dev/null +++ b/.changeset/tender-poems-rhyme.md @@ -0,0 +1,5 @@ +--- +'@callstack/react-native-brownfield': minor +--- + +Add an opt-in iOS Debug mode for loading the embedded JavaScript bundle with `preferBundledBundleInDebug`, fix `bundleURLOverride` fallback behavior when the override returns `nil`, and add native bundle-resolution tests. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f12d7b3..bc09be78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,25 @@ jobs: run: | yarn workspace @callstack/react-native-brownfield brownfield --version + ios-native-tests: + name: iOS native tests + runs-on: macos-26 + needs: build-lint + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + + - name: Setup + uses: ./.github/actions/setup + + - name: Run Swift bundle resolver tests + run: | + cd packages/react-native-brownfield/ios + mkdir -p "$RUNNER_TEMP/swift-home" "$RUNNER_TEMP/swift-cache/clang" "$RUNNER_TEMP/swift-cache/swiftpm" + HOME="$RUNNER_TEMP/swift-home" \ + CLANG_MODULE_CACHE_PATH="$RUNNER_TEMP/swift-cache/clang" \ + swift test --scratch-path "$RUNNER_TEMP/swift-cache/swiftpm" + android-androidapp-expo: name: Android road test (RNApp & AndroidApp - Expo ${{ matrix.version }}) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 51764612..2115113f 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,4 @@ secring.gpg # Typescript **/*.tsbuildinfo +packages/react-native-brownfield/ios/.build/ diff --git a/apps/RNApp/ios/Podfile.lock b/apps/RNApp/ios/Podfile.lock index 391b3e8d..710db466 100644 --- a/apps/RNApp/ios/Podfile.lock +++ b/apps/RNApp/ios/Podfile.lock @@ -1,6 +1,6 @@ PODS: - boost (1.84.0) - - BrownfieldNavigation (3.6.0): + - BrownfieldNavigation (3.6.1): - boost - DoubleConversion - fast_float @@ -28,7 +28,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - Brownie (3.6.0): + - Brownie (3.6.1): - boost - DoubleConversion - fast_float @@ -2461,7 +2461,7 @@ PODS: - SocketRocket - ReactAppDependencyProvider (0.85.0): - ReactCodegen - - ReactBrownfield (3.6.0): + - ReactBrownfield (3.6.1): - boost - DoubleConversion - fast_float @@ -2898,14 +2898,14 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 - BrownfieldNavigation: 0a4abcd0295639640d0222ac5c47ab63d94983c8 - Brownie: c75e781646955724c3b385e1a53704cc06491bf0 + BrownfieldNavigation: 814180cb04b5cef3ecc4da5f7c91e83f8b5e4d24 + Brownie: cd20e6cc71ab50983941cdb371c22a8f55d3e232 DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 FBLazyVector: dfb9ab6ee2eac316f7869edf6ec27b9e872329f0 fmt: 530618a01105dae0fa3a2f27c81ae11fa8f67eac glog: e56ede4028c4b7418e6b1195a36b1656bb35e225 - hermes-engine: 133acc7688f66a6db232bff7de874c7129b01e1e + hermes-engine: 4d7529a5cdee0d79a872e3f164da84c1ec01f559 RCT-Folly: 36c4f904fb6cd0219dcb76b94e9502d2a72fab0b RCTDeprecation: df7412cdad525035c3adeb14c1dc35b344e98187 RCTRequired: 28a4bf1ef190650fcd6973d8a6a8f8beb30ef807 @@ -2974,7 +2974,7 @@ SPEC CHECKSUMS: React-utils: f2dc3878565c3cc54bdf7f65a106efaf93f189a6 React-webperformancenativemodule: 214e42892a044b865f73ad4f88cac6979c27aa76 ReactAppDependencyProvider: 5787b37b8e2e51dfeab697ec031cc7c4080dcea2 - ReactBrownfield: 9e36bd174c53254c7a283a6305a4b26589e75f97 + ReactBrownfield: 4ff15e707d420a617cb8ad1a225f03a88f0baf3f ReactCodegen: 6ddd8f44847646a047320a22f5ddb10b27a515c9 ReactCommon: 6a42764f1136fb9ac210e05e88a0733a00ee23d3 RNScreens: e902eba58a27d3ad399a495d578e8aba3ea0f490 diff --git a/apps/RNApp/ios/RNApp.xcodeproj/project.pbxproj b/apps/RNApp/ios/RNApp.xcodeproj/project.pbxproj index 62818347..ab5cb89a 100644 --- a/apps/RNApp/ios/RNApp.xcodeproj/project.pbxproj +++ b/apps/RNApp/ios/RNApp.xcodeproj/project.pbxproj @@ -47,12 +47,12 @@ 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = RNApp/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = RNApp/Info.plist; sourceTree = ""; }; 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = RNApp/PrivacyInfo.xcprivacy; sourceTree = ""; }; - 2065C22D9167CC092D4BB5F7 /* Pods_RNApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RNApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B4392A12AC88292D35C810B /* Pods-RNApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNApp.debug.xcconfig"; path = "Target Support Files/Pods-RNApp/Pods-RNApp.debug.xcconfig"; sourceTree = ""; }; 5709B34CF0A7D63546082F79 /* Pods-RNApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNApp.release.xcconfig"; path = "Target Support Files/Pods-RNApp/Pods-RNApp.release.xcconfig"; sourceTree = ""; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = RNApp/AppDelegate.swift; sourceTree = ""; }; 79BD1EE32EEBFB76003AA29F /* BrownfieldLib.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = BrownfieldLib.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 79F35E8A2EEC1D4500E64860 /* BrownfieldLib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrownfieldLib.swift; sourceTree = ""; }; + 2065C22D9167CC092D4BB5F7 /* Pods_RNApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RNApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = RNApp/LaunchScreen.storyboard; sourceTree = ""; }; 8A02E03D9F74B585B0A8F7F7 /* Pods-RNApp-BrownfieldLib.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNApp-BrownfieldLib.debug.xcconfig"; path = "Target Support Files/Pods-RNApp-BrownfieldLib/Pods-RNApp-BrownfieldLib.debug.xcconfig"; sourceTree = ""; }; D8C030F60E402FD6CFBB3904 /* Pods-RNApp-BrownfieldLib.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNApp-BrownfieldLib.release.xcconfig"; path = "Target Support Files/Pods-RNApp-BrownfieldLib/Pods-RNApp-BrownfieldLib.release.xcconfig"; sourceTree = ""; }; diff --git a/docs/docs/docs/api-reference/react-native-brownfield/objective-c.mdx b/docs/docs/docs/api-reference/react-native-brownfield/objective-c.mdx index 19f6a905..9b0e3f91 100644 --- a/docs/docs/docs/api-reference/react-native-brownfield/objective-c.mdx +++ b/docs/docs/docs/api-reference/react-native-brownfield/objective-c.mdx @@ -33,6 +33,7 @@ A singleton that keeps an instance of `ReactNativeBrownfield` object. | `entryFile` | `NSString` | `index` | Path to JavaScript entry file in development. | | `bundlePath` | `NSString` | `main.jsbundle` | Path to JavaScript bundle file. | | `bundle` | `NSBundle` | `Bundle.main` | Bundle instance to lookup the JavaScript bundle resource. | +| `preferBundledBundleInDebug` | `BOOL` | `NO` | Prefer the embedded JavaScript bundle instead of Metro when the framework is built in Debug. | | `bundleURLOverride` | `NSURL *(^)(void)` | `nil` | Dynamic bundle URL provider called on every bundle load. When set, overrides default bundle load behavior. | --- diff --git a/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx b/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx index 01c356c3..00ab217f 100644 --- a/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx +++ b/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx @@ -33,6 +33,7 @@ ReactNativeBrownfield.shared | `entryFile` | `String` | `index` | Path to JavaScript entry file in development. | | `bundlePath` | `String` | `main.jsbundle` | Path to JavaScript bundle file. | | `bundle` | `Bundle` | `Bundle.main` | Bundle instance to lookup the JavaScript bundle resource. | +| `preferBundledBundleInDebug` | `Bool` | `false` | Prefer the embedded JavaScript bundle instead of Metro when the framework is built in Debug. | | `bundleURLOverride` | `(() -> URL?)?` | `nil` | Dynamic bundle URL provider called on every bundle load. When set, overrides default behavior. | --- diff --git a/docs/docs/docs/getting-started/expo.mdx b/docs/docs/docs/getting-started/expo.mdx index adb55595..daf326af 100644 --- a/docs/docs/docs/getting-started/expo.mdx +++ b/docs/docs/docs/getting-started/expo.mdx @@ -124,6 +124,8 @@ struct IosApp: App { init() { ReactNativeBrownfield.shared.bundle = ReactNativeBundle + // Optional: use the packaged bundle even when the consumed framework is built in Debug. + // ReactNativeBrownfield.shared.preferBundledBundleInDebug = true ReactNativeBrownfield.shared.startReactNative { print("React Native has been loaded") } diff --git a/docs/docs/docs/getting-started/ios.mdx b/docs/docs/docs/getting-started/ios.mdx index b4f353e9..f97463fa 100644 --- a/docs/docs/docs/getting-started/ios.mdx +++ b/docs/docs/docs/getting-started/ios.mdx @@ -171,6 +171,14 @@ When running in **Debug**, React Native Brownfield expects a JS dev server runni npx react-native start ``` +If you want to run a **Debug-built** framework without Metro, enable the bundled bundle explicitly before calling `startReactNative`: + +```swift +ReactNativeBrownfield.shared.bundle = ReactNativeBundle +ReactNativeBrownfield.shared.preferBundledBundleInDebug = true +ReactNativeBrownfield.shared.startReactNative() +``` + ### Release Configuration In **Release**, the JS bundle is loaded directly from the XCFramework - no dev server needed. diff --git a/packages/react-native-brownfield/ReactBrownfield.podspec b/packages/react-native-brownfield/ReactBrownfield.podspec index d839b157..a9abe22c 100644 --- a/packages/react-native-brownfield/ReactBrownfield.podspec +++ b/packages/react-native-brownfield/ReactBrownfield.podspec @@ -15,6 +15,7 @@ Pod::Spec.new do |spec| spec.module_name = "ReactBrownfield" spec.source = { :git => "git@github.com:callstack/react-native-brownfield.git", :tag => "#{spec.version}" } spec.source_files = "ios/**/*.{h,m,mm,swift}" + spec.exclude_files = "ios/Package.swift", "ios/Tests/**/*" spec.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'OTHER_SWIFT_FLAGS' => "-enable-experimental-feature AccessLevelOnImport" diff --git a/packages/react-native-brownfield/ios/BrownfieldBundleURLResolver.swift b/packages/react-native-brownfield/ios/BrownfieldBundleURLResolver.swift new file mode 100644 index 00000000..95881ffc --- /dev/null +++ b/packages/react-native-brownfield/ios/BrownfieldBundleURLResolver.swift @@ -0,0 +1,26 @@ +import Foundation + +enum BrownfieldBundleURLResolver { + static func resolve( + isDebug: Bool, + preferBundledBundleInDebug: Bool, + bundlePath: String, + bundle: Bundle, + bundleURLOverride: (() -> URL?)?, + metroURL: () -> URL? + ) throws -> URL? { + if let overriddenURL = bundleURLOverride?() { + return overriddenURL + } + + if isDebug && !preferBundledBundleInDebug { + return metroURL() + } + + let (resourceName, fileExtension) = try BrownfieldBundlePathResolver.resourceComponents( + from: bundlePath + ) + + return bundle.url(forResource: resourceName, withExtension: fileExtension) + } +} diff --git a/packages/react-native-brownfield/ios/ExpoHostRuntime.swift b/packages/react-native-brownfield/ios/ExpoHostRuntime.swift index 2beb99fe..cf1b241d 100644 --- a/packages/react-native-brownfield/ios/ExpoHostRuntime.swift +++ b/packages/react-native-brownfield/ios/ExpoHostRuntime.swift @@ -85,6 +85,16 @@ final class ExpoHostRuntime { delegate.bundle = bundle } } + + /** + * Prefer the embedded JavaScript bundle instead of Metro when this framework is built in Debug. + * Default value: false + */ + public var preferBundledBundleInDebug: Bool = false { + didSet { + delegate.preferBundledBundleInDebug = preferBundledBundleInDebug + } + } /** * Dynamic bundle URL provider called on every bundle load. * When set, this overrides the default bundleURL() behavior in the delegate. @@ -157,6 +167,7 @@ class ExpoHostRuntimeDelegate: ExpoReactNativeFactoryDelegate { var entryFile = ".expo/.virtual-metro-entry" var bundlePath = "main.jsbundle" var bundle = Bundle.main + var preferBundledBundleInDebug = false var bundleURLOverride: (() -> URL?)? = nil override func sourceURL(for bridge: RCTBridge) -> URL? { @@ -165,21 +176,28 @@ class ExpoHostRuntimeDelegate: ExpoReactNativeFactoryDelegate { } override func bundleURL() -> URL? { - if let bundleURLProvider = bundleURLOverride { return bundleURLProvider() } -#if DEBUG - return RCTBundleURLProvider.sharedSettings().jsBundleURL( - forBundleRoot: entryFile) -#else do { - let (resourceName, fileExtension) = try BrownfieldBundlePathResolver.resourceComponents( - from: bundlePath + #if DEBUG + let isDebug = true + #else + let isDebug = false + #endif + + return try BrownfieldBundleURLResolver.resolve( + isDebug: isDebug, + preferBundledBundleInDebug: preferBundledBundleInDebug, + bundlePath: bundlePath, + bundle: bundle, + bundleURLOverride: bundleURLOverride, + metroURL: { + RCTBundleURLProvider.sharedSettings().jsBundleURL( + forBundleRoot: entryFile) + } ) - return bundle.url(forResource: resourceName, withExtension: fileExtension) } catch { assertionFailure("Invalid bundlePath '\(bundlePath)': \(error)") return nil } -#endif } } #endif diff --git a/packages/react-native-brownfield/ios/Package.swift b/packages/react-native-brownfield/ios/Package.swift new file mode 100644 index 00000000..a0b4f679 --- /dev/null +++ b/packages/react-native-brownfield/ios/Package.swift @@ -0,0 +1,48 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "BrownfieldBundleSupport", + platforms: [ + .macOS(.v13), + ], + products: [ + .library( + name: "BrownfieldBundleSupport", + targets: ["BrownfieldBundleSupport"] + ), + ], + targets: [ + .target( + name: "BrownfieldBundleSupport", + path: ".", + exclude: [ + "ExpoHostRuntime.swift", + "JSBundleLoadObserver.swift", + "Notification+Brownfield.swift", + "ReactNativeBrownfield.swift", + "ReactNativeBrownfield.xcodeproj", + "ReactNativeBrownfieldModule.h", + "ReactNativeBrownfieldModule.mm", + "ReactNativeBrownfieldModule.swift", + "ReactNativeHostRuntime.swift", + "ReactNativeView.swift", + "ReactNativeViewController.swift", + "Tests", + ], + sources: [ + "BrownfieldBundlePathResolver.swift", + "BrownfieldBundleURLResolver.swift", + ] + ), + .testTarget( + name: "BrownfieldBundleSupportTests", + dependencies: ["BrownfieldBundleSupport"], + path: "Tests", + resources: [ + .copy("Fixtures/main.jsbundle"), + ] + ), + ] +) diff --git a/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift b/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift index be72ab95..35dac1a8 100644 --- a/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift +++ b/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift @@ -55,6 +55,20 @@ internal import Expo } } + /** + * Prefer the embedded JavaScript bundle instead of Metro when this framework is built in Debug. + * Default value: false + */ + @objc public var preferBundledBundleInDebug: Bool = false { + didSet { + #if canImport(Expo) + ExpoHostRuntime.shared.preferBundledBundleInDebug = preferBundledBundleInDebug + #else + ReactNativeHostRuntime.shared.preferBundledBundleInDebug = preferBundledBundleInDebug + #endif + } + } + /** * Dynamic bundle URL provider called on every bundle load. * When set, this overrides the default bundleURL() behavior in the delegate. diff --git a/packages/react-native-brownfield/ios/ReactNativeHostRuntime.swift b/packages/react-native-brownfield/ios/ReactNativeHostRuntime.swift index 097dc0a2..6e8b27a3 100644 --- a/packages/react-native-brownfield/ios/ReactNativeHostRuntime.swift +++ b/packages/react-native-brownfield/ios/ReactNativeHostRuntime.swift @@ -7,6 +7,7 @@ class ReactNativeBrownfieldDelegate: RCTDefaultReactNativeFactoryDelegate { var entryFile = "index" var bundlePath = "main.jsbundle" var bundle = Bundle.main + var preferBundledBundleInDebug = false var bundleURLOverride: (() -> URL?)? = nil // MARK: - RCTReactNativeFactoryDelegate Methods @@ -15,23 +16,27 @@ class ReactNativeBrownfieldDelegate: RCTDefaultReactNativeFactoryDelegate { } public override func bundleURL() -> URL? { - if let bundleURLProvider = bundleURLOverride { - return bundleURLProvider() - } - -#if DEBUG - return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: entryFile) -#else do { - let (resourceName, fileExtension) = try BrownfieldBundlePathResolver.resourceComponents( - from: bundlePath + #if DEBUG + let isDebug = true + #else + let isDebug = false + #endif + + return try BrownfieldBundleURLResolver.resolve( + isDebug: isDebug, + preferBundledBundleInDebug: preferBundledBundleInDebug, + bundlePath: bundlePath, + bundle: bundle, + bundleURLOverride: bundleURLOverride, + metroURL: { + RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: entryFile) + } ) - return bundle.url(forResource: resourceName, withExtension: fileExtension) } catch { assertionFailure("Invalid bundlePath '\(bundlePath)': \(error)") return nil } -#endif } } @@ -70,6 +75,16 @@ final class ReactNativeHostRuntime { } } + /** + * Prefer the embedded JavaScript bundle instead of Metro when this framework is built in Debug. + * Default value: false + */ + public var preferBundledBundleInDebug: Bool = false { + didSet { + delegate.preferBundledBundleInDebug = preferBundledBundleInDebug + } + } + /** * Dynamic bundle URL provider called on every bundle load. * When set, this overrides the default bundleURL() behavior in the delegate. diff --git a/packages/react-native-brownfield/ios/Tests/BrownfieldBundleURLResolverTests.swift b/packages/react-native-brownfield/ios/Tests/BrownfieldBundleURLResolverTests.swift new file mode 100644 index 00000000..2a509e13 --- /dev/null +++ b/packages/react-native-brownfield/ios/Tests/BrownfieldBundleURLResolverTests.swift @@ -0,0 +1,105 @@ +import XCTest +@testable import BrownfieldBundleSupport + +final class BrownfieldBundleURLResolverTests: XCTestCase { + func test_debugResolutionPrefersBundledResourceWhenEnabled() throws { + let metroURL = URL(string: "http://localhost:8081/index.bundle?platform=ios")! + + let resolvedURL = try BrownfieldBundleURLResolver.resolve( + isDebug: true, + preferBundledBundleInDebug: true, + bundlePath: "main.jsbundle", + bundle: .module, + bundleURLOverride: nil, + metroURL: { metroURL } + ) + + XCTAssertNotNil(resolvedURL) + XCTAssertEqual(resolvedURL?.lastPathComponent, "main.jsbundle") + XCTAssertNotEqual(resolvedURL, metroURL) + } + + func test_debugResolutionUsesMetroByDefault() throws { + let metroURL = URL(string: "http://localhost:8081/index.bundle?platform=ios")! + + let resolvedURL = try BrownfieldBundleURLResolver.resolve( + isDebug: true, + preferBundledBundleInDebug: false, + bundlePath: "main.jsbundle", + bundle: .module, + bundleURLOverride: nil, + metroURL: { metroURL } + ) + + XCTAssertEqual(resolvedURL, metroURL) + } + + func test_releaseResolutionUsesBundledResource() throws { + let metroURL = URL(string: "http://localhost:8081/index.bundle?platform=ios")! + + let resolvedURL = try BrownfieldBundleURLResolver.resolve( + isDebug: false, + preferBundledBundleInDebug: false, + bundlePath: "main.jsbundle", + bundle: .module, + bundleURLOverride: nil, + metroURL: { metroURL } + ) + + XCTAssertNotNil(resolvedURL) + XCTAssertEqual(resolvedURL?.lastPathComponent, "main.jsbundle") + XCTAssertNotEqual(resolvedURL, metroURL) + } + + func test_bundleURLOverrideTakesPrecedenceWhenItReturnsAURL() throws { + let metroURL = URL(string: "http://localhost:8081/index.bundle?platform=ios")! + let overrideURL = URL(string: "https://example.com/custom.bundle")! + + let resolvedURL = try BrownfieldBundleURLResolver.resolve( + isDebug: true, + preferBundledBundleInDebug: false, + bundlePath: "main.jsbundle", + bundle: .module, + bundleURLOverride: { overrideURL }, + metroURL: { metroURL } + ) + + XCTAssertEqual(resolvedURL, overrideURL) + } + + func test_bundleURLOverrideFallsBackWhenItReturnsNil() throws { + let metroURL = URL(string: "http://localhost:8081/index.bundle?platform=ios")! + + let resolvedURL = try BrownfieldBundleURLResolver.resolve( + isDebug: true, + preferBundledBundleInDebug: true, + bundlePath: "main.jsbundle", + bundle: .module, + bundleURLOverride: { nil }, + metroURL: { metroURL } + ) + + XCTAssertNotNil(resolvedURL) + XCTAssertEqual(resolvedURL?.lastPathComponent, "main.jsbundle") + XCTAssertNotEqual(resolvedURL, metroURL) + } + + func test_invalidBundlePathThrows() { + XCTAssertThrowsError( + try BrownfieldBundleURLResolver.resolve( + isDebug: false, + preferBundledBundleInDebug: false, + bundlePath: "mainjsbundle", + bundle: .module, + bundleURLOverride: nil, + metroURL: { nil } + ) + ) { error in + guard case let BrownfieldBundlePathResolver.Error.invalidBundlePath(bundlePath) = error else { + return XCTFail("Expected invalid bundle path error, got \(error)") + } + + XCTAssertEqual(bundlePath, "mainjsbundle") + } + } +} From ff2a8a7b71b01a97ac5d0d880c12e4d1a4c2d979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Trzci=C5=84ski?= Date: Thu, 7 May 2026 14:17:12 +0200 Subject: [PATCH 02/12] fix(cli): embed debug JS bundle in simulator xcframeworks --- .../components/ContentView.swift | 4 +- .../RNApp/ios/RNApp.xcodeproj/project.pbxproj | 2 +- .../BrownfieldNavigationDelegate.kt | 5 +- .../NativeBrownfieldNavigationModule.kt | 10 +- .../ios/BrownfieldNavigationDelegate.swift | 3 +- .../ios/NativeBrownfieldNavigation.mm | 8 +- .../src/NativeBrownfieldNavigation.ts | 3 +- packages/brownfield-navigation/src/index.ts | 7 +- .../cli/src/brownfield/commands/packageIos.ts | 37 +++- ...py-debug-bundle-to-simulator-slice.test.ts | 177 ++++++++++++++++++ .../utils/copyDebugBundleToSimulatorSlice.ts | 58 ++++++ 11 files changed, 299 insertions(+), 15 deletions(-) create mode 100644 packages/cli/src/brownfield/utils/__tests__/copy-debug-bundle-to-simulator-slice.test.ts create mode 100644 packages/cli/src/brownfield/utils/copyDebugBundleToSimulatorSlice.ts diff --git a/apps/AppleApp/Brownfield Apple App/components/ContentView.swift b/apps/AppleApp/Brownfield Apple App/components/ContentView.swift index 05f0766b..067f44d3 100644 --- a/apps/AppleApp/Brownfield Apple App/components/ContentView.swift +++ b/apps/AppleApp/Brownfield Apple App/components/ContentView.swift @@ -19,12 +19,12 @@ struct ContentView: View { NavigationView { VStack(spacing: 16) { - GreetingCard(name: "iOS Expo") + GreetingCard(name: "iOS Vanilla") MessagesView() ReactNativeView( - moduleName: "main", + moduleName: "RNApp", initialProperties: [ "nativeOsVersionLabel": "\(UIDevice.current.systemName) \(UIDevice.current.systemVersion)" diff --git a/apps/RNApp/ios/RNApp.xcodeproj/project.pbxproj b/apps/RNApp/ios/RNApp.xcodeproj/project.pbxproj index ab5cb89a..62818347 100644 --- a/apps/RNApp/ios/RNApp.xcodeproj/project.pbxproj +++ b/apps/RNApp/ios/RNApp.xcodeproj/project.pbxproj @@ -47,12 +47,12 @@ 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = RNApp/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = RNApp/Info.plist; sourceTree = ""; }; 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = RNApp/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 2065C22D9167CC092D4BB5F7 /* Pods_RNApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RNApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B4392A12AC88292D35C810B /* Pods-RNApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNApp.debug.xcconfig"; path = "Target Support Files/Pods-RNApp/Pods-RNApp.debug.xcconfig"; sourceTree = ""; }; 5709B34CF0A7D63546082F79 /* Pods-RNApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNApp.release.xcconfig"; path = "Target Support Files/Pods-RNApp/Pods-RNApp.release.xcconfig"; sourceTree = ""; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = RNApp/AppDelegate.swift; sourceTree = ""; }; 79BD1EE32EEBFB76003AA29F /* BrownfieldLib.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = BrownfieldLib.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 79F35E8A2EEC1D4500E64860 /* BrownfieldLib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrownfieldLib.swift; sourceTree = ""; }; - 2065C22D9167CC092D4BB5F7 /* Pods_RNApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RNApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = RNApp/LaunchScreen.storyboard; sourceTree = ""; }; 8A02E03D9F74B585B0A8F7F7 /* Pods-RNApp-BrownfieldLib.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNApp-BrownfieldLib.debug.xcconfig"; path = "Target Support Files/Pods-RNApp-BrownfieldLib/Pods-RNApp-BrownfieldLib.debug.xcconfig"; sourceTree = ""; }; D8C030F60E402FD6CFBB3904 /* Pods-RNApp-BrownfieldLib.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNApp-BrownfieldLib.release.xcconfig"; path = "Target Support Files/Pods-RNApp-BrownfieldLib/Pods-RNApp-BrownfieldLib.release.xcconfig"; sourceTree = ""; }; diff --git a/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/BrownfieldNavigationDelegate.kt b/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/BrownfieldNavigationDelegate.kt index 780059a3..c6b745d1 100644 --- a/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/BrownfieldNavigationDelegate.kt +++ b/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/BrownfieldNavigationDelegate.kt @@ -1,3 +1,6 @@ package com.callstack.nativebrownfieldnavigation -interface BrownfieldNavigationDelegate +interface BrownfieldNavigationDelegate { + fun navigateToSettings() + fun navigateToReferrals(userId: String) +} diff --git a/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/NativeBrownfieldNavigationModule.kt b/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/NativeBrownfieldNavigationModule.kt index f118105a..f47b94ff 100644 --- a/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/NativeBrownfieldNavigationModule.kt +++ b/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/NativeBrownfieldNavigationModule.kt @@ -1,6 +1,5 @@ package com.callstack.nativebrownfieldnavigation -import android.util.Log import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactMethod @@ -8,8 +7,13 @@ class NativeBrownfieldNavigationModule( reactContext: ReactApplicationContext ) : NativeBrownfieldNavigationSpec(reactContext) { @ReactMethod - override fun temporary() { - Log.d(NAME, "temporary") + override fun navigateToSettings() { + BrownfieldNavigationManager.getDelegate().navigateToSettings() + } + + @ReactMethod + override fun navigateToReferrals(userId: String) { + BrownfieldNavigationManager.getDelegate().navigateToReferrals(userId) } companion object { diff --git a/packages/brownfield-navigation/ios/BrownfieldNavigationDelegate.swift b/packages/brownfield-navigation/ios/BrownfieldNavigationDelegate.swift index f4b2828d..c84e9651 100644 --- a/packages/brownfield-navigation/ios/BrownfieldNavigationDelegate.swift +++ b/packages/brownfield-navigation/ios/BrownfieldNavigationDelegate.swift @@ -1,5 +1,6 @@ import Foundation @objc public protocol BrownfieldNavigationDelegate: AnyObject { - + @objc func navigateToSettings() + @objc func navigateToReferrals(_ userId: String) } diff --git a/packages/brownfield-navigation/ios/NativeBrownfieldNavigation.mm b/packages/brownfield-navigation/ios/NativeBrownfieldNavigation.mm index d92e3ef0..28371c45 100644 --- a/packages/brownfield-navigation/ios/NativeBrownfieldNavigation.mm +++ b/packages/brownfield-navigation/ios/NativeBrownfieldNavigation.mm @@ -8,8 +8,12 @@ @implementation NativeBrownfieldNavigation -- (void)temporary { - NSLog(@"temporary"); +- (void)navigateToSettings { + [[[BrownfieldNavigationManager shared] getDelegate] navigateToSettings]; +} + +- (void)navigateToReferrals:(NSString *)userId { + [[[BrownfieldNavigationManager shared] getDelegate] navigateToReferrals:userId]; } - (std::shared_ptr)getTurboModule: diff --git a/packages/brownfield-navigation/src/NativeBrownfieldNavigation.ts b/packages/brownfield-navigation/src/NativeBrownfieldNavigation.ts index 5d4ed84d..4d620932 100644 --- a/packages/brownfield-navigation/src/NativeBrownfieldNavigation.ts +++ b/packages/brownfield-navigation/src/NativeBrownfieldNavigation.ts @@ -1,7 +1,8 @@ import { TurboModuleRegistry, type TurboModule } from 'react-native'; export interface Spec extends TurboModule { - temporary(): void; + navigateToSettings(): void; + navigateToReferrals(userId: string): void; } export default TurboModuleRegistry.getEnforcing( diff --git a/packages/brownfield-navigation/src/index.ts b/packages/brownfield-navigation/src/index.ts index 5af516da..81704d0d 100644 --- a/packages/brownfield-navigation/src/index.ts +++ b/packages/brownfield-navigation/src/index.ts @@ -1,8 +1,11 @@ import NativeBrownfieldNavigation from './NativeBrownfieldNavigation'; const BrownfieldNavigation = { - temporary: () => { - NativeBrownfieldNavigation.temporary(); + navigateToSettings: () => { + NativeBrownfieldNavigation.navigateToSettings(); + }, + navigateToReferrals: (userId: string) => { + NativeBrownfieldNavigation.navigateToReferrals(userId); }, }; diff --git a/packages/cli/src/brownfield/commands/packageIos.ts b/packages/cli/src/brownfield/commands/packageIos.ts index 501f5856..e58f3621 100644 --- a/packages/cli/src/brownfield/commands/packageIos.ts +++ b/packages/cli/src/brownfield/commands/packageIos.ts @@ -26,6 +26,7 @@ import { import { runBrownieCodegenIfApplicable } from '../../brownie/helpers/runBrownieCodegenIfApplicable.js'; import { runNavigationCodegenIfApplicable } from '../../navigation/helpers/runNavigationCodegenIfApplicable.js'; import { stripFrameworkBinary } from '../utils/stripFrameworkBinary.js'; +import { copyDebugBundleToSimulatorSlice } from '../utils/copyDebugBundleToSimulatorSlice.js'; export const packageIosCommand = curryOptions( new Command('package:ios').description('Build iOS XCFramework'), @@ -96,6 +97,40 @@ export const packageIosCommand = curryOptions( platformConfig ); + const productsPath = path.join(options.buildFolder, 'Build', 'Products'); + const frameworkName = options.scheme; + + if (frameworkName) { + copyDebugBundleToSimulatorSlice({ + productsPath, + configuration, + frameworkName, + }); + + if (configuration.includes('Debug')) { + await mergeFrameworks({ + sourceDir: userConfig.project.ios.sourceDir, + frameworkPaths: [ + path.join( + productsPath, + `${configuration}-iphoneos`, + `${frameworkName}.framework` + ), + path.join( + productsPath, + `${configuration}-iphonesimulator`, + `${frameworkName}.framework` + ), + ], + outputPath: path.join(packageDir, `${frameworkName}.xcframework`), + }); + } + } else if (configuration.includes('Debug')) { + logger.warn( + 'Skipping Debug simulator JS bundle copy: scheme is required to locate the framework output' + ); + } + const reactBrownfieldXcframeworkPath = path.join( packageDir, 'ReactBrownfield.xcframework' @@ -108,7 +143,6 @@ export const packageIosCommand = curryOptions( } if (hasBrownie) { - const productsPath = path.join(options.buildFolder, 'Build', 'Products'); const brownieOutputPath = path.join(packageDir, 'Brownie.xcframework'); await mergeFrameworks({ @@ -141,7 +175,6 @@ export const packageIosCommand = curryOptions( } if (hasNavigation) { - const productsPath = path.join(options.buildFolder, 'Build', 'Products'); const brownfieldNavigationOutputPath = path.join(packageDir, 'BrownfieldNavigation.xcframework'); await mergeFrameworks({ diff --git a/packages/cli/src/brownfield/utils/__tests__/copy-debug-bundle-to-simulator-slice.test.ts b/packages/cli/src/brownfield/utils/__tests__/copy-debug-bundle-to-simulator-slice.test.ts new file mode 100644 index 00000000..035ff1b9 --- /dev/null +++ b/packages/cli/src/brownfield/utils/__tests__/copy-debug-bundle-to-simulator-slice.test.ts @@ -0,0 +1,177 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import * as rockTools from '@rock-js/tools'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { copyDebugBundleToSimulatorSlice } from '../copyDebugBundleToSimulatorSlice.js'; + +vi.mock('@rock-js/tools', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + logger: { + ...actual.logger, + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + success: vi.fn(), + debug: vi.fn(), + }, + }; +}); + +const mockLoggerWarn = rockTools.logger.warn as ReturnType; +const mockLoggerSuccess = rockTools.logger.success as ReturnType; + +function createFramework(pathname: string) { + fs.mkdirSync(pathname, { recursive: true }); + fs.writeFileSync(path.join(pathname, 'BrownfieldLib'), 'fake binary'); +} + +describe('copyDebugBundleToSimulatorSlice', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'copy-debug-bundle-test-')); + vi.clearAllMocks(); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('copies main.jsbundle into the Debug simulator slice when it is missing', () => { + const productsPath = path.join(tempDir, 'Build', 'Products'); + + const deviceFrameworkPath = path.join( + productsPath, + 'Debug-iphoneos', + 'BrownfieldLib.framework' + ); + const simulatorFrameworkPath = path.join( + productsPath, + 'Debug-iphonesimulator', + 'BrownfieldLib.framework' + ); + + createFramework(deviceFrameworkPath); + createFramework(simulatorFrameworkPath); + + fs.writeFileSync( + path.join(deviceFrameworkPath, 'main.jsbundle'), + 'debug bundled output' + ); + + copyDebugBundleToSimulatorSlice({ + productsPath, + configuration: 'Debug', + frameworkName: 'BrownfieldLib', + }); + + const simulatorBundlePath = path.join( + simulatorFrameworkPath, + 'main.jsbundle' + ); + + expect(fs.readFileSync(simulatorBundlePath, 'utf8')).toBe( + 'debug bundled output' + ); + expect(mockLoggerSuccess).toHaveBeenCalledWith( + expect.stringContaining('Copied Debug JS bundle to simulator slice') + ); + }); + + it('does nothing for non-Debug configurations', () => { + const productsPath = path.join(tempDir, 'Build', 'Products'); + + const deviceFrameworkPath = path.join( + productsPath, + 'Release-iphoneos', + 'BrownfieldLib.framework' + ); + const simulatorFrameworkPath = path.join( + productsPath, + 'Release-iphonesimulator', + 'BrownfieldLib.framework' + ); + + createFramework(deviceFrameworkPath); + createFramework(simulatorFrameworkPath); + fs.writeFileSync( + path.join(deviceFrameworkPath, 'main.jsbundle'), + 'release bundle' + ); + + copyDebugBundleToSimulatorSlice({ + productsPath, + configuration: 'Release', + frameworkName: 'BrownfieldLib', + }); + + expect( + fs.existsSync(path.join(simulatorFrameworkPath, 'main.jsbundle')) + ).toBe(false); + expect(mockLoggerSuccess).not.toHaveBeenCalled(); + }); + + it('warns and skips when the device bundle is missing', () => { + const productsPath = path.join(tempDir, 'Build', 'Products'); + + const simulatorFrameworkPath = path.join( + productsPath, + 'Debug-iphonesimulator', + 'BrownfieldLib.framework' + ); + + createFramework(simulatorFrameworkPath); + + copyDebugBundleToSimulatorSlice({ + productsPath, + configuration: 'Debug', + frameworkName: 'BrownfieldLib', + }); + + expect(mockLoggerWarn).toHaveBeenCalledWith( + expect.stringContaining('Skipping simulator JS bundle copy') + ); + }); + + it('overwrites an existing simulator bundle with the Debug device bundle', () => { + const productsPath = path.join(tempDir, 'Build', 'Products'); + + const deviceFrameworkPath = path.join( + productsPath, + 'Debug-iphoneos', + 'BrownfieldLib.framework' + ); + const simulatorFrameworkPath = path.join( + productsPath, + 'Debug-iphonesimulator', + 'BrownfieldLib.framework' + ); + + createFramework(deviceFrameworkPath); + createFramework(simulatorFrameworkPath); + + fs.writeFileSync( + path.join(deviceFrameworkPath, 'main.jsbundle'), + 'fresh debug bundle' + ); + fs.writeFileSync( + path.join(simulatorFrameworkPath, 'main.jsbundle'), + 'stale simulator bundle' + ); + + copyDebugBundleToSimulatorSlice({ + productsPath, + configuration: 'Debug', + frameworkName: 'BrownfieldLib', + }); + + expect( + fs.readFileSync(path.join(simulatorFrameworkPath, 'main.jsbundle'), 'utf8') + ).toBe('fresh debug bundle'); + }); +}); diff --git a/packages/cli/src/brownfield/utils/copyDebugBundleToSimulatorSlice.ts b/packages/cli/src/brownfield/utils/copyDebugBundleToSimulatorSlice.ts new file mode 100644 index 00000000..bf9c719d --- /dev/null +++ b/packages/cli/src/brownfield/utils/copyDebugBundleToSimulatorSlice.ts @@ -0,0 +1,58 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { colorLink, logger, relativeToCwd } from '@rock-js/tools'; + +interface CopyDebugBundleToSimulatorSliceOptions { + productsPath: string; + configuration: string; + frameworkName: string; +} + +export function copyDebugBundleToSimulatorSlice({ + productsPath, + configuration, + frameworkName, +}: CopyDebugBundleToSimulatorSliceOptions) { + if (!configuration.includes('Debug')) { + return; + } + + const deviceBundlePath = path.join( + productsPath, + `${configuration}-iphoneos`, + `${frameworkName}.framework`, + 'main.jsbundle' + ); + + const simulatorFrameworkPath = path.join( + productsPath, + `${configuration}-iphonesimulator`, + `${frameworkName}.framework` + ); + + const simulatorBundlePath = path.join( + simulatorFrameworkPath, + 'main.jsbundle' + ); + + if (!fs.existsSync(deviceBundlePath)) { + logger.warn( + `Skipping simulator JS bundle copy: missing ${relativeToCwd(deviceBundlePath)}` + ); + return; + } + + if (!fs.existsSync(simulatorFrameworkPath)) { + logger.warn( + `Skipping simulator JS bundle copy: missing ${relativeToCwd(simulatorFrameworkPath)}` + ); + return; + } + + fs.copyFileSync(deviceBundlePath, simulatorBundlePath); + + logger.success( + `Copied Debug JS bundle to simulator slice at ${colorLink(relativeToCwd(simulatorBundlePath))}` + ); +} From c57e753499ab4cce242dbabb2631bef6035b5b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Trzci=C5=84ski?= Date: Fri, 8 May 2026 10:11:06 +0200 Subject: [PATCH 03/12] feat(ios): enable development loading view configuration in debug mode - Added a method to configure the development loading view based on the debug settings. - Introduced `BrownfieldDevLoadingViewBridge` to manage the loading view state. - Updated `BrownfieldAppleApp` to prefer bundled bundles in debug mode. - Adjusted `ExpoHostRuntime` and `ReactNativeHostRuntime` to call the new configuration method. --- .../Brownfield Apple App/BrownfieldAppleApp.swift | 1 + .../ios/BrownfieldDevLoadingViewBridge.h | 11 +++++++++++ .../ios/BrownfieldDevLoadingViewBridge.m | 12 ++++++++++++ .../ios/ExpoHostRuntime.swift | 13 +++++++++++++ .../react-native-brownfield/ios/Package.swift | 2 ++ .../ios/ReactNativeHostRuntime.swift | 15 ++++++++++++++- 6 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 packages/react-native-brownfield/ios/BrownfieldDevLoadingViewBridge.h create mode 100644 packages/react-native-brownfield/ios/BrownfieldDevLoadingViewBridge.m diff --git a/apps/AppleApp/Brownfield Apple App/BrownfieldAppleApp.swift b/apps/AppleApp/Brownfield Apple App/BrownfieldAppleApp.swift index 22e2e83f..97e65f41 100644 --- a/apps/AppleApp/Brownfield Apple App/BrownfieldAppleApp.swift +++ b/apps/AppleApp/Brownfield Apple App/BrownfieldAppleApp.swift @@ -75,6 +75,7 @@ struct BrownfieldAppleApp: App { init() { ReactNativeBrownfield.shared.bundle = ReactNativeBundle + ReactNativeBrownfield.shared.preferBundledBundleInDebug = true ReactNativeBrownfield.shared.startReactNative { print("React Native has been loaded") } diff --git a/packages/react-native-brownfield/ios/BrownfieldDevLoadingViewBridge.h b/packages/react-native-brownfield/ios/BrownfieldDevLoadingViewBridge.h new file mode 100644 index 00000000..247c2ce3 --- /dev/null +++ b/packages/react-native-brownfield/ios/BrownfieldDevLoadingViewBridge.h @@ -0,0 +1,11 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface BrownfieldDevLoadingViewBridge : NSObject + ++ (void)setEnabled:(BOOL)enabled; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-brownfield/ios/BrownfieldDevLoadingViewBridge.m b/packages/react-native-brownfield/ios/BrownfieldDevLoadingViewBridge.m new file mode 100644 index 00000000..568622fe --- /dev/null +++ b/packages/react-native-brownfield/ios/BrownfieldDevLoadingViewBridge.m @@ -0,0 +1,12 @@ +#import "BrownfieldDevLoadingViewBridge.h" + +#import + +@implementation BrownfieldDevLoadingViewBridge + ++ (void)setEnabled:(BOOL)enabled +{ + RCTDevLoadingViewSetEnabled(enabled); +} + +@end diff --git a/packages/react-native-brownfield/ios/ExpoHostRuntime.swift b/packages/react-native-brownfield/ios/ExpoHostRuntime.swift index cf1b241d..fd2ae569 100644 --- a/packages/react-native-brownfield/ios/ExpoHostRuntime.swift +++ b/packages/react-native-brownfield/ios/ExpoHostRuntime.swift @@ -14,6 +14,16 @@ final class ExpoHostRuntime { private var reactNativeFactory: RCTReactNativeFactory? private var expoDelegate: ExpoAppDelegate? + private func configureDevLoadingView(with bundleURL: URL? = nil) { + #if DEBUG + let resolvedBundleURL = bundleURL ?? delegate.bundleURL() + let shouldDisableDevLoadingView = + preferBundledBundleInDebug && (resolvedBundleURL?.isFileURL ?? false) + + BrownfieldDevLoadingViewBridge.setEnabled(!shouldDisableDevLoadingView) + #endif + } + /** * Starts React Native with default parameters. */ @@ -28,6 +38,8 @@ final class ExpoHostRuntime { public func startReactNative(onBundleLoaded: (() -> Void)?) { guard reactNativeFactory == nil else { return } + configureDevLoadingView() + let appDelegate = ExpoAppDelegate() delegate.dependencyProvider = RCTAppDependencyProvider() reactNativeFactory = ExpoReactNativeFactory(delegate: delegate) @@ -142,6 +154,7 @@ final class ExpoHostRuntime { launchOptions: [AnyHashable: Any]? ) -> UIView? { let bundleURL = delegate.bundleURL() + configureDevLoadingView(with: bundleURL) // below: https://github.com/expo/expo/commit/2013760c46cde1404872d181a691da72fbf207a4 // has moved the recreateRootView method to ExpoReactNativeFactory diff --git a/packages/react-native-brownfield/ios/Package.swift b/packages/react-native-brownfield/ios/Package.swift index a0b4f679..d2ae1e8c 100644 --- a/packages/react-native-brownfield/ios/Package.swift +++ b/packages/react-native-brownfield/ios/Package.swift @@ -18,6 +18,8 @@ let package = Package( name: "BrownfieldBundleSupport", path: ".", exclude: [ + "BrownfieldDevLoadingViewBridge.h", + "BrownfieldDevLoadingViewBridge.m", "ExpoHostRuntime.swift", "JSBundleLoadObserver.swift", "Notification+Brownfield.swift", diff --git a/packages/react-native-brownfield/ios/ReactNativeHostRuntime.swift b/packages/react-native-brownfield/ios/ReactNativeHostRuntime.swift index 6e8b27a3..50feb565 100644 --- a/packages/react-native-brownfield/ios/ReactNativeHostRuntime.swift +++ b/packages/react-native-brownfield/ios/ReactNativeHostRuntime.swift @@ -45,6 +45,15 @@ final class ReactNativeHostRuntime { private let jsBundleLoadObserver = JSBundleLoadObserver() private var delegate = ReactNativeBrownfieldDelegate() + private func configureDevLoadingView() { + #if DEBUG + let shouldDisableDevLoadingView = + preferBundledBundleInDebug && (delegate.bundleURL()?.isFileURL ?? false) + + BrownfieldDevLoadingViewBridge.setEnabled(!shouldDisableDevLoadingView) + #endif + } + /** * Path to JavaScript root. * Default value: "index" @@ -127,7 +136,9 @@ final class ReactNativeHostRuntime { initialProps: [AnyHashable: Any]?, launchOptions: [AnyHashable: Any]? = nil ) -> UIView? { - reactNativeFactory?.rootViewFactory.view( + configureDevLoadingView() + + return reactNativeFactory?.rootViewFactory.view( withModuleName: moduleName, initialProperties: initialProps, launchOptions: launchOptions @@ -168,6 +179,8 @@ final class ReactNativeHostRuntime { public func startReactNative(onBundleLoaded: (() -> Void)?) { guard reactNativeFactory == nil else { return } + configureDevLoadingView() + delegate.dependencyProvider = RCTAppDependencyProvider() reactNativeFactory = RCTReactNativeFactory(delegate: delegate) From 5967c3ed46e210c48c2ca7192e92f36828c73629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Trzci=C5=84ski?= Date: Mon, 11 May 2026 09:33:27 +0200 Subject: [PATCH 04/12] fix(cli): resolve packaged framework without scheme --- .../cli/src/brownfield/commands/packageIos.ts | 18 +++- .../resolve-packaged-framework-name.test.ts | 92 +++++++++++++++++ .../utils/resolvePackagedFrameworkName.ts | 98 +++++++++++++++++++ .../ios/__tests__/xcodeHelpers.test.ts | 43 ++++++++ .../expo-config-plugin/ios/xcodeHelpers.ts | 4 +- 5 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/brownfield/utils/__tests__/resolve-packaged-framework-name.test.ts create mode 100644 packages/cli/src/brownfield/utils/resolvePackagedFrameworkName.ts create mode 100644 packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/xcodeHelpers.test.ts diff --git a/packages/cli/src/brownfield/commands/packageIos.ts b/packages/cli/src/brownfield/commands/packageIos.ts index e58f3621..53fe869b 100644 --- a/packages/cli/src/brownfield/commands/packageIos.ts +++ b/packages/cli/src/brownfield/commands/packageIos.ts @@ -27,6 +27,7 @@ import { runBrownieCodegenIfApplicable } from '../../brownie/helpers/runBrownieC import { runNavigationCodegenIfApplicable } from '../../navigation/helpers/runNavigationCodegenIfApplicable.js'; import { stripFrameworkBinary } from '../utils/stripFrameworkBinary.js'; import { copyDebugBundleToSimulatorSlice } from '../utils/copyDebugBundleToSimulatorSlice.js'; +import { resolvePackagedFrameworkName } from '../utils/resolvePackagedFrameworkName.js'; export const packageIosCommand = curryOptions( new Command('package:ios').description('Build iOS XCFramework'), @@ -98,7 +99,13 @@ export const packageIosCommand = curryOptions( ); const productsPath = path.join(options.buildFolder, 'Build', 'Products'); - const frameworkName = options.scheme; + const { frameworkName, resolution, candidates } = resolvePackagedFrameworkName( + { + explicitScheme: options.scheme, + productsPath, + configuration, + } + ); if (frameworkName) { copyDebugBundleToSimulatorSlice({ @@ -126,9 +133,12 @@ export const packageIosCommand = curryOptions( }); } } else if (configuration.includes('Debug')) { - logger.warn( - 'Skipping Debug simulator JS bundle copy: scheme is required to locate the framework output' - ); + const debugResolutionMessage = + resolution === 'ambiguous' + ? `Skipping Debug simulator JS bundle copy: found multiple bundled framework candidates (${candidates?.join(', ') ?? 'none'}); pass --scheme explicitly` + : 'Skipping Debug simulator JS bundle copy: could not resolve the packaged framework output automatically; pass --scheme explicitly'; + + logger.warn(debugResolutionMessage); } const reactBrownfieldXcframeworkPath = path.join( diff --git a/packages/cli/src/brownfield/utils/__tests__/resolve-packaged-framework-name.test.ts b/packages/cli/src/brownfield/utils/__tests__/resolve-packaged-framework-name.test.ts new file mode 100644 index 00000000..a9baea3f --- /dev/null +++ b/packages/cli/src/brownfield/utils/__tests__/resolve-packaged-framework-name.test.ts @@ -0,0 +1,92 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { resolvePackagedFrameworkName } from '../resolvePackagedFrameworkName.js'; + +function createFramework(baseDir: string, frameworkName: string, withBundle = false) { + const frameworkPath = path.join(baseDir, `${frameworkName}.framework`); + fs.mkdirSync(frameworkPath, { recursive: true }); + fs.writeFileSync(path.join(frameworkPath, frameworkName), 'fake binary'); + + if (withBundle) { + fs.writeFileSync(path.join(frameworkPath, 'main.jsbundle'), 'bundled js'); + } +} + +describe('resolvePackagedFrameworkName', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-packaged-framework-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('prefers the explicit scheme when provided', () => { + expect( + resolvePackagedFrameworkName({ + explicitScheme: 'BrownfieldLib', + productsPath: tempDir, + configuration: 'Debug', + }) + ).toEqual({ + frameworkName: 'BrownfieldLib', + resolution: 'explicit', + }); + }); + + it('resolves the packaged framework automatically from the device build output', () => { + const deviceProductsPath = path.join(tempDir, 'Debug-iphoneos'); + createFramework(deviceProductsPath, 'BrownfieldLib', true); + createFramework(path.join(deviceProductsPath, 'Brownie'), 'Brownie'); + createFramework(path.join(deviceProductsPath, 'BrownfieldNavigation'), 'BrownfieldNavigation'); + + expect( + resolvePackagedFrameworkName({ + productsPath: tempDir, + configuration: 'Debug', + }) + ).toEqual({ + frameworkName: 'BrownfieldLib', + resolution: 'detected', + }); + }); + + it('reports when the framework cannot be resolved automatically', () => { + const deviceProductsPath = path.join(tempDir, 'Debug-iphoneos'); + createFramework(path.join(deviceProductsPath, 'Brownie'), 'Brownie'); + + expect( + resolvePackagedFrameworkName({ + productsPath: tempDir, + configuration: 'Debug', + }) + ).toEqual({ + frameworkName: null, + resolution: 'not_found', + candidates: [], + }); + }); + + it('reports ambiguity when multiple frameworks contain a packaged bundle', () => { + const deviceProductsPath = path.join(tempDir, 'Debug-iphoneos'); + createFramework(deviceProductsPath, 'BrownfieldLib', true); + createFramework(deviceProductsPath, 'OtherFramework', true); + + expect( + resolvePackagedFrameworkName({ + productsPath: tempDir, + configuration: 'Debug', + }) + ).toEqual({ + frameworkName: null, + resolution: 'ambiguous', + candidates: ['BrownfieldLib', 'OtherFramework'], + }); + }); +}); diff --git a/packages/cli/src/brownfield/utils/resolvePackagedFrameworkName.ts b/packages/cli/src/brownfield/utils/resolvePackagedFrameworkName.ts new file mode 100644 index 00000000..4213742f --- /dev/null +++ b/packages/cli/src/brownfield/utils/resolvePackagedFrameworkName.ts @@ -0,0 +1,98 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +type Resolution = 'explicit' | 'detected' | 'not_found' | 'ambiguous'; + +export interface ResolvePackagedFrameworkNameResult { + frameworkName: string | null; + resolution: Resolution; + candidates?: string[]; +} + +interface ResolvePackagedFrameworkNameOptions { + explicitScheme?: string; + productsPath: string; + configuration: string; +} + +function collectFrameworkCandidates(configurationProductsPath: string): string[] { + if (!fs.existsSync(configurationProductsPath)) { + return []; + } + + const discoveredFrameworks = new Set(); + + for (const entry of fs.readdirSync(configurationProductsPath, { withFileTypes: true })) { + const entryPath = path.join(configurationProductsPath, entry.name); + + if (entry.isDirectory() && entry.name.endsWith('.framework')) { + const frameworkName = path.basename(entry.name, '.framework'); + const bundlePath = path.join(entryPath, 'main.jsbundle'); + + if (fs.existsSync(bundlePath)) { + discoveredFrameworks.add(frameworkName); + } + + continue; + } + + if (!entry.isDirectory()) { + continue; + } + + for (const nestedEntry of fs.readdirSync(entryPath, { withFileTypes: true })) { + if (!nestedEntry.isDirectory() || !nestedEntry.name.endsWith('.framework')) { + continue; + } + + const frameworkName = path.basename(nestedEntry.name, '.framework'); + const bundlePath = path.join(entryPath, nestedEntry.name, 'main.jsbundle'); + + if (fs.existsSync(bundlePath)) { + discoveredFrameworks.add(frameworkName); + } + } + } + + return [...discoveredFrameworks].sort(); +} + +export function resolvePackagedFrameworkName({ + explicitScheme, + productsPath, + configuration, +}: ResolvePackagedFrameworkNameOptions): ResolvePackagedFrameworkNameResult { + if (explicitScheme) { + return { + frameworkName: explicitScheme, + resolution: 'explicit', + }; + } + + const configurationProductsPath = path.join( + productsPath, + `${configuration}-iphoneos` + ); + const candidates = collectFrameworkCandidates(configurationProductsPath); + + if (candidates.length === 1) { + return { + frameworkName: candidates[0] ?? null, + resolution: 'detected', + }; + } + + if (candidates.length === 0) { + return { + frameworkName: null, + resolution: 'not_found', + candidates, + }; + } + + return { + frameworkName: null, + resolution: 'ambiguous', + candidates, + }; +} diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/xcodeHelpers.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/xcodeHelpers.test.ts new file mode 100644 index 00000000..68bc0182 --- /dev/null +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/xcodeHelpers.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { getFrameworkBuildSettings } from '../xcodeHelpers'; +import type { ResolvedBrownfieldPluginIosConfig } from '../../types'; + +const baseOptions: ResolvedBrownfieldPluginIosConfig = { + frameworkName: 'BrownfieldLib', + bundleIdentifier: 'com.example.brownfield', + deploymentTarget: '15.0', + frameworkVersion: '1', + buildSettings: {}, +}; + +describe('getFrameworkBuildSettings', () => { + it('uses rpath-based install settings for generated framework targets', () => { + const settings = getFrameworkBuildSettings( + { configuration: 'Debug' }, + baseOptions + ); + + expect(settings.DYLIB_INSTALL_NAME_BASE).toBe('"@rpath"'); + expect(settings.INSTALL_PATH).toBe('"$(LOCAL_LIBRARY_DIR)/Frameworks"'); + expect(settings.SKIP_INSTALL).toBe('NO'); + }); + + it('preserves custom build settings while keeping required framework settings', () => { + const settings = getFrameworkBuildSettings( + { configuration: 'Release' }, + { + ...baseOptions, + buildSettings: { + SWIFT_VERSION: '5.10', + MARKETING_VERSION: '9.9.9', + }, + } + ); + + expect(settings.DYLIB_INSTALL_NAME_BASE).toBe('"@rpath"'); + expect(settings.INSTALL_PATH).toBe('"$(LOCAL_LIBRARY_DIR)/Frameworks"'); + expect(settings.SWIFT_VERSION).toBe('5.10'); + expect(settings.MARKETING_VERSION).toBe('9.9.9'); + }); +}); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts index 82f577eb..8b7355b7 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts @@ -187,7 +187,7 @@ export function addSourceFilesBuildPhase( * @param options The user configuration * @returns Build settings object */ -function getFrameworkBuildSettings( +export function getFrameworkBuildSettings( { configuration, }: { @@ -210,6 +210,8 @@ function getFrameworkBuildSettings( USER_SCRIPT_SANDBOXING: 'NO', SKIP_INSTALL: 'NO', ENABLE_MODULE_VERIFIER: 'NO', + DYLIB_INSTALL_NAME_BASE: '"@rpath"', + INSTALL_PATH: '"$(LOCAL_LIBRARY_DIR)/Frameworks"', // basic settings PRODUCT_BUNDLE_IDENTIFIER: `"${bundleIdentifier}"`, From 50e9b91a673328a995aeb1a4d874b08e45156d1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Trzci=C5=84ski?= Date: Mon, 11 May 2026 10:15:15 +0200 Subject: [PATCH 05/12] fix(ios): make swift tests self-contained --- .../react-native-brownfield/ios/Package.swift | 4 +- .../BrownfieldBundleURLResolverTests.swift | 63 +++++++++++++++++-- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/packages/react-native-brownfield/ios/Package.swift b/packages/react-native-brownfield/ios/Package.swift index d2ae1e8c..1351ebd8 100644 --- a/packages/react-native-brownfield/ios/Package.swift +++ b/packages/react-native-brownfield/ios/Package.swift @@ -42,9 +42,7 @@ let package = Package( name: "BrownfieldBundleSupportTests", dependencies: ["BrownfieldBundleSupport"], path: "Tests", - resources: [ - .copy("Fixtures/main.jsbundle"), - ] + exclude: ["Fixtures"] ), ] ) diff --git a/packages/react-native-brownfield/ios/Tests/BrownfieldBundleURLResolverTests.swift b/packages/react-native-brownfield/ios/Tests/BrownfieldBundleURLResolverTests.swift index 2a509e13..e1262920 100644 --- a/packages/react-native-brownfield/ios/Tests/BrownfieldBundleURLResolverTests.swift +++ b/packages/react-native-brownfield/ios/Tests/BrownfieldBundleURLResolverTests.swift @@ -4,12 +4,13 @@ import XCTest final class BrownfieldBundleURLResolverTests: XCTestCase { func test_debugResolutionPrefersBundledResourceWhenEnabled() throws { let metroURL = URL(string: "http://localhost:8081/index.bundle?platform=ios")! + let bundle = try makeFixtureBundle() let resolvedURL = try BrownfieldBundleURLResolver.resolve( isDebug: true, preferBundledBundleInDebug: true, bundlePath: "main.jsbundle", - bundle: .module, + bundle: bundle, bundleURLOverride: nil, metroURL: { metroURL } ) @@ -21,12 +22,13 @@ final class BrownfieldBundleURLResolverTests: XCTestCase { func test_debugResolutionUsesMetroByDefault() throws { let metroURL = URL(string: "http://localhost:8081/index.bundle?platform=ios")! + let bundle = try makeFixtureBundle() let resolvedURL = try BrownfieldBundleURLResolver.resolve( isDebug: true, preferBundledBundleInDebug: false, bundlePath: "main.jsbundle", - bundle: .module, + bundle: bundle, bundleURLOverride: nil, metroURL: { metroURL } ) @@ -36,12 +38,13 @@ final class BrownfieldBundleURLResolverTests: XCTestCase { func test_releaseResolutionUsesBundledResource() throws { let metroURL = URL(string: "http://localhost:8081/index.bundle?platform=ios")! + let bundle = try makeFixtureBundle() let resolvedURL = try BrownfieldBundleURLResolver.resolve( isDebug: false, preferBundledBundleInDebug: false, bundlePath: "main.jsbundle", - bundle: .module, + bundle: bundle, bundleURLOverride: nil, metroURL: { metroURL } ) @@ -54,12 +57,13 @@ final class BrownfieldBundleURLResolverTests: XCTestCase { func test_bundleURLOverrideTakesPrecedenceWhenItReturnsAURL() throws { let metroURL = URL(string: "http://localhost:8081/index.bundle?platform=ios")! let overrideURL = URL(string: "https://example.com/custom.bundle")! + let bundle = try makeFixtureBundle() let resolvedURL = try BrownfieldBundleURLResolver.resolve( isDebug: true, preferBundledBundleInDebug: false, bundlePath: "main.jsbundle", - bundle: .module, + bundle: bundle, bundleURLOverride: { overrideURL }, metroURL: { metroURL } ) @@ -69,12 +73,13 @@ final class BrownfieldBundleURLResolverTests: XCTestCase { func test_bundleURLOverrideFallsBackWhenItReturnsNil() throws { let metroURL = URL(string: "http://localhost:8081/index.bundle?platform=ios")! + let bundle = try makeFixtureBundle() let resolvedURL = try BrownfieldBundleURLResolver.resolve( isDebug: true, preferBundledBundleInDebug: true, bundlePath: "main.jsbundle", - bundle: .module, + bundle: bundle, bundleURLOverride: { nil }, metroURL: { metroURL } ) @@ -90,7 +95,7 @@ final class BrownfieldBundleURLResolverTests: XCTestCase { isDebug: false, preferBundledBundleInDebug: false, bundlePath: "mainjsbundle", - bundle: .module, + bundle: Bundle(for: Self.self), bundleURLOverride: nil, metroURL: { nil } ) @@ -102,4 +107,50 @@ final class BrownfieldBundleURLResolverTests: XCTestCase { XCTAssertEqual(bundlePath, "mainjsbundle") } } + + private func makeFixtureBundle() throws -> Bundle { + let fileManager = FileManager.default + let bundleURL = fileManager.temporaryDirectory + .appendingPathComponent("BrownfieldBundleFixture-\(UUID().uuidString).bundle") + let contentsURL = bundleURL.appendingPathComponent("Contents") + let resourcesURL = contentsURL.appendingPathComponent("Resources") + let plistURL = contentsURL.appendingPathComponent("Info.plist") + let fixtureURL = resourcesURL.appendingPathComponent("main.jsbundle") + + try fileManager.createDirectory(at: resourcesURL, withIntermediateDirectories: true) + + let plist = """ + + + + + CFBundleIdentifier + com.callstack.BrownfieldBundleFixture + CFBundleName + BrownfieldBundleFixture + CFBundlePackageType + BNDL + CFBundleVersion + 1 + + + """ + + try plist.write(to: plistURL, atomically: true, encoding: .utf8) + try "console.log(\"fixture\");".write(to: fixtureURL, atomically: true, encoding: .utf8) + + addTeardownBlock { + try? fileManager.removeItem(at: bundleURL) + } + + guard let bundle = Bundle(url: bundleURL) else { + throw NSError( + domain: "BrownfieldBundleURLResolverTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to create fixture bundle"] + ) + } + + return bundle + } } From 768c19652c1f2b8931104e4df69a69eca6dfbc60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Trzci=C5=84ski?= Date: Tue, 12 May 2026 08:52:55 +0200 Subject: [PATCH 06/12] fix: ftm issue --- .../ios/__tests__/withFmtFix.test.ts | 43 +++++++++++ .../ios/withBrownfieldIos.ts | 5 +- .../src/expo-config-plugin/ios/withFmtFix.ts | 72 +++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withFmtFix.test.ts create mode 100644 packages/react-native-brownfield/src/expo-config-plugin/ios/withFmtFix.ts diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withFmtFix.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withFmtFix.test.ts new file mode 100644 index 00000000..189e5097 --- /dev/null +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withFmtFix.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { injectFmtFixIntoPodfile } from '../withFmtFix'; + +describe('injectFmtFixIntoPodfile', () => { + it('injects the fmt fix into an Expo post_install block', () => { + const podfile = `target 'ExpoApp54' do + use_expo_modules! + + post_install do |installer| + react_native_post_install( + installer, + config[:reactNativePath], + :mac_catalyst_enabled => false, + :ccache_enabled => ccache_enabled?(podfile_properties), + ) + end +end +`; + + const patched = injectFmtFixIntoPodfile(podfile); + + expect(patched).toContain( + '# Fix fmt 11.0.2 consteval compilation error with Xcode 26.4+' + ); + expect(patched).toContain( + "fmt_base = File.join(installer.sandbox.pod_dir('fmt'), 'include', 'fmt', 'base.h')" + ); + expect(patched).toMatch( + /react_native_post_install\([\s\S]*?\n\s+# Fix fmt 11\.0\.2 consteval compilation error with Xcode 26\.4\+\n[\s\S]*?\n\s+end\nend/ + ); + }); + + it('is idempotent when the fix is already present', () => { + const podfile = `post_install do |installer| + # Fix fmt 11.0.2 consteval compilation error with Xcode 26.4+ + fmt_base = File.join(installer.sandbox.pod_dir('fmt'), 'include', 'fmt', 'base.h') +end +`; + + expect(injectFmtFixIntoPodfile(podfile)).toBe(podfile); + }); +}); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/withBrownfieldIos.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/withBrownfieldIos.ts index 3402f05b..a3cd43f0 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/withBrownfieldIos.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/withBrownfieldIos.ts @@ -11,6 +11,7 @@ import { copyBundleReactNativePhase, } from './xcodeHelpers'; import { modifyPodfile } from './podfileHelpers'; +import { injectFmtFixIntoPodfile } from './withFmtFix'; import { withIosFrameworkFiles } from './withIosFrameworkFiles'; import type { ResolvedBrownfieldPluginConfigWithIos } from '../types'; import { Logger } from '../logging'; @@ -77,11 +78,13 @@ export const withBrownfieldIos: ConfigPlugin< config = withPodfile(config, (podfileConfig) => { const { frameworkName } = props.ios; - podfileConfig.modResults.contents = modifyPodfile( + const modifiedPodfile = modifyPodfile( podfileConfig.modResults.contents, frameworkName, expoMajor ); + podfileConfig.modResults.contents = + injectFmtFixIntoPodfile(modifiedPodfile); return podfileConfig; }); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/withFmtFix.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/withFmtFix.ts new file mode 100644 index 00000000..99aaca4c --- /dev/null +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/withFmtFix.ts @@ -0,0 +1,72 @@ +import { withPodfile, type ConfigPlugin } from '@expo/config-plugins'; + +import type { ResolvedBrownfieldPluginConfigWithIos } from '../types'; + +const FMT_FIX_MARKER = + '# Fix fmt 11.0.2 consteval compilation error with Xcode 26.4+'; + +const FMT_FIX_RUBY = `\ + ${FMT_FIX_MARKER} + fmt_base = File.join(installer.sandbox.pod_dir('fmt'), 'include', 'fmt', 'base.h') + if File.exist?(fmt_base) + content = File.read(fmt_base) + patched = content.gsub(/^#\\s*define FMT_USE_CONSTEVAL 1$/, '# define FMT_USE_CONSTEVAL 0') + if patched != content + File.chmod(0644, fmt_base) + File.write(fmt_base, patched) + end + end`; + +export function injectFmtFixIntoPodfile(podfile: string): string { + if (podfile.includes(FMT_FIX_MARKER)) { + return podfile; + } + + const lines = podfile.split('\n'); + const postInstallIndex = lines.findIndex((line) => + /^\s*post_install do \|installer\|/.test(line) + ); + + if (postInstallIndex === -1) { + return podfile; + } + + let depth = 0; + let insertionIndex = -1; + + for (let index = postInstallIndex; index < lines.length; index += 1) { + const trimmed = lines[index].trim(); + + if (trimmed.endsWith(' do |installer|')) { + depth += 1; + continue; + } + + if (trimmed === 'end') { + depth -= 1; + + if (depth === 0) { + insertionIndex = index; + break; + } + } + } + + if (insertionIndex === -1) { + return podfile; + } + + lines.splice(insertionIndex, 0, FMT_FIX_RUBY); + return lines.join('\n'); +} + +export const withFmtFix: ConfigPlugin = ( + config +) => + withPodfile(config, (podfileConfig) => { + podfileConfig.modResults.contents = injectFmtFixIntoPodfile( + podfileConfig.modResults.contents + ); + + return podfileConfig; + }); From 51820484a1f85ba92cdc0931fb857c943f3f6805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Trzci=C5=84ski?= Date: Mon, 18 May 2026 12:18:58 +0200 Subject: [PATCH 07/12] fix(ios): address pr review follow-ups --- .changeset/tender-poems-rhyme.md | 2 +- .github/workflows/ci.yml | 2 +- .gitignore | 1 + .../BrownfieldAppleApp.swift | 2 +- .../react-native-brownfield/objective-c.mdx | 2 +- .../react-native-brownfield/swift.mdx | 2 +- docs/docs/docs/getting-started/expo.mdx | 2 +- docs/docs/docs/getting-started/ios.mdx | 4 +- .../BrownfieldNavigationDelegate.kt | 5 +- .../NativeBrownfieldNavigationModule.kt | 10 ++-- .../ios/BrownfieldNavigationDelegate.swift | 3 +- .../ios/NativeBrownfieldNavigation.mm | 8 +--- .../src/NativeBrownfieldNavigation.ts | 3 +- packages/brownfield-navigation/src/index.ts | 7 +-- .../ReactBrownfield.podspec | 2 +- .../ios/ExpoHostRuntime.swift | 10 ++-- .../react-native-brownfield/ios/Package.swift | 48 ------------------- .../ios/ReactNativeBrownfield.swift | 6 +-- .../ios/ReactNativeHostRuntime.swift | 10 ++-- .../ios/swiftpm/Package.swift | 27 +++++++++++ .../BrownfieldBundlePathResolver.swift | 0 .../BrownfieldBundleURLResolver.swift | 4 +- .../BrownfieldBundleURLResolverTests.swift | 12 ++--- 23 files changed, 69 insertions(+), 103 deletions(-) delete mode 100644 packages/react-native-brownfield/ios/Package.swift create mode 100644 packages/react-native-brownfield/ios/swiftpm/Package.swift rename packages/react-native-brownfield/ios/{ => swiftpm/Sources/BrownfieldBundleSupport}/BrownfieldBundlePathResolver.swift (100%) rename packages/react-native-brownfield/ios/{ => swiftpm/Sources/BrownfieldBundleSupport}/BrownfieldBundleURLResolver.swift (86%) rename packages/react-native-brownfield/ios/{Tests => swiftpm/Tests/BrownfieldBundleSupportTests}/BrownfieldBundleURLResolverTests.swift (95%) diff --git a/.changeset/tender-poems-rhyme.md b/.changeset/tender-poems-rhyme.md index 3c92820a..e22d1899 100644 --- a/.changeset/tender-poems-rhyme.md +++ b/.changeset/tender-poems-rhyme.md @@ -2,4 +2,4 @@ '@callstack/react-native-brownfield': minor --- -Add an opt-in iOS Debug mode for loading the embedded JavaScript bundle with `preferBundledBundleInDebug`, fix `bundleURLOverride` fallback behavior when the override returns `nil`, and add native bundle-resolution tests. +Add an opt-in iOS Debug mode for loading the embedded JavaScript bundle with `preferEmbeddedBundleInDebug`, fix `bundleURLOverride` fallback behavior when the override returns `nil`, and add native bundle-resolution tests. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc09be78..ee093fe8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: - name: Run Swift bundle resolver tests run: | - cd packages/react-native-brownfield/ios + cd packages/react-native-brownfield/ios/swiftpm mkdir -p "$RUNNER_TEMP/swift-home" "$RUNNER_TEMP/swift-cache/clang" "$RUNNER_TEMP/swift-cache/swiftpm" HOME="$RUNNER_TEMP/swift-home" \ CLANG_MODULE_CACHE_PATH="$RUNNER_TEMP/swift-cache/clang" \ diff --git a/.gitignore b/.gitignore index 2115113f..296eec94 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,4 @@ secring.gpg # Typescript **/*.tsbuildinfo packages/react-native-brownfield/ios/.build/ +packages/react-native-brownfield/ios/swiftpm/.build/ diff --git a/apps/AppleApp/Brownfield Apple App/BrownfieldAppleApp.swift b/apps/AppleApp/Brownfield Apple App/BrownfieldAppleApp.swift index 97e65f41..67787880 100644 --- a/apps/AppleApp/Brownfield Apple App/BrownfieldAppleApp.swift +++ b/apps/AppleApp/Brownfield Apple App/BrownfieldAppleApp.swift @@ -75,7 +75,7 @@ struct BrownfieldAppleApp: App { init() { ReactNativeBrownfield.shared.bundle = ReactNativeBundle - ReactNativeBrownfield.shared.preferBundledBundleInDebug = true + ReactNativeBrownfield.shared.preferEmbeddedBundleInDebug = true ReactNativeBrownfield.shared.startReactNative { print("React Native has been loaded") } diff --git a/docs/docs/docs/api-reference/react-native-brownfield/objective-c.mdx b/docs/docs/docs/api-reference/react-native-brownfield/objective-c.mdx index 9b0e3f91..e3b5128b 100644 --- a/docs/docs/docs/api-reference/react-native-brownfield/objective-c.mdx +++ b/docs/docs/docs/api-reference/react-native-brownfield/objective-c.mdx @@ -33,7 +33,7 @@ A singleton that keeps an instance of `ReactNativeBrownfield` object. | `entryFile` | `NSString` | `index` | Path to JavaScript entry file in development. | | `bundlePath` | `NSString` | `main.jsbundle` | Path to JavaScript bundle file. | | `bundle` | `NSBundle` | `Bundle.main` | Bundle instance to lookup the JavaScript bundle resource. | -| `preferBundledBundleInDebug` | `BOOL` | `NO` | Prefer the embedded JavaScript bundle instead of Metro when the framework is built in Debug. | +| `preferEmbeddedBundleInDebug` | `BOOL` | `NO` | Prefer the embedded JavaScript bundle instead of Metro when the framework is built in Debug. | | `bundleURLOverride` | `NSURL *(^)(void)` | `nil` | Dynamic bundle URL provider called on every bundle load. When set, overrides default bundle load behavior. | --- diff --git a/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx b/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx index 00ab217f..62a536e6 100644 --- a/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx +++ b/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx @@ -33,7 +33,7 @@ ReactNativeBrownfield.shared | `entryFile` | `String` | `index` | Path to JavaScript entry file in development. | | `bundlePath` | `String` | `main.jsbundle` | Path to JavaScript bundle file. | | `bundle` | `Bundle` | `Bundle.main` | Bundle instance to lookup the JavaScript bundle resource. | -| `preferBundledBundleInDebug` | `Bool` | `false` | Prefer the embedded JavaScript bundle instead of Metro when the framework is built in Debug. | +| `preferEmbeddedBundleInDebug` | `Bool` | `false` | Prefer the embedded JavaScript bundle instead of Metro when the framework is built in Debug. | | `bundleURLOverride` | `(() -> URL?)?` | `nil` | Dynamic bundle URL provider called on every bundle load. When set, overrides default behavior. | --- diff --git a/docs/docs/docs/getting-started/expo.mdx b/docs/docs/docs/getting-started/expo.mdx index daf326af..f946fe50 100644 --- a/docs/docs/docs/getting-started/expo.mdx +++ b/docs/docs/docs/getting-started/expo.mdx @@ -125,7 +125,7 @@ struct IosApp: App { init() { ReactNativeBrownfield.shared.bundle = ReactNativeBundle // Optional: use the packaged bundle even when the consumed framework is built in Debug. - // ReactNativeBrownfield.shared.preferBundledBundleInDebug = true + // ReactNativeBrownfield.shared.preferEmbeddedBundleInDebug = true ReactNativeBrownfield.shared.startReactNative { print("React Native has been loaded") } diff --git a/docs/docs/docs/getting-started/ios.mdx b/docs/docs/docs/getting-started/ios.mdx index f97463fa..83f2b9e4 100644 --- a/docs/docs/docs/getting-started/ios.mdx +++ b/docs/docs/docs/getting-started/ios.mdx @@ -171,11 +171,13 @@ When running in **Debug**, React Native Brownfield expects a JS dev server runni npx react-native start ``` +## Embedded bundle in Development + If you want to run a **Debug-built** framework without Metro, enable the bundled bundle explicitly before calling `startReactNative`: ```swift ReactNativeBrownfield.shared.bundle = ReactNativeBundle -ReactNativeBrownfield.shared.preferBundledBundleInDebug = true +ReactNativeBrownfield.shared.preferEmbeddedBundleInDebug = true ReactNativeBrownfield.shared.startReactNative() ``` diff --git a/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/BrownfieldNavigationDelegate.kt b/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/BrownfieldNavigationDelegate.kt index c6b745d1..780059a3 100644 --- a/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/BrownfieldNavigationDelegate.kt +++ b/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/BrownfieldNavigationDelegate.kt @@ -1,6 +1,3 @@ package com.callstack.nativebrownfieldnavigation -interface BrownfieldNavigationDelegate { - fun navigateToSettings() - fun navigateToReferrals(userId: String) -} +interface BrownfieldNavigationDelegate diff --git a/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/NativeBrownfieldNavigationModule.kt b/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/NativeBrownfieldNavigationModule.kt index f47b94ff..f118105a 100644 --- a/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/NativeBrownfieldNavigationModule.kt +++ b/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/NativeBrownfieldNavigationModule.kt @@ -1,5 +1,6 @@ package com.callstack.nativebrownfieldnavigation +import android.util.Log import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactMethod @@ -7,13 +8,8 @@ class NativeBrownfieldNavigationModule( reactContext: ReactApplicationContext ) : NativeBrownfieldNavigationSpec(reactContext) { @ReactMethod - override fun navigateToSettings() { - BrownfieldNavigationManager.getDelegate().navigateToSettings() - } - - @ReactMethod - override fun navigateToReferrals(userId: String) { - BrownfieldNavigationManager.getDelegate().navigateToReferrals(userId) + override fun temporary() { + Log.d(NAME, "temporary") } companion object { diff --git a/packages/brownfield-navigation/ios/BrownfieldNavigationDelegate.swift b/packages/brownfield-navigation/ios/BrownfieldNavigationDelegate.swift index c84e9651..f4b2828d 100644 --- a/packages/brownfield-navigation/ios/BrownfieldNavigationDelegate.swift +++ b/packages/brownfield-navigation/ios/BrownfieldNavigationDelegate.swift @@ -1,6 +1,5 @@ import Foundation @objc public protocol BrownfieldNavigationDelegate: AnyObject { - @objc func navigateToSettings() - @objc func navigateToReferrals(_ userId: String) + } diff --git a/packages/brownfield-navigation/ios/NativeBrownfieldNavigation.mm b/packages/brownfield-navigation/ios/NativeBrownfieldNavigation.mm index 28371c45..d92e3ef0 100644 --- a/packages/brownfield-navigation/ios/NativeBrownfieldNavigation.mm +++ b/packages/brownfield-navigation/ios/NativeBrownfieldNavigation.mm @@ -8,12 +8,8 @@ @implementation NativeBrownfieldNavigation -- (void)navigateToSettings { - [[[BrownfieldNavigationManager shared] getDelegate] navigateToSettings]; -} - -- (void)navigateToReferrals:(NSString *)userId { - [[[BrownfieldNavigationManager shared] getDelegate] navigateToReferrals:userId]; +- (void)temporary { + NSLog(@"temporary"); } - (std::shared_ptr)getTurboModule: diff --git a/packages/brownfield-navigation/src/NativeBrownfieldNavigation.ts b/packages/brownfield-navigation/src/NativeBrownfieldNavigation.ts index 4d620932..5d4ed84d 100644 --- a/packages/brownfield-navigation/src/NativeBrownfieldNavigation.ts +++ b/packages/brownfield-navigation/src/NativeBrownfieldNavigation.ts @@ -1,8 +1,7 @@ import { TurboModuleRegistry, type TurboModule } from 'react-native'; export interface Spec extends TurboModule { - navigateToSettings(): void; - navigateToReferrals(userId: string): void; + temporary(): void; } export default TurboModuleRegistry.getEnforcing( diff --git a/packages/brownfield-navigation/src/index.ts b/packages/brownfield-navigation/src/index.ts index 81704d0d..5af516da 100644 --- a/packages/brownfield-navigation/src/index.ts +++ b/packages/brownfield-navigation/src/index.ts @@ -1,11 +1,8 @@ import NativeBrownfieldNavigation from './NativeBrownfieldNavigation'; const BrownfieldNavigation = { - navigateToSettings: () => { - NativeBrownfieldNavigation.navigateToSettings(); - }, - navigateToReferrals: (userId: string) => { - NativeBrownfieldNavigation.navigateToReferrals(userId); + temporary: () => { + NativeBrownfieldNavigation.temporary(); }, }; diff --git a/packages/react-native-brownfield/ReactBrownfield.podspec b/packages/react-native-brownfield/ReactBrownfield.podspec index a9abe22c..44149ca2 100644 --- a/packages/react-native-brownfield/ReactBrownfield.podspec +++ b/packages/react-native-brownfield/ReactBrownfield.podspec @@ -15,7 +15,7 @@ Pod::Spec.new do |spec| spec.module_name = "ReactBrownfield" spec.source = { :git => "git@github.com:callstack/react-native-brownfield.git", :tag => "#{spec.version}" } spec.source_files = "ios/**/*.{h,m,mm,swift}" - spec.exclude_files = "ios/Package.swift", "ios/Tests/**/*" + spec.exclude_files = "ios/swiftpm/Package.swift", "ios/swiftpm/Tests/**/*" spec.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'OTHER_SWIFT_FLAGS' => "-enable-experimental-feature AccessLevelOnImport" diff --git a/packages/react-native-brownfield/ios/ExpoHostRuntime.swift b/packages/react-native-brownfield/ios/ExpoHostRuntime.swift index fd2ae569..debdc8ce 100644 --- a/packages/react-native-brownfield/ios/ExpoHostRuntime.swift +++ b/packages/react-native-brownfield/ios/ExpoHostRuntime.swift @@ -18,7 +18,7 @@ final class ExpoHostRuntime { #if DEBUG let resolvedBundleURL = bundleURL ?? delegate.bundleURL() let shouldDisableDevLoadingView = - preferBundledBundleInDebug && (resolvedBundleURL?.isFileURL ?? false) + preferEmbeddedBundleInDebug && (resolvedBundleURL?.isFileURL ?? false) BrownfieldDevLoadingViewBridge.setEnabled(!shouldDisableDevLoadingView) #endif @@ -102,9 +102,9 @@ final class ExpoHostRuntime { * Prefer the embedded JavaScript bundle instead of Metro when this framework is built in Debug. * Default value: false */ - public var preferBundledBundleInDebug: Bool = false { + public var preferEmbeddedBundleInDebug: Bool = false { didSet { - delegate.preferBundledBundleInDebug = preferBundledBundleInDebug + delegate.preferEmbeddedBundleInDebug = preferEmbeddedBundleInDebug } } /** @@ -180,7 +180,7 @@ class ExpoHostRuntimeDelegate: ExpoReactNativeFactoryDelegate { var entryFile = ".expo/.virtual-metro-entry" var bundlePath = "main.jsbundle" var bundle = Bundle.main - var preferBundledBundleInDebug = false + var preferEmbeddedBundleInDebug = false var bundleURLOverride: (() -> URL?)? = nil override func sourceURL(for bridge: RCTBridge) -> URL? { @@ -198,7 +198,7 @@ class ExpoHostRuntimeDelegate: ExpoReactNativeFactoryDelegate { return try BrownfieldBundleURLResolver.resolve( isDebug: isDebug, - preferBundledBundleInDebug: preferBundledBundleInDebug, + preferEmbeddedBundleInDebug: preferEmbeddedBundleInDebug, bundlePath: bundlePath, bundle: bundle, bundleURLOverride: bundleURLOverride, diff --git a/packages/react-native-brownfield/ios/Package.swift b/packages/react-native-brownfield/ios/Package.swift deleted file mode 100644 index 1351ebd8..00000000 --- a/packages/react-native-brownfield/ios/Package.swift +++ /dev/null @@ -1,48 +0,0 @@ -// swift-tools-version: 5.9 - -import PackageDescription - -let package = Package( - name: "BrownfieldBundleSupport", - platforms: [ - .macOS(.v13), - ], - products: [ - .library( - name: "BrownfieldBundleSupport", - targets: ["BrownfieldBundleSupport"] - ), - ], - targets: [ - .target( - name: "BrownfieldBundleSupport", - path: ".", - exclude: [ - "BrownfieldDevLoadingViewBridge.h", - "BrownfieldDevLoadingViewBridge.m", - "ExpoHostRuntime.swift", - "JSBundleLoadObserver.swift", - "Notification+Brownfield.swift", - "ReactNativeBrownfield.swift", - "ReactNativeBrownfield.xcodeproj", - "ReactNativeBrownfieldModule.h", - "ReactNativeBrownfieldModule.mm", - "ReactNativeBrownfieldModule.swift", - "ReactNativeHostRuntime.swift", - "ReactNativeView.swift", - "ReactNativeViewController.swift", - "Tests", - ], - sources: [ - "BrownfieldBundlePathResolver.swift", - "BrownfieldBundleURLResolver.swift", - ] - ), - .testTarget( - name: "BrownfieldBundleSupportTests", - dependencies: ["BrownfieldBundleSupport"], - path: "Tests", - exclude: ["Fixtures"] - ), - ] -) diff --git a/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift b/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift index 35dac1a8..f8b8525a 100644 --- a/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift +++ b/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift @@ -59,12 +59,12 @@ internal import Expo * Prefer the embedded JavaScript bundle instead of Metro when this framework is built in Debug. * Default value: false */ - @objc public var preferBundledBundleInDebug: Bool = false { + @objc public var preferEmbeddedBundleInDebug: Bool = false { didSet { #if canImport(Expo) - ExpoHostRuntime.shared.preferBundledBundleInDebug = preferBundledBundleInDebug + ExpoHostRuntime.shared.preferEmbeddedBundleInDebug = preferEmbeddedBundleInDebug #else - ReactNativeHostRuntime.shared.preferBundledBundleInDebug = preferBundledBundleInDebug + ReactNativeHostRuntime.shared.preferEmbeddedBundleInDebug = preferEmbeddedBundleInDebug #endif } } diff --git a/packages/react-native-brownfield/ios/ReactNativeHostRuntime.swift b/packages/react-native-brownfield/ios/ReactNativeHostRuntime.swift index 50feb565..50c7481b 100644 --- a/packages/react-native-brownfield/ios/ReactNativeHostRuntime.swift +++ b/packages/react-native-brownfield/ios/ReactNativeHostRuntime.swift @@ -7,7 +7,7 @@ class ReactNativeBrownfieldDelegate: RCTDefaultReactNativeFactoryDelegate { var entryFile = "index" var bundlePath = "main.jsbundle" var bundle = Bundle.main - var preferBundledBundleInDebug = false + var preferEmbeddedBundleInDebug = false var bundleURLOverride: (() -> URL?)? = nil // MARK: - RCTReactNativeFactoryDelegate Methods @@ -25,7 +25,7 @@ class ReactNativeBrownfieldDelegate: RCTDefaultReactNativeFactoryDelegate { return try BrownfieldBundleURLResolver.resolve( isDebug: isDebug, - preferBundledBundleInDebug: preferBundledBundleInDebug, + preferEmbeddedBundleInDebug: preferEmbeddedBundleInDebug, bundlePath: bundlePath, bundle: bundle, bundleURLOverride: bundleURLOverride, @@ -48,7 +48,7 @@ final class ReactNativeHostRuntime { private func configureDevLoadingView() { #if DEBUG let shouldDisableDevLoadingView = - preferBundledBundleInDebug && (delegate.bundleURL()?.isFileURL ?? false) + preferEmbeddedBundleInDebug && (delegate.bundleURL()?.isFileURL ?? false) BrownfieldDevLoadingViewBridge.setEnabled(!shouldDisableDevLoadingView) #endif @@ -88,9 +88,9 @@ final class ReactNativeHostRuntime { * Prefer the embedded JavaScript bundle instead of Metro when this framework is built in Debug. * Default value: false */ - public var preferBundledBundleInDebug: Bool = false { + public var preferEmbeddedBundleInDebug: Bool = false { didSet { - delegate.preferBundledBundleInDebug = preferBundledBundleInDebug + delegate.preferEmbeddedBundleInDebug = preferEmbeddedBundleInDebug } } diff --git a/packages/react-native-brownfield/ios/swiftpm/Package.swift b/packages/react-native-brownfield/ios/swiftpm/Package.swift new file mode 100644 index 00000000..3a8fe1c3 --- /dev/null +++ b/packages/react-native-brownfield/ios/swiftpm/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "BrownfieldBundleSupport", + platforms: [ + .macOS(.v13), + ], + products: [ + .library( + name: "BrownfieldBundleSupport", + targets: ["BrownfieldBundleSupport"] + ), + ], + targets: [ + .target( + name: "BrownfieldBundleSupport", + path: "Sources/BrownfieldBundleSupport" + ), + .testTarget( + name: "BrownfieldBundleSupportTests", + dependencies: ["BrownfieldBundleSupport"], + path: "Tests/BrownfieldBundleSupportTests" + ), + ] +) diff --git a/packages/react-native-brownfield/ios/BrownfieldBundlePathResolver.swift b/packages/react-native-brownfield/ios/swiftpm/Sources/BrownfieldBundleSupport/BrownfieldBundlePathResolver.swift similarity index 100% rename from packages/react-native-brownfield/ios/BrownfieldBundlePathResolver.swift rename to packages/react-native-brownfield/ios/swiftpm/Sources/BrownfieldBundleSupport/BrownfieldBundlePathResolver.swift diff --git a/packages/react-native-brownfield/ios/BrownfieldBundleURLResolver.swift b/packages/react-native-brownfield/ios/swiftpm/Sources/BrownfieldBundleSupport/BrownfieldBundleURLResolver.swift similarity index 86% rename from packages/react-native-brownfield/ios/BrownfieldBundleURLResolver.swift rename to packages/react-native-brownfield/ios/swiftpm/Sources/BrownfieldBundleSupport/BrownfieldBundleURLResolver.swift index 95881ffc..47aab4c9 100644 --- a/packages/react-native-brownfield/ios/BrownfieldBundleURLResolver.swift +++ b/packages/react-native-brownfield/ios/swiftpm/Sources/BrownfieldBundleSupport/BrownfieldBundleURLResolver.swift @@ -3,7 +3,7 @@ import Foundation enum BrownfieldBundleURLResolver { static func resolve( isDebug: Bool, - preferBundledBundleInDebug: Bool, + preferEmbeddedBundleInDebug: Bool, bundlePath: String, bundle: Bundle, bundleURLOverride: (() -> URL?)?, @@ -13,7 +13,7 @@ enum BrownfieldBundleURLResolver { return overriddenURL } - if isDebug && !preferBundledBundleInDebug { + if isDebug && !preferEmbeddedBundleInDebug { return metroURL() } diff --git a/packages/react-native-brownfield/ios/Tests/BrownfieldBundleURLResolverTests.swift b/packages/react-native-brownfield/ios/swiftpm/Tests/BrownfieldBundleSupportTests/BrownfieldBundleURLResolverTests.swift similarity index 95% rename from packages/react-native-brownfield/ios/Tests/BrownfieldBundleURLResolverTests.swift rename to packages/react-native-brownfield/ios/swiftpm/Tests/BrownfieldBundleSupportTests/BrownfieldBundleURLResolverTests.swift index e1262920..6e4a52f1 100644 --- a/packages/react-native-brownfield/ios/Tests/BrownfieldBundleURLResolverTests.swift +++ b/packages/react-native-brownfield/ios/swiftpm/Tests/BrownfieldBundleSupportTests/BrownfieldBundleURLResolverTests.swift @@ -8,7 +8,7 @@ final class BrownfieldBundleURLResolverTests: XCTestCase { let resolvedURL = try BrownfieldBundleURLResolver.resolve( isDebug: true, - preferBundledBundleInDebug: true, + preferEmbeddedBundleInDebug: true, bundlePath: "main.jsbundle", bundle: bundle, bundleURLOverride: nil, @@ -26,7 +26,7 @@ final class BrownfieldBundleURLResolverTests: XCTestCase { let resolvedURL = try BrownfieldBundleURLResolver.resolve( isDebug: true, - preferBundledBundleInDebug: false, + preferEmbeddedBundleInDebug: false, bundlePath: "main.jsbundle", bundle: bundle, bundleURLOverride: nil, @@ -42,7 +42,7 @@ final class BrownfieldBundleURLResolverTests: XCTestCase { let resolvedURL = try BrownfieldBundleURLResolver.resolve( isDebug: false, - preferBundledBundleInDebug: false, + preferEmbeddedBundleInDebug: false, bundlePath: "main.jsbundle", bundle: bundle, bundleURLOverride: nil, @@ -61,7 +61,7 @@ final class BrownfieldBundleURLResolverTests: XCTestCase { let resolvedURL = try BrownfieldBundleURLResolver.resolve( isDebug: true, - preferBundledBundleInDebug: false, + preferEmbeddedBundleInDebug: false, bundlePath: "main.jsbundle", bundle: bundle, bundleURLOverride: { overrideURL }, @@ -77,7 +77,7 @@ final class BrownfieldBundleURLResolverTests: XCTestCase { let resolvedURL = try BrownfieldBundleURLResolver.resolve( isDebug: true, - preferBundledBundleInDebug: true, + preferEmbeddedBundleInDebug: true, bundlePath: "main.jsbundle", bundle: bundle, bundleURLOverride: { nil }, @@ -93,7 +93,7 @@ final class BrownfieldBundleURLResolverTests: XCTestCase { XCTAssertThrowsError( try BrownfieldBundleURLResolver.resolve( isDebug: false, - preferBundledBundleInDebug: false, + preferEmbeddedBundleInDebug: false, bundlePath: "mainjsbundle", bundle: Bundle(for: Self.self), bundleURLOverride: nil, From d44775d8ef306d6042be6b822174d810cca8f38d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Trzci=C5=84ski?= Date: Mon, 18 May 2026 12:31:04 +0200 Subject: [PATCH 08/12] fix(expo): sync brownfield framework target updates --- .../__tests__/withIosFrameworkFiles.test.ts | 31 ++++ .../ios/__tests__/xcodeHelpers.test.ts | 42 +++++- .../ios/withBrownfieldIos.ts | 11 +- .../ios/withIosFrameworkFiles.ts | 20 +-- .../expo-config-plugin/ios/xcodeHelpers.ts | 140 ++++++++++++++++-- .../template/ios/FrameworkInterface.swift | 5 +- 6 files changed, 221 insertions(+), 28 deletions(-) create mode 100644 packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withIosFrameworkFiles.test.ts diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withIosFrameworkFiles.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withIosFrameworkFiles.test.ts new file mode 100644 index 00000000..9876ea54 --- /dev/null +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withIosFrameworkFiles.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { getFrameworkSourceFiles } from '../withIosFrameworkFiles'; +import type { ResolvedBrownfieldPluginIosConfig } from '../../types/ios/BrownfieldPluginIosConfig'; + +const iosConfig: ResolvedBrownfieldPluginIosConfig = { + frameworkName: 'BrownfieldLib', + bundleIdentifier: 'com.example.brownfield.framework', + deploymentTarget: '15.0', + frameworkVersion: '1', + buildSettings: {}, +}; + +describe('getFrameworkSourceFiles', () => { + it('renders the framework interface with an explicit bundle identifier lookup', () => { + const files = getFrameworkSourceFiles(iosConfig); + const frameworkInterface = files.find( + (file) => file.relativePath === 'BrownfieldLib.swift' + ); + + expect(frameworkInterface?.content).toContain( + 'Bundle(identifier: "com.example.brownfield.framework")' + ); + expect(frameworkInterface?.content).toContain( + 'Bundle.allFrameworks.first { $0.bundleIdentifier == "com.example.brownfield.framework" }' + ); + expect(frameworkInterface?.content).toContain( + 'Bundle(for: InternalClassForBundle.self)' + ); + }); +}); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/xcodeHelpers.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/xcodeHelpers.test.ts index 68bc0182..3494aee8 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/xcodeHelpers.test.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/xcodeHelpers.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { getFrameworkBuildSettings } from '../xcodeHelpers'; +import { + getFrameworkBuildSettings, + rewriteBundleReactNativePhaseScriptForFrameworkTarget, +} from '../xcodeHelpers'; import type { ResolvedBrownfieldPluginIosConfig } from '../../types'; const baseOptions: ResolvedBrownfieldPluginIosConfig = { @@ -41,3 +44,40 @@ describe('getFrameworkBuildSettings', () => { expect(settings.MARKETING_VERSION).toBe('9.9.9'); }); }); + +describe('rewriteBundleReactNativePhaseScriptForFrameworkTarget', () => { + it('replaces Expo debug skip-bundling logic with a force-bundling override', () => { + const script = `if [[ "$CONFIGURATION" = *Debug* ]]; then + export SKIP_BUNDLING=1 +fi + +if [[ -z "$BUNDLE_COMMAND" ]]; then + export BUNDLE_COMMAND="export:embed" +fi + +\`"$NODE_BINARY" --print "require.resolve('react-native/package.json')"\`/scripts/react-native-xcode.sh +`; + + const rewritten = + rewriteBundleReactNativePhaseScriptForFrameworkTarget(script); + + expect(rewritten).toContain('unset SKIP_BUNDLING'); + expect(rewritten).toContain('export FORCE_BUNDLING=1'); + expect(rewritten).not.toContain('export SKIP_BUNDLING=1'); + expect(rewritten).toContain('export BUNDLE_COMMAND="export:embed"'); + expect(rewritten).toContain('react-native-xcode.sh'); + }); + + it('prepends the debug override when the source script has no Expo skip block', () => { + const script = `export ENTRY_FILE="index.js" +\`"$NODE_BINARY" --print "require.resolve('react-native/package.json')"\`/scripts/react-native-xcode.sh +`; + + const rewritten = + rewriteBundleReactNativePhaseScriptForFrameworkTarget(script); + + expect(rewritten).toMatch( + /^# Brownfield framework packaging must embed JS in Debug builds\.\nif \[\[ "\$CONFIGURATION" = \*Debug\* \]\]; then\n {2}unset SKIP_BUNDLING\n {2}export FORCE_BUNDLING=1\nfi\n\nexport ENTRY_FILE="index\.js"/ + ); + }); +}); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/withBrownfieldIos.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/withBrownfieldIos.ts index a3cd43f0..a8981dcb 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/withBrownfieldIos.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/withBrownfieldIos.ts @@ -44,9 +44,18 @@ export const withBrownfieldIos: ConfigPlugin< if (targetAlreadyExists) { Logger.logDebug( - `Skipping further Xcode modifications as framework target was already present` + `Framework target already present, syncing Brownfield build phases` ); + copyBundleReactNativePhase(project, frameworkTargetUUID); + + if (isExpoPre55) { + addExpoPre55ShellPatchScriptPhase(modRequest, project, { + frameworkName: props.ios.frameworkName, + frameworkTargetUUID: frameworkTargetUUID, + }); + } + return xcodeConfig; } diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts index 46823be4..3c652ab1 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts @@ -21,7 +21,9 @@ export function getFrameworkSourceFiles( return [ { relativePath: `${ios.frameworkName}.swift`, - content: renderTemplate('ios', 'FrameworkInterface.swift', {}), + content: renderTemplate('ios', 'FrameworkInterface.swift', { + '{{BUNDLE_IDENTIFIER}}': ios.bundleIdentifier, + }), }, { relativePath: 'Info.plist', @@ -44,17 +46,15 @@ export function createIosFramework( const { ios } = config; const frameworkDir = path.join(iosDir, ios.frameworkName); - // check if framework directory if it exists - if (fs.existsSync(frameworkDir)) { - Logger.logDebug(`Framework directory already exists: ${frameworkDir}`); - - return; - } - - Logger.logDebug(`Creating iOS framework in: ${frameworkDir}`); + const frameworkDirExists = fs.existsSync(frameworkDir); + Logger.logDebug( + frameworkDirExists + ? `Updating iOS framework files in: ${frameworkDir}` + : `Creating iOS framework in: ${frameworkDir}` + ); // create framework directory - if (!fs.existsSync(frameworkDir)) { + if (!frameworkDirExists) { fs.mkdirSync(frameworkDir, { recursive: true }); Logger.logDebug(`Created directory: ${frameworkDir}`); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts index 8b7355b7..c1a1333e 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts @@ -230,6 +230,62 @@ export function getFrameworkBuildSettings( }; } +export function rewriteBundleReactNativePhaseScriptForFrameworkTarget( + shellScript: string +): string { + const debugBundlingOverride = `# Brownfield framework packaging must embed JS in Debug builds. +if [[ "$CONFIGURATION" = *Debug* ]]; then + unset SKIP_BUNDLING + export FORCE_BUNDLING=1 +fi +`; + const debugSkipBundlingBlock = + /if \[\[ "\$CONFIGURATION" = \*Debug\* \]\]; then\s+export SKIP_BUNDLING=1\s+fi\s*/m; + + if (debugSkipBundlingBlock.test(shellScript)) { + return shellScript.replace( + debugSkipBundlingBlock, + `${debugBundlingOverride}\n` + ); + } + + if (shellScript.includes('export FORCE_BUNDLING=1')) { + return shellScript; + } + + return `${debugBundlingOverride}\n${shellScript}`; +} + +function decodePbxString(value: string | undefined): string { + if (!value) { + return ''; + } + + if (value.startsWith('"') && value.endsWith('"')) { + try { + return JSON.parse(value) as string; + } catch { + return value.slice(1, -1).replace(/\\"/g, '"'); + } + } + + return value; +} + +function encodePbxString(value: string): string { + return JSON.stringify(value); +} + +function hasBuildPhaseComment( + phase: { comment?: string }, + expectedComment: string +): boolean { + return ( + phase.comment === expectedComment || + phase.comment === `"${expectedComment}"` + ); +} + /** * Finds the "Bundle React Native code and images" build phase from the main app target * and adds it to the framework target's build phases @@ -253,41 +309,85 @@ export function copyBundleReactNativePhase( // find the phase by name let existingPhaseUuid: string | null = null; + let existingPhase: Record | null = null; for (const key of Object.keys(shellScriptPhases)) { if (key.endsWith('_comment')) continue; const phase = shellScriptPhases[key]; if (phase.name === `"${buildPhaseName}"` || phase.name === buildPhaseName) { existingPhaseUuid = key; + existingPhase = phase; break; } } - if (!existingPhaseUuid) { + if (!existingPhaseUuid || !existingPhase) { throw new SourceModificationError( `Could not find "${buildPhaseName}" build phase, skipping` ); } - // add the phase reference to the framework target's buildPhases array const nativeTargets = project.hash.project.objects.PBXNativeTarget; if (nativeTargets && nativeTargets[targetUuid]) { const target = nativeTargets[targetUuid]; if (target.buildPhases) { - // check if phase is already added - if ( - !target.buildPhases.some( - (phase: { value: string }) => phase.value === existingPhaseUuid - ) - ) { - target.buildPhases.push({ - value: existingPhaseUuid, - comment: buildPhaseName, - }); - - Logger.logDebug( - `Added "${buildPhaseName}" build phase to framework target ${target.name}` + const targetPhaseIndex = target.buildPhases.findIndex( + (phase: { comment?: string }) => + hasBuildPhaseComment(phase, buildPhaseName) + ); + const frameworkShellPath = + decodePbxString(existingPhase.shellPath) || '/bin/sh'; + const frameworkShellScript = + rewriteBundleReactNativePhaseScriptForFrameworkTarget( + decodePbxString(existingPhase.shellScript) ); + + if (targetPhaseIndex !== -1) { + const currentPhaseUuid = target.buildPhases[targetPhaseIndex].value; + const currentPhase = shellScriptPhases[currentPhaseUuid]; + + if (currentPhase && currentPhaseUuid !== existingPhaseUuid) { + currentPhase.inputPaths = existingPhase.inputPaths ?? []; + currentPhase.outputPaths = existingPhase.outputPaths ?? []; + currentPhase.shellPath = encodePbxString(frameworkShellPath); + currentPhase.shellScript = encodePbxString(frameworkShellScript); + + if (existingPhase.showEnvVarsInLog !== undefined) { + currentPhase.showEnvVarsInLog = existingPhase.showEnvVarsInLog; + } + + Logger.logDebug( + `Updated framework-specific "${buildPhaseName}" build phase on target ${target.name}` + ); + return; + } + + if (currentPhaseUuid === existingPhaseUuid) { + target.buildPhases.splice(targetPhaseIndex, 1); + } else { + return; + } } + + const addedPhase = project.addBuildPhase( + [], + 'PBXShellScriptBuildPhase', + buildPhaseName, + targetUuid, + { + inputPaths: existingPhase.inputPaths ?? [], + outputPaths: existingPhase.outputPaths ?? [], + shellPath: frameworkShellPath, + shellScript: frameworkShellScript, + } + ); + + if (existingPhase.showEnvVarsInLog !== undefined) { + addedPhase.buildPhase.showEnvVarsInLog = existingPhase.showEnvVarsInLog; + } + + Logger.logDebug( + `Added framework-specific "${buildPhaseName}" build phase to target ${target.name}` + ); } } } @@ -371,6 +471,16 @@ export function addExpoPre55ShellPatchScriptPhase( ); } + const existingBuildPhases = + project.pbxNativeTargetSection()[frameworkTargetUUID]?.buildPhases ?? []; + if ( + existingBuildPhases.some((phase: { comment?: string }) => + hasBuildPhaseComment(phase, 'Patch ExpoModulesProvider') + ) + ) { + return; + } + project.addBuildPhase( [ // no associated files diff --git a/packages/react-native-brownfield/src/expo-config-plugin/template/ios/FrameworkInterface.swift b/packages/react-native-brownfield/src/expo-config-plugin/template/ios/FrameworkInterface.swift index bbff2930..a2dbeb1d 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/template/ios/FrameworkInterface.swift +++ b/packages/react-native-brownfield/src/expo-config-plugin/template/ios/FrameworkInterface.swift @@ -2,7 +2,10 @@ import Foundation import ReactBrownfield // Initializes a Bundle instance that points at the framework target. -public let ReactNativeBundle = Bundle(for: InternalClassForBundle.self) +public let ReactNativeBundle = + Bundle(identifier: "{{BUNDLE_IDENTIFIER}}") + ?? Bundle.allFrameworks.first { $0.bundleIdentifier == "{{BUNDLE_IDENTIFIER}}" } + ?? Bundle(for: InternalClassForBundle.self) class InternalClassForBundle {} From 414a943e9ea47694e97c919dee5a8da71c5b8408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Trzci=C5=84ski?= Date: Mon, 18 May 2026 14:48:35 +0200 Subject: [PATCH 09/12] refactor(ios): make bundle URL resolver a class --- .../BrownfieldBundleSupport/BrownfieldBundleURLResolver.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-native-brownfield/ios/swiftpm/Sources/BrownfieldBundleSupport/BrownfieldBundleURLResolver.swift b/packages/react-native-brownfield/ios/swiftpm/Sources/BrownfieldBundleSupport/BrownfieldBundleURLResolver.swift index 47aab4c9..ce744833 100644 --- a/packages/react-native-brownfield/ios/swiftpm/Sources/BrownfieldBundleSupport/BrownfieldBundleURLResolver.swift +++ b/packages/react-native-brownfield/ios/swiftpm/Sources/BrownfieldBundleSupport/BrownfieldBundleURLResolver.swift @@ -1,6 +1,8 @@ import Foundation -enum BrownfieldBundleURLResolver { +final class BrownfieldBundleURLResolver { + private init() {} + static func resolve( isDebug: Bool, preferEmbeddedBundleInDebug: Bool, From 15b30365ccc94152e514408b5e24f27112ab2669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Trzci=C5=84ski?= Date: Tue, 19 May 2026 12:32:01 +0200 Subject: [PATCH 10/12] fix: code review --- .../ios/ExpoHostRuntime.swift | 490 ++++++++++-------- .../expo-config-plugin/ios/xcodeHelpers.ts | 4 + 2 files changed, 266 insertions(+), 228 deletions(-) diff --git a/packages/react-native-brownfield/ios/ExpoHostRuntime.swift b/packages/react-native-brownfield/ios/ExpoHostRuntime.swift index 5c913bca..ccd93e97 100644 --- a/packages/react-native-brownfield/ios/ExpoHostRuntime.swift +++ b/packages/react-native-brownfield/ios/ExpoHostRuntime.swift @@ -1,240 +1,274 @@ -import UIKit internal import React -internal import React_RCTAppDelegate internal import ReactAppDependencyProvider +internal import React_RCTAppDelegate +import UIKit #if canImport(Expo) -internal import Expo -#if canImport(EXUpdates) -internal import EXUpdates -#endif + internal import Expo + #if canImport(EXUpdates) + internal import EXUpdates + #endif -final class ExpoHostRuntime { - static let shared = ExpoHostRuntime() + final class ExpoHostRuntime { + static let shared = ExpoHostRuntime() - private let jsBundleLoadObserver = JSBundleLoadObserver() - private var delegate = ExpoHostRuntimeDelegate() - private var reactNativeFactory: RCTReactNativeFactory? - private var expoDelegate: ExpoAppDelegate? + private let jsBundleLoadObserver = JSBundleLoadObserver() + private var delegate = ExpoHostRuntimeDelegate() + private var reactNativeFactory: RCTReactNativeFactory? + private var expoDelegate: ExpoAppDelegate? - private func configureDevLoadingView(with bundleURL: URL? = nil) { - #if DEBUG - let resolvedBundleURL = bundleURL ?? delegate.bundleURL() - let shouldDisableDevLoadingView = - preferEmbeddedBundleInDebug && (resolvedBundleURL?.isFileURL ?? false) + private func configureDevLoadingView(with bundleURL: URL? = nil) { + #if DEBUG + let resolvedBundleURL = bundleURL ?? delegate.bundleURL() + let shouldDisableDevLoadingView = + preferEmbeddedBundleInDebug + && (resolvedBundleURL?.isFileURL ?? false) - BrownfieldDevLoadingViewBridge.setEnabled(!shouldDisableDevLoadingView) - #endif - } - - /** - * Starts React Native with default parameters. - */ - public func startReactNative() { - startReactNative(onBundleLoaded: nil) - } - /** - * Starts React Native with optional callback when bundle is loaded. - * - * @param onBundleLoaded Optional callback invoked after JS bundle is fully loaded. - */ - public func startReactNative(onBundleLoaded: (() -> Void)?) { - guard reactNativeFactory == nil else { return } - - configureDevLoadingView() - - let appDelegate = ExpoAppDelegate() - delegate.dependencyProvider = RCTAppDependencyProvider() - reactNativeFactory = ExpoReactNativeFactory(delegate: delegate) - // below: https://github.com/expo/expo/pull/39418/changes/5abd332b55b2ee7daee848284ed5f7fe1639452e - // has removed bindReactNativeFactory method from ExpoAppDelegate - #if !EXPO_SDK_GTE_55 // this define comes from the Brownfield Expo config plugin - guard let reactNativeFactory else { return } - appDelegate.bindReactNativeFactory(reactNativeFactory) - #endif - expoDelegate = appDelegate - - if let onBundleLoaded { - jsBundleLoadObserver.observeOnce(onBundleLoaded: onBundleLoaded) - } - } - - /** - * Stops React Native and releases the underlying factory instance. - */ - public func stopReactNative() { - if !Thread.isMainThread { - DispatchQueue.main.async { [weak self] in self?.stopReactNative() } - return - } + BrownfieldDevLoadingViewBridge.setEnabled( + !shouldDisableDevLoadingView + ) + #endif + } - reactNativeFactory = nil - expoDelegate = nil - } - - /** - * Path to JavaScript root. - * Default value: ".expo/.virtual-metro-entry" - */ - public var entryFile: String = ".expo/.virtual-metro-entry" { - didSet { - delegate.entryFile = entryFile - } - } - - /** - * Path to JavaScript bundle file. - * Default value: "main.jsbundle" - */ - public var bundlePath: String = "main.jsbundle" { - didSet { - delegate.bundlePath = bundlePath - } - } - /** - * Bundle instance to lookup the JavaScript bundle. - * Default value: Bundle.main - */ - public var bundle: Bundle = Bundle.main { - didSet { - delegate.bundle = bundle - } - } - - /** - * Prefer the embedded JavaScript bundle instead of Metro when this framework is built in Debug. - * Default value: false - */ - public var preferEmbeddedBundleInDebug: Bool = false { - didSet { - delegate.preferEmbeddedBundleInDebug = preferEmbeddedBundleInDebug - } - } - /** - * Dynamic bundle URL provider called on every bundle load. - * When set, this overrides the default bundleURL() behavior in the delegate. - * Returns a URL to load a custom bundle, or nil to use default behavior. - * Default value: nil - */ - public var bundleURLOverride: (() -> URL?)? = nil { - didSet { - delegate.bundleURLOverride = bundleURLOverride - } - } - - func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil - ) -> Bool { -#if canImport(EXUpdates) - if !AppController.isInitialized() { - AppController.initializeWithoutStarting() + /** + * Starts React Native with default parameters. + */ + public func startReactNative() { + startReactNative(onBundleLoaded: nil) + } + /** + * Starts React Native with optional callback when bundle is loaded. + * + * @param onBundleLoaded Optional callback invoked after JS bundle is fully loaded. + */ + public func startReactNative(onBundleLoaded: (() -> Void)?) { + guard reactNativeFactory == nil else { return } + + configureDevLoadingView() + + let appDelegate = ExpoAppDelegate() + delegate.dependencyProvider = RCTAppDependencyProvider() + reactNativeFactory = ExpoReactNativeFactory(delegate: delegate) + // below: https://github.com/expo/expo/pull/39418/changes/5abd332b55b2ee7daee848284ed5f7fe1639452e + // has removed bindReactNativeFactory method from ExpoAppDelegate + #if !EXPO_SDK_GTE_55 // this define comes from the Brownfield Expo config plugin + guard let reactNativeFactory else { return } + appDelegate.bindReactNativeFactory(reactNativeFactory) + #endif + expoDelegate = appDelegate + + if let onBundleLoaded { + jsBundleLoadObserver.observeOnce(onBundleLoaded: onBundleLoaded) + } + } + + /** + * Stops React Native and releases the underlying factory instance. + */ + public func stopReactNative() { + if !Thread.isMainThread { + DispatchQueue.main.async { [weak self] in + self?.stopReactNative() + } + return + } + + reactNativeFactory = nil + expoDelegate = nil + } + + /** + * Path to JavaScript root. + * Default value: ".expo/.virtual-metro-entry" + */ + public var entryFile: String = ".expo/.virtual-metro-entry" { + didSet { + delegate.entryFile = entryFile + } + } + + /** + * Path to JavaScript bundle file. + * Default value: "main.jsbundle" + */ + public var bundlePath: String = "main.jsbundle" { + didSet { + delegate.bundlePath = bundlePath + } + } + /** + * Bundle instance to lookup the JavaScript bundle. + * Default value: Bundle.main + */ + public var bundle: Bundle = Bundle.main { + didSet { + delegate.bundle = bundle + } + } + + /** + * Prefer the embedded JavaScript bundle instead of Metro when this framework is built in Debug. + * Default value: false + */ + public var preferEmbeddedBundleInDebug: Bool = false { + didSet { + delegate.preferEmbeddedBundleInDebug = + preferEmbeddedBundleInDebug + } + } + /** + * Dynamic bundle URL provider called on every bundle load. + * When set, this overrides the default bundleURL() behavior in the delegate. + * Returns a URL to load a custom bundle, or nil to use default behavior. + * Default value: nil + */ + public var bundleURLOverride: (() -> URL?)? = nil { + didSet { + delegate.bundleURLOverride = bundleURLOverride + } + } + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication + .LaunchOptionsKey: Any]? = nil + ) -> Bool { + #if canImport(EXUpdates) + if !AppController.isInitialized() { + AppController.initializeWithoutStarting() + } + #endif + return ExpoAppDelegateSubscriberManager.application( + application, + didFinishLaunchingWithOptions: launchOptions + ) + } + + // Linking API; base implementation courtesy of Expo, licensed under the MIT License - changes were made to call the method on expo delegate - https://github.com/expo/expo/blob/main/apps/bare-expo/ios/AppDelegate.swift + func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + return + (expoDelegate?.application(app, open: url, options: options) + ?? false) + || RCTLinkingManager.application( + app, + open: url, + options: options + ) + } + + // Universal Links; base implementation courtesy of Expo, licensed under the MIT License - changes were made to call the method on expo delegate - https://github.com/expo/expo/blob/main/apps/bare-expo/ios/AppDelegate.swift + func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void + ) -> Bool { + let result = RCTLinkingManager.application( + application, + continue: userActivity, + restorationHandler: restorationHandler + ) + return + (expoDelegate?.application( + application, + continue: userActivity, + restorationHandler: restorationHandler + ) ?? false) || result + } + + func application( + _ application: UIApplication, + willFinishLaunchingWithOptions launchOptions: [UIApplication + .LaunchOptionsKey: Any]? = nil + ) -> Bool { + return ExpoAppDelegateSubscriberManager.application( + application, + willFinishLaunchingWithOptions: launchOptions + ) + } + + func view( + moduleName: String, + initialProps: [AnyHashable: Any]?, + launchOptions: [AnyHashable: Any]? + ) -> UIView? { + let bundleURL = delegate.bundleURL() + configureDevLoadingView(with: bundleURL) + + // below: https://github.com/expo/expo/commit/2013760c46cde1404872d181a691da72fbf207a4 + // has moved the recreateRootView method to ExpoReactNativeFactory + #if EXPO_SDK_GTE_55 // this define comes from the Brownfield Expo config plugin + return (reactNativeFactory as? ExpoReactNativeFactory)? + .recreateRootView( + withBundleURL: bundleURL, + moduleName: moduleName, + initialProps: initialProps, + launchOptions: launchOptions + ) + #else + return expoDelegate?.recreateRootView( + withBundleURL: bundleURL, + moduleName: moduleName, + initialProps: initialProps, + launchOptions: launchOptions + ) + #endif + } } -#endif - return ExpoAppDelegateSubscriberManager.application(application, didFinishLaunchingWithOptions: launchOptions) - } - - // Linking API; base implementation courtesy of Expo, licensed under the MIT License - changes were made to call the method on expo delegate - https://github.com/expo/expo/blob/main/apps/bare-expo/ios/AppDelegate.swift - func application( - _ app: UIApplication, - open url: URL, - options: [UIApplication.OpenURLOptionsKey: Any] = [:] - ) -> Bool { - return (expoDelegate?.application(app, open: url, options: options) ?? false) || RCTLinkingManager.application(app, open: url, options: options) - } - - // Universal Links; base implementation courtesy of Expo, licensed under the MIT License - changes were made to call the method on expo delegate - https://github.com/expo/expo/blob/main/apps/bare-expo/ios/AppDelegate.swift - func application( - _ application: UIApplication, - continue userActivity: NSUserActivity, - restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void - ) -> Bool { - let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) - return (expoDelegate?.application(application, continue: userActivity, restorationHandler: restorationHandler) ?? false) || result - } - - func application( - _ application: UIApplication, - willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil - ) -> Bool { - return ExpoAppDelegateSubscriberManager.application(application, willFinishLaunchingWithOptions: launchOptions) - } - - func view( - moduleName: String, - initialProps: [AnyHashable: Any]?, - launchOptions: [AnyHashable: Any]? - ) -> UIView? { - let bundleURL = delegate.bundleURL() - configureDevLoadingView(with: bundleURL) - - // below: https://github.com/expo/expo/commit/2013760c46cde1404872d181a691da72fbf207a4 - // has moved the recreateRootView method to ExpoReactNativeFactory - #if EXPO_SDK_GTE_55 // this define comes from the Brownfield Expo config plugin - return (reactNativeFactory as? ExpoReactNativeFactory)?.recreateRootView( - withBundleURL: bundleURL, - moduleName: moduleName, - initialProps: initialProps, - launchOptions: launchOptions - ) - #else - return expoDelegate?.recreateRootView( - withBundleURL: bundleURL, - moduleName: moduleName, - initialProps: initialProps, - launchOptions: launchOptions - ) - #endif - } -} - -class ExpoHostRuntimeDelegate: ExpoReactNativeFactoryDelegate { - var entryFile = ".expo/.virtual-metro-entry" - var bundlePath = "main.jsbundle" - var bundle = Bundle.main - var preferEmbeddedBundleInDebug = false - var bundleURLOverride: (() -> URL?)? = nil - - override func sourceURL(for bridge: RCTBridge) -> URL? { - // needed to return the correct URL for expo-dev-client. - bridge.bundleURL ?? bundleURL() - } - - override func bundleURL() -> URL? { - do { - #if DEBUG - let isDebug = true - #else - let isDebug = false - #endif - - if let overriddenURL = bundleURLOverride?() { - return overriddenURL - } - - #if canImport(EXUpdates) - if !isDebug, - AppController.isInitialized(), - let launchAssetURL = AppController.sharedInstance.launchAssetUrl() { - return launchAssetURL - } - #endif - - return try BrownfieldBundleURLResolver.resolve( - isDebug: isDebug, - preferEmbeddedBundleInDebug: preferEmbeddedBundleInDebug, - bundlePath: bundlePath, - bundle: bundle, - bundleURLOverride: nil, - metroURL: { - RCTBundleURLProvider.sharedSettings().jsBundleURL( - forBundleRoot: entryFile) - } - ) - } catch { - assertionFailure("Invalid bundlePath '\(bundlePath)': \(error)") - return nil + + class ExpoHostRuntimeDelegate: ExpoReactNativeFactoryDelegate { + var entryFile = ".expo/.virtual-metro-entry" + var bundlePath = "main.jsbundle" + var bundle = Bundle.main + var preferEmbeddedBundleInDebug = false + var bundleURLOverride: (() -> URL?)? = nil + + override func sourceURL(for bridge: RCTBridge) -> URL? { + // needed to return the correct URL for expo-dev-client. + bridge.bundleURL ?? bundleURL() + } + + override func bundleURL() -> URL? { + do { + #if DEBUG + let isDebug = true + #else + let isDebug = false + #endif + + if let overriddenURL = bundleURLOverride?() { + return overriddenURL + } + + #if canImport(EXUpdates) + if !isDebug, + AppController.isInitialized(), + let launchAssetURL = AppController.sharedInstance + .launchAssetUrl() + { + return launchAssetURL + } + #endif + + return try BrownfieldBundleURLResolver.resolve( + isDebug: isDebug, + preferEmbeddedBundleInDebug: preferEmbeddedBundleInDebug, + bundlePath: bundlePath, + bundle: bundle, + bundleURLOverride: bundleURLOverride, + metroURL: { + RCTBundleURLProvider.sharedSettings().jsBundleURL( + forBundleRoot: entryFile + ) + } + ) + } catch { + assertionFailure("Invalid bundlePath '\(bundlePath)': \(error)") + return nil + } + } } - } -} #endif diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts index 952122fb..1319d235 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts @@ -664,6 +664,10 @@ function resolveAppTargetName( return null; } +/** + * Adds the "Patch ExpoModulesProvider" shell script phase to the framework target. + * Safe to call on every prebuild: skips creation when the phase is already present. + */ export function addExpoPre55ShellPatchScriptPhase( modRequest: ModProps, project: XcodeProject, From b288918dcada14c9be01fc4a833be1ad19f13ca9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Trzci=C5=84ski?= Date: Thu, 21 May 2026 09:32:24 +0200 Subject: [PATCH 11/12] fix(ios): address follow-up review comments --- docs/docs/docs/getting-started/expo.mdx | 10 ++++++++-- docs/docs/docs/getting-started/ios.mdx | 2 +- packages/cli/src/brownfield/commands/packageIos.ts | 1 + .../react-native-brownfield/ios/ExpoHostRuntime.swift | 6 +++--- .../ios/ReactNativeHostRuntime.swift | 2 -- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/docs/docs/getting-started/expo.mdx b/docs/docs/docs/getting-started/expo.mdx index f946fe50..3045029a 100644 --- a/docs/docs/docs/getting-started/expo.mdx +++ b/docs/docs/docs/getting-started/expo.mdx @@ -124,8 +124,6 @@ struct IosApp: App { init() { ReactNativeBrownfield.shared.bundle = ReactNativeBundle - // Optional: use the packaged bundle even when the consumed framework is built in Debug. - // ReactNativeBrownfield.shared.preferEmbeddedBundleInDebug = true ReactNativeBrownfield.shared.startReactNative { print("React Native has been loaded") } @@ -140,6 +138,14 @@ struct IosApp: App { } ``` +If you package the framework in **Debug** and want to run it without Metro, enable the embedded bundle explicitly before calling `startReactNative`: + +```swift +ReactNativeBrownfield.shared.bundle = ReactNativeBundle +ReactNativeBrownfield.shared.preferEmbeddedBundleInDebug = true +ReactNativeBrownfield.shared.startReactNative() +``` + 2. Propagate the didFinishLaunchingWithOptions ```swift diff --git a/docs/docs/docs/getting-started/ios.mdx b/docs/docs/docs/getting-started/ios.mdx index 83f2b9e4..f6ee6fb5 100644 --- a/docs/docs/docs/getting-started/ios.mdx +++ b/docs/docs/docs/getting-started/ios.mdx @@ -173,7 +173,7 @@ npx react-native start ## Embedded bundle in Development -If you want to run a **Debug-built** framework without Metro, enable the bundled bundle explicitly before calling `startReactNative`: +If you want to run a **Debug-built** framework without Metro, enable the embedded bundle explicitly before calling `startReactNative`: ```swift ReactNativeBrownfield.shared.bundle = ReactNativeBundle diff --git a/packages/cli/src/brownfield/commands/packageIos.ts b/packages/cli/src/brownfield/commands/packageIos.ts index 53fe869b..63f4b236 100644 --- a/packages/cli/src/brownfield/commands/packageIos.ts +++ b/packages/cli/src/brownfield/commands/packageIos.ts @@ -115,6 +115,7 @@ export const packageIosCommand = curryOptions( }); if (configuration.includes('Debug')) { + // Re-merge only Debug frameworks so the simulator slice includes main.jsbundle. await mergeFrameworks({ sourceDir: userConfig.project.ios.sourceDir, frameworkPaths: [ diff --git a/packages/react-native-brownfield/ios/ExpoHostRuntime.swift b/packages/react-native-brownfield/ios/ExpoHostRuntime.swift index ccd93e97..dfd1bd9d 100644 --- a/packages/react-native-brownfield/ios/ExpoHostRuntime.swift +++ b/packages/react-native-brownfield/ios/ExpoHostRuntime.swift @@ -44,8 +44,6 @@ import UIKit public func startReactNative(onBundleLoaded: (() -> Void)?) { guard reactNativeFactory == nil else { return } - configureDevLoadingView() - let appDelegate = ExpoAppDelegate() delegate.dependencyProvider = RCTAppDependencyProvider() reactNativeFactory = ExpoReactNativeFactory(delegate: delegate) @@ -253,12 +251,14 @@ import UIKit } #endif + // The override is resolved here so it wins over the Expo Updates launch asset + // and the closure is not evaluated a second time inside the shared resolver. return try BrownfieldBundleURLResolver.resolve( isDebug: isDebug, preferEmbeddedBundleInDebug: preferEmbeddedBundleInDebug, bundlePath: bundlePath, bundle: bundle, - bundleURLOverride: bundleURLOverride, + bundleURLOverride: nil, metroURL: { RCTBundleURLProvider.sharedSettings().jsBundleURL( forBundleRoot: entryFile diff --git a/packages/react-native-brownfield/ios/ReactNativeHostRuntime.swift b/packages/react-native-brownfield/ios/ReactNativeHostRuntime.swift index 140b503b..ab253be6 100644 --- a/packages/react-native-brownfield/ios/ReactNativeHostRuntime.swift +++ b/packages/react-native-brownfield/ios/ReactNativeHostRuntime.swift @@ -189,8 +189,6 @@ final class ReactNativeHostRuntime { public func startReactNative(onBundleLoaded: (() -> Void)?) { guard reactNativeFactory == nil else { return } - configureDevLoadingView() - delegate.dependencyProvider = RCTAppDependencyProvider() reactNativeFactory = RCTReactNativeFactory(delegate: delegate) From 1494fdb4f16254f7e746688a20e51105dac1462f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Trzci=C5=84ski?= Date: Fri, 22 May 2026 11:21:38 +0200 Subject: [PATCH 12/12] fix: merge conflict --- .../src/expo-config-plugin/ios/xcodeHelpers.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts index 71ddfa9b..53a508d1 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts @@ -424,7 +424,6 @@ export function getFrameworkBuildSettings( USER_SCRIPT_SANDBOXING: 'NO', SKIP_INSTALL: 'NO', ENABLE_MODULE_VERIFIER: 'NO', - DYLIB_INSTALL_NAME_BASE: '"@rpath"', INSTALL_PATH: '"$(LOCAL_LIBRARY_DIR)/Frameworks"', // basic settings