Compare commits

...
Author SHA1 Message Date
AbbanMustafa 0abc4f1554 code cov 2026-06-01 18:51:04 -04:00
AbbanMustafa 5ea4ac4c39 [build-tools] - Resolve Expo transitive dependencies during builds 2026-06-01 18:24:49 -04:00
3 changed files with 268 additions and 1 deletions

No files matched your search

@@ -0,0 +1,166 @@
import { Env } from '@expo/eas-build-job';
import path from 'path';
import resolveFrom from 'resolve-from';
import { createMockLogger } from '../../__tests__/utils/logger';
import { configureExpoTransitiveDependenciesNodePathAsync } from '../expoTransitiveDependenciesNodePath';
jest.mock('resolve-from', () => ({
__esModule: true,
default: Object.assign(jest.fn(), {
silent: jest.fn(),
}),
}));
const mockedResolveFrom = resolveFrom as jest.MockedFunction<typeof resolveFrom> & {
silent: jest.MockedFunction<typeof resolveFrom.silent>;
};
describe(configureExpoTransitiveDependenciesNodePathAsync, () => {
beforeEach(() => {
mockedResolveFrom.silent.mockReset();
});
it('extends NODE_PATH with Expo transitive dependency locations', async () => {
const projectDir = '/workingdir/build/apps/app';
const packagerDir = '/workingdir/build';
const expoPackageDir = '/workingdir/build/node_modules/.pnpm/expo@52/node_modules/expo';
const expoNodeModulesDir = path.join(expoPackageDir, 'node_modules');
const env: Env = { NODE_PATH: '/custom/node_modules' };
const logger = createMockLogger();
mockResolveFrom({
[resolutionKey(projectDir, 'expo/package.json')]: path.join(expoPackageDir, 'package.json'),
[resolutionKey(expoPackageDir, 'babel-preset-expo/package.json')]: path.join(
expoNodeModulesDir,
'babel-preset-expo',
'package.json'
),
[resolutionKey(expoPackageDir, 'expo-asset/package.json')]: path.join(
expoNodeModulesDir,
'expo-asset',
'package.json'
),
});
await configureExpoTransitiveDependenciesNodePathAsync({
projectDir,
packagerDir,
env,
logger,
});
expect(env.NODE_PATH).toBe(['/custom/node_modules', expoNodeModulesDir].join(path.delimiter));
expect(logger.info).toHaveBeenCalledWith(
`Extending NODE_PATH with Expo dependency paths: ${expoNodeModulesDir}`
);
});
it('does not update NODE_PATH when expo is not installed', async () => {
const env: Env = { NODE_PATH: '/custom/node_modules' };
const logger = createMockLogger();
await configureExpoTransitiveDependenciesNodePathAsync({
projectDir: '/workingdir/build',
packagerDir: '/workingdir/build',
env,
logger,
});
expect(env.NODE_PATH).toBe('/custom/node_modules');
expect(logger.info).not.toHaveBeenCalled();
});
it('does not update NODE_PATH when transitive dependencies already resolve from the project', async () => {
const projectDir = '/workingdir/build';
const expoPackageDir = '/workingdir/build/node_modules/expo';
const env: Env = {};
const logger = createMockLogger();
mockResolveFrom({
[resolutionKey(projectDir, 'expo/package.json')]: path.join(expoPackageDir, 'package.json'),
[resolutionKey(projectDir, 'babel-preset-expo/package.json')]:
'/workingdir/build/node_modules/babel-preset-expo/package.json',
[resolutionKey(projectDir, 'expo-asset/package.json')]:
'/workingdir/build/node_modules/expo-asset/package.json',
});
await configureExpoTransitiveDependenciesNodePathAsync({
projectDir,
packagerDir: projectDir,
env,
logger,
});
expect(env.NODE_PATH).toBeUndefined();
expect(logger.info).not.toHaveBeenCalled();
});
it('skips transitive dependencies that do not resolve from expo', async () => {
const projectDir = '/workingdir/build';
const expoPackageDir = '/workingdir/build/node_modules/.pnpm/expo@52/node_modules/expo';
const expoNodeModulesDir = path.join(expoPackageDir, 'node_modules');
const env: Env = {};
const logger = createMockLogger();
mockResolveFrom({
[resolutionKey(projectDir, 'expo/package.json')]: path.join(expoPackageDir, 'package.json'),
[resolutionKey(expoPackageDir, 'babel-preset-expo/package.json')]: path.join(
expoNodeModulesDir,
'babel-preset-expo',
'package.json'
),
});
await configureExpoTransitiveDependenciesNodePathAsync({
projectDir,
packagerDir: projectDir,
env,
logger,
});
expect(env.NODE_PATH).toBe(expoNodeModulesDir);
});
it('does not duplicate existing NODE_PATH entries', async () => {
const projectDir = '/workingdir/build';
const expoPackageDir = '/workingdir/build/node_modules/.pnpm/expo@52/node_modules/expo';
const expoNodeModulesDir = path.join(expoPackageDir, 'node_modules');
const env: Env = { NODE_PATH: expoNodeModulesDir };
const logger = createMockLogger();
mockResolveFrom({
[resolutionKey(projectDir, 'expo/package.json')]: path.join(expoPackageDir, 'package.json'),
[resolutionKey(expoPackageDir, 'babel-preset-expo/package.json')]: path.join(
expoNodeModulesDir,
'babel-preset-expo',
'package.json'
),
[resolutionKey(expoPackageDir, 'expo-asset/package.json')]: path.join(
expoNodeModulesDir,
'expo-asset',
'package.json'
),
});
await configureExpoTransitiveDependenciesNodePathAsync({
projectDir,
packagerDir: projectDir,
env,
logger,
});
expect(env.NODE_PATH).toBe(expoNodeModulesDir);
expect(logger.info).not.toHaveBeenCalled();
});
});
function mockResolveFrom(resolutions: Record<string, string>): void {
mockedResolveFrom.silent.mockImplementation((fromDir, moduleId) => {
return resolutions[resolutionKey(fromDir, moduleId)];
});
}
function resolutionKey(fromDir: string, moduleId: string): string {
return `${fromDir}:${moduleId}`;
}
@@ -0,0 +1,90 @@
import { Env } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import fs from 'fs-extra';
import path from 'path';
import resolveFrom from 'resolve-from';
const EXPO_TRANSITIVE_DEPENDENCIES_TO_RESOLVE = ['babel-preset-expo', 'expo-asset'];
export async function configureExpoTransitiveDependenciesNodePathAsync({
projectDir,
packagerDir,
env,
logger,
}: {
projectDir: string;
packagerDir: string;
env: Env;
logger: bunyan;
}): Promise<void> {
const projectResolutionRoots = uniqueItems([projectDir, packagerDir]);
const expoPackageJsonPath = resolvePackageJson(projectResolutionRoots, 'expo');
if (!expoPackageJsonPath) {
return;
}
const expoResolutionRoots = await getPackageResolutionRootsAsync(expoPackageJsonPath);
const nodePathEntriesToAdd = new Set<string>();
for (const packageName of EXPO_TRANSITIVE_DEPENDENCIES_TO_RESOLVE) {
if (resolvePackageJson(projectResolutionRoots, packageName)) {
continue;
}
const packageJsonPath = resolvePackageJson(expoResolutionRoots, packageName);
if (!packageJsonPath) {
continue;
}
nodePathEntriesToAdd.add(getNodeModulesDirForPackage(packageJsonPath));
}
if (nodePathEntriesToAdd.size === 0) {
return;
}
const existingNodePathEntries = splitNodePath(env.NODE_PATH);
const addedNodePathEntries = [...nodePathEntriesToAdd].filter(
nodePathEntry => !existingNodePathEntries.includes(nodePathEntry)
);
if (addedNodePathEntries.length === 0) {
return;
}
env.NODE_PATH = [...existingNodePathEntries, ...addedNodePathEntries].join(path.delimiter);
logger.info(
`Extending NODE_PATH with Expo dependency paths: ${addedNodePathEntries.join(path.delimiter)}`
);
}
function resolvePackageJson(fromDirs: string[], packageName: string): string | null {
for (const fromDir of fromDirs) {
const packageJsonPath = resolveFrom.silent(fromDir, `${packageName}/package.json`);
if (packageJsonPath) {
return packageJsonPath;
}
}
return null;
}
async function getPackageResolutionRootsAsync(packageJsonPath: string): Promise<string[]> {
const packageDir = path.dirname(packageJsonPath);
try {
return uniqueItems([packageDir, await fs.realpath(packageDir)]);
} catch {
return [packageDir];
}
}
function getNodeModulesDirForPackage(packageJsonPath: string): string {
const packageDir = path.dirname(packageJsonPath);
return path.dirname(packageDir);
}
function splitNodePath(nodePath: string | undefined): string[] {
return nodePath?.split(path.delimiter).filter(Boolean) ?? [];
}
function uniqueItems<T>(items: T[]): T[] {
return [...new Set(items)];
}
+12 -1
View File
@@ -14,6 +14,7 @@ import nullthrows from 'nullthrows';
import path from 'path';
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from './easBuildInternal';
import { configureExpoTransitiveDependenciesNodePathAsync } from './expoTransitiveDependenciesNodePath';
import { installDependenciesAsync, resolvePackagerDir } from './installDependencies';
import { prepareProjectSourcesAsync } from './projectSources';
import { BuildContext } from '../context';
@@ -86,6 +87,7 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
});
await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
const packagerDir = resolvePackagerDir(ctx);
const expoVersion =
ctx.metadata?.sdkVersion ??
getPackageVersionFromPackageJson({
@@ -101,12 +103,19 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
});
await runInstallDependenciesAsync(ctx, {
cwd: packagerDir,
useFrozenLockfile: shouldUseFrozenLockfile({
env: ctx.env,
sdkVersion: expoVersion,
reactNativeVersion,
}),
});
await configureExpoTransitiveDependenciesNodePathAsync({
projectDir: ctx.getReactNativeProjectDirectory(),
packagerDir,
env: ctx.env,
logger: ctx.logger,
});
});
await ctx.runBuildPhase(BuildPhase.READ_APP_CONFIG, async () => {
@@ -196,8 +205,10 @@ async function runExpoDoctor<TJob extends Job>(ctx: BuildContext<TJob>): Promise
async function runInstallDependenciesAsync<TJob extends Job>(
ctx: BuildContext<TJob>,
{
cwd,
useFrozenLockfile,
}: {
cwd: string;
useFrozenLockfile: boolean;
}
): Promise<void> {
@@ -218,7 +229,7 @@ async function runInstallDependenciesAsync<TJob extends Job>(
killTimeout.refresh();
}
},
cwd: resolvePackagerDir(ctx),
cwd,
useFrozenLockfile,
})
).spawnPromise;