From 531bccaa00e003c7e468b8c2e99d55e0ef9649c2 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Tue, 13 Oct 2026 10:03:08 +0200 Subject: [PATCH 01/10] fix(cli): skip expo prebuild in React Native CLI apps using Expo modules The CLI ran `expo prebuild` whenever it found Expo and an app.json. In a React Native CLI app that installed Expo modules, prebuild regenerated the committed android/ and ios/ directories and deleted the brownfield library module, so packaging failed. Only run prebuild when the native directory is missing or gitignored (CNG). Also document the manual iOS step for these apps: Expo modules only register through the Expo host runtime, which the config plugin enables in Expo apps. --- .changeset/bare-app-skip-expo-prebuild.md | 5 ++ docs/docs/docs/getting-started/rnc-cli.mdx | 47 +++++++++++++++ .../brownfield/utils/__tests__/expo.test.ts | 57 +++++++++++++++++++ packages/cli/src/brownfield/utils/expo.ts | 34 ++++++++++- 4 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 .changeset/bare-app-skip-expo-prebuild.md create mode 100644 packages/cli/src/brownfield/utils/__tests__/expo.test.ts diff --git a/.changeset/bare-app-skip-expo-prebuild.md b/.changeset/bare-app-skip-expo-prebuild.md new file mode 100644 index 000000000..6aec03965 --- /dev/null +++ b/.changeset/bare-app-skip-expo-prebuild.md @@ -0,0 +1,5 @@ +--- +'@callstack/brownfield-cli': patch +--- + +Skip `expo prebuild` in React Native CLI apps that use Expo modules. Prebuild used to regenerate their committed `android/` and `ios/` directories and delete the brownfield library module. diff --git a/docs/docs/docs/getting-started/rnc-cli.mdx b/docs/docs/docs/getting-started/rnc-cli.mdx index 9d2923816..6cad21012 100644 --- a/docs/docs/docs/getting-started/rnc-cli.mdx +++ b/docs/docs/docs/getting-started/rnc-cli.mdx @@ -77,6 +77,53 @@ 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. +## Using Expo Modules + +You can use Expo modules (installed with `npx install-expo-modules`) in a React Native CLI app. The Brownfield CLI packages them with the rest of your app. It skips `expo prebuild` because your `android/` and `ios/` directories are committed and not generated. + +### Android + +No extra setup is needed. If your app has product flavors, see [Troubleshooting](/docs/guides/troubleshooting) for setting `missingDimensionStrategy`. + +### iOS + +Expo modules only register through Brownfield's Expo runtime. In Expo projects, the config plugin turns it on. In a React Native CLI app, you turn it on yourself: + +1. In your `Podfile`, turn on the Expo runtime in the framework target and add the Expo SDK define to `ReactBrownfield`: + +```ruby +target '' do + ENV['REACT_NATIVE_BROWNFIELD_USE_EXPO_HOST'] = '1' + inherit! :complete +end + +post_install do |installer| + # ... + installer.pods_project.targets.each do |target| + next unless target.name == 'ReactBrownfield' + + target.build_configurations.each do |config| + conditions = config.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'] || '$(inherited)' + config.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'] = "#{conditions} EXPO_SDK_GTE_55" + end + end +end +``` + +2. Add this to your framework's public interface file (`.swift`), so the generated Expo module list gets linked into the framework: + +```swift +extension ReactNativeBrownfield { + public func ensureExpoModulesProvider() { + let _ = ExpoModulesProvider() + } +} +``` + +3. Run `pod install`, then package as usual. Expo SDK 56 and newer require iOS 16.4, so set at least that deployment target in the `Podfile` and in your framework target. + +4. In your iOS app, add the Expo XCFrameworks listed in [Add the Framework to Your iOS App](/docs/getting-started/ios#7-add-the-framework-to-your-ios-app). Then call `ensureExpoModulesProvider()` before starting React Native, as shown in [Present RN UI](/docs/getting-started/expo#xcframework-present-rn-ui). + ## Next Steps - [iOS API Reference](/docs/api-reference/react-native-brownfield/swift) diff --git a/packages/cli/src/brownfield/utils/__tests__/expo.test.ts b/packages/cli/src/brownfield/utils/__tests__/expo.test.ts new file mode 100644 index 000000000..80721edbe --- /dev/null +++ b/packages/cli/src/brownfield/utils/__tests__/expo.test.ts @@ -0,0 +1,57 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { usesContinuousNativeGeneration } from '../expo.js'; + +describe('usesContinuousNativeGeneration', () => { + let tempDir: string | null = null; + + function createGitProject(gitignore?: string): string { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'brownfield-cng-')); + execFileSync('git', ['init', '-q'], { cwd: tempDir }); + if (gitignore) { + fs.writeFileSync(path.join(tempDir, '.gitignore'), gitignore); + } + return tempDir; + } + + afterEach(() => { + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it('returns true when the native directory does not exist', () => { + const projectRoot = createGitProject(); + + expect(usesContinuousNativeGeneration(projectRoot, 'android')).toBe(true); + }); + + it('returns true when the native directory is gitignored (Expo CNG)', () => { + const projectRoot = createGitProject('/android\n/ios\n'); + fs.mkdirSync(path.join(projectRoot, 'android')); + fs.mkdirSync(path.join(projectRoot, 'ios')); + + expect(usesContinuousNativeGeneration(projectRoot, 'android')).toBe(true); + expect(usesContinuousNativeGeneration(projectRoot, 'ios')).toBe(true); + }); + + it('returns false for a bare app whose native directory is committed', () => { + const projectRoot = createGitProject(); + fs.mkdirSync(path.join(projectRoot, 'android')); + + expect(usesContinuousNativeGeneration(projectRoot, 'android')).toBe(false); + }); + + it('returns false when the native directory exists outside a git repository', () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'brownfield-cng-')); + fs.mkdirSync(path.join(tempDir, 'ios')); + + expect(usesContinuousNativeGeneration(tempDir, 'ios')).toBe(false); + }); +}); diff --git a/packages/cli/src/brownfield/utils/expo.ts b/packages/cli/src/brownfield/utils/expo.ts index 36a1fbc7b..c02ee9dab 100644 --- a/packages/cli/src/brownfield/utils/expo.ts +++ b/packages/cli/src/brownfield/utils/expo.ts @@ -1,4 +1,6 @@ -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; import { logger } from '@rock-js/tools'; @@ -41,6 +43,29 @@ function getNpxCommand() { return process.platform === 'win32' ? 'npx.cmd' : 'npx'; } +function isGitIgnored(targetPath: string): boolean { + // `git check-ignore` exits 0 when ignored, 1 when not, 128 outside a repo. + const result = spawnSync('git', ['check-ignore', '-q', targetPath], { + cwd: path.dirname(targetPath), + stdio: 'ignore', + }); + return result.status === 0; +} + +/** + * Whether the platform's native project is generated by `expo prebuild` (Continuous Native + * Generation) rather than maintained by hand. A bare React Native app that installed Expo modules + * commits its native directories; running prebuild there would wipe them (including the + * brownfield library module), so prebuild only runs when the directory is missing or gitignored. + */ +export function usesContinuousNativeGeneration( + projectRoot: string, + platform: ExpoPlatform +): boolean { + const nativeDir = path.join(projectRoot, platform); + return !fs.existsSync(nativeDir) || isGitIgnored(nativeDir); +} + export async function runExpoPrebuildIfNeeded({ projectRoot, platform, @@ -52,6 +77,13 @@ export async function runExpoPrebuildIfNeeded({ return false; } + if (!usesContinuousNativeGeneration(projectRoot, platform)) { + logger.info( + `Expo modules detected in a project with a committed ${platform} directory. Skipping expo prebuild.` + ); + return false; + } + logger.info( `Expo project detected. Running expo prebuild for ${platform}...` ); From c201665f8839dc274f7589015a9b2c0f8fd6b831 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Tue, 13 Oct 2026 13:28:28 +0200 Subject: [PATCH 02/10] feat(scaffold): set up the Expo host for React Native CLI apps using Expo Expo modules only register through the Expo host runtime, which the config plugin enables in Expo apps. create-react-native-brownfield now does the same when the app's package.json depends on expo: the Podfile framework target gets the Expo host env and the EXPO_SDK_GTE_55 define, and the framework interface gets ensureExpoModulesProvider(). Re-running it after adding Expo switches an existing framework target over. Only the app's own package.json counts, so a hoisted expo in a monorepo does not turn the Expo host on. The Podfile hook no longer assumes Expo's podfile_properties exists. --- .changeset/bare-app-skip-expo-prebuild.md | 4 +- docs/docs/docs/getting-started/rnc-cli.mdx | 45 ++++------------- .../ios/__tests__/podfileHelpers.test.ts | 15 +++++- .../__tests__/withIosFrameworkFiles.test.ts | 14 ++++++ .../expo-config-plugin/ios/podfileHelpers.ts | 44 ++++++++++++----- .../ios/withIosFrameworkFiles.ts | 12 ++++- .../ios/ExpoModulesProviderExtension.swift | 7 +++ .../src/scaffold/index.ts | 49 +++++++++++++++++-- 8 files changed, 136 insertions(+), 54 deletions(-) create mode 100644 packages/react-native-brownfield/src/expo-config-plugin/template/ios/ExpoModulesProviderExtension.swift diff --git a/.changeset/bare-app-skip-expo-prebuild.md b/.changeset/bare-app-skip-expo-prebuild.md index 6aec03965..b137f9fd9 100644 --- a/.changeset/bare-app-skip-expo-prebuild.md +++ b/.changeset/bare-app-skip-expo-prebuild.md @@ -1,5 +1,7 @@ --- '@callstack/brownfield-cli': patch +'@callstack/react-native-brownfield': patch +'@callstack/create-react-native-brownfield': patch --- -Skip `expo prebuild` in React Native CLI apps that use Expo modules. Prebuild used to regenerate their committed `android/` and `ios/` directories and delete the brownfield library module. +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. `create-react-native-brownfield` now detects Expo and sets up the iOS framework to use the Expo runtime. diff --git a/docs/docs/docs/getting-started/rnc-cli.mdx b/docs/docs/docs/getting-started/rnc-cli.mdx index 6cad21012..a05c38eb1 100644 --- a/docs/docs/docs/getting-started/rnc-cli.mdx +++ b/docs/docs/docs/getting-started/rnc-cli.mdx @@ -87,42 +87,15 @@ No extra setup is needed. If your app has product flavors, see [Troubleshooting] ### iOS -Expo modules only register through Brownfield's Expo runtime. In Expo projects, the config plugin turns it on. In a React Native CLI app, you turn it on yourself: - -1. In your `Podfile`, turn on the Expo runtime in the framework target and add the Expo SDK define to `ReactBrownfield`: - -```ruby -target '' do - ENV['REACT_NATIVE_BROWNFIELD_USE_EXPO_HOST'] = '1' - inherit! :complete -end - -post_install do |installer| - # ... - installer.pods_project.targets.each do |target| - next unless target.name == 'ReactBrownfield' - - target.build_configurations.each do |config| - conditions = config.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'] || '$(inherited)' - config.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'] = "#{conditions} EXPO_SDK_GTE_55" - end - end -end -``` - -2. Add this to your framework's public interface file (`.swift`), so the generated Expo module list gets linked into the framework: - -```swift -extension ReactNativeBrownfield { - public func ensureExpoModulesProvider() { - let _ = ExpoModulesProvider() - } -} -``` - -3. Run `pod install`, then package as usual. Expo SDK 56 and newer require iOS 16.4, so set at least that deployment target in the `Podfile` and in your framework target. - -4. In your iOS app, add the Expo XCFrameworks listed in [Add the Framework to Your iOS App](/docs/getting-started/ios#7-add-the-framework-to-your-ios-app). Then call `ensureExpoModulesProvider()` before starting React Native, as shown in [Present RN UI](/docs/getting-started/expo#xcframework-present-rn-ui). +Expo modules only register through Brownfield's Expo runtime. The setup command turns it on when your `package.json` depends on `expo`. If you added Expo after running the setup, run it again: + + + +It switches the framework target in your `Podfile` to the Expo runtime and adds `ensureExpoModulesProvider()` to `.swift`. Then run `pod install` and package as usual. + +Expo SDK 56 and newer require iOS 16.4, so make sure the `platform :ios` line in your `Podfile` and your framework target use at least that version. + +In your iOS app, add the Expo XCFrameworks listed in [Add the Framework to Your iOS App](/docs/getting-started/ios#7-add-the-framework-to-your-ios-app). Then call `ensureExpoModulesProvider()` before starting React Native, as shown in [Present RN UI](/docs/getting-started/expo#xcframework-present-rn-ui). ## Next Steps diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/podfileHelpers.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/podfileHelpers.test.ts index d5336cec2..de438d114 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/podfileHelpers.test.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/podfileHelpers.test.ts @@ -82,7 +82,7 @@ end // SDK 56+ raises the default deployment target used by the hook. expect(modified).toContain( - "podfile_properties['ios.deploymentTarget'] || '16.4'" + "(defined?(podfile_properties) && podfile_properties['ios.deploymentTarget']) || '16.4'" ); }); @@ -102,6 +102,19 @@ end expect(twice.split(EXPO_DEFINES_MARKER)).toHaveLength(2); }); + it('switches an existing vanilla framework target to the Expo host', () => { + // A React Native CLI app that adds Expo after it was scaffolded. + const vanilla = modifyPodfile(createPodfile(), 'BrownfieldLib'); + const expo = modifyPodfile(vanilla, 'BrownfieldLib', 58); + + expect(expo).toContain( + " target 'BrownfieldLib' do\n ENV['REACT_NATIVE_BROWNFIELD_USE_EXPO_HOST'] = '1'\n" + ); + expect(expo).toContain(EXPO_DEFINES_MARKER); + expect(expo.match(/target 'BrownfieldLib'/g)).toHaveLength(1); + expect(modifyPodfile(expo, 'BrownfieldLib', 58)).toBe(expo); + }); + it('throws when the Podfile has no main target to insert after', () => { expect(() => modifyPodfile('# empty Podfile\n', 'BrownfieldLib')).toThrow( /Could not find main target in Podfile/ diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withIosFrameworkFiles.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withIosFrameworkFiles.test.ts index d7aeaee13..386a8307e 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withIosFrameworkFiles.test.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withIosFrameworkFiles.test.ts @@ -74,6 +74,20 @@ describe('getFrameworkSourceFiles', () => { expect(frameworkInterface?.content).not.toContain('ExpoModulesProvider'); }); + it('adds ensureExpoModulesProvider to the vanilla interface when the app uses Expo modules', () => { + const vanilla = getFrameworkSourceFiles(iosConfig, { useExpoHost: false }); + const withExpo = getFrameworkSourceFiles(iosConfig, { + useExpoHost: false, + withExpoModules: true, + }); + + expect(withExpo[0].content.startsWith(vanilla[0].content)).toBe(true); + expect(withExpo[0].content).toContain( + 'public func ensureExpoModulesProvider()' + ); + expect(withExpo[0].content).toContain('let _ = ExpoModulesProvider()'); + }); + it('keeps the Info.plist bundle identifier for vanilla hosts', () => { const files = getFrameworkSourceFiles(iosConfig, { useExpoHost: false }); const infoPlist = files.find((file) => file.relativePath === 'Info.plist'); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts index 096a6fd4d..8c53f53f4 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts @@ -20,7 +20,7 @@ function ensureExpoDefinesForSDK55AndAbove( const defaultDeploymentTarget = expoMajor >= 56 ? '16.4' : '15.1'; const hook = ` ${BROWNFIELD_EXPO_GTE_55_SWIFT_DEFINES_MARKER_START} - brownfield_ios_deployment_target = podfile_properties['ios.deploymentTarget'] || '${defaultDeploymentTarget}' + brownfield_ios_deployment_target = (defined?(podfile_properties) && podfile_properties['ios.deploymentTarget']) || '${defaultDeploymentTarget}' installer.pods_project.targets.each do |target| if target.name == 'ReactBrownfield' puts "[Brownfield] Adding definition of EXPO_SDK_GTE_55 to target: #{target.name}" @@ -66,10 +66,19 @@ export function modifyPodfile( ): string { // check if the framework target is already included if (podfile.includes(`target '${frameworkName}'`)) { - Logger.logDebug( - `Framework target "${frameworkName}" already in Podfile, skipping modification` + if (typeof expoMajor !== 'number') { + Logger.logDebug( + `Framework target "${frameworkName}" already in Podfile, skipping modification` + ); + return podfile; + } + + // A React Native CLI app can add Expo after it was scaffolded, so a re-run + // switches the existing target to the Expo host. + return applyExpoHooks( + ensureExpoHostInFrameworkTarget(podfile, frameworkName), + expoMajor ); - return podfile; } Logger.logDebug(`Modifying Podfile for framework: ${frameworkName}`); @@ -113,12 +122,28 @@ export function modifyPodfile( Logger.logDebug(`Added framework target "${frameworkName}" to Podfile`); // Vanilla projects (expoMajor === undefined) get no Expo-only hooks. - // Early-returning here lets TypeScript narrow `expoMajor` to `number` - // for the rest of the function (no casts needed below). if (typeof expoMajor !== 'number') { return modifiedPodfile; } + return applyExpoHooks(modifiedPodfile, expoMajor); +} + +function ensureExpoHostInFrameworkTarget( + podfile: string, + frameworkName: string +): string { + if (podfile.includes('REACT_NATIVE_BROWNFIELD_USE_EXPO_HOST')) { + return podfile; + } + + return podfile.replace( + new RegExp(`^([ \\t]*)(target\\s+'${frameworkName}'\\s+do[ \\t]*\\n)`, 'm'), + `$1$2$1 ENV['REACT_NATIVE_BROWNFIELD_USE_EXPO_HOST'] = '1'\n` + ); +} + +function applyExpoHooks(podfile: string, expoMajor: number): string { if (expoMajor < MIN_SUPPORTED_EXPO_SDK_MAJOR_VERSION) { const versionLabel = expoMajor < 0 ? 'unknown' : String(expoMajor); throw new SourceModificationError( @@ -126,10 +151,5 @@ export function modifyPodfile( ); } - modifiedPodfile = ensureExpoDefinesForSDK55AndAbove( - modifiedPodfile, - expoMajor - ); - - return modifiedPodfile; + return ensureExpoDefinesForSDK55AndAbove(podfile, expoMajor); } diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts index 1e22d92a0..eec969e64 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts @@ -26,9 +26,19 @@ export function getFrameworkSourceFiles( * opposite polarity: `false` here === 'vanilla' there. */ useExpoHost?: boolean; + /** + * Vanilla template only: a React Native CLI app that uses Expo modules + * also needs `ensureExpoModulesProvider()` (the Expo template has it). + */ + withExpoModules?: boolean; } ): RenderedTemplateFile[] { const useExpoHost = options?.useExpoHost ?? true; + const vanillaInterface = + renderTemplate('ios', 'FrameworkInterface.vanilla.swift', {}) + + (options?.withExpoModules + ? renderTemplate('ios', 'ExpoModulesProviderExtension.swift', {}) + : ''); return [ { @@ -37,7 +47,7 @@ export function getFrameworkSourceFiles( ? renderTemplate('ios', 'FrameworkInterface.swift', { '{{BUNDLE_IDENTIFIER}}': ios.bundleIdentifier, }) - : renderTemplate('ios', 'FrameworkInterface.vanilla.swift', {}), + : vanillaInterface, }, { relativePath: 'Info.plist', diff --git a/packages/react-native-brownfield/src/expo-config-plugin/template/ios/ExpoModulesProviderExtension.swift b/packages/react-native-brownfield/src/expo-config-plugin/template/ios/ExpoModulesProviderExtension.swift new file mode 100644 index 000000000..ad8aac8e4 --- /dev/null +++ b/packages/react-native-brownfield/src/expo-config-plugin/template/ios/ExpoModulesProviderExtension.swift @@ -0,0 +1,7 @@ +// Keeps the generated ExpoModulesProvider linked into the framework, so Expo +// modules register when the host app starts React Native. +extension ReactNativeBrownfield { + public func ensureExpoModulesProvider() { + let _ = ExpoModulesProvider() + } +} diff --git a/packages/react-native-brownfield/src/scaffold/index.ts b/packages/react-native-brownfield/src/scaffold/index.ts index d312b2d89..1f200bb53 100644 --- a/packages/react-native-brownfield/src/scaffold/index.ts +++ b/packages/react-native-brownfield/src/scaffold/index.ts @@ -167,6 +167,35 @@ function resolveReactNativeVersion(projectRoot: string): string { return rnPkg.version; } +/** + * Major Expo SDK version when the app itself depends on `expo` (installed + * with `install-expo-modules`), otherwise undefined. Only the app's own + * package.json counts: in a monorepo, `expo` can resolve from a hoisted + * node_modules even though this app never set it up. Returns -1 when the + * dependency is declared but not installed, which modifyPodfile reports. + */ +function resolveExpoMajor(projectRoot: string): number | undefined { + const pkg = JSON.parse( + fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8') + ); + if (!pkg.dependencies?.expo && !pkg.devDependencies?.expo) { + return undefined; + } + + const localRequire: NodeRequire = + typeof require === 'undefined' + ? createRequire(path.join(process.cwd(), 'noop.js')) + : require; + try { + const expoPkg = localRequire( + localRequire.resolve('expo/package.json', { paths: [projectRoot] }) + ); + return Number.parseInt(String(expoPkg.version).split('.')[0] ?? '', 10); + } catch { + return -1; + } +} + export async function scaffoldBrownfieldInRncCliProject( options: BrownfieldScaffoldOptions = {} ): Promise { @@ -202,6 +231,12 @@ export async function scaffoldBrownfieldInRncCliProject( : path.join(projectRoot, iosSourceDir || 'ios'); const rnVersion = resolveReactNativeVersion(projectRoot); + const expoMajor = resolveExpoMajor(projectRoot); + if (expoMajor !== undefined) { + Logger.logInfo( + 'Expo modules detected. The iOS framework will start React Native through the Expo host.' + ); + } const iosFrameworkName = options.iosFrameworkName ?? 'BrownfieldLib'; const androidModuleName = options.androidModuleName ?? 'brownfieldlib'; @@ -306,7 +341,9 @@ export async function scaffoldBrownfieldInRncCliProject( // an iOS deployment target directly. // TODO: derive from the Podfile `platform :ios` line or RN's // min_ios_version_supported, or expose a flag. - deploymentTarget: '15.0', + // Expo SDK 56+ modules require iOS 16.4. + deploymentTarget: + expoMajor !== undefined && expoMajor >= 56 ? '16.4' : '15.0', // Standard CFBundleShortVersionString for the generated framework // (matches the Expo plugin default); unrelated to package versioning. frameworkVersion: '1', @@ -355,10 +392,16 @@ export async function scaffoldBrownfieldInRncCliProject( if (!podfile) { throw new Error(`Missing ${podfilePath}`); } - writeFileIfChanged(podfilePath, modifyPodfile(podfile, iosFrameworkName)); + writeFileIfChanged( + podfilePath, + modifyPodfile(podfile, iosFrameworkName, expoMajor) + ); currentStep = 'ios-sources'; - createIosFramework(iosDir, resolvedIosConfig, { useExpoHost: false }); + createIosFramework(iosDir, resolvedIosConfig, { + useExpoHost: false, + withExpoModules: expoMajor !== undefined, + }); // --- App package wiring: Brownfield deps + package:* scripts --- // The scaffolded Kotlin host imports com.callstack.reactnativebrownfield.*, From b70b82402f1c4b16b2db4ec6e5b0a2a998884cec Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Tue, 13 Oct 2026 14:35:36 +0200 Subject: [PATCH 03/10] fix(cli): make precompiled Expo work in React Native CLI apps Three things broke packaging with precompiled Expo modules outside Expo apps: - `--use-prebuilt-expo` only picked which XCFrameworks to copy. It never told CocoaPods to use precompiled modules, which only Expo-generated Podfiles turn on, so a bare app failed with "XCFramework not found". The CLI now sets EXPO_USE_PRECOMPILED_MODULES from the flag. - Older React Native versions wrote SWIFT_ENABLE_EXPLICIT_MODULES = NO into app projects. With precompiled Expo the React Native pods are static libraries, and with explicit modules off Xcode can't find their module maps when it verifies the framework's Swift interface ("cannot load underlying module"). The CLI turns explicit modules on for these builds. - Expo's precompiled modules are built against the React Native version the SDK targets, and crash at runtime on another minor version. The CLI now defaults to building Expo from source in that case and warns if it's forced. --- .changeset/bare-app-skip-expo-prebuild.md | 2 + docs/docs/docs/getting-started/ios.mdx | 6 +- docs/docs/docs/getting-started/rnc-cli.mdx | 2 + .../__tests__/packageIos.action.test.ts | 40 ++++++++++- .../cli/src/brownfield/commands/packageIos.ts | 48 +++++++++++-- .../__tests__/supportsPrebuiltExpo.test.ts | 64 ++++++++++++++++- .../brownfield/utils/supportsPrebuiltExpo.ts | 69 ++++++++++++++++++- 7 files changed, 218 insertions(+), 13 deletions(-) diff --git a/.changeset/bare-app-skip-expo-prebuild.md b/.changeset/bare-app-skip-expo-prebuild.md index b137f9fd9..d16b810fe 100644 --- a/.changeset/bare-app-skip-expo-prebuild.md +++ b/.changeset/bare-app-skip-expo-prebuild.md @@ -5,3 +5,5 @@ --- 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. `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. diff --git a/docs/docs/docs/getting-started/ios.mdx b/docs/docs/docs/getting-started/ios.mdx index e2fdf96b5..8ae15ef69 100644 --- a/docs/docs/docs/getting-started/ios.mdx +++ b/docs/docs/docs/getting-started/ios.mdx @@ -341,11 +341,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`. -:::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 diff --git a/docs/docs/docs/getting-started/rnc-cli.mdx b/docs/docs/docs/getting-started/rnc-cli.mdx index a05c38eb1..06d0dffd8 100644 --- a/docs/docs/docs/getting-started/rnc-cli.mdx +++ b/docs/docs/docs/getting-started/rnc-cli.mdx @@ -95,6 +95,8 @@ It switches the framework target in your `Podfile` to the Expo runtime and adds Expo SDK 56 and newer require iOS 16.4, so make sure the `platform :ios` line in your `Podfile` and your framework target use at least that version. +Expo's precompiled modules only work with the React Native version your Expo SDK targets. Otherwise Brownfield builds them from source. See [Expo Prebuilts](/docs/getting-started/ios#expo-prebuilts). + In your iOS app, add the Expo XCFrameworks listed in [Add the Framework to Your iOS App](/docs/getting-started/ios#7-add-the-framework-to-your-ios-app). Then call `ensureExpoModulesProvider()` before starting React Native, as shown in [Present RN UI](/docs/getting-started/expo#xcframework-present-rn-ui). ## Next Steps diff --git a/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts b/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts index 1495e8128..cb43ca4e9 100644 --- a/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts +++ b/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts @@ -9,6 +9,7 @@ import { copyDebugBundleToSimulatorSlice } from '../../utils/copyDebugBundleToSi import { createLocalSpmPackage } from '../../utils/createLocalSpmPackage.js'; import { emitExpoSupportXcframeworks } from '../../utils/emitExpoSupportXcframeworks.js'; import { resolvePackagedFrameworkName } from '../../utils/resolvePackagedFrameworkName.js'; +import { isExpoProject } from '../../utils/project.js'; import { supportsPrebuiltExpo } from '../../utils/supportsPrebuiltExpo.js'; vi.mock('@rock-js/platform-apple-helpers', async (importOriginal) => { @@ -64,6 +65,7 @@ vi.mock('../../utils/paths.js', () => ({ })); vi.mock('../../utils/project.js', () => ({ + isExpoProject: vi.fn(() => false), getProjectInfo: vi.fn(() => ({ projectRoot: '/repo', platformConfig: {}, @@ -84,7 +86,7 @@ vi.mock('../../utils/project.js', () => ({ vi.mock('../../utils/supportsPrebuiltRNCore.js', () => ({ supportsPrebuiltRNCore: vi.fn(() => ({ supported: true, - enabledByDefault: false, + enabledByDefault: true, reason: '', })), })); @@ -170,6 +172,7 @@ const mockSupportsPrebuiltExpo = supportsPrebuiltExpo as Mock; describe('package:ios action --add-spm-package', () => { beforeEach(() => { vi.clearAllMocks(); + delete process.env.EXPO_USE_PRECOMPILED_MODULES; mockResolvePackagedFrameworkName.mockReturnValue({ frameworkName: 'BrownfieldLib', @@ -294,4 +297,39 @@ 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'); + }); }); diff --git a/packages/cli/src/brownfield/commands/packageIos.ts b/packages/cli/src/brownfield/commands/packageIos.ts index b50ed3ae5..9dc52bb08 100644 --- a/packages/cli/src/brownfield/commands/packageIos.ts +++ b/packages/cli/src/brownfield/commands/packageIos.ts @@ -18,7 +18,7 @@ import { Command, Option } from 'commander'; import { runExpoPrebuildIfNeeded } from '../utils/expo.js'; import { findProjectRoot } from '../utils/paths.js'; -import { getProjectInfo } from '../utils/project.js'; +import { getProjectInfo, isExpoProject } from '../utils/project.js'; import { supportsPrebuiltExpo } from '../utils/supportsPrebuiltExpo.js'; import { supportsPrebuiltRNCore } from '../utils/supportsPrebuiltRNCore.js'; import { @@ -75,9 +75,7 @@ export function parseUsePrebuiltRnCoreArgument( return parseBooleanOptionArgument('--use-prebuilt-rn-core', value); } -export function parseUsePrebuiltExpoArgument( - value: string | boolean -): boolean { +export function parseUsePrebuiltExpoArgument(value: string | boolean): boolean { return parseBooleanOptionArgument('--use-prebuilt-expo', value); } @@ -139,11 +137,29 @@ export const packageIosCommand = curryOptions( ? prebuiltRNCoreSupport.enabledByDefault : false; - // version-aware default when the flag is omitted + const usePrebuiltExpoWasPassed = options.usePrebuiltExpo !== undefined; + + // version-aware default when the flag is omitted; Expo's precompiled + // modules also need the prebuilt React Native core options.usePrebuiltExpo ??= prebuiltExpoSupport.supported - ? prebuiltExpoSupport.enabledByDefault + ? prebuiltExpoSupport.enabledByDefault && !!options.usePrebuiltRnCore : false; + if ( + prebuiltExpoSupport.supported && + prebuiltExpoSupport.defaultDisabledReason + ) { + if (usePrebuiltExpoWasPassed && options.usePrebuiltExpo) { + logger.warn( + `${prebuiltExpoSupport.defaultDisabledReason} The packaged app will likely crash at runtime. Pass --use-prebuilt-expo false to build Expo modules from source.` + ); + } else if (!usePrebuiltExpoWasPassed) { + logger.info( + `${prebuiltExpoSupport.defaultDisabledReason} Building Expo modules from source.` + ); + } + } + if (prebuiltRNCoreSupport) { logger.info( `${options.usePrebuiltRnCore ? 'Using' : 'Not using'} prebuilt RN core` @@ -167,6 +183,26 @@ export const packageIosCommand = curryOptions( throw new RockError(prebuiltExpoSupport.reason); } + if (isExpoProject(projectRoot)) { + // Read by Expo's autolinking during `pod install`. Expo-generated + // Podfiles default it to on, but a React Native CLI app's Podfile + // never sets it, so the flag has to drive it. + process.env.EXPO_USE_PRECOMPILED_MODULES = options.usePrebuiltExpo + ? '1' + : '0'; + } + + if (options.usePrebuiltExpo) { + // Precompiled Expo builds the React Native pods as static libraries. + // With explicit modules off (a leftover older React Native versions + // wrote into app projects), Xcode cannot find their module maps when + // verifying the framework's Swift interface. + options.extraParams = [ + ...(options.extraParams ?? []), + 'SWIFT_ENABLE_EXPLICIT_MODULES=YES', + ]; + } + if (!userConfig.project.ios) { throw new Error('iOS project not found.'); } diff --git a/packages/cli/src/brownfield/utils/__tests__/supportsPrebuiltExpo.test.ts b/packages/cli/src/brownfield/utils/__tests__/supportsPrebuiltExpo.test.ts index 9f524adf9..f806ce32b 100644 --- a/packages/cli/src/brownfield/utils/__tests__/supportsPrebuiltExpo.test.ts +++ b/packages/cli/src/brownfield/utils/__tests__/supportsPrebuiltExpo.test.ts @@ -1,4 +1,8 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import * as projectUtils from '../project.js'; import { supportsPrebuiltExpo } from '../supportsPrebuiltExpo.js'; @@ -60,4 +64,62 @@ describe('supportsPrebuiltExpo', () => { reason: expect.stringMatching(/Expo SDK unknown/), }); }); + + describe('React Native version', () => { + let projectRoot: string; + + function writePackage(name: string, files: Record) { + const dir = path.join(projectRoot, 'node_modules', name); + fs.mkdirSync(dir, { recursive: true }); + for (const [file, content] of Object.entries(files)) { + fs.writeFileSync(path.join(dir, file), JSON.stringify(content)); + } + } + + function createProject(expoReactNative: string, appReactNative: string) { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'brownfield-expo-')); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ name: 'app' }) + ); + writePackage('expo', { + 'package.json': { name: 'expo', version: '58.0.0' }, + 'bundledNativeModules.json': { 'react-native': expoReactNative }, + }); + writePackage('react-native', { + 'package.json': { name: 'react-native', version: appReactNative }, + 'index.js': 'module.exports = {};', + }); + } + + beforeEach(() => { + vi.mocked(projectUtils.isExpoProject).mockReturnValue(true); + vi.mocked(projectUtils.getExpoSdkMajor).mockReturnValue(58); + }); + + afterEach(() => { + fs.rmSync(projectRoot, { recursive: true, force: true }); + }); + + test('keeps prebuilts on when only the patch or prerelease differs', () => { + createProject('0.88.0-rc.4', '0.88.0-rc.1'); + + expect(supportsPrebuiltExpo({ projectRoot })).toEqual({ + supported: true, + enabledByDefault: true, + }); + }); + + test('turns prebuilts off by default for another React Native minor', () => { + createProject('0.88.0-rc.4', '0.87.0'); + + expect(supportsPrebuiltExpo({ projectRoot })).toEqual({ + supported: true, + enabledByDefault: false, + defaultDisabledReason: expect.stringMatching( + /built for React Native 0\.88, but this project uses 0\.87\.0/ + ), + }); + }); + }); }); diff --git a/packages/cli/src/brownfield/utils/supportsPrebuiltExpo.ts b/packages/cli/src/brownfield/utils/supportsPrebuiltExpo.ts index 38cc1c510..dc64e906a 100644 --- a/packages/cli/src/brownfield/utils/supportsPrebuiltExpo.ts +++ b/packages/cli/src/brownfield/utils/supportsPrebuiltExpo.ts @@ -1,11 +1,69 @@ +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +import { getReactNativeVersion } from '@rock-js/tools'; + import { getExpoSdkMajor, isExpoProject } from './project.js'; export const MIN_EXPO_SDK_MAJOR_FOR_PREBUILT_EXPO = 56; export type PrebuiltExpoSupportResult = - | { supported: true; enabledByDefault: boolean; reason?: never } + | { + supported: true; + enabledByDefault: boolean; + /** Why prebuilts are off by default even though they are supported. */ + defaultDisabledReason?: string; + reason?: never; + } | { supported: false; enabledByDefault?: never; reason: string }; +function majorMinor(version: string): string { + return version.split('.').slice(0, 2).join('.'); +} + +/** + * The React Native version the installed Expo SDK pins. Expo ships its + * precompiled modules built against this version. + */ +export function getExpoReactNativeVersion(projectRoot: string): string | null { + try { + const require = createRequire(path.join(projectRoot, 'package.json')); + const expoPackageJson = require.resolve('expo/package.json'); + const bundledNativeModules = JSON.parse( + fs.readFileSync( + path.join(path.dirname(expoPackageJson), 'bundledNativeModules.json'), + 'utf8' + ) + ) as Record; + return bundledNativeModules['react-native'] ?? null; + } catch { + return null; + } +} + +/** + * Describes a React Native version the Expo prebuilts were not built for, or + * returns null when they match. Prebuilt Expo modules link against React + * Native's C++ API, which changes between minor versions, so a mismatch + * crashes at runtime. + */ +export function getPrebuiltExpoReactNativeMismatch( + projectRoot: string +): string | null { + const expected = getExpoReactNativeVersion(projectRoot); + const found = getReactNativeVersion(projectRoot); + if (!expected || found === 'unknown') { + return null; + } + + if (majorMinor(expected) === majorMinor(found)) { + return null; + } + + return `Expo's precompiled modules are built for React Native ${majorMinor(expected)}, but this project uses ${found}.`; +} + export function supportsPrebuiltExpo({ projectRoot, }: { @@ -27,6 +85,15 @@ export function supportsPrebuiltExpo({ }; } + const reactNativeMismatch = getPrebuiltExpoReactNativeMismatch(projectRoot); + if (reactNativeMismatch) { + return { + supported: true, + enabledByDefault: false, + defaultDisabledReason: reactNativeMismatch, + }; + } + return { supported: true, enabledByDefault: true, From 179f5cc03299868119c8a4441e6b9c84a2d9dd63 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Tue, 13 Oct 2026 21:19:01 +0200 Subject: [PATCH 04/10] fix(cli): copy symlinked Expo XCFrameworks as real files With precompiled Expo, CocoaPods symlinks expo-image's dependency XCFrameworks into node_modules. The package step copied the links, so the output broke when moved to another machine, and stripping signatures modified the files in node_modules. --- .changeset/bare-app-skip-expo-prebuild.md | 2 + .../emitExpoSupportXcframeworks.test.ts | 53 +++++++++++++++++++ .../utils/emitExpoSupportXcframeworks.ts | 8 ++- 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/.changeset/bare-app-skip-expo-prebuild.md b/.changeset/bare-app-skip-expo-prebuild.md index d16b810fe..b645a29ca 100644 --- a/.changeset/bare-app-skip-expo-prebuild.md +++ b/.changeset/bare-app-skip-expo-prebuild.md @@ -7,3 +7,5 @@ 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. `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. diff --git a/packages/cli/src/brownfield/utils/__tests__/emitExpoSupportXcframeworks.test.ts b/packages/cli/src/brownfield/utils/__tests__/emitExpoSupportXcframeworks.test.ts index a12137b44..7dc87732b 100644 --- a/packages/cli/src/brownfield/utils/__tests__/emitExpoSupportXcframeworks.test.ts +++ b/packages/cli/src/brownfield/utils/__tests__/emitExpoSupportXcframeworks.test.ts @@ -133,6 +133,59 @@ describe('emitExpoSupportXcframeworks', () => { expect(childProcess.execFileSync).toHaveBeenCalled(); }); + it('copies symlinked XCFrameworks as real directories and leaves the link target untouched', () => { + vi.mocked(projectUtils.isExpoProject).mockReturnValue(true); + vi.mocked(projectUtils.getExpoSdkMajor).mockReturnValue(56); + + createAllRequiredExpoSupportXcframeworks(projectRoot); + + // CocoaPods links expo-image's dependencies into node_modules. + const linkPath = path.join( + projectRoot, + 'ios', + 'Pods', + 'ExpoImage', + 'SDWebImage.xcframework' + ); + const targetDir = path.join( + projectRoot, + 'node_modules', + 'expo-image', + 'prebuilds', + 'release' + ); + fs.rmSync(linkPath, { recursive: true, force: true }); + createSignedMockXcframework(targetDir, 'SDWebImage'); + fs.symlinkSync(path.join(targetDir, 'SDWebImage.xcframework'), linkPath); + + emitExpoSupportXcframeworks({ projectRoot, packageDir }); + + const copiedXcframework = path.join(packageDir, 'SDWebImage.xcframework'); + expect(fs.lstatSync(copiedXcframework).isSymbolicLink()).toBe(false); + expect( + fs.existsSync( + path.join( + copiedXcframework, + 'ios-arm64_x86_64-simulator', + 'SDWebImage.framework', + 'SDWebImage' + ) + ) + ).toBe(true); + expect( + fs.existsSync( + path.join( + targetDir, + 'SDWebImage.xcframework', + 'ios-arm64_x86_64-simulator', + 'SDWebImage.framework', + '_CodeSignature', + 'CodeResources' + ) + ) + ).toBe(true); + }); + it('emits only ExpoModulesJSI when prebuilt Expo support is disabled', () => { vi.mocked(projectUtils.isExpoProject).mockReturnValue(true); vi.mocked(projectUtils.getExpoSdkMajor).mockReturnValue(56); diff --git a/packages/cli/src/brownfield/utils/emitExpoSupportXcframeworks.ts b/packages/cli/src/brownfield/utils/emitExpoSupportXcframeworks.ts index 93698602c..ea605f29c 100644 --- a/packages/cli/src/brownfield/utils/emitExpoSupportXcframeworks.ts +++ b/packages/cli/src/brownfield/utils/emitExpoSupportXcframeworks.ts @@ -124,7 +124,13 @@ export function emitExpoSupportXcframeworks({ `${frameworkName}.xcframework` ); fs.rmSync(destinationPath, { recursive: true, force: true }); - fs.cpSync(sourcePath, destinationPath, { recursive: true }); + // With precompiled Expo, CocoaPods symlinks expo-image's dependencies into + // node_modules. Copy what the link points to, so the package is portable + // and normalizing it doesn't modify node_modules. + fs.cpSync(fs.realpathSync(sourcePath), destinationPath, { + recursive: true, + verbatimSymlinks: true, + }); normalizeCopiedXcframework(destinationPath); } From e0f5940170405e2ce9088d1d29d6274440b78ac8 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Tue, 13 Oct 2026 21:50:06 +0200 Subject: [PATCH 05/10] docs(changeset): note when expo prebuild no longer runs --- .changeset/bare-app-skip-expo-prebuild.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/bare-app-skip-expo-prebuild.md b/.changeset/bare-app-skip-expo-prebuild.md index b645a29ca..68880bbee 100644 --- a/.changeset/bare-app-skip-expo-prebuild.md +++ b/.changeset/bare-app-skip-expo-prebuild.md @@ -4,7 +4,7 @@ '@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. `create-react-native-brownfield` now detects Expo and sets up the iOS framework to use the Expo runtime. +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. From fff5e51d5ffc52c131ae897e31a3e8edc76aede7 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Wed, 14 Oct 2026 15:02:38 +0200 Subject: [PATCH 06/10] fix: address review feedback on Expo setup and precompiled defaults - warn when git can't tell whether the native dir is gitignored - make the Expo host Podfile edit handle CRLF, comments and fail loudly - respect Podfile.properties.json and an exported opt-out for precompiled Expo - keep a user-provided SWIFT_ENABLE_EXPLICIT_MODULES value - add tests for the new defaults --- docs/docs/docs/getting-started/ios.mdx | 2 +- .../__tests__/packageIos.action.test.ts | 133 +++++++++++++++++- .../cli/src/brownfield/commands/packageIos.ts | 62 +++++++- .../brownfield/utils/__tests__/expo.test.ts | 33 ++++- packages/cli/src/brownfield/utils/expo.ts | 5 + .../brownfield/utils/supportsPrebuiltExpo.ts | 21 +++ .../ios/__tests__/podfileHelpers.test.ts | 22 +++ .../expo-config-plugin/ios/podfileHelpers.ts | 17 ++- 8 files changed, 280 insertions(+), 15 deletions(-) diff --git a/docs/docs/docs/getting-started/ios.mdx b/docs/docs/docs/getting-started/ios.mdx index 8ae15ef69..503bc4c4d 100644 --- a/docs/docs/docs/getting-started/ios.mdx +++ b/docs/docs/docs/getting-started/ios.mdx @@ -341,7 +341,7 @@ When using the `--use-prebuilt-rn-core` flag: ## Expo Prebuilts -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`. +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. 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. diff --git a/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts b/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts index cb43ca4e9..cafbf49ca 100644 --- a/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts +++ b/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts @@ -2,7 +2,15 @@ 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'; @@ -10,7 +18,10 @@ import { createLocalSpmPackage } from '../../utils/createLocalSpmPackage.js'; import { emitExpoSupportXcframeworks } from '../../utils/emitExpoSupportXcframeworks.js'; import { resolvePackagedFrameworkName } from '../../utils/resolvePackagedFrameworkName.js'; import { isExpoProject } from '../../utils/project.js'; -import { supportsPrebuiltExpo } from '../../utils/supportsPrebuiltExpo.js'; +import { + isPrecompiledExpoDisabledByPodfile, + supportsPrebuiltExpo, +} from '../../utils/supportsPrebuiltExpo.js'; vi.mock('@rock-js/platform-apple-helpers', async (importOriginal) => { const actual = await importOriginal(); @@ -25,6 +36,11 @@ vi.mock('@rock-js/platform-apple-helpers', async (importOriginal) => { name: '--scheme ', description: 'test scheme option', }, + { + name: '--extra-params ', + description: 'test extra params option', + parse: (value: string) => value.split(' '), + }, ]), mergeFrameworks: vi.fn(), }; @@ -92,6 +108,7 @@ vi.mock('../../utils/supportsPrebuiltRNCore.js', () => ({ })); vi.mock('../../utils/supportsPrebuiltExpo.js', () => ({ + isPrecompiledExpoDisabledByPodfile: vi.fn(() => false), supportsPrebuiltExpo: vi.fn(() => ({ supported: true, enabledByDefault: true, @@ -169,10 +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', @@ -190,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', @@ -332,4 +364,101 @@ describe('package:ios action --add-spm-package', () => { 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']); + }); }); diff --git a/packages/cli/src/brownfield/commands/packageIos.ts b/packages/cli/src/brownfield/commands/packageIos.ts index 9dc52bb08..0de55e963 100644 --- a/packages/cli/src/brownfield/commands/packageIos.ts +++ b/packages/cli/src/brownfield/commands/packageIos.ts @@ -19,7 +19,10 @@ import { Command, Option } from 'commander'; import { runExpoPrebuildIfNeeded } from '../utils/expo.js'; import { findProjectRoot } from '../utils/paths.js'; import { getProjectInfo, isExpoProject } from '../utils/project.js'; -import { supportsPrebuiltExpo } from '../utils/supportsPrebuiltExpo.js'; +import { + isPrecompiledExpoDisabledByPodfile, + supportsPrebuiltExpo, +} from '../utils/supportsPrebuiltExpo.js'; import { supportsPrebuiltRNCore } from '../utils/supportsPrebuiltRNCore.js'; import { actionRunner, @@ -138,22 +141,62 @@ export const packageIosCommand = curryOptions( : false; const usePrebuiltExpoWasPassed = options.usePrebuiltExpo !== undefined; + const precompiledExpoDisabledByPodfile = + isExpoProject(projectRoot) && + isPrecompiledExpoDisabledByPodfile(projectRoot); + + if (precompiledExpoDisabledByPodfile) { + if (options.usePrebuiltExpo) { + logger.warn( + 'ios/Podfile.properties.json sets EXPO_USE_PRECOMPILED_MODULES to "false", so pod install builds Expo modules from source and --use-prebuilt-expo will fail. Remove that property or pass --use-prebuilt-expo false.' + ); + } else if (!usePrebuiltExpoWasPassed) { + logger.info( + 'ios/Podfile.properties.json turns Expo precompiled modules off. Building Expo modules from source.' + ); + options.usePrebuiltExpo = false; + } + } else if ( + !usePrebuiltExpoWasPassed && + isExpoProject(projectRoot) && + process.env.EXPO_USE_PRECOMPILED_MODULES === '0' + ) { + // Respect an exported opt-out, the same way Expo's Podfile does. + logger.info( + 'EXPO_USE_PRECOMPILED_MODULES is set to 0. Building Expo modules from source.' + ); + options.usePrebuiltExpo = false; + } + + const usePrebuiltExpoWasChosen = + usePrebuiltExpoWasPassed || options.usePrebuiltExpo !== undefined; // version-aware default when the flag is omitted; Expo's precompiled // modules also need the prebuilt React Native core - options.usePrebuiltExpo ??= prebuiltExpoSupport.supported - ? prebuiltExpoSupport.enabledByDefault && !!options.usePrebuiltRnCore - : false; + if (options.usePrebuiltExpo === undefined) { + options.usePrebuiltExpo = prebuiltExpoSupport.supported + ? prebuiltExpoSupport.enabledByDefault && !!options.usePrebuiltRnCore + : false; + + if ( + prebuiltExpoSupport.enabledByDefault && + !options.usePrebuiltRnCore + ) { + logger.info( + 'Expo precompiled modules need the prebuilt React Native core, which is off. Building Expo modules from source.' + ); + } + } if ( prebuiltExpoSupport.supported && prebuiltExpoSupport.defaultDisabledReason ) { - if (usePrebuiltExpoWasPassed && options.usePrebuiltExpo) { + if (usePrebuiltExpoWasChosen && options.usePrebuiltExpo) { logger.warn( `${prebuiltExpoSupport.defaultDisabledReason} The packaged app will likely crash at runtime. Pass --use-prebuilt-expo false to build Expo modules from source.` ); - } else if (!usePrebuiltExpoWasPassed) { + } else if (!usePrebuiltExpoWasChosen) { logger.info( `${prebuiltExpoSupport.defaultDisabledReason} Building Expo modules from source.` ); @@ -192,7 +235,12 @@ export const packageIosCommand = curryOptions( : '0'; } - if (options.usePrebuiltExpo) { + if ( + options.usePrebuiltExpo && + !options.extraParams?.some((param) => + param.startsWith('SWIFT_ENABLE_EXPLICIT_MODULES=') + ) + ) { // Precompiled Expo builds the React Native pods as static libraries. // With explicit modules off (a leftover older React Native versions // wrote into app projects), Xcode cannot find their module maps when diff --git a/packages/cli/src/brownfield/utils/__tests__/expo.test.ts b/packages/cli/src/brownfield/utils/__tests__/expo.test.ts index 80721edbe..a885856de 100644 --- a/packages/cli/src/brownfield/utils/__tests__/expo.test.ts +++ b/packages/cli/src/brownfield/utils/__tests__/expo.test.ts @@ -3,9 +3,17 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { logger } from '@rock-js/tools'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { usesContinuousNativeGeneration } from '../expo.js'; +import { + runExpoPrebuildIfNeeded, + usesContinuousNativeGeneration, +} from '../expo.js'; + +vi.mock('../project.js', () => ({ + isExpoProject: vi.fn(() => true), +})); describe('usesContinuousNativeGeneration', () => { let tempDir: string | null = null; @@ -20,6 +28,7 @@ describe('usesContinuousNativeGeneration', () => { } afterEach(() => { + vi.restoreAllMocks(); if (tempDir) { fs.rmSync(tempDir, { recursive: true, force: true }); tempDir = null; @@ -52,6 +61,26 @@ describe('usesContinuousNativeGeneration', () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'brownfield-cng-')); fs.mkdirSync(path.join(tempDir, 'ios')); + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + expect(usesContinuousNativeGeneration(tempDir, 'ios')).toBe(false); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + "Couldn't check with git whether ios/ is gitignored" + ) + ); + }); + + it('skips expo prebuild for a committed native directory', async () => { + const projectRoot = createGitProject(); + fs.mkdirSync(path.join(projectRoot, 'ios')); + const info = vi.spyOn(logger, 'info').mockImplementation(() => {}); + + await expect( + runExpoPrebuildIfNeeded({ projectRoot, platform: 'ios' }) + ).resolves.toBe(false); + expect(info).toHaveBeenCalledWith( + 'Expo modules detected in a project with a committed ios directory. Skipping expo prebuild.' + ); }); }); diff --git a/packages/cli/src/brownfield/utils/expo.ts b/packages/cli/src/brownfield/utils/expo.ts index c02ee9dab..8c308fed8 100644 --- a/packages/cli/src/brownfield/utils/expo.ts +++ b/packages/cli/src/brownfield/utils/expo.ts @@ -49,6 +49,11 @@ function isGitIgnored(targetPath: string): boolean { cwd: path.dirname(targetPath), stdio: 'ignore', }); + if (result.status !== 0 && result.status !== 1) { + logger.warn( + `Couldn't check with git whether ${path.basename(targetPath)}/ is gitignored, so it's treated as committed and expo prebuild is skipped. If this project uses Continuous Native Generation, run \`npx expo prebuild\` before packaging.` + ); + } return result.status === 0; } diff --git a/packages/cli/src/brownfield/utils/supportsPrebuiltExpo.ts b/packages/cli/src/brownfield/utils/supportsPrebuiltExpo.ts index dc64e906a..8fd9eb7ae 100644 --- a/packages/cli/src/brownfield/utils/supportsPrebuiltExpo.ts +++ b/packages/cli/src/brownfield/utils/supportsPrebuiltExpo.ts @@ -99,3 +99,24 @@ export function supportsPrebuiltExpo({ enabledByDefault: true, }; } + +/** + * Whether the project's Podfile turns Expo's precompiled modules off. Expo + * Podfiles force `EXPO_USE_PRECOMPILED_MODULES` to '0' when + * `ios/Podfile.properties.json` sets it to "false", whatever the environment says. + */ +export function isPrecompiledExpoDisabledByPodfile( + projectRoot: string +): boolean { + try { + const properties = JSON.parse( + fs.readFileSync( + path.join(projectRoot, 'ios', 'Podfile.properties.json'), + 'utf8' + ) + ); + return properties.EXPO_USE_PRECOMPILED_MODULES === 'false'; + } catch { + return false; + } +} diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/podfileHelpers.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/podfileHelpers.test.ts index de438d114..67313b291 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/podfileHelpers.test.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/podfileHelpers.test.ts @@ -115,6 +115,28 @@ end expect(modifyPodfile(expo, 'BrownfieldLib', 58)).toBe(expo); }); + it('switches an existing target with CRLF line endings and a trailing comment', () => { + const vanilla = modifyPodfile(createPodfile(), 'BrownfieldLib') + .replace( + "target 'BrownfieldLib' do", + "target 'BrownfieldLib' do # framework" + ) + .replace(/\n/g, '\r\n'); + const expo = modifyPodfile(vanilla, 'BrownfieldLib', 58); + + expect(expo).toContain( + " target 'BrownfieldLib' do # framework\r\n ENV['REACT_NATIVE_BROWNFIELD_USE_EXPO_HOST'] = '1'\r\n" + ); + }); + + it('throws instead of silently skipping when the existing target line cannot be found', () => { + const podfile = `${createPodfile()}\n# target 'BrownfieldLib' is defined elsewhere\n`; + + expect(() => modifyPodfile(podfile, 'BrownfieldLib', 58)).toThrow( + /Couldn't find the "target 'BrownfieldLib' do" line/ + ); + }); + it('throws when the Podfile has no main target to insert after', () => { expect(() => modifyPodfile('# empty Podfile\n', 'BrownfieldLib')).toThrow( /Could not find main target in Podfile/ diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts index 8c53f53f4..8aaa62450 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts @@ -133,13 +133,24 @@ function ensureExpoHostInFrameworkTarget( podfile: string, frameworkName: string ): string { - if (podfile.includes('REACT_NATIVE_BROWNFIELD_USE_EXPO_HOST')) { + if (/^[ \t]*ENV\['REACT_NATIVE_BROWNFIELD_USE_EXPO_HOST'\]/m.test(podfile)) { return podfile; } + const escapedName = frameworkName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const targetLine = new RegExp( + `^([ \\t]*)(target\\s+['"]${escapedName}['"]\\s+do\\b[^\\r\\n]*(\\r?\\n))`, + 'm' + ); + if (!targetLine.test(podfile)) { + throw new SourceModificationError( + `Couldn't find the "target '${frameworkName}' do" line in the Podfile to enable the Expo host. Add ENV['REACT_NATIVE_BROWNFIELD_USE_EXPO_HOST'] = '1' inside that target block manually.` + ); + } + return podfile.replace( - new RegExp(`^([ \\t]*)(target\\s+'${frameworkName}'\\s+do[ \\t]*\\n)`, 'm'), - `$1$2$1 ENV['REACT_NATIVE_BROWNFIELD_USE_EXPO_HOST'] = '1'\n` + targetLine, + `$1$2$1 ENV['REACT_NATIVE_BROWNFIELD_USE_EXPO_HOST'] = '1'$3` ); } From edfc478877f0e983bc511bc1e23224dcc0020317 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Wed, 14 Oct 2026 15:05:05 +0200 Subject: [PATCH 07/10] test: cover Podfile properties check and double-quoted Podfile targets --- .../__tests__/supportsPrebuiltExpo.test.ts | 45 ++++++++++++++++++- .../ios/__tests__/podfileHelpers.test.ts | 13 ++++++ .../expo-config-plugin/ios/podfileHelpers.ts | 5 ++- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/brownfield/utils/__tests__/supportsPrebuiltExpo.test.ts b/packages/cli/src/brownfield/utils/__tests__/supportsPrebuiltExpo.test.ts index f806ce32b..693349fde 100644 --- a/packages/cli/src/brownfield/utils/__tests__/supportsPrebuiltExpo.test.ts +++ b/packages/cli/src/brownfield/utils/__tests__/supportsPrebuiltExpo.test.ts @@ -5,7 +5,10 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import * as projectUtils from '../project.js'; -import { supportsPrebuiltExpo } from '../supportsPrebuiltExpo.js'; +import { + isPrecompiledExpoDisabledByPodfile, + supportsPrebuiltExpo, +} from '../supportsPrebuiltExpo.js'; vi.mock('../project.js', async (importOriginal) => { const actual = await importOriginal(); @@ -123,3 +126,43 @@ describe('supportsPrebuiltExpo', () => { }); }); }); + +describe('isPrecompiledExpoDisabledByPodfile', () => { + let projectRoot: string; + + beforeEach(() => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'podfile-properties-')); + fs.mkdirSync(path.join(projectRoot, 'ios')); + }); + + afterEach(() => { + fs.rmSync(projectRoot, { recursive: true, force: true }); + }); + + function writeProperties(content: string) { + fs.writeFileSync( + path.join(projectRoot, 'ios', 'Podfile.properties.json'), + content + ); + } + + test('returns true when the property is the string "false"', () => { + writeProperties('{"EXPO_USE_PRECOMPILED_MODULES": "false"}'); + + expect(isPrecompiledExpoDisabledByPodfile(projectRoot)).toBe(true); + }); + + test('returns false when the property is "true"', () => { + writeProperties('{"EXPO_USE_PRECOMPILED_MODULES": "true"}'); + + expect(isPrecompiledExpoDisabledByPodfile(projectRoot)).toBe(false); + }); + + test('returns false when the file is missing or invalid', () => { + expect(isPrecompiledExpoDisabledByPodfile(projectRoot)).toBe(false); + + writeProperties('not json'); + + expect(isPrecompiledExpoDisabledByPodfile(projectRoot)).toBe(false); + }); +}); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/podfileHelpers.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/podfileHelpers.test.ts index 67313b291..b7d2cad42 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/podfileHelpers.test.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/podfileHelpers.test.ts @@ -129,6 +129,19 @@ end ); }); + it('recognizes an existing target written with double quotes', () => { + const vanilla = modifyPodfile(createPodfile(), 'BrownfieldLib').replace( + "target 'BrownfieldLib' do", + 'target "BrownfieldLib" do' + ); + const expo = modifyPodfile(vanilla, 'BrownfieldLib', 58); + + expect(expo.match(/target ["']BrownfieldLib["']/g)).toHaveLength(1); + expect(expo).toContain( + "ENV['REACT_NATIVE_BROWNFIELD_USE_EXPO_HOST'] = '1'" + ); + }); + it('throws instead of silently skipping when the existing target line cannot be found', () => { const podfile = `${createPodfile()}\n# target 'BrownfieldLib' is defined elsewhere\n`; diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts index 8aaa62450..877f2a8bd 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts @@ -65,7 +65,10 @@ export function modifyPodfile( expoMajor?: number ): string { // check if the framework target is already included - if (podfile.includes(`target '${frameworkName}'`)) { + if ( + podfile.includes(`target '${frameworkName}'`) || + podfile.includes(`target "${frameworkName}"`) + ) { if (typeof expoMajor !== 'number') { Logger.logDebug( `Framework target "${frameworkName}" already in Podfile, skipping modification` From 8f44836c32273f079612e0d805ada848603ce53d Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Wed, 14 Oct 2026 15:27:54 +0200 Subject: [PATCH 08/10] fix(scaffold): don't duplicate the Sources phase after pod install CocoaPods renames the framework target's Sources phase comment to "Sources", so the idempotency check missed it and a re-run added a second phase, which broke the next pod install. --- .changeset/bare-app-skip-expo-prebuild.md | 2 ++ .../ios/__tests__/xcodeHelpers.test.ts | 35 +++++++++++++++++++ .../expo-config-plugin/ios/xcodeHelpers.ts | 28 ++++++++++----- 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/.changeset/bare-app-skip-expo-prebuild.md b/.changeset/bare-app-skip-expo-prebuild.md index 68880bbee..60501317f 100644 --- a/.changeset/bare-app-skip-expo-prebuild.md +++ b/.changeset/bare-app-skip-expo-prebuild.md @@ -9,3 +9,5 @@ Support Expo modules in React Native CLI apps. `brownfield package:*` no longer `--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. 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 d1828d271..4fe866036 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 @@ -214,6 +214,41 @@ describe('addSourceFilesBuildPhase', () => { expect(project.calls).toHaveLength(0); }); + it('skips adding a sources phase after pod install renamed the existing one', () => { + // CocoaPods rewrites the phase comment from the framework name to "Sources". + const project = { + calls: [] as unknown[][], + hash: { + project: { + objects: { + PBXNativeTarget: { + FRAMEWORK_UUID: { + isa: 'PBXNativeTarget', + name: 'BrownfieldLib', + buildPhases: [ + { value: 'SOURCES_PHASE_UUID', comment: 'Sources' }, + ], + }, + }, + PBXSourcesBuildPhase: { + SOURCES_PHASE_UUID: { isa: 'PBXSourcesBuildPhase', files: [] }, + SOURCES_PHASE_UUID_comment: 'Sources', + }, + }, + }, + }, + addBuildPhase(...args: unknown[]) { + (this as any).calls.push(args); + }, + } as any; + + addSourceFilesBuildPhase(project, 'FRAMEWORK_UUID', baseOptions, { + useExpoHost: false, + }); + + expect(project.calls).toHaveLength(0); + }); + it('renders Expo-hosted source files when no brownfield options are given', async () => { const { getFrameworkSourceFiles } = await import('../withIosFrameworkFiles'); 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 fd9cd5a99..721bfe7f2 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 @@ -216,19 +216,29 @@ export function addSourceFilesBuildPhase( (filePath) => !filePath.endsWith('.plist') ); - // Idempotency: skip when the target already has a sources phase with this - // comment, so re-running scaffolding does not duplicate the build phase. - const nativeTargets = getRawProjectObjects(project).PBXNativeTarget as + // Idempotency: skip when the target already has a sources phase, so + // re-running scaffolding does not duplicate the build phase. CocoaPods + // rewrites the phase's comment to "Sources" on `pod install`, so match by + // the phase type as well as by our comment. + const rawObjects = getRawProjectObjects(project); + const nativeTargets = rawObjects.PBXNativeTarget as | Record | undefined; + const sourcesPhases = (rawObjects.PBXSourcesBuildPhase ?? {}) as Record< + string, + unknown + >; const targetBuildPhases = nativeTargets?.[frameworkTargetUUID]?.buildPhases ?? []; - const alreadyHasSourcePhase = targetBuildPhases.some((phase) => - hasBuildPhaseComment( - { comment: typeof phase === 'string' ? undefined : phase.comment }, - options.frameworkName - ) - ); + const alreadyHasSourcePhase = targetBuildPhases.some((phase) => { + const phaseUuid = getReferencedUuid(phase); + return ( + hasBuildPhaseComment( + { comment: typeof phase === 'string' ? undefined : phase.comment }, + options.frameworkName + ) || Boolean(phaseUuid && sourcesPhases[phaseUuid]) + ); + }); if (alreadyHasSourcePhase) { Logger.logDebug( `Sources build phase "${options.frameworkName}" already present on target ${frameworkTargetUUID}, skipping` From 3f1560cd05570a1725a78bf564b3b9583064c52e Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Wed, 14 Oct 2026 15:58:28 +0200 Subject: [PATCH 09/10] docs: make the Expo modules iOS steps easier to follow --- docs/docs/docs/getting-started/rnc-cli.mdx | 33 ++++++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/docs/docs/docs/getting-started/rnc-cli.mdx b/docs/docs/docs/getting-started/rnc-cli.mdx index 06d0dffd8..3a8d59b5c 100644 --- a/docs/docs/docs/getting-started/rnc-cli.mdx +++ b/docs/docs/docs/getting-started/rnc-cli.mdx @@ -87,17 +87,38 @@ No extra setup is needed. If your app has product flavors, see [Troubleshooting] ### iOS -Expo modules only register through Brownfield's Expo runtime. The setup command turns it on when your `package.json` depends on `expo`. If you added Expo after running the setup, run it again: +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 + +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](#automated-setup). If you ran it before you added Expo, run it again. It only changes what's missing. + + + + 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//.swift` + +3. Make sure the framework target uses iOS 16.4 or newer. Open `ios/.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: + + -It switches the framework target in your `Podfile` to the Expo runtime and adds `ensureExpoModulesProvider()` to `.swift`. Then run `pod install` and package as usual. + 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). -Expo SDK 56 and newer require iOS 16.4, so make sure the `platform :ios` line in your `Podfile` and your framework target use at least that version. +#### In your native iOS app -Expo's precompiled modules only work with the React Native version your Expo SDK targets. Otherwise Brownfield builds them from source. See [Expo Prebuilts](/docs/getting-started/ios#expo-prebuilts). +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: -In your iOS app, add the Expo XCFrameworks listed in [Add the Framework to Your iOS App](/docs/getting-started/ios#7-add-the-framework-to-your-ios-app). Then call `ensureExpoModulesProvider()` before starting React Native, as shown in [Present RN UI](/docs/getting-started/expo#xcframework-present-rn-ui). +```swift +ReactNativeBrownfield.shared.bundle = ReactNativeBundle +ReactNativeBrownfield.shared.ensureExpoModulesProvider() +ReactNativeBrownfield.shared.startReactNative { + print("React Native has been loaded") +} +``` ## Next Steps From 0b3fba29e2c92d83722054f08b3021feb83eeed8 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Wed, 14 Oct 2026 16:06:06 +0200 Subject: [PATCH 10/10] docs: move Expo modules setup for RN CLI apps to its own guide --- docs/docs/docs/getting-started/ios.mdx | 1 + docs/docs/docs/getting-started/rnc-cli.mdx | 45 ++------------------- docs/docs/docs/guides/_meta.json | 1 + docs/docs/docs/guides/expo-modules.mdx | 46 ++++++++++++++++++++++ 4 files changed, 51 insertions(+), 42 deletions(-) create mode 100644 docs/docs/docs/guides/expo-modules.mdx diff --git a/docs/docs/docs/getting-started/ios.mdx b/docs/docs/docs/getting-started/ios.mdx index 503bc4c4d..263f3fe86 100644 --- a/docs/docs/docs/getting-started/ios.mdx +++ b/docs/docs/docs/getting-started/ios.mdx @@ -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) diff --git a/docs/docs/docs/getting-started/rnc-cli.mdx b/docs/docs/docs/getting-started/rnc-cli.mdx index 3a8d59b5c..ed562105e 100644 --- a/docs/docs/docs/getting-started/rnc-cli.mdx +++ b/docs/docs/docs/getting-started/rnc-cli.mdx @@ -77,48 +77,9 @@ 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. -## Using Expo Modules - -You can use Expo modules (installed with `npx install-expo-modules`) in a React Native CLI app. The Brownfield CLI packages them with the rest of your app. It skips `expo prebuild` because your `android/` and `ios/` directories are committed and not generated. - -### 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 - -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](#automated-setup). If you ran it before you added Expo, run it again. It only changes what's missing. - - - - 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//.swift` - -3. Make sure the framework target uses iOS 16.4 or newer. Open `ios/.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: - - - - 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") -} -``` +:::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 diff --git a/docs/docs/docs/guides/_meta.json b/docs/docs/docs/guides/_meta.json index cf8e4956b..72c239232 100644 --- a/docs/docs/docs/guides/_meta.json +++ b/docs/docs/docs/guides/_meta.json @@ -1,6 +1,7 @@ [ "guidelines", "minification", + "expo-modules", "troubleshooting", { "type": "dir", diff --git a/docs/docs/docs/guides/expo-modules.mdx b/docs/docs/docs/guides/expo-modules.mdx new file mode 100644 index 000000000..f89821725 --- /dev/null +++ b/docs/docs/docs/guides/expo-modules.mdx @@ -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 + +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. + + + + 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//.swift` + +3. Make sure the framework target uses iOS 16.4 or newer. Open `ios/.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: + + + + 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") +} +```