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
149async function patchReanimatedAsync(nightlyVersion: string) {
150  const root = path.join(EXPO_DIR, 'node_modules', 'react-native-reanimated');
151
152  await transformFileAsync(path.join(root, 'android', 'build.gradle'), [
153    {
154      find: /\$minor/g,
155      replaceWith: '$rnMinorVersion',
156    },
157  ]);
158
159  await transformFileAsync(path.join(root, 'RNReanimated.podspec'), [
160    {
161      find: /^(\s*['"]USE_HEADERMAP['"]\s+=>\s+['"]YES['"],\s*)$/gm,
162      replaceWith: `$1\n    "CLANG_CXX_LANGUAGE_STANDARD" => "c++17",`,
163    },
164  ]);
165}
166
167async function patchDetoxAsync() {
168  const patchFile = path.join(PATCHES_ROOT, 'detox.patch');
169  const patchContent = await fs.readFile(patchFile, 'utf8');
170  await applyPatchAsync({ patchContent, cwd: EXPO_DIR, stripPrefixNum: 1 });
171}
172
173async function patchReactNavigationAsync() {
174  await transformFileAsync(
175    path.join(EXPO_DIR, 'node_modules', '@react-navigation/elements', 'src/Header/Header.tsx'),
176    [
177      {
178        // Weird that the nightlies will break if pass `undefined` to the `transform` prop
179        find: 'style={[{ height, minHeight, maxHeight, opacity, transform }]}',
180        replaceWith:
181          'style={[{ height, minHeight, maxHeight, opacity, transform: transform ?? [] }]}',
182      },
183    ]
184  );
185}
186
187async function patchGestureHandlerAsync() {
188  await transformFileAsync(
189    path.join(
190      EXPO_DIR,
191      'node_modules',
192      'react-native-gesture-handler',
193      'android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt'
194    ),
195    [
196      {
197        find: 'decorateRuntime(jsContext.get())',
198        replaceWith: 'decorateRuntime(jsContext!!.get())',
199      },
200    ]
201  );
202}
203
204async function updateExpoModulesAsync() {
205  // no-op currently
206}
207
208async function updateBareExpoAsync(nightlyVersion: string) {
209  const root = path.join(EXPO_DIR, 'apps', 'bare-expo');
210  await transformFileAsync(path.join(root, 'android', 'settings.gradle'), [
211    {
212      find: /react-native-gradle-plugin/g,
213      replaceWith: '@react-native/gradle-plugin',
214    },
215  ]);
216
217  await transformFileAsync(path.join(root, 'ios', 'Podfile'), [
218    {
219      find: /(platform :ios, )['"]13\.0['"]/g,
220      replaceWith: "$1'13.4'",
221    },
222  ]);
223}
224