diff --git a/.changeset/bare-app-skip-expo-prebuild.md b/.changeset/bare-app-skip-expo-prebuild.md new file mode 100644 index 000000000..60501317f --- /dev/null +++ b/.changeset/bare-app-skip-expo-prebuild.md @@ -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. diff --git a/docs/docs/docs/getting-started/ios.mdx b/docs/docs/docs/getting-started/ios.mdx index e2fdf96b5..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) @@ -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 diff --git a/docs/docs/docs/getting-started/rnc-cli.mdx b/docs/docs/docs/getting-started/rnc-cli.mdx index 9d2923816..ed562105e 100644 --- a/docs/docs/docs/getting-started/rnc-cli.mdx +++ b/docs/docs/docs/getting-started/rnc-cli.mdx @@ -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) 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") +} +``` 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..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,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(); @@ -24,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(), }; @@ -64,6 +81,7 @@ vi.mock('../../utils/paths.js', () => ({ })); vi.mock('../../utils/project.js', () => ({ + isExpoProject: vi.fn(() => false), getProjectInfo: vi.fn(() => ({ projectRoot: '/repo', platformConfig: {}, @@ -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, @@ -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', @@ -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', @@ -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']); + }); }); diff --git a/packages/cli/src/brownfield/commands/packageIos.ts b/packages/cli/src/brownfield/commands/packageIos.ts index b50ed3ae5..0de55e963 100644 --- a/packages/cli/src/brownfield/commands/packageIos.ts +++ b/packages/cli/src/brownfield/commands/packageIos.ts @@ -18,8 +18,11 @@ import { Command, Option } from 'commander'; import { runExpoPrebuildIfNeeded } from '../utils/expo.js'; import { findProjectRoot } from '../utils/paths.js'; -import { getProjectInfo } from '../utils/project.js'; -import { supportsPrebuiltExpo } from '../utils/supportsPrebuiltExpo.js'; +import { getProjectInfo, isExpoProject } from '../utils/project.js'; +import { + isPrecompiledExpoDisabledByPodfile, + supportsPrebuiltExpo, +} from '../utils/supportsPrebuiltExpo.js'; import { supportsPrebuiltRNCore } from '../utils/supportsPrebuiltRNCore.js'; import { actionRunner, @@ -75,9 +78,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,10 +140,68 @@ export const packageIosCommand = curryOptions( ? prebuiltRNCoreSupport.enabledByDefault : false; - // version-aware default when the flag is omitted - options.usePrebuiltExpo ??= prebuiltExpoSupport.supported - ? prebuiltExpoSupport.enabledByDefault - : 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 + 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 (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 (!usePrebuiltExpoWasChosen) { + logger.info( + `${prebuiltExpoSupport.defaultDisabledReason} Building Expo modules from source.` + ); + } + } if (prebuiltRNCoreSupport) { logger.info( @@ -167,6 +226,31 @@ 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 && + !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 + // 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__/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/__tests__/expo.test.ts b/packages/cli/src/brownfield/utils/__tests__/expo.test.ts new file mode 100644 index 000000000..a885856de --- /dev/null +++ b/packages/cli/src/brownfield/utils/__tests__/expo.test.ts @@ -0,0 +1,86 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { logger } from '@rock-js/tools'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + runExpoPrebuildIfNeeded, + usesContinuousNativeGeneration, +} from '../expo.js'; + +vi.mock('../project.js', () => ({ + isExpoProject: vi.fn(() => true), +})); + +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(() => { + vi.restoreAllMocks(); + 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')); + + 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/__tests__/supportsPrebuiltExpo.test.ts b/packages/cli/src/brownfield/utils/__tests__/supportsPrebuiltExpo.test.ts index 9f524adf9..693349fde 100644 --- a/packages/cli/src/brownfield/utils/__tests__/supportsPrebuiltExpo.test.ts +++ b/packages/cli/src/brownfield/utils/__tests__/supportsPrebuiltExpo.test.ts @@ -1,7 +1,14 @@ -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'; +import { + isPrecompiledExpoDisabledByPodfile, + supportsPrebuiltExpo, +} from '../supportsPrebuiltExpo.js'; vi.mock('../project.js', async (importOriginal) => { const actual = await importOriginal(); @@ -60,4 +67,102 @@ 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/ + ), + }); + }); + }); +}); + +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/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); } diff --git a/packages/cli/src/brownfield/utils/expo.ts b/packages/cli/src/brownfield/utils/expo.ts index 36a1fbc7b..8c308fed8 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,34 @@ 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', + }); + 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; +} + +/** + * 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 +82,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}...` ); diff --git a/packages/cli/src/brownfield/utils/supportsPrebuiltExpo.ts b/packages/cli/src/brownfield/utils/supportsPrebuiltExpo.ts index 38cc1c510..8fd9eb7ae 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,8 +85,38 @@ export function supportsPrebuiltExpo({ }; } + const reactNativeMismatch = getPrebuiltExpoReactNativeMismatch(projectRoot); + if (reactNativeMismatch) { + return { + supported: true, + enabledByDefault: false, + defaultDisabledReason: reactNativeMismatch, + }; + } + return { supported: true, 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 d5336cec2..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 @@ -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,54 @@ 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('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('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`; + + 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/__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/__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/podfileHelpers.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts index 096a6fd4d..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 @@ -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}" @@ -65,11 +65,23 @@ export function modifyPodfile( expoMajor?: number ): 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 ( + podfile.includes(`target '${frameworkName}'`) || + podfile.includes(`target "${frameworkName}"`) + ) { + 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 +125,39 @@ 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 (/^[ \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( + targetLine, + `$1$2$1 ENV['REACT_NATIVE_BROWNFIELD_USE_EXPO_HOST'] = '1'$3` + ); +} + +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 +165,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/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` 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.*,