expo-modules-autolinking
Version:
Scripts that autolink Expo modules.
251 lines (229 loc) • 8.76 kB
text/typescript
import fs from 'fs';
import path from 'path';
import type { SupportedPlatform } from '../types';
import {
findGradleAndManifestAsync,
parsePackageNameAsync,
resolveDependencyConfigImplAndroidAsync,
} from './androidResolver';
import { loadConfigAsync } from './config';
import { resolveDependencyConfigImplIosAsync } from './iosResolver';
import type {
RNConfigDependency,
RNConfigDependencyAndroid,
RNConfigDependencyIos,
RNConfigDependencyWeb,
RNConfigReactNativeAppProjectConfig,
RNConfigReactNativeLibraryConfig,
RNConfigReactNativeProjectConfig,
RNConfigResult,
} from './reactNativeConfig.types';
import type { ExpoModuleConfig } from '../ExpoModuleConfig';
import { discoverExpoModuleConfigAsync } from '../ExpoModuleConfig';
import type { AutolinkingOptions } from '../commands/autolinkingOptions';
import type { DependencyResolution } from '../dependencies';
import {
filterMapResolutionResult,
mergeResolutionResults,
scanDependenciesFromRNProjectConfig,
scanDependenciesInSearchPath,
scanDependenciesRecursively,
} from '../dependencies';
import { checkDependencyWebAsync } from './webResolver';
const deepObjectMerge = (target: any, source: any): any => {
if (
source !== undefined &&
typeof target === 'object' &&
target != null &&
!Array.isArray(target) &&
(!target.constructor || target.constructor === Object) &&
typeof source === 'object' &&
!Array.isArray(source)
) {
target = { ...target };
for (const key in source) {
target[key] = deepObjectMerge(target[key], source[key]);
}
return target;
}
return source !== undefined ? source : target;
};
const isMissingFBReactNativeSpecCodegenOutput = async (reactNativePath: string) => {
const generatedDir = path.resolve(reactNativePath, 'React/FBReactNativeSpec');
try {
const stat = await fs.promises.lstat(generatedDir);
return !stat.isDirectory();
} catch {
return true;
}
};
export async function resolveReactNativeModule(
resolution: DependencyResolution,
projectConfig: RNConfigReactNativeProjectConfig | null,
platform: SupportedPlatform,
excludeNames: Set<string>
): Promise<RNConfigDependency | null> {
if (excludeNames.has(resolution.name)) {
return null;
} else if (resolution.name === 'react-native' || resolution.name === 'react-native-macos') {
// Starting from version 0.76, the `react-native` package only defines platforms
// when @react-native-community/cli-platform-android/ios is installed.
// Therefore, we need to manually filter it out.
// NOTE(@kitten): `loadConfigAsync` is skipped too, because react-native's config is too slow
return null;
}
// Workaround for Android Gradle/Prefab issue with special characters in paths.
// pnpm creates virtual store paths with '=' characters (e.g., _patch_hash=abc123),
// which cause build failures on Android due to Prefab not properly escaping them.
// See: https://github.com/google/prefab/issues/187
const shouldUseOriginPath =
platform === 'android' && resolution.path.includes('=') && resolution.path.includes('.pnpm');
const modulePath = shouldUseOriginPath ? resolution.originPath : resolution.path;
const libraryConfig = (await loadConfigAsync(modulePath)) as RNConfigReactNativeLibraryConfig;
if (Object.keys(libraryConfig?.platforms ?? {}).length > 0) {
// Package defines platforms would be a platform host package.
// The rnc-cli will skip this package.
return null;
}
let reactNativeConfig = libraryConfig?.dependency ?? {};
const projectDependencyOverride = projectConfig?.dependencies?.[resolution.name];
if (projectDependencyOverride != null) {
reactNativeConfig = deepObjectMerge(reactNativeConfig, projectDependencyOverride);
}
let maybeExpoModuleConfig: ExpoModuleConfig | null | undefined;
if (!libraryConfig) {
// NOTE(@kitten): If we don't have an explicit react-native.config.{js,ts} file,
// we should pass the Expo Module config (if it exists) to the resolvers below,
// which can then decide if the React Native inferred config and Expo Module
// configs conflict
try {
maybeExpoModuleConfig = await discoverExpoModuleConfigAsync(resolution.path);
} catch {
// We ignore invalid Expo Modules for the purpose of auto-linking and
// pretend the config doesn't exist, if it isn't valid JSON
}
}
let platformData:
| RNConfigDependencyAndroid
| RNConfigDependencyIos
| RNConfigDependencyWeb
| null = null;
if (platform === 'android') {
platformData = await resolveDependencyConfigImplAndroidAsync(
modulePath,
reactNativeConfig.platforms?.android,
maybeExpoModuleConfig
);
} else if (platform === 'ios') {
platformData = await resolveDependencyConfigImplIosAsync(
resolution,
reactNativeConfig.platforms?.ios,
maybeExpoModuleConfig
);
} else if (platform === 'web') {
platformData = await checkDependencyWebAsync(
resolution,
reactNativeConfig,
maybeExpoModuleConfig
);
}
return (
platformData && {
root: modulePath,
name: resolution.name,
platforms: {
[platform]: platformData,
},
}
);
}
interface CreateRNConfigParams {
appRoot: string;
sourceDir: string | undefined;
autolinkingOptions: AutolinkingOptions & { platform: SupportedPlatform };
}
/**
* Create config for react-native core autolinking.
*/
export async function createReactNativeConfigAsync({
appRoot,
sourceDir,
autolinkingOptions,
}: CreateRNConfigParams): Promise<RNConfigResult> {
const excludeNames = new Set(autolinkingOptions.exclude);
const projectConfig = (await loadConfigAsync(appRoot)) as RNConfigReactNativeProjectConfig;
// custom native modules should be resolved first so that they can override other modules
const searchPaths = autolinkingOptions.nativeModulesDir
? [autolinkingOptions.nativeModulesDir, ...autolinkingOptions.searchPaths]
: autolinkingOptions.searchPaths;
const limitDepth = autolinkingOptions.legacy_shallowReactNativeLinking ? 1 : undefined;
const resolutions = mergeResolutionResults(
await Promise.all([
scanDependenciesFromRNProjectConfig(appRoot, projectConfig),
...searchPaths.map((searchPath) => scanDependenciesInSearchPath(searchPath)),
scanDependenciesRecursively(appRoot, { limitDepth }),
])
);
const dependencies = await filterMapResolutionResult(resolutions, (resolution) =>
resolveReactNativeModule(resolution, projectConfig, autolinkingOptions.platform, excludeNames)
);
// See: https://github.com/facebook/react-native/pull/53690
// When we're building react-native from source without these generated files, we need to force them to be generated
// Every published react-native version (or out-of-tree version) should have these files, but building from the raw repo won't (e.g. Expo Go)
const reactNativeResolution = resolutions['react-native'];
if (
reactNativeResolution &&
autolinkingOptions.platform === 'ios' &&
(await isMissingFBReactNativeSpecCodegenOutput(reactNativeResolution.path))
) {
dependencies['react-native'] = {
root: reactNativeResolution.path,
name: 'react-native',
platforms: {
ios: {
// This will trigger a warning in list_native_modules but will trigger the artifacts
// codegen codepath as expected
podspecPath: '',
version: reactNativeResolution.version,
configurations: [],
scriptPhases: [],
},
},
};
}
return {
root: appRoot,
reactNativePath: resolutions['react-native']?.path!,
dependencies,
project: await resolveAppProjectConfigAsync(appRoot, autolinkingOptions.platform, sourceDir),
};
}
export async function resolveAppProjectConfigAsync(
projectRoot: string,
platform: SupportedPlatform,
sourceDir?: string
): Promise<RNConfigReactNativeAppProjectConfig> {
// TODO(@kitten): use the commandRoot here to find these files in non <projectRoot>/<platform> folders
if (platform === 'android') {
const androidDir = sourceDir ?? path.join(projectRoot, 'android');
const { gradle, manifest } = await findGradleAndManifestAsync({ androidDir, isLibrary: false });
if (gradle == null || manifest == null) {
return {};
}
const packageName = await parsePackageNameAsync(manifest, gradle);
return {
android: {
packageName: packageName ?? '',
sourceDir: sourceDir ?? path.join(projectRoot, 'android'),
},
};
}
if (platform === 'ios') {
return {
ios: {
sourceDir: sourceDir ?? path.join(projectRoot, 'ios'),
},
};
}
return {};
}