diff --git a/.changeset/expo-debug-embedded-bundle.md b/.changeset/expo-debug-embedded-bundle.md new file mode 100644 index 00000000..6659623d --- /dev/null +++ b/.changeset/expo-debug-embedded-bundle.md @@ -0,0 +1,5 @@ +--- +'@callstack/react-native-brownfield': patch +--- + +Fix Expo iOS framework debug bundle packaging so Debug builds load from Metro when it is available and fall back to embedded bundles without dev-server-only runtime code. diff --git a/apps/brownfield-example-shared-tests/detox-ios-simulator-device.cjs b/apps/brownfield-example-shared-tests/detox-ios-simulator-device.cjs index aa2d450d..11252b51 100644 --- a/apps/brownfield-example-shared-tests/detox-ios-simulator-device.cjs +++ b/apps/brownfield-example-shared-tests/detox-ios-simulator-device.cjs @@ -60,6 +60,27 @@ function scoreDeviceType(name) { return score; } +function pickBestDeviceType(types) { + let best = null; + let bestScore = -1; + for (const type of types) { + const score = scoreDeviceType(type); + if (score > bestScore) { + bestScore = score; + best = type; + } + } + return best; +} + +function pickIosSimulatorDeviceType({ deviceTypes, availableDevices }) { + return ( + pickBestDeviceType(availableDevices) || + pickBestDeviceType(deviceTypes) || + FALLBACK_DEVICE_TYPE + ); +} + /** * Device `type` string for Detox `ios.simulator` config (matches Xcode / simctl names). * @@ -74,21 +95,10 @@ function getIosSimulatorDeviceType() { process.env.DETOX_IOS_SIMULATOR_DEVICE?.trim(); if (fromEnv) return fromEnv; - const types = [ - ...listSimctlIphoneDeviceTypes(), - ...listSimctlAvailableIphoneDevices(), - ]; - - let best = null; - let bestScore = -1; - for (const t of types) { - const s = scoreDeviceType(t); - if (s > bestScore) { - bestScore = s; - best = t; - } - } - return best || FALLBACK_DEVICE_TYPE; + return pickIosSimulatorDeviceType({ + deviceTypes: listSimctlIphoneDeviceTypes(), + availableDevices: listSimctlAvailableIphoneDevices(), + }); } -module.exports = { getIosSimulatorDeviceType }; +module.exports = { getIosSimulatorDeviceType, pickIosSimulatorDeviceType }; diff --git a/apps/brownfield-example-shared-tests/detox-ios-simulator-device.test.cjs b/apps/brownfield-example-shared-tests/detox-ios-simulator-device.test.cjs new file mode 100644 index 00000000..f8394537 --- /dev/null +++ b/apps/brownfield-example-shared-tests/detox-ios-simulator-device.test.cjs @@ -0,0 +1,36 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + pickIosSimulatorDeviceType, +} = require('./detox-ios-simulator-device.cjs'); + +test('prefers an installed simulator over a newer uncreated device type', () => { + assert.equal( + pickIosSimulatorDeviceType({ + deviceTypes: ['iPhone 16', 'iPhone 17 Pro Max'], + availableDevices: ['iPhone 16'], + }), + 'iPhone 16' + ); +}); + +test('falls back to available device types when no simulators are installed', () => { + assert.equal( + pickIosSimulatorDeviceType({ + deviceTypes: ['iPhone 16', 'iPhone 17 Pro'], + availableDevices: [], + }), + 'iPhone 17 Pro' + ); +}); + +test('uses the default fallback when simctl returns no iPhone data', () => { + assert.equal( + pickIosSimulatorDeviceType({ + deviceTypes: [], + availableDevices: [], + }), + 'iPhone 16' + ); +}); diff --git a/apps/brownfield-example-shared-tests/e2e/expoPostMessageBrownfield.e2e.js b/apps/brownfield-example-shared-tests/e2e/expoPostMessageBrownfield.e2e.js index 60fdab94..18092330 100644 --- a/apps/brownfield-example-shared-tests/e2e/expoPostMessageBrownfield.e2e.js +++ b/apps/brownfield-example-shared-tests/e2e/expoPostMessageBrownfield.e2e.js @@ -8,14 +8,25 @@ const { waitForVisible, } = require('@callstack/brownfield-example-shared-tests/e2e/detoxUtils'); +async function openPostMessageTab() { + await device.disableSynchronization(); + try { + await waitForVisible(by.label('postMessage API'), 45000); + await element(by.label('postMessage API')).atIndex(0).tap(); + await waitForVisible(by.id(ids.sendMessageToNative), 45000); + } finally { + await device.enableSynchronization(); + } +} + describe('Brownfield postMessage (Expo demo)', () => { beforeEach(async () => { await launchBrownfieldAppForDetox({ newInstance: true }); try { - await waitForVisible(by.id(ids.sendMessageToNative), 45000); + await openPostMessageTab(); } catch { await device.reloadReactNative(); - await waitForVisible(by.id(ids.sendMessageToNative), 45000); + await openPostMessageTab(); } }); diff --git a/apps/brownfield-example-shared-tests/package.json b/apps/brownfield-example-shared-tests/package.json index 2533d8cd..7e8949c4 100644 --- a/apps/brownfield-example-shared-tests/package.json +++ b/apps/brownfield-example-shared-tests/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "test": "node -e \"process.exit(0)\"" + "test": "node --test ./*.test.cjs" }, "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/react-native-brownfield/ios/Expo/ExpoHostRuntime.swift b/packages/react-native-brownfield/ios/Expo/ExpoHostRuntime.swift index c38eeb2d..099b93bc 100644 --- a/packages/react-native-brownfield/ios/Expo/ExpoHostRuntime.swift +++ b/packages/react-native-brownfield/ios/Expo/ExpoHostRuntime.swift @@ -102,7 +102,7 @@ final class ExpoHostRuntime { } /** - * Prefer the embedded JavaScript bundle instead of Metro when this framework is built in Debug. + * Use the embedded JavaScript bundle as a Debug fallback when Metro is unavailable. * Default value: false */ public var preferEmbeddedBundleInDebug: Bool = false { @@ -232,7 +232,8 @@ class ExpoHostRuntimeDelegate: ExpoReactNativeFactoryDelegate { bundleURLOverride: nil, metroURL: { RCTBundleURLProvider.sharedSettings().jsBundleURL( - forBundleRoot: entryFile + forBundleRoot: entryFile, + fallbackURLProvider: { nil } ) } ) diff --git a/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift b/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift index bd11c281..0c0cc054 100644 --- a/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift +++ b/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift @@ -56,7 +56,7 @@ internal import Expo } /** - * Prefer the embedded JavaScript bundle instead of Metro when this framework is built in Debug. + * Use the embedded JavaScript bundle as a Debug fallback when Metro is unavailable. * Default value: false */ @objc public var preferEmbeddedBundleInDebug: Bool = false { diff --git a/packages/react-native-brownfield/ios/Vanilla/ReactNativeHostRuntime.swift b/packages/react-native-brownfield/ios/Vanilla/ReactNativeHostRuntime.swift index 5a3f6fd6..f2671aec 100644 --- a/packages/react-native-brownfield/ios/Vanilla/ReactNativeHostRuntime.swift +++ b/packages/react-native-brownfield/ios/Vanilla/ReactNativeHostRuntime.swift @@ -31,7 +31,10 @@ class ReactNativeBrownfieldDelegate: RCTDefaultReactNativeFactoryDelegate { bundle: bundle, bundleURLOverride: bundleURLOverride, metroURL: { - RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: entryFile) + RCTBundleURLProvider.sharedSettings().jsBundleURL( + forBundleRoot: entryFile, + fallbackURLProvider: { nil } + ) } ) } catch { @@ -86,7 +89,7 @@ final class ReactNativeHostRuntime { } /** - * Prefer the embedded JavaScript bundle instead of Metro when this framework is built in Debug. + * Use the embedded JavaScript bundle as a Debug fallback when Metro is unavailable. * Default value: false */ public var preferEmbeddedBundleInDebug: Bool = false { 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 ce744833..85528d44 100644 --- a/packages/react-native-brownfield/ios/swiftpm/Sources/BrownfieldBundleSupport/BrownfieldBundleURLResolver.swift +++ b/packages/react-native-brownfield/ios/swiftpm/Sources/BrownfieldBundleSupport/BrownfieldBundleURLResolver.swift @@ -15,14 +15,20 @@ final class BrownfieldBundleURLResolver { return overriddenURL } - if isDebug && !preferEmbeddedBundleInDebug { - return metroURL() - } - let (resourceName, fileExtension) = try BrownfieldBundlePathResolver.resourceComponents( from: bundlePath ) - return bundle.url(forResource: resourceName, withExtension: fileExtension) + let embeddedBundleURL = bundle.url(forResource: resourceName, withExtension: fileExtension) + + if isDebug { + if preferEmbeddedBundleInDebug { + return metroURL() ?? embeddedBundleURL + } + + return metroURL() + } + + return embeddedBundleURL } } diff --git a/packages/react-native-brownfield/ios/swiftpm/Tests/BrownfieldBundleSupportTests/BrownfieldBundleURLResolverTests.swift b/packages/react-native-brownfield/ios/swiftpm/Tests/BrownfieldBundleSupportTests/BrownfieldBundleURLResolverTests.swift index 6e4a52f1..1212a031 100644 --- a/packages/react-native-brownfield/ios/swiftpm/Tests/BrownfieldBundleSupportTests/BrownfieldBundleURLResolverTests.swift +++ b/packages/react-native-brownfield/ios/swiftpm/Tests/BrownfieldBundleSupportTests/BrownfieldBundleURLResolverTests.swift @@ -2,7 +2,7 @@ import XCTest @testable import BrownfieldBundleSupport final class BrownfieldBundleURLResolverTests: XCTestCase { - func test_debugResolutionPrefersBundledResourceWhenEnabled() throws { + func test_debugResolutionUsesMetroWhenAvailableWithBundledFallbackEnabled() throws { let metroURL = URL(string: "http://localhost:8081/index.bundle?platform=ios")! let bundle = try makeFixtureBundle() @@ -15,9 +15,23 @@ final class BrownfieldBundleURLResolverTests: XCTestCase { metroURL: { metroURL } ) + XCTAssertEqual(resolvedURL, metroURL) + } + + func test_debugResolutionFallsBackToBundledResourceWhenMetroIsUnavailable() throws { + let bundle = try makeFixtureBundle() + + let resolvedURL = try BrownfieldBundleURLResolver.resolve( + isDebug: true, + preferEmbeddedBundleInDebug: true, + bundlePath: "main.jsbundle", + bundle: bundle, + bundleURLOverride: nil, + metroURL: { nil } + ) + XCTAssertNotNil(resolvedURL) XCTAssertEqual(resolvedURL?.lastPathComponent, "main.jsbundle") - XCTAssertNotEqual(resolvedURL, metroURL) } func test_debugResolutionUsesMetroByDefault() throws { @@ -84,9 +98,7 @@ final class BrownfieldBundleURLResolverTests: XCTestCase { metroURL: { metroURL } ) - XCTAssertNotNil(resolvedURL) - XCTAssertEqual(resolvedURL?.lastPathComponent, "main.jsbundle") - XCTAssertNotEqual(resolvedURL, metroURL) + XCTAssertEqual(resolvedURL, metroURL) } func test_invalidBundlePathThrows() { 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 3494aee8..fbc6b376 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 @@ -63,6 +63,9 @@ fi expect(rewritten).toContain('unset SKIP_BUNDLING'); expect(rewritten).toContain('export FORCE_BUNDLING=1'); + expect(rewritten).toContain( + 'export EXTRA_PACKAGER_ARGS="$EXTRA_PACKAGER_ARGS --dev false"' + ); expect(rewritten).not.toContain('export SKIP_BUNDLING=1'); expect(rewritten).toContain('export BUNDLE_COMMAND="export:embed"'); expect(rewritten).toContain('react-native-xcode.sh'); @@ -77,7 +80,28 @@ fi 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"/ + /^# Brownfield framework packaging must embed JS in Debug builds\.\nif \[\[ "\$CONFIGURATION" = \*Debug\* \]\]; then\n {2}unset SKIP_BUNDLING\n {2}export FORCE_BUNDLING=1\n {2}export EXTRA_PACKAGER_ARGS="\$EXTRA_PACKAGER_ARGS --dev false"\nfi\n\nexport ENTRY_FILE="index\.js"/ + ); + }); + + it('backfills dev-mode disabling when an existing debug override is missing it', () => { + const script = `# Brownfield framework packaging must embed JS in Debug builds. +if [[ "$CONFIGURATION" = *Debug* ]]; then + unset SKIP_BUNDLING + export FORCE_BUNDLING=1 +fi + +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).toContain('export FORCE_BUNDLING=1'); + expect(rewritten).toContain( + 'export EXTRA_PACKAGER_ARGS="$EXTRA_PACKAGER_ARGS --dev false"' ); + expect(rewritten).toContain('export ENTRY_FILE="index.js"'); }); }); 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 53a508d1..49b53b1d 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 @@ -454,10 +454,12 @@ export function rewriteBundleReactNativePhaseScriptForFrameworkTarget( if [[ "$CONFIGURATION" = *Debug* ]]; then unset SKIP_BUNDLING export FORCE_BUNDLING=1 + export EXTRA_PACKAGER_ARGS="$EXTRA_PACKAGER_ARGS --dev false" fi `; const debugSkipBundlingBlock = /if \[\[ "\$CONFIGURATION" = \*Debug\* \]\]; then\s+export SKIP_BUNDLING=1\s+fi\s*/m; + const forceBundlingExport = /^([ \t]*)export FORCE_BUNDLING=1[ \t]*$/m; if (debugSkipBundlingBlock.test(shellScript)) { return shellScript.replace( @@ -467,6 +469,18 @@ fi } if (shellScript.includes('export FORCE_BUNDLING=1')) { + if (shellScript.includes('--dev false')) { + return shellScript; + } + + if (forceBundlingExport.test(shellScript)) { + return shellScript.replace( + forceBundlingExport, + (_, indentation: string) => + `${indentation}export FORCE_BUNDLING=1\n${indentation}export EXTRA_PACKAGER_ARGS="$EXTRA_PACKAGER_ARGS --dev false"` + ); + } + return shellScript; } diff --git a/scripts/ci-local-ios-e2e-common.sh b/scripts/ci-local-ios-e2e-common.sh index a29daaa6..2fedab42 100644 --- a/scripts/ci-local-ios-e2e-common.sh +++ b/scripts/ci-local-ios-e2e-common.sh @@ -107,11 +107,13 @@ ci_local_e2e_register_pod_settings_restore() { return 0 fi - for existing in "${CI_LOCAL_E2E_IOS_PATHS_FOR_POD_RESTORE[@]}"; do - if [[ "${existing}" == "${ios_path}" ]]; then - return 0 - fi - done + if [[ "${#CI_LOCAL_E2E_IOS_PATHS_FOR_POD_RESTORE[@]}" -gt 0 ]]; then + for existing in "${CI_LOCAL_E2E_IOS_PATHS_FOR_POD_RESTORE[@]}"; do + if [[ "${existing}" == "${ios_path}" ]]; then + return 0 + fi + done + fi CI_LOCAL_E2E_IOS_PATHS_FOR_POD_RESTORE+=("${ios_path}") @@ -229,17 +231,21 @@ ci_local_e2e_ensure_ios_xcode_env_updates() { local file="${ios_path}/.xcode.env.updates" local marker='# Detox / CI embedded bundle' - if [[ -f "${file}" ]] && grep -q "${marker}" "${file}"; then + if [[ -f "${file}" ]] && + grep -q "${marker}" "${file}" && + grep -q -- "--dev false" "${file}"; then return 0 fi # Detox sets FORCE_BUNDLING=1, but Expo Debug also sets SKIP_BUNDLING=1 in the Xcode - # bundle script. Unset SKIP_BUNDLING when FORCE_BUNDLING is set so main.jsbundle is embedded. + # bundle script. Unset SKIP_BUNDLING and disable dev transforms when FORCE_BUNDLING is set + # so main.jsbundle is embedded and can run without Metro/devtools. cat > "${file}" <<'EOF' # Detox / CI embedded bundle -# When FORCE_BUNDLING=1 (see apps/*/.detoxrc.cjs), embed JS for simulator E2E without Metro. +# When FORCE_BUNDLING=1 (see apps/*/.detoxrc.cjs), embed production JS for simulator E2E without Metro. if [[ -n "$FORCE_BUNDLING" ]]; then unset SKIP_BUNDLING + export EXTRA_PACKAGER_ARGS="${EXTRA_PACKAGER_ARGS:-} --dev false" fi EOF echo "==> Wrote ${file} for Detox embedded bundle builds"