1import { Command } from '@expo/commander';
2import JsonFile from '@expo/json-file';
3import spawnAsync from '@expo/spawn-async';
4import assert from 'assert';
5import glob from 'glob-promise';
6import path from 'path';
7
8import { EXPO_DIR } from '../Constants';
9import logger from '../Logger';
10import { getPackageViewAsync } from '../Npm';
11import { transformFileAsync } from '../Transforms';
12import { installAsync as workspaceInstallAsync } from '../Workspace';
13
14export default (program: Command) => {
15  program
16    .command('setup-react-native-nightly')
17    .description('Setup expo/expo monorepo to install react-native nightly build for testing')
18    .asyncAction(main);
19};
20
21async function main() {
22  const nightlyVersion = (await getPackageViewAsync('react-native'))?.['dist-tags'].nightly;
23  if (!nightlyVersion) {
24    throw new Error('Unable to get react-native nightly version.');
25  }
26
27  logger.info('Adding bare-expo optional packages:');
28  await addBareExpoOptionalPackagesAsync();
29
30  logger.info('Adding pinned packages:');
31  const pinnedPackages = {
32    'react-native': nightlyVersion,
33  };
34  await addPinnedPackagesAsync(pinnedPackages);
35
36  logger.info('Yarning...');
37  await workspaceInstallAsync();
38
39  await updateReactNativePackageAsync();
40
41  await patchReanimatedAsync(nightlyVersion);
42  await patchSkiaAsync(nightlyVersion);
43
44  logger.info('Setting up Expo modules files');
45  await updateExpoModulesAsync();
46
47  logger.info('Setting up project files for bare-expo.');
48  await updateBareExpoAsync(nightlyVersion);
49}
50
51/**
52 * To save the CI build time, some third-party libraries are intentionally not listed as dependencies in bare-expo.
53 * Adding these packages for nightly testing to increase coverage.
54 */
55async function addBareExpoOptionalPackagesAsync() {
56  const bareExpoRoot = path.join(EXPO_DIR, 'apps', 'bare-expo');
57  const OPTIONAL_PKGS = ['@shopify/react-native-skia', 'lottie-react-native', 'react-native-maps'];
58
59  const packageJsonNCL = await JsonFile.readAsync(
60    path.join(EXPO_DIR, 'apps', 'native-component-list', 'package.json')
61  );
62  const versionMap = {
63    ...(packageJsonNCL.devDependencies as object),
64    ...(packageJsonNCL.dependencies as object),
65  };
66
67  const installPackages = OPTIONAL_PKGS.map((pkg) => {
68    const version = versionMap[pkg];
69    assert(version);
70    return `${pkg}@${version}`;
71  });
72  for (const pkg of installPackages) {
73    logger.log('  ', pkg);
74  }
75
76  await spawnAsync('yarn', ['add', ...installPackages], { cwd: bareExpoRoot });
77}
78
79async function addPinnedPackagesAsync(packages: Record<string, string>) {
80  const workspacePackageJsonPath = path.join(EXPO_DIR, 'package.json');
81  const json = await JsonFile.readAsync(workspacePackageJsonPath);
82  json.resolutions ||= {};
83  for (const [name, version] of Object.entries(packages)) {
84    logger.log('  ', `${name}@${version}`);
85    json.resolutions[name] = version;
86  }
87  await JsonFile.writeAsync(workspacePackageJsonPath, json);
88}
89
90async function updateReactNativePackageAsync() {
91  // Workaround for react-native-gradle-plugin
92  const gradlePluginRoot = path.join(EXPO_DIR, 'node_modules', 'react-native-gradle-plugin');
93  await transformFileAsync(
94    path.join(gradlePluginRoot, 'src/main/kotlin/com/facebook/react/ReactExtension.kt'),
95    [
96      {
97        find: 'internal val reactNativeDir:',
98        replaceWith: 'val reactNativeDir:',
99      },
100    ]
101  );
102
103  const reactNativeRoot = path.join(EXPO_DIR, 'node_modules', 'react-native');
104  // Workaround duplicated libc++_shared.so from linked fbjni
105  await transformFileAsync(path.join(reactNativeRoot, 'ReactAndroid', 'build.gradle'), [
106    {
107      find: /^(\s*packagingOptions \{)$/gm,
108      replaceWith: '$1\n        pickFirst("**/libc++_shared.so")',
109    },
110  ]);
111}
112
113async function patchReanimatedAsync(nightlyVersion: string) {
114  const root = path.join(EXPO_DIR, 'node_modules', 'react-native-reanimated');
115
116  await transformFileAsync(path.join(root, 'scripts', 'reanimated_utils.rb'), [
117    // Add REACT_NATIVE_OVERRIDE_VERSION support
118    {
119      find: `result[:react_native_version] = react_native_json['version']`,
120      replaceWith: `result[:react_native_version] = ENV["REACT_NATIVE_OVERRIDE_VERSION"] ? ENV["REACT_NATIVE_OVERRIDE_VERSION"] : react_native_json['version']`,
121    },
122    {
123      find: `result[:react_native_minor_version] = react_native_json['version'].split('.')[1].to_i`,
124      replaceWith: `result[:react_native_minor_version] = result[:react_native_version].split('.')[1].to_i`,
125    },
126  ]);
127  await transformFileAsync(path.join(root, 'android', 'build.gradle'), [
128    // Add REACT_NATIVE_OVERRIDE_VERSION support
129    {
130      find: `def REACT_NATIVE_VERSION = reactProperties.getProperty("VERSION_NAME")`,
131      replaceWith: `def REACT_NATIVE_VERSION = System.getenv("REACT_NATIVE_OVERRIDE_VERSION") ?: reactProperties.getProperty("VERSION_NAME")`,
132    },
133    // Workaround $minor is undefined
134    {
135      find: /\$minor/g,
136      replaceWith: '$rnMinorVersion',
137    },
138    // BUILD_FROM_SOURCE
139    {
140      find: /^(boolean BUILD_FROM_SOURCE)\s*=.*/gm,
141      replaceWith: '$1 = true',
142    },
143    // duplicated class from jni, because ReactAndroid now uses fbjni rather than fbjni-java-only
144    {
145      find: 'implementation "com.facebook.fbjni:fbjni-java-only:',
146      replaceWith: 'compileOnly "com.facebook.fbjni:fbjni:',
147    },
148    {
149      // no-op tasks
150      find: /\b(task (prepareHermes).*\{)$/gm,
151      replaceWith: `$1\n    return`,
152    },
153    {
154      // download nightly react-native aar
155      find: /^(task unpackReactNativeAAR \{[\s\S]*?^\})/gm,
156      replaceWith: `
157def reactNativeIsNightly = reactProperties.getProperty("VERSION_NAME").startsWith("0.0.0-")
158
159def downloadReactNativeNightlyAAR = { buildType, version, downloadFile ->
160  def classifier = buildType == 'Debug' ? 'debug' : 'release'
161  download.run {
162    src("https://oss.sonatype.org/service/local/artifact/maven/redirect?r=snapshots&g=com.facebook.react&a=react-native&c=\${classifier}&e=aar&v=\${version}-SNAPSHOT")
163    onlyIfNewer(true)
164    overwrite(false)
165    dest(downloadFile)
166  }
167}
168
169task unpackReactNativeAAR {
170  def buildType = resolveBuildType()
171  def rnAAR
172  if (reactNativeIsNightly) {
173    def downloadFile = file("\${downloadsDir}/react-native-nightly.aar")
174    downloadReactNativeNightlyAAR(buildType, reactProperties.getProperty("VERSION_NAME"), downloadFile)
175    rnAAR = downloadFile
176  } else {
177    def rnAarMatcher = "**/react-native/**/*\${buildType}.aar"
178    if (REACT_NATIVE_MINOR_VERSION < 69) {
179      rnAarMatcher = "**/**/*.aar"
180    }
181    rnAAR = fileTree("$reactNativeRootDir/android").matching({ it.include rnAarMatcher }).singleFile
182  }
183  def file = rnAAR.absoluteFile
184  def packageName = file.name.tokenize('-')[0]
185  copy {
186    from zipTree(file)
187    into "$reactNativeRootDir/ReactAndroid/src/main/jni/first-party/$packageName/"
188    include "jni/**/*.so"
189  }
190}
191      `,
192    },
193    {
194      // add prefab support, setup task dependencies and hermes-engine dependencies
195      transform: (text: string) =>
196        text +
197        '\n\n' +
198        `android {\n` +
199        `  buildFeatures {\n` +
200        `    prefab true\n` +
201        `  }\n` +
202        `}\n` +
203        `\n` +
204        `dependencies {\n` +
205        `  compileOnly "com.facebook.react:hermes-android:${nightlyVersion}-SNAPSHOT"\n` +
206        `}\n`,
207    },
208  ]);
209
210  await transformFileAsync(path.join(root, 'android', 'CMakeLists.txt'), [
211    {
212      // Remove this after reanimated support react-native 0.71
213      find: /(\s*"\$\{REACT_NATIVE_DIR\}\/ReactAndroid\/src\/main\/jni")/g,
214      replaceWith: '$1\n        "${REACT_NATIVE_DIR}/ReactAndroid/src/main/jni/react/turbomodule"',
215    },
216    {
217      // find hermes from prefab
218      find: /(string\(APPEND CMAKE_CXX_FLAGS " -DJS_RUNTIME_HERMES=1"\))/g,
219      replaceWith: `find_package(hermes-engine REQUIRED CONFIG)\n    $1`,
220    },
221    {
222      // find hermes from prefab
223      find: /"\$\{BUILD_DIR\}\/.+\/libhermes\.so"/g,
224      replaceWith: `hermes-engine::libhermes`,
225    },
226  ]);
227
228  // Workaround for UIImplementationProvider breaking change, that would break reanimated layout animation somehow
229  await transformFileAsync(
230    path.join(
231      root,
232      'android/src/main/java/com/swmansion/reanimated/layoutReanimation/ReanimatedUIManager.java'
233    ),
234    [
235      {
236        find: /^class ReaUiImplementationProvider extends UIImplementationProvider \{[\s\S]*?^\}/gm,
237        replaceWith: '',
238      },
239      {
240        find: `new ReaUiImplementationProvider(),`,
241        replaceWith: '',
242      },
243    ]
244  );
245}
246
247async function patchSkiaAsync(nightlyVersion: string) {
248  const root = path.join(EXPO_DIR, 'node_modules', '@shopify', 'react-native-skia');
249
250  await transformFileAsync(path.join(root, 'android', 'build.gradle'), [
251    {
252      // Add REACT_NATIVE_OVERRIDE_VERSION support
253      find: `def REACT_NATIVE_VERSION = reactProperties.getProperty("VERSION_NAME").split("\\.")[1].toInteger()`,
254      replaceWith: `def REACT_NATIVE_VERSION = (System.getenv("REACT_NATIVE_OVERRIDE_VERSION") ?: reactProperties.getProperty("VERSION_NAME")).split("\\.")[1].toInteger()`,
255    },
256    {
257      // Remove builtin aar extraction from react-native node_modules
258      find: `defaultDir = file("$nodeModules/react-native/android")`,
259      replaceWith: `defaultDir = file("$nodeModules/react-native")`,
260    },
261    {
262      // Remove builtin aar extraction from react-native node_modules
263      find: /^\s*def rnAAR.*\n\s*extractJNI.*$/gm,
264      replaceWith: '',
265    },
266    {
267      // Add prefab support
268      transform: (text: string) =>
269        text +
270        '\n\n' +
271        `android {\n` +
272        `  buildFeatures {\n` +
273        `    prefab true\n` +
274        `  }\n` +
275        `}\n`,
276    },
277  ]);
278
279  await transformFileAsync(path.join(root, 'android', 'CMakeLists.txt'), [
280    {
281      find: /^(\s*target_link_libraries\(\s*)$/gm,
282      replaceWith: `\
283find_package(fbjni REQUIRED CONFIG)
284find_package(ReactAndroid REQUIRED CONFIG)
285$1`,
286    },
287    {
288      find: '${FBJNI_LIBRARY}',
289      replaceWith: 'fbjni::fbjni',
290    },
291    {
292      find: '${REACT_LIB}',
293      replaceWith: 'ReactAndroid::react_nativemodule_core',
294    },
295    {
296      find: '${JSI_LIB}',
297      replaceWith: 'ReactAndroid::jsi',
298    },
299    {
300      find: '${TURBOMODULES_LIB}',
301      replaceWith: 'ReactAndroid::turbomodulejsijni',
302    },
303  ]);
304}
305
306async function updateExpoModulesAsync() {
307  const gradleFiles = await glob('packages/**/build.gradle', { cwd: EXPO_DIR });
308  await Promise.all(
309    gradleFiles.map((file) =>
310      transformFileAsync(file, [
311        {
312          find: /\b(com.facebook.fbjni:fbjni):0\.2\.2/g,
313          replaceWith: '$1:0.3.0',
314        },
315      ])
316    )
317  );
318}
319
320async function updateBareExpoAsync(nightlyVersion: string) {
321  const root = path.join(EXPO_DIR, 'apps', 'bare-expo');
322  await transformFileAsync(path.join(root, 'android', 'build.gradle'), [
323    {
324      find: 'resolutionStrategy.force "com.facebook.react:react-native:${reactNativeVersion}"',
325      replaceWith: `resolutionStrategy.dependencySubstitution {
326                    substitute module("com.facebook.react:react-native") using module("com.facebook.react:react-android:${nightlyVersion}-SNAPSHOT")
327                    substitute module("com.facebook.react:hermes-engine") using module("com.facebook.react:hermes-android:${nightlyVersion}-SNAPSHOT")
328            }`,
329    },
330  ]);
331
332  await transformFileAsync(path.join(root, 'ios', 'BareExpo', 'AppDelegate.mm'), [
333    {
334      // Remove this when we upgrade bare-expo to 0.71
335      find: `  RCTAppSetupPrepareApp(application);`,
336      replaceWith: `
337#if RCT_NEW_ARCH_ENABLED
338  RCTAppSetupPrepareApp(application, YES);
339#else
340  RCTAppSetupPrepareApp(application, NO);
341#endif
342`,
343    },
344  ]);
345}
346