Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/expo-debug-embedded-bundle.md
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 26 additions & 16 deletions apps/brownfield-example-shared-tests/detox-ios-simulator-device.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand All @@ -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 };
Original file line number Diff line number Diff line change
@@ -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'
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Comment thread
artus9033 marked this conversation as resolved.

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();
}
});

Expand Down
2 changes: 1 addition & 1 deletion apps/brownfield-example-shared-tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -232,7 +232,8 @@ class ExpoHostRuntimeDelegate: ExpoReactNativeFactoryDelegate {
bundleURLOverride: nil,
metroURL: {
RCTBundleURLProvider.sharedSettings().jsBundleURL(
forBundleRoot: entryFile
forBundleRoot: entryFile,
fallbackURLProvider: { nil }
)
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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 {
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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"');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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;
}

Expand Down
22 changes: 14 additions & 8 deletions scripts/ci-local-ios-e2e-common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}")

Expand Down Expand Up @@ -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"
Expand Down
Loading