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