1import { Command } from '@expo/commander'; 2import JsonFile from '@expo/json-file'; 3import spawnAsync from '@expo/spawn-async'; 4import assert from 'assert'; 5import fs from 'fs-extra'; 6import glob from 'glob-promise'; 7import path from 'path'; 8 9import { EXPO_DIR, EXPOTOOLS_DIR } from '../Constants'; 10import logger from '../Logger'; 11import { getPackageViewAsync } from '../Npm'; 12import { transformFileAsync } from '../Transforms'; 13import { applyPatchAsync } from '../Utils'; 14import { installAsync as workspaceInstallAsync } from '../Workspace'; 15 16const PATCHES_ROOT = path.join(EXPOTOOLS_DIR, 'src', 'react-native-nightlies', 'patches'); 17 18export default (program: Command) => { 19 program 20 .command('setup-react-native-nightly') 21 .description('Setup expo/expo monorepo to install react-native nightly build for testing') 22 .asyncAction(main); 23}; 24 25async function main() { 26 const nightlyVersion = (await getPackageViewAsync('react-native'))?.['dist-tags'].nightly; 27 if (!nightlyVersion) { 28 throw new Error('Unable to get react-native nightly version.'); 29 } 30 31 logger.info('Adding bare-expo optional packages:'); 32 await addBareExpoOptionalPackagesAsync(); 33 34 logger.info('Adding pinned packages:'); 35 const pinnedPackages = { 36 'react-native': nightlyVersion, 37 }; 38 await addPinnedPackagesAsync(pinnedPackages); 39 40 logger.info('Yarning...'); 41 await workspaceInstallAsync(); 42 43 await updateReactNativePackageAsync(); 44 45 await patchReanimatedAsync(nightlyVersion); 46 await patchDetoxAsync(); 47 await patchReactNavigationAsync(); 48 await patchGestureHandlerAsync(); 49 50 logger.info('Setting up Expo modules files'); 51 await updateExpoModulesAsync(); 52 53 logger.info('Setting up project files for bare-expo.'); 54 await updateBareExpoAsync(nightlyVersion); 55} 56 57/** 58 * To save the CI build time, some third-party libraries are intentionally not listed as dependencies in bare-expo. 59 * Adding these packages for nightly testing to increase coverage. 60 */ 61async function addBareExpoOptionalPackagesAsync() { 62 const bareExpoRoot = path.join(EXPO_DIR, 'apps', 'bare-expo'); 63 const OPTIONAL_PKGS = ['@shopify/react-native-skia', 'lottie-react-native', 'react-native-maps']; 64 65 const packageJsonNCL = await JsonFile.readAsync( 66 path.join(EXPO_DIR, 'apps', 'native-component-list', 'package.json') 67 ); 68 const versionMap = { 69 ...(packageJsonNCL.devDependencies as object), 70 ...(packageJsonNCL.dependencies as object), 71 // override @shopify/react-native-skia version to fix xcode 14.3 build error 72 '@shopify/react-native-skia': '0.1.184', 73 }; 74 75 const installPackages = OPTIONAL_PKGS.map((pkg) => { 76 const version = versionMap[pkg]; 77 assert(version); 78 return `${pkg}@${version}`; 79 }); 80 for (const pkg of installPackages) { 81 logger.log(' ', pkg); 82 } 83 84 await spawnAsync('yarn', ['add', ...installPackages], { cwd: bareExpoRoot }); 85} 86 87async function addPinnedPackagesAsync(packages: Record<string, string>) { 88 const workspacePackageJsonPath = path.join(EXPO_DIR, 'package.json'); 89 const json = await JsonFile.readAsync(workspacePackageJsonPath); 90 json.resolutions ||= {}; 91 for (const [name, version] of Object.entries(packages)) { 92 logger.log(' ', `${name}@${version}`); 93 json.resolutions[name] = version; 94 } 95 await JsonFile.writeAsync(workspacePackageJsonPath, json); 96} 97 98async function updateReactNativePackageAsync() { 99 const reactNativeRoot = path.join(EXPO_DIR, 'node_modules', 'react-native'); 100 101 // Update native ReactNativeVersion 102 const versions = (process.env.REACT_NATIVE_OVERRIDE_VERSION ?? '9999.9999.9999').split('.'); 103 await transformFileAsync( 104 path.join( 105 reactNativeRoot, 106 'ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/ReactNativeVersion.java' 107 ), 108 [ 109 { 110 find: /("major", )\d+,/g, 111 replaceWith: `$1${versions[0]},`, 112 }, 113 { 114 find: /("minor", )\d+,/g, 115 replaceWith: `$1${versions[1]},`, 116 }, 117 { 118 find: /("patch", )\d+,/g, 119 replaceWith: `$1${versions[2]},`, 120 }, 121 ] 122 ); 123 124 // Workaround build error for React-bridging depending on butter 125 const bridgingFiles = await glob('ReactCommon/react/bridging/*.{h,cpp}', { 126 cwd: reactNativeRoot, 127 absolute: true, 128 }); 129 await Promise.all( 130 bridgingFiles.map((file) => 131 transformFileAsync(file, [ 132 { 133 find: /<butter\/map\.h>/g, 134 replaceWith: '<map>', 135 }, 136 { 137 find: /<butter\/function\.h>/g, 138 replaceWith: '<functional>', 139 }, 140 { 141 find: /butter::(map|function)/g, 142 replaceWith: 'std::$1', 143 }, 144 ]) 145 ) 146 ); 147 148 // Workaround build error for outdated `@react-native/gradle-plugin` 149 const reactGradlePluginRoot = path.join( 150 EXPO_DIR, 151 'node_modules', 152 '@react-native', 153 'gradle-plugin' 154 ); 155 await transformFileAsync( 156 path.join(reactGradlePluginRoot, 'src/main/kotlin/com/facebook/react/utils/DependencyUtils.kt'), 157 [ 158 { 159 find: 'return if (versionString.startsWith("0.0.0")) {', 160 replaceWith: 161 'return if (versionString.startsWith("0.0.0") || "-nightly-" in versionString) {', 162 }, 163 ] 164 ); 165} 166 167async function patchReanimatedAsync(nightlyVersion: string) { 168 const root = path.join(EXPO_DIR, 'node_modules', 'react-native-reanimated'); 169 170 await transformFileAsync(path.join(root, 'android', 'build.gradle'), [ 171 { 172 find: /\$minor/g, 173 replaceWith: '$rnMinorVersion', 174 }, 175 ]); 176 177 await transformFileAsync(path.join(root, 'RNReanimated.podspec'), [ 178 { 179 find: /^(\s*['"]USE_HEADERMAP['"]\s+=>\s+['"]YES['"],\s*)$/gm, 180 replaceWith: `$1\n "CLANG_CXX_LANGUAGE_STANDARD" => "c++17",`, 181 }, 182 ]); 183} 184 185async function patchDetoxAsync() { 186 const patchFile = path.join(PATCHES_ROOT, 'detox.patch'); 187 const patchContent = await fs.readFile(patchFile, 'utf8'); 188 await applyPatchAsync({ patchContent, cwd: EXPO_DIR, stripPrefixNum: 1 }); 189} 190 191async function patchReactNavigationAsync() { 192 await transformFileAsync( 193 path.join(EXPO_DIR, 'node_modules', '@react-navigation/elements', 'src/Header/Header.tsx'), 194 [ 195 { 196 // Weird that the nightlies will break if pass `undefined` to the `transform` prop 197 find: 'style={[{ height, minHeight, maxHeight, opacity, transform }]}', 198 replaceWith: 199 'style={[{ height, minHeight, maxHeight, opacity, transform: transform ?? [] }]}', 200 }, 201 ] 202 ); 203} 204 205async function patchGestureHandlerAsync() { 206 await transformFileAsync( 207 path.join( 208 EXPO_DIR, 209 'node_modules', 210 'react-native-gesture-handler', 211 'android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt' 212 ), 213 [ 214 { 215 find: 'decorateRuntime(jsContext.get())', 216 replaceWith: 'decorateRuntime(jsContext!!.get())', 217 }, 218 ] 219 ); 220} 221 222async function updateExpoModulesAsync() { 223 // no-op currently 224} 225 226async function updateBareExpoAsync(nightlyVersion: string) { 227 const root = path.join(EXPO_DIR, 'apps', 'bare-expo'); 228 await transformFileAsync(path.join(root, 'android', 'settings.gradle'), [ 229 { 230 find: /react-native-gradle-plugin/g, 231 replaceWith: '@react-native/gradle-plugin', 232 }, 233 ]); 234 235 await transformFileAsync(path.join(root, 'ios', 'Podfile'), [ 236 { 237 find: /(platform :ios, )['"]13\.0['"]/g, 238 replaceWith: "$1'13.4'", 239 }, 240 ]); 241} 242