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/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/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/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/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))}` + ); +} 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") + } + } +}