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/tender-poems-rhyme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@callstack/react-native-brownfield': minor
---

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.
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,26 @@
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/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" \
swift test --scratch-path "$RUNNER_TEMP/swift-cache/swiftpm"

android-androidapp-expo:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
name: Android road test (AndroidApp - Expo ${{ matrix.version }})
runs-on: ubuntu-latest
needs: [filter, build-lint]
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,10 @@ secring.gpg

# Typescript
**/*.tsbuildinfo
packages/react-native-brownfield/ios/.build/
packages/react-native-brownfield/ios/swiftpm/.build/

# skillgym
.skillgym-results/

.cursor
.cursor
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ struct BrownfieldAppleApp: App {

init() {
ReactNativeBrownfield.shared.bundle = ReactNativeBundle
ReactNativeBrownfield.shared.preferEmbeddedBundleInDebug = true
ReactNativeBrownfield.shared.startReactNative {
print("React Native has been loaded")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
| `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. |

---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
| `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. |

---
Expand Down
8 changes: 8 additions & 0 deletions docs/docs/docs/getting-started/expo.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -138,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
Expand Down
10 changes: 10 additions & 0 deletions docs/docs/docs/getting-started/ios.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,16 @@ 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 embedded bundle explicitly before calling `startReactNative`:

```swift
ReactNativeBrownfield.shared.bundle = ReactNativeBundle
ReactNativeBrownfield.shared.preferEmbeddedBundleInDebug = true
ReactNativeBrownfield.shared.startReactNative()
```

### Release Configuration

In **Release**, the JS bundle is loaded directly from the XCFramework - no dev server needed.
Expand Down
55 changes: 45 additions & 10 deletions packages/cli/src/brownfield/commands/packageIos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
} from '../../shared/index.js';
import { runBrownieCodegenIfApplicable } from '../../brownie/helpers/runBrownieCodegenIfApplicable.js';
import { runNavigationCodegenIfApplicable } from '../../navigation/helpers/runNavigationCodegenIfApplicable.js';
import { copyDebugBundleToSimulatorSlice } from '../utils/copyDebugBundleToSimulatorSlice.js';
import { resolvePackagedFrameworkName } from '../utils/resolvePackagedFrameworkName.js';
import { stripFrameworkBinary } from '../utils/stripFrameworkBinary.js';

/** Help text for `--use-prebuilt-rn-core` (keep in sync with docs/docs/docs/getting-started/ios.mdx, "React Native Prebuilts" section). */
Expand Down Expand Up @@ -163,6 +165,49 @@ export const packageIosCommand = curryOptions(
platformConfig
);

const productsPath = path.join(options.buildFolder, 'Build', 'Products');
const { frameworkName, resolution, candidates } =
resolvePackagedFrameworkName({
explicitScheme: options.scheme,
productsPath,
configuration,
});

if (frameworkName) {
copyDebugBundleToSimulatorSlice({
productsPath,
configuration,
frameworkName,
});

if (configuration.includes('Debug')) {
// Re-merge only Debug frameworks so the simulator slice includes main.jsbundle.
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')) {
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(
packageDir,
'ReactBrownfield.xcframework'
Expand All @@ -175,11 +220,6 @@ export const packageIosCommand = curryOptions(
}

if (hasBrownie) {
const productsPath = path.join(
options.buildFolder,
'Build',
'Products'
);
const brownieOutputPath = path.join(packageDir, 'Brownie.xcframework');

await mergeFrameworks({
Expand Down Expand Up @@ -212,11 +252,6 @@ export const packageIosCommand = curryOptions(
}

if (hasNavigation) {
const productsPath = path.join(
options.buildFolder,
'Build',
'Products'
);
const brownfieldNavigationOutputPath = path.join(
packageDir,
'BrownfieldNavigation.xcframework'
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof rockTools>();
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<typeof vi.fn>;
const mockLoggerSuccess = rockTools.logger.success as ReturnType<typeof vi.fn>;

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');
});
});
Loading
Loading