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 patchSkiaAsync(nightlyVersion);
47
48  logger.info('Setting up Expo modules files');
49  await updateExpoModulesAsync();
50
51  logger.info('Setting up project files for bare-expo.');
52  await updateBareExpoAsync(nightlyVersion);
53}
54
55/**
56 * To save the CI build time, some third-party libraries are intentionally not listed as dependencies in bare-expo.
57 * Adding these packages for nightly testing to increase coverage.
58 */
59async function addBareExpoOptionalPackagesAsync() {
60  const bareExpoRoot = path.join(EXPO_DIR, 'apps', 'bare-expo');
61  const OPTIONAL_PKGS = ['@shopify/react-native-skia', 'lottie-react-native', 'react-native-maps'];
62
63  const packageJsonNCL = await JsonFile.readAsync(
64    path.join(EXPO_DIR, 'apps', 'native-component-list', 'package.json')
65  );
66  const versionMap = {
67    ...(packageJsonNCL.devDependencies as object),
68    ...(packageJsonNCL.dependencies as object),
69  };
70
71  const installPackages = OPTIONAL_PKGS.map((pkg) => {
72    const version = versionMap[pkg];
73    assert(version);
74    return `${pkg}@${version}`;
75  });
76  for (const pkg of installPackages) {
77    logger.log('  ', pkg);
78  }
79
80  await spawnAsync('yarn', ['add', ...installPackages], { cwd: bareExpoRoot });
81}
82
83async function addPinnedPackagesAsync(packages: Record<string, string>) {
84  const workspacePackageJsonPath = path.join(EXPO_DIR, 'package.json');
85  const json = await JsonFile.readAsync(workspacePackageJsonPath);
86  json.resolutions ||= {};
87  for (const [name, version] of Object.entries(packages)) {
88    logger.log('  ', `${name}@${version}`);
89    json.resolutions[name] = version;
90  }
91  await JsonFile.writeAsync(workspacePackageJsonPath, json);
92}
93
94async function updateReactNativePackageAsync() {
95  const reactNativeRoot = path.join(EXPO_DIR, 'node_modules', 'react-native');
96  // Workaround duplicated libc++_shared.so from linked fbjni
97  await transformFileAsync(path.join(reactNativeRoot, 'ReactAndroid', 'build.gradle'), [
98    {
99      find: /^(\s*packagingOptions \{)$/gm,
100      replaceWith: '$1\n        pickFirst("**/libc++_shared.so")',
101    },
102  ]);
103
104  // Update native ReactNativeVersion
105  const versions = (process.env.REACT_NATIVE_OVERRIDE_VERSION ?? '9999.9999.9999').split('.');
106  await transformFileAsync(
107    path.join(
108      reactNativeRoot,
109      'ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/ReactNativeVersion.java'
110    ),
111    [
112      {
113        find: /("major", )\d+,/g,
114        replaceWith: `$1${versions[0]},`,
115      },
116      {
117        find: /("minor", )\d+,/g,
118        replaceWith: `$1${versions[1]},`,
119      },
120      {
121        find: /("patch", )\d+,/g,
122        replaceWith: `$1${versions[2]},`,
123      },
124    ]
125  );
126
127  // Workaround build error for React-bridging depending on butter
128  const bridgingFiles = await glob('ReactCommon/react/bridging/*.{h,cpp}', {
129    cwd: reactNativeRoot,
130    absolute: true,
131  });
132  await Promise.all(
133    bridgingFiles.map((file) =>
134      transformFileAsync(file, [
135        {
136          find: /<butter\/map\.h>/g,
137          replaceWith: '<map>',
138        },
139        {
140          find: /<butter\/function\.h>/g,
141          replaceWith: '<functional>',
142        },
143        {
144          find: /butter::(map|function)/g,
145          replaceWith: 'std::$1',
146        },
147      ])
148    )
149  );
150}
151
152async function patchReanimatedAsync(nightlyVersion: string) {
153  const root = path.join(EXPO_DIR, 'node_modules', 'react-native-reanimated');
154
155  await transformFileAsync(path.join(root, 'android', 'build.gradle'), [
156    {
157      // add prefab support, setup task dependencies and hermes-engine dependencies
158      transform: (text: string) =>
159        text +
160        '\n\n' +
161        `android {\n` +
162        `  buildFeatures {\n` +
163        `    prefab true\n` +
164        `  }\n` +
165        `}\n` +
166        `\n` +
167        `dependencies {\n` +
168        `  compileOnly "com.facebook.react:hermes-android:${nightlyVersion}-SNAPSHOT"\n` +
169        `}\n`,
170    },
171  ]);
172
173  const patchFile = path.join(PATCHES_ROOT, 'react-native-reanimated+2.12.0.patch');
174  const patchContent = await fs.readFile(patchFile, 'utf8');
175  await applyPatchAsync({ patchContent, cwd: EXPO_DIR, stripPrefixNum: 1 });
176}
177
178async function patchSkiaAsync(nightlyVersion: string) {
179  const root = path.join(EXPO_DIR, 'node_modules', '@shopify', 'react-native-skia');
180
181  await transformFileAsync(path.join(root, 'android', 'build.gradle'), [
182    {
183      // Add REACT_NATIVE_OVERRIDE_VERSION support
184      find: `def REACT_NATIVE_VERSION = reactProperties.getProperty("VERSION_NAME").split("\\.")[1].toInteger()`,
185      replaceWith: `def REACT_NATIVE_VERSION = (System.getenv("REACT_NATIVE_OVERRIDE_VERSION") ?: reactProperties.getProperty("VERSION_NAME")).split("\\.")[1].toInteger()`,
186    },
187    {
188      // Remove builtin aar extraction from react-native node_modules
189      find: `defaultDir = file("$nodeModules/react-native/android")`,
190      replaceWith: `defaultDir = file("$nodeModules/react-native")`,
191    },
192    {
193      // Remove builtin aar extraction from react-native node_modules
194      find: /^\s*def rnAAR.*\n\s*extractJNI.*$/gm,
195      replaceWith: '',
196    },
197    {
198      // Add prefab support
199      transform: (text: string) =>
200        text +
201        '\n\n' +
202        `android {\n` +
203        `  buildFeatures {\n` +
204        `    prefab true\n` +
205        `  }\n` +
206        `}\n`,
207    },
208  ]);
209
210  await transformFileAsync(path.join(root, 'android', 'CMakeLists.txt'), [
211    {
212      find: /^(\s*target_link_libraries\(\s*)$/gm,
213      replaceWith: `\
214find_package(fbjni REQUIRED CONFIG)
215find_package(ReactAndroid REQUIRED CONFIG)
216$1`,
217    },
218    {
219      find: '${FBJNI_LIBRARY}',
220      replaceWith: 'fbjni::fbjni',
221    },
222    {
223      find: '${REACT_LIB}',
224      replaceWith: 'ReactAndroid::react_nativemodule_core',
225    },
226    {
227      find: '${JSI_LIB}',
228      replaceWith: 'ReactAndroid::jsi',
229    },
230    {
231      find: '${TURBOMODULES_LIB}',
232      replaceWith: 'ReactAndroid::turbomodulejsijni',
233    },
234  ]);
235}
236
237async function updateExpoModulesAsync() {
238  const gradleFiles = await glob('packages/**/build.gradle', { cwd: EXPO_DIR });
239  await Promise.all(
240    gradleFiles.map((file) =>
241      transformFileAsync(file, [
242        {
243          find: /\b(com.facebook.fbjni:fbjni):0\.2\.2/g,
244          replaceWith: '$1:0.3.0',
245        },
246        {
247          find: /ndkVersion = ['"]21\.4\.7075529['"]/g,
248          replaceWith: '',
249        },
250      ])
251    )
252  );
253
254  await transformFileAsync(
255    path.join(EXPO_DIR, 'packages/expo-modules-core/android/src/main/cpp/MethodMetadata.cpp'),
256    [
257      {
258        // Workaround build error for CallbackWrapper interface change:
259        // https://github.com/facebook/react-native/commit/229a1ded15772497fd632c299b336566d001e37d
260        find: 'auto weakWrapper = react::CallbackWrapper::createWeak(strongLongLiveObjectCollection,',
261        replaceWith: 'auto weakWrapper = react::CallbackWrapper::createWeak(',
262      },
263    ]
264  );
265}
266
267async function updateBareExpoAsync(nightlyVersion: string) {
268  const root = path.join(EXPO_DIR, 'apps', 'bare-expo');
269  const patchFile = path.join(PATCHES_ROOT, 'bare-expo.patch');
270  const patchContent = await fs.readFile(patchFile, 'utf8');
271  await applyPatchAsync({ patchContent, cwd: EXPO_DIR, stripPrefixNum: 1 });
272
273  await transformFileAsync(path.join(root, 'ios', 'BareExpo', 'AppDelegate.mm'), [
274    {
275      // Remove this when we upgrade bare-expo to 0.71
276      find: `  RCTAppSetupPrepareApp(application);`,
277      replaceWith: `
278#if RCT_NEW_ARCH_ENABLED
279  RCTAppSetupPrepareApp(application, YES);
280#else
281  RCTAppSetupPrepareApp(application, NO);
282#endif
283`,
284    },
285  ]);
286
287  // Try to workaround detox hanging on CI
288  await transformFileAsync(path.join(root, 'ios', 'Podfile.properties.json'), [
289    {
290      find: `"expo.jsEngine": "hermes"`,
291      replaceWith: `"expo.jsEngine": "jsc"`,
292    },
293  ]);
294}
295