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