Skip to content
Open
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
13 changes: 13 additions & 0 deletions .changeset/bare-app-skip-expo-prebuild.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@callstack/brownfield-cli': patch
'@callstack/react-native-brownfield': patch
'@callstack/create-react-native-brownfield': patch
---

Support Expo modules in React Native CLI apps. `brownfield package:*` no longer runs `expo prebuild` on projects with committed `android/` and `ios/` directories, which used to delete the brownfield library module. Prebuild now runs only when the native directory is missing or gitignored. If you commit the generated directories of an Expo app, run `npx expo prebuild` yourself after changing `app.json` or config plugins. `create-react-native-brownfield` now detects Expo and sets up the iOS framework to use the Expo runtime.

`--use-prebuilt-expo` now also turns Expo's precompiled modules on or off for `pod install`, and turns on Swift explicit modules so packaging works in projects that still carry the old `SWIFT_ENABLE_EXPLICIT_MODULES = NO` workaround. It defaults to building Expo from source when the app's React Native version doesn't match the one Expo's precompiled modules were built for.

`package:ios` now copies the expo-image dependency XCFrameworks (SDWebImage and libavif) as real files instead of symlinks into `node_modules`, so the package works when moved to another machine.

Re-running `create-react-native-brownfield` after `pod install` no longer adds a second Sources build phase to the framework target, which made the next `pod install` fail.
7 changes: 3 additions & 4 deletions docs/docs/docs/getting-started/ios.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ That command writes `Package.swift` into the same `ios/.brownfield/package/build
- These are required with the PreCompiled Expo option enabled. If it's disabled, only `ExpoModulesJSI.xcframework` is required to be linked.
- `libavif.xcframework` | `SDWebImage.xcframework` | `SDWebImageAVIFCoder.xcframework` | `SDWebImageSVGCoder.xcframework` and `SDWebImageWebPCoder.xcframework`
- These are required to be linked for `ExpoImage` to work correctly.
- In a React Native CLI app that uses Expo modules, also see [Expo Modules in React Native CLI Apps](/docs/guides/expo-modules).

![Expo Frameworks in Xcode Sidebar](/images/expo-frameworks.png)

Expand Down Expand Up @@ -341,11 +342,9 @@ When using the `--use-prebuilt-rn-core` flag:

## Expo Prebuilts

Starting Expo SDK 56, the prebuilts for expo modules are enabled by default. If you wish to opt out of prebuilt, pass the `--use-prebuilt-expo false` flag to the brownfield CLI.
Starting with Expo SDK 56, Brownfield uses Expo's [precompiled modules](https://docs.expo.dev/guides/prebuilt-expo-modules/) by default. To build Expo modules from source instead, pass `--use-prebuilt-expo false`. The flag also sets `EXPO_USE_PRECOMPILED_MODULES` for `pod install`, so you don't need to change your `Podfile`. Without the flag, Brownfield also builds from source when you export `EXPO_USE_PRECOMPILED_MODULES=0` or when `ios/Podfile.properties.json` sets it to `"false"`. Expo's Podfile always honors that property, so remove it if you want precompiled modules.

:::info
The above only instructs the brownfield CLI whether to use prebuilt. You also need to disable prebuilt for Expo. See the [guide](https://docs.expo.dev/guides/prebuilt-expo-modules/)
:::
Expo builds its precompiled modules against the React Native version your Expo SDK targets. If your app uses a different React Native minor version, Brownfield builds Expo modules from source by default, because precompiled ones would crash at runtime.


## Next Steps
Expand Down
4 changes: 4 additions & 0 deletions docs/docs/docs/getting-started/rnc-cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ Embedding the artifact in your native app is a manual step, and the guides below

Both guides also describe the manual alternative in full, so you can use them to check the generated wiring or to customize it beyond what the command offers.

:::tip Using Expo modules?
If your app uses Expo modules such as `expo-image`, complete the extra steps in [Expo Modules in React Native CLI Apps](/docs/guides/expo-modules) before you package.
:::

## Next Steps

- [iOS API Reference](/docs/api-reference/react-native-brownfield/swift)
Expand Down
1 change: 1 addition & 0 deletions docs/docs/docs/guides/_meta.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[
"guidelines",
"minification",
"expo-modules",
"troubleshooting",
{
"type": "dir",
Expand Down
46 changes: 46 additions & 0 deletions docs/docs/docs/guides/expo-modules.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { PackageManagerTabs } from '@theme';

# Expo Modules in React Native CLI Apps

This guide is for React Native Community CLI apps that use Expo modules, such as `expo-image`, installed with `npx install-expo-modules`. Apps without Expo modules don't need any of these steps. Expo apps that use `expo prebuild` should follow [Expo Integration](/docs/getting-started/expo) instead.

Complete [React Native CLI Integration](/docs/getting-started/rnc-cli) first. The Brownfield CLI then packages Expo modules with the rest of your app.

## Android

No extra setup is needed. If your app has product flavors, see [Troubleshooting](/docs/guides/troubleshooting) for setting `missingDimensionStrategy`.

## iOS

On iOS, Expo modules only work when React Native starts through Brownfield's Expo runtime. You set this up once in your React Native project, then update your native iOS app.

### In your React Native project

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a possibility to have the manual steps collapsed into brownfield CLI? For example, using spm instead of manual linking the frameworks?


1. Install Expo modules with `npx install-expo-modules`, if you haven't already. It also raises the `platform :ios` line in `ios/Podfile` to iOS 16.4, which Expo SDK 56 and newer require.
2. From the project root, run the command from [Automated Setup](/docs/getting-started/rnc-cli#automated-setup). If you ran it before you added Expo, run it again. It only changes what's missing.

<PackageManagerTabs command="create @callstack/react-native-brownfield@latest" />

Because your `package.json` depends on `expo`, the command:
- adds `ENV['REACT_NATIVE_BROWNFIELD_USE_EXPO_HOST'] = '1'` to the framework target in `ios/Podfile`
- adds `ensureExpoModulesProvider()` to `ios/<framework_target_name>/<framework_target_name>.swift`

3. Make sure the framework target uses iOS 16.4 or newer. Open `ios/<project>.xcworkspace` in Xcode, select the framework target, and check **Minimum Deployments**. Targets the command creates after Expo is installed already use 16.4.
4. Package your app:

<PackageManagerTabs command="run package:ios" />

Expo's precompiled modules only work with the React Native version your Expo SDK targets. With a different version, Brownfield builds them from source. See [Expo Prebuilts](/docs/getting-started/ios#expo-prebuilts).

### In your native iOS app

1. Add the Expo XCFrameworks from `ios/.brownfield/package/build` along with your framework. [Add the Framework to Your iOS App](/docs/getting-started/ios#7-add-the-framework-to-your-ios-app) lists which ones you need.
2. Call `ensureExpoModulesProvider()` before you start React Native:

```swift
ReactNativeBrownfield.shared.bundle = ReactNativeBundle
ReactNativeBrownfield.shared.ensureExpoModulesProvider()
ReactNativeBrownfield.shared.startReactNative {
print("React Native has been loaded")
}
```
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,26 @@ import * as appleHelpers from '@rock-js/platform-apple-helpers';
import { packageIosAction } from '@rock-js/plugin-brownfield-ios';
import * as rockTools from '@rock-js/tools';

import { beforeEach, describe, expect, test, vi, type Mock } from 'vitest';
import {
afterEach,
beforeEach,
describe,
expect,
test,
vi,
type Mock,
} from 'vitest';

import { packageIosCommand } from '../packageIos.js';
import { copyDebugBundleToSimulatorSlice } from '../../utils/copyDebugBundleToSimulatorSlice.js';
import { createLocalSpmPackage } from '../../utils/createLocalSpmPackage.js';
import { emitExpoSupportXcframeworks } from '../../utils/emitExpoSupportXcframeworks.js';
import { resolvePackagedFrameworkName } from '../../utils/resolvePackagedFrameworkName.js';
import { supportsPrebuiltExpo } from '../../utils/supportsPrebuiltExpo.js';
import { isExpoProject } from '../../utils/project.js';
import {
isPrecompiledExpoDisabledByPodfile,
supportsPrebuiltExpo,
} from '../../utils/supportsPrebuiltExpo.js';

vi.mock('@rock-js/platform-apple-helpers', async (importOriginal) => {
const actual = await importOriginal<typeof appleHelpers>();
Expand All @@ -24,6 +36,11 @@ vi.mock('@rock-js/platform-apple-helpers', async (importOriginal) => {
name: '--scheme <scheme>',
description: 'test scheme option',
},
{
name: '--extra-params <string>',
description: 'test extra params option',
parse: (value: string) => value.split(' '),
},
]),
mergeFrameworks: vi.fn(),
};
Expand Down Expand Up @@ -64,6 +81,7 @@ vi.mock('../../utils/paths.js', () => ({
}));

vi.mock('../../utils/project.js', () => ({
isExpoProject: vi.fn(() => false),
getProjectInfo: vi.fn(() => ({
projectRoot: '/repo',
platformConfig: {},
Expand All @@ -84,12 +102,13 @@ vi.mock('../../utils/project.js', () => ({
vi.mock('../../utils/supportsPrebuiltRNCore.js', () => ({
supportsPrebuiltRNCore: vi.fn(() => ({
supported: true,
enabledByDefault: false,
enabledByDefault: true,
reason: '',
})),
}));

vi.mock('../../utils/supportsPrebuiltExpo.js', () => ({
isPrecompiledExpoDisabledByPodfile: vi.fn(() => false),
supportsPrebuiltExpo: vi.fn(() => ({
supported: true,
enabledByDefault: true,
Expand Down Expand Up @@ -167,9 +186,17 @@ const mockEmitExpoSupportXcframeworks = emitExpoSupportXcframeworks as Mock;
const mockResolvePackagedFrameworkName = resolvePackagedFrameworkName as Mock;
const mockSupportsPrebuiltExpo = supportsPrebuiltExpo as Mock;

const originalPrecompiledModules = process.env.EXPO_USE_PRECOMPILED_MODULES;

describe('package:ios action --add-spm-package', () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.EXPO_USE_PRECOMPILED_MODULES;
(isExpoProject as Mock).mockReturnValue(false);
(isPrecompiledExpoDisabledByPodfile as Mock).mockReturnValue(false);
for (const option of ['usePrebuiltExpo', 'extraParams']) {
packageIosCommand.setOptionValue(option, undefined);
}

mockResolvePackagedFrameworkName.mockReturnValue({
frameworkName: 'BrownfieldLib',
Expand All @@ -187,6 +214,14 @@ describe('package:ios action --add-spm-package', () => {
mockEmitExpoSupportXcframeworks.mockReturnValue(false);
});

afterEach(() => {
if (originalPrecompiledModules === undefined) {
delete process.env.EXPO_USE_PRECOMPILED_MODULES;
} else {
process.env.EXPO_USE_PRECOMPILED_MODULES = originalPrecompiledModules;
}
});

test('calls createLocalSpmPackage with the resolved framework name', async () => {
await invokePackageIosAction([
'--add-spm-package',
Expand Down Expand Up @@ -294,4 +329,136 @@ describe('package:ios action --add-spm-package', () => {
mockEmitExpoSupportXcframeworks.mock.invocationCallOrder[0]
).toBeLessThan(mockCreateLocalSpmPackage.mock.invocationCallOrder[0] ?? 0);
});

test('turns on precompiled Expo modules and explicit modules when using prebuilt Expo', async () => {
(isExpoProject as Mock).mockReturnValue(true);

await invokePackageIosAction([
'--use-prebuilt-expo',
'true',
'--configuration',
'Release',
]);

expect(process.env.EXPO_USE_PRECOMPILED_MODULES).toBe('1');
expect(packageIosAction).toHaveBeenCalledWith(
expect.objectContaining({
extraParams: expect.arrayContaining([
'SWIFT_ENABLE_EXPLICIT_MODULES=YES',
]),
}),
expect.anything(),
expect.anything()
);
});

test('turns off precompiled Expo modules when building Expo from source', async () => {
(isExpoProject as Mock).mockReturnValue(true);

await invokePackageIosAction([
'--use-prebuilt-expo',
'false',
'--configuration',
'Release',
]);

expect(process.env.EXPO_USE_PRECOMPILED_MODULES).toBe('0');
});
test('builds Expo from source by default when the React Native version does not match', async () => {
(isExpoProject as Mock).mockReturnValue(true);
mockSupportsPrebuiltExpo.mockReturnValue({
supported: true,
enabledByDefault: false,
defaultDisabledReason: 'Version mismatch.',
});

await invokePackageIosAction(['--configuration', 'Release']);

expect(process.env.EXPO_USE_PRECOMPILED_MODULES).toBe('0');
expect(mockLoggerInfo).toHaveBeenCalledWith(
'Version mismatch. Building Expo modules from source.'
);
expect(mockLoggerWarn).not.toHaveBeenCalled();
});

test('warns when prebuilt Expo is forced despite a React Native version mismatch', async () => {
(isExpoProject as Mock).mockReturnValue(true);
mockSupportsPrebuiltExpo.mockReturnValue({
supported: true,
enabledByDefault: false,
defaultDisabledReason: 'Version mismatch.',
});

await invokePackageIosAction([
'--use-prebuilt-expo',
'true',
'--configuration',
'Release',
]);

expect(process.env.EXPO_USE_PRECOMPILED_MODULES).toBe('1');
expect(mockLoggerWarn).toHaveBeenCalledWith(
expect.stringContaining(
'Version mismatch. The packaged app will likely crash'
)
);
});

test('builds Expo from source when Podfile.properties.json turns precompiled modules off', async () => {
(isExpoProject as Mock).mockReturnValue(true);
(isPrecompiledExpoDisabledByPodfile as Mock).mockReturnValue(true);

await invokePackageIosAction(['--configuration', 'Release']);

expect(process.env.EXPO_USE_PRECOMPILED_MODULES).toBe('0');
expect(mockEmitExpoSupportXcframeworks).toHaveBeenCalledWith(
expect.objectContaining({ usePrebuiltExpo: false })
);
});

test('warns when prebuilt Expo is forced but Podfile.properties.json turns it off', async () => {
(isExpoProject as Mock).mockReturnValue(true);
(isPrecompiledExpoDisabledByPodfile as Mock).mockReturnValue(true);

await invokePackageIosAction([
'--use-prebuilt-expo',
'true',
'--configuration',
'Release',
]);

expect(mockLoggerWarn).toHaveBeenCalledWith(
expect.stringContaining(
'ios/Podfile.properties.json sets EXPO_USE_PRECOMPILED_MODULES'
)
);
});

test('respects an exported EXPO_USE_PRECOMPILED_MODULES=0 when the flag is omitted', async () => {
(isExpoProject as Mock).mockReturnValue(true);
process.env.EXPO_USE_PRECOMPILED_MODULES = '0';

await invokePackageIosAction(['--configuration', 'Release']);

expect(process.env.EXPO_USE_PRECOMPILED_MODULES).toBe('0');
expect(mockEmitExpoSupportXcframeworks).toHaveBeenCalledWith(
expect.objectContaining({ usePrebuiltExpo: false })
);
});

test('keeps a user-provided SWIFT_ENABLE_EXPLICIT_MODULES value', async () => {
(isExpoProject as Mock).mockReturnValue(true);

await invokePackageIosAction([
'--use-prebuilt-expo',
'true',
'--extra-params',
'SWIFT_ENABLE_EXPLICIT_MODULES=NO',
'--configuration',
'Release',
]);

const [options] = (packageIosAction as Mock).mock.calls[0];
expect(options.extraParams).toEqual(['SWIFT_ENABLE_EXPLICIT_MODULES=NO']);
});
});
Loading
Loading