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 path from 'path'; 7 8import { EXPO_DIR, EXPOTOOLS_DIR } from '../Constants'; 9import logger from '../Logger'; 10import { getPackageViewAsync } from '../Npm'; 11import { transformFileAsync } from '../Transforms'; 12import { applyPatchAsync } from '../Utils'; 13import { installAsync as workspaceInstallAsync } from '../Workspace'; 14 15const PATCHES_ROOT = path.join(EXPOTOOLS_DIR, 'src', 'react-native-nightlies', 'patches'); 16 17export default (program: Command) => { 18 program 19 .command('setup-react-native-nightly') 20 .description('Setup expo/expo monorepo to install react-native nightly build for testing') 21 .asyncAction(main); 22}; 23 24async function main() { 25 const nightlyVersion = (await getPackageViewAsync('react-native'))?.['dist-tags'].nightly; 26 if (!nightlyVersion) { 27 throw new Error('Unable to get react-native nightly version.'); 28 } 29 30 logger.info('Adding bare-expo optional packages:'); 31 await addBareExpoOptionalPackagesAsync(); 32 33 logger.info('Adding pinned packages:'); 34 const pinnedPackages = { 35 'react-native': nightlyVersion, 36 '@react-native-async-storage/async-storage': '~1.19.1', // fix AGP 8 build error 37 '@react-native-community/netinfo': '~9.4.1', // fix AGP 8 build error 38 }; 39 await addPinnedPackagesAsync(pinnedPackages); 40 41 logger.info('Yarning...'); 42 await workspaceInstallAsync(); 43 44 await updateReactNativePackageAsync(); 45 46 await patchAndroidBuildConfigAsync(); 47 await patchReactNavigationAsync(); 48 await patchDetoxAsync(); 49 await patchReanimatedAsync(); 50 await patchScreensAsync(); 51 await patchGestureHandlerAsync(); 52 await patchSafeAreaContextAsync(); 53 54 logger.info('Setting up Expo modules files'); 55 await updateExpoModulesAsync(); 56 57 logger.info('Setting up project files for bare-expo.'); 58 await updateBareExpoAsync(nightlyVersion); 59} 60 61/** 62 * To save the CI build time, some third-party libraries are intentionally not listed as dependencies in bare-expo. 63 * Adding these packages for nightly testing to increase coverage. 64 */ 65async function addBareExpoOptionalPackagesAsync() { 66 const bareExpoRoot = path.join(EXPO_DIR, 'apps', 'bare-expo'); 67 const OPTIONAL_PKGS = ['@shopify/react-native-skia', 'lottie-react-native', 'react-native-maps']; 68 69 const packageJsonNCL = await JsonFile.readAsync( 70 path.join(EXPO_DIR, 'apps', 'native-component-list', 'package.json') 71 ); 72 const versionMap = { 73 ...(packageJsonNCL.devDependencies as object), 74 ...(packageJsonNCL.dependencies as object), 75 }; 76 77 const installPackages = OPTIONAL_PKGS.map((pkg) => { 78 const version = versionMap[pkg]; 79 assert(version); 80 return `${pkg}@${version}`; 81 }); 82 for (const pkg of installPackages) { 83 logger.log(' ', pkg); 84 } 85 86 await spawnAsync('yarn', ['add', ...installPackages], { cwd: bareExpoRoot }); 87} 88 89async function addPinnedPackagesAsync(packages: Record<string, string>) { 90 const workspacePackageJsonPath = path.join(EXPO_DIR, 'package.json'); 91 const json = await JsonFile.readAsync(workspacePackageJsonPath); 92 json.resolutions ||= {}; 93 for (const [name, version] of Object.entries(packages)) { 94 logger.log(' ', `${name}@${version}`); 95 json.resolutions[name] = version; 96 } 97 await JsonFile.writeAsync(workspacePackageJsonPath, json); 98} 99 100async function updateReactNativePackageAsync() { 101 const reactNativeRoot = path.join(EXPO_DIR, 'node_modules', 'react-native'); 102 103 // Update native ReactNativeVersion 104 const versions = (process.env.REACT_NATIVE_OVERRIDE_VERSION ?? '9999.9999.9999').split('.'); 105 await transformFileAsync( 106 path.join( 107 reactNativeRoot, 108 'ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/ReactNativeVersion.java' 109 ), 110 [ 111 { 112 find: /("major", )\d+,/g, 113 replaceWith: `$1${versions[0]},`, 114 }, 115 { 116 find: /("minor", )\d+,/g, 117 replaceWith: `$1${versions[1]},`, 118 }, 119 { 120 find: /("patch", )\d+,/g, 121 replaceWith: `$1${versions[2]},`, 122 }, 123 ] 124 ); 125 126 // https://github.com/facebook/react-native/pull/38993 127 await transformFileAsync(path.join(reactNativeRoot, 'React-Core.podspec'), [ 128 { 129 find: '"React/CxxLogUtils/*.h"', 130 replaceWith: '"React/Cxx*/*.h"', 131 }, 132 ]); 133} 134 135async function patchReactNavigationAsync() { 136 await transformFileAsync( 137 path.join(EXPO_DIR, 'node_modules', '@react-navigation/elements', 'src/Header/Header.tsx'), 138 [ 139 { 140 // Weird that the nightlies will break if pass `undefined` to the `transform` prop 141 find: 'style={[{ height, minHeight, maxHeight, opacity, transform }]}', 142 replaceWith: 143 'style={[{ height, minHeight, maxHeight, opacity, transform: transform ?? [] }]}', 144 }, 145 ] 146 ); 147} 148 149async function patchDetoxAsync() { 150 await transformFileAsync( 151 path.join(EXPO_DIR, 'node_modules', 'detox', 'android/detox/build.gradle'), 152 [ 153 { 154 // namespace 155 find: /^(android \{[\s\S]*?)(\n})/gm, 156 replaceWith: '$1\n namespace "com.wix.detox"\n$2', 157 }, 158 ] 159 ); 160} 161 162async function patchReanimatedAsync() { 163 await transformFileAsync( 164 path.join( 165 EXPO_DIR, 166 'node_modules', 167 'react-native-reanimated', 168 'android/src/main/java/com/swmansion/reanimated/keyboardObserver/ReanimatedKeyboardEventListener.java' 169 ), 170 [ 171 { 172 // AGP 8 `nonTransitiveRClass` 173 find: /\bcom\.swmansion\.reanimated\.(R\.id\.action_bar_root)/g, 174 replaceWith: 'androidx.appcompat.$1', 175 }, 176 ] 177 ); 178} 179 180async function patchScreensAsync() { 181 await transformFileAsync( 182 path.join( 183 EXPO_DIR, 184 'node_modules', 185 'react-native-screens', 186 'android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt' 187 ), 188 [ 189 { 190 // AGP 8 `nonTransitiveRClass` 191 find: /\b(R\.attr\.colorPrimary)/g, 192 replaceWith: 'android.$1', 193 }, 194 ] 195 ); 196} 197 198async function patchGestureHandlerAsync() { 199 await transformFileAsync( 200 path.join( 201 EXPO_DIR, 202 'node_modules', 203 'react-native-gesture-handler', 204 'android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt' 205 ), 206 [ 207 { 208 find: 'decorateRuntime(jsContext.get())', 209 replaceWith: 'decorateRuntime(jsContext!!.get())', 210 }, 211 ] 212 ); 213} 214 215async function patchSafeAreaContextAsync() { 216 const patchFile = path.join(PATCHES_ROOT, 'react-native-safe-area-context.patch'); 217 const patchContent = await fs.readFile(patchFile, 'utf8'); 218 await applyPatchAsync({ patchContent, cwd: EXPO_DIR, stripPrefixNum: 1 }); 219} 220 221async function updateExpoModulesAsync() { 222 // no-op currently 223} 224 225async function updateBareExpoAsync(nightlyVersion: string) { 226 const root = path.join(EXPO_DIR, 'apps', 'bare-expo'); 227 await transformFileAsync(path.join(root, 'ios', 'Podfile'), [ 228 { 229 find: /(platform :ios, )['"]13\.0['"]/g, 230 replaceWith: "$1'13.4'", 231 }, 232 ]); 233 234 // flipper-integration 235 await transformFileAsync(path.join(root, 'android', 'app', 'build.gradle'), [ 236 { 237 find: 'debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}")', 238 replaceWith: 'debugImplementation("com.facebook.fresco:flipper-fresco-plugin:3.0.0")', 239 }, 240 ]); 241 await transformFileAsync(path.join(root, 'android', 'gradle.properties'), [ 242 { 243 find: /FLIPPER_VERSION=0\.182\.0/, 244 replaceWith: 'FLIPPER_VERSION=0.201.0', 245 }, 246 ]); 247} 248 249async function patchAndroidBuildConfigAsync() { 250 const missingBuildConfigModules = [ 251 '@react-native-async-storage/async-storage', 252 '@react-native-community/datetimepicker', 253 '@react-native-community/netinfo', 254 '@react-native-community/slider', 255 'lottie-react-native', 256 'react-native-gesture-handler', 257 'react-native-maps', 258 'react-native-pager-view', 259 'react-native-reanimated', 260 'react-native-safe-area-context', 261 'react-native-screens', 262 'react-native-svg', 263 'react-native-webview', 264 ]; 265 const searchPattern = /^(android \{[\s\S]*?)(\n})/gm; 266 const replacement = `$1 267 buildFeatures { 268 buildConfig true 269 }$2`; 270 for (const module of missingBuildConfigModules) { 271 const gradleFile = path.join(EXPO_DIR, 'node_modules', module, 'android', 'build.gradle'); 272 await transformFileAsync(gradleFile, [ 273 { 274 find: searchPattern, 275 replaceWith: replacement, 276 }, 277 ]); 278 } 279 280 const missingNamespaceModules = { 281 '@shopify/flash-list': 'com.shopify.reactnative.flash_list', 282 '@shopify/react-native-skia': 'com.shopify.reactnative.skia', 283 '@react-native-community/slider': 'com.reactnativecommunity.slider', 284 '@react-native-masked-view/masked-view': 'org.reactnative.maskedview', 285 '@react-native-picker/picker': 'com.reactnativecommunity.picker', 286 'react-native-maps': 'com.rnmaps.maps', 287 'react-native-pager-view': 'com.reactnativepagerview', 288 'react-native-view-shot': 'fr.greweb.reactnativeviewshot', 289 'react-native-webview': 'com.reactnativecommunity.webview', 290 }; 291 for (const [module, namespace] of Object.entries(missingNamespaceModules)) { 292 const gradleFile = path.join(EXPO_DIR, 'node_modules', module, 'android', 'build.gradle'); 293 await transformFileAsync(gradleFile, [ 294 { 295 find: searchPattern, 296 replaceWith: `$1\n namespace "${namespace}"\n$2`, 297 }, 298 ]); 299 } 300} 301