Skip to content

Commit

Permalink
MultiRoot Workspace support (#663)
Browse files Browse the repository at this point in the history
1) This PR refactors how we handle the `appRoot` to allow for changing
it in runtime. To make sure that the app root is unified across all the
components we now store it in `project.ts` and move all objects
dependent on `appRoot` to project as well. We listen for
launchConfiguration changes and restart everything dependent on appRoot
after it changes.

2) We refactor how appRoot is detected: 
after analyzing the old approach that was using `workspace.findFiles` we
realized that on more elaborate setups it took almost 2 seconds to
traverse all of the workspace directories. To combat that we introduce a
new `findAppRootCandidates` implementation that takes only a couple of
ms in most setups.
*note*: the new function is not perfectly equivalent to the old one as
it removes a check if `node_modules` contains `react-native` it is done
on purpose as it has a following drawbacks:
- generetes false positives as some not applications directories (e.g.
libries) might contain `react-native` in its node modules,
- mono repositories might generate false positives at top level
depending on the implementation
- it requires searching through `node_modules` which are usually quite
large
- it is not even that useful, because it requires `node_modules` to be
installed and first time users could encounter problems if we relied on
this check.

3) Additionally we enhance the Lunch Configuration View to allow the
user for selecting the appRoot from the list.

### How has dis been tested? 
- Run the mono repo with 2 application and switch between them.

- Run `react-native-svg` repo and select test apps in it 
- Run `react-native-reanimated` repo and select test apps in it,
additionally check if cashes are stored properly after switching between
applications.
- To benchmark the `findAppRootCandidates` I added the following code to
at the end of the `activate` function in the extension:

```js
const start = performance.now();

  await findAppRootFolder() 

  const end = performance.now()

  Logger.debug("My benchmark result:", end-start);
```

note: that the benchmark make sens only if you don't have appRoot set in
launchConfiguration.

Results for `react-native-reanimated` repository:

| old    | new |
| -------- | ------- |
| 1479.05  ms| 1.27  ms |
  • Loading branch information
filip131311 authored Feb 28, 2025
1 parent 12ed4f2 commit 9b4ab57
Show file tree
Hide file tree
Showing 27 changed files with 811 additions and 396 deletions.
48 changes: 25 additions & 23 deletions packages/vscode-extension/src/builders/BuildCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import path from "path";
import fs from "fs";
import { createFingerprintAsync } from "@expo/fingerprint";
import { Logger } from "../Logger";
import { extensionContext, getAppRootFolder } from "../utilities/extensionContext";
import { extensionContext } from "../utilities/extensionContext";
import { DevicePlatform } from "../common/DeviceManager";
import { IOSBuildResult } from "./buildIOS";
import { AndroidBuildResult } from "./buildAndroid";
Expand Down Expand Up @@ -37,30 +37,31 @@ function makeCacheKey(platform: DevicePlatform, appRoot: string) {
}

export class BuildCache {
private readonly cacheKey: string;

constructor(private readonly platform: DevicePlatform, private readonly appRoot: string) {
this.cacheKey = makeCacheKey(platform, appRoot);
}
constructor(private readonly appRootFolder: string) {}

/**
* Passed fingerprint should be calculated at the time build is started.
*/
public async storeBuild(buildFingerprint: string, build: BuildResult) {
const appPath = await getAppHash(getAppPath(build));
await extensionContext.globalState.update(this.cacheKey, {
await extensionContext.globalState.update(makeCacheKey(build.platform, this.appRootFolder), {
fingerprint: buildFingerprint,
buildHash: appPath,
buildResult: build,
});
}

public async clearCache() {
await extensionContext.globalState.update(this.cacheKey, undefined);
public async clearCache(platform: DevicePlatform) {
await extensionContext.globalState.update(
makeCacheKey(platform, this.appRootFolder),
undefined
);
}

public async getBuild(currentFingerprint: string) {
const cache = extensionContext.globalState.get<BuildCacheInfo>(this.cacheKey);
public async getBuild(currentFingerprint: string, platform: DevicePlatform) {
const cache = extensionContext.globalState.get<BuildCacheInfo>(
makeCacheKey(platform, this.appRootFolder)
);
if (!cache) {
Logger.debug("No cached build found.");
return undefined;
Expand Down Expand Up @@ -96,45 +97,48 @@ export class BuildCache {
}
}

public async isCacheStale() {
const currentFingerprint = await this.calculateFingerprint();
const { fingerprint } = extensionContext.globalState.get<BuildCacheInfo>(this.cacheKey) ?? {};
public async isCacheStale(platform: DevicePlatform) {
const currentFingerprint = await this.calculateFingerprint(platform);
const { fingerprint } =
extensionContext.globalState.get<BuildCacheInfo>(
makeCacheKey(platform, this.appRootFolder)
) ?? {};

return currentFingerprint !== fingerprint;
}

public async calculateFingerprint() {
public async calculateFingerprint(platform: DevicePlatform) {
Logger.debug("Calculating fingerprint");
const customFingerprint = await this.calculateCustomFingerprint();
const customFingerprint = await this.calculateCustomFingerprint(platform);

if (customFingerprint) {
Logger.debug("Using custom fingerprint", customFingerprint);
return customFingerprint;
}

const fingerprint = await createFingerprintAsync(getAppRootFolder(), {
const fingerprint = await createFingerprintAsync(this.appRootFolder, {
ignorePaths: IGNORE_PATHS,
});
Logger.debug("App folder fingerprint", fingerprint.hash);
return fingerprint.hash;
}

private async calculateCustomFingerprint() {
private async calculateCustomFingerprint(platform: DevicePlatform) {
const { customBuild, env } = getLaunchConfiguration();
const configPlatform = (
{
[DevicePlatform.Android]: "android",
[DevicePlatform.IOS]: "ios",
} as const
)[this.platform];
)[platform];
const fingerprintCommand = customBuild?.[configPlatform]?.fingerprintCommand;

if (!fingerprintCommand) {
return undefined;
}

Logger.debug(`Using custom fingerprint script '${fingerprintCommand}'`);
const fingerprint = await runfingerprintCommand(fingerprintCommand, env);
const fingerprint = await runfingerprintCommand(fingerprintCommand, env, this.appRootFolder);

if (!fingerprint) {
throw new Error("Failed to generate application fingerprint using custom script.");
Expand All @@ -153,10 +157,8 @@ async function getAppHash(appPath: string) {
return (await calculateMD5(appPath)).digest("hex");
}

export async function migrateOldBuildCachesToNewStorage() {
export async function migrateOldBuildCachesToNewStorage(appRoot: string) {
try {
const appRoot = getAppRootFolder();

for (const platform of [DevicePlatform.Android, DevicePlatform.IOS]) {
const oldKey =
platform === DevicePlatform.Android ? ANDROID_BUILD_CACHE_KEY : IOS_BUILD_CACHE_KEY;
Expand Down
16 changes: 8 additions & 8 deletions packages/vscode-extension/src/builders/BuildManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { BuildCache } from "./BuildCache";
import { AndroidBuildResult, buildAndroid } from "./buildAndroid";
import { IOSBuildResult, buildIos } from "./buildIOS";
import { DeviceInfo, DevicePlatform } from "../common/DeviceManager";
import { getAppRootFolder } from "../utilities/extensionContext";
import { DependencyManager } from "../dependency/DependencyManager";
import { CancelToken } from "./cancelToken";
import { getTelemetryReporter } from "../utilities/telemetry";
Expand All @@ -16,6 +15,7 @@ export interface DisposableBuild<R> extends Disposable {
}

type BuildOptions = {
appRoot: string;
clean: boolean;
progressListener: (newProgress: number) => void;
onSuccess: () => void;
Expand Down Expand Up @@ -47,7 +47,7 @@ export class BuildManager {
}

public startBuild(deviceInfo: DeviceInfo, options: BuildOptions): DisposableBuild<BuildResult> {
const { clean: forceCleanBuild, progressListener, onSuccess } = options;
const { clean: forceCleanBuild, progressListener, onSuccess, appRoot } = options;
const { platform } = deviceInfo;

getTelemetryReporter().sendTelemetryEvent("build:requested", {
Expand All @@ -58,7 +58,7 @@ export class BuildManager {
const cancelToken = new CancelToken();

const buildApp = async () => {
const currentFingerprint = await this.buildCache.calculateFingerprint();
const currentFingerprint = await this.buildCache.calculateFingerprint(platform);

// Native build dependencies when changed, should invalidate cached build (even if the fingerprint is the same)
const buildDependenciesChanged = await this.checkBuildDependenciesChanged(deviceInfo);
Expand All @@ -70,9 +70,9 @@ export class BuildManager {
"Build cache is being invalidated",
forceCleanBuild ? "on request" : "due to build dependencies change"
);
await this.buildCache.clearCache();
await this.buildCache.clearCache(platform);
} else {
const cachedBuild = await this.buildCache.getBuild(currentFingerprint);
const cachedBuild = await this.buildCache.getBuild(currentFingerprint, platform);
if (cachedBuild) {
Logger.debug("Skipping native build – using cached");
getTelemetryReporter().sendTelemetryEvent("build:cache-hit", { platform });
Expand All @@ -95,7 +95,7 @@ export class BuildManager {
});
this.buildOutputChannel.clear();
buildResult = await buildAndroid(
getAppRootFolder(),
appRoot,
forceCleanBuild,
cancelToken,
this.buildOutputChannel,
Expand Down Expand Up @@ -124,11 +124,11 @@ export class BuildManager {
await this.dependencyManager.installPods(iOSBuildOutputChannel, cancelToken);
// Installing pods may impact the fingerprint as new pods may be created under the project directory.
// For this reason we need to recalculate the fingerprint after installing pods.
buildFingerprint = await this.buildCache.calculateFingerprint();
buildFingerprint = await this.buildCache.calculateFingerprint(platform);
}
};
buildResult = await buildIos(
getAppRootFolder(),
appRoot,
forceCleanBuild,
cancelToken,
this.buildOutputChannel,
Expand Down
17 changes: 9 additions & 8 deletions packages/vscode-extension/src/builders/buildAndroid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ function makeBuildTaskName(productFlavor: string, buildType: string) {
}

export async function buildAndroid(
appRootFolder: string,
appRoot: string,
forceCleanBuild: boolean,
cancelToken: CancelToken,
outputChannel: OutputChannel,
Expand All @@ -97,7 +97,8 @@ export async function buildAndroid(
cancelToken,
customBuild.android.buildCommand,
env,
DevicePlatform.Android
DevicePlatform.Android,
appRoot
);
if (!apkPath) {
throw new Error("Failed to build Android app using custom script.");
Expand All @@ -114,7 +115,7 @@ export async function buildAndroid(
getTelemetryReporter().sendTelemetryEvent("build:eas-build-requested", {
platform: DevicePlatform.Android,
});
const apkPath = await fetchEasBuild(cancelToken, eas.android, DevicePlatform.Android);
const apkPath = await fetchEasBuild(cancelToken, eas.android, DevicePlatform.Android, appRoot);
if (!apkPath) {
throw new Error("Failed to build Android app using EAS build.");
}
Expand All @@ -126,11 +127,11 @@ export async function buildAndroid(
};
}

if (await isExpoGoProject()) {
if (await isExpoGoProject(appRoot)) {
getTelemetryReporter().sendTelemetryEvent("build:expo-go-requested", {
platform: DevicePlatform.Android,
});
const apkPath = await downloadExpoGo(DevicePlatform.Android, cancelToken);
const apkPath = await downloadExpoGo(DevicePlatform.Android, cancelToken, appRoot);
return { apkPath, packageName: EXPO_GO_PACKAGE_NAME, platform: DevicePlatform.Android };
}

Expand All @@ -140,7 +141,7 @@ export async function buildAndroid(
);
}

const androidSourceDir = getAndroidSourceDir(appRootFolder);
const androidSourceDir = getAndroidSourceDir(appRoot);
const productFlavor = android?.productFlavor || "";
const buildType = android?.buildType || "debug";
const gradleArgs = [
Expand All @@ -158,7 +159,7 @@ export async function buildAndroid(
),
];
// configureReactNativeOverrides init script is only necessary for RN versions older then 0.74.0 see comments in configureReactNativeOverrides.gradle for more details
if (semver.lt(getReactNativeVersion(), "0.74.0")) {
if (semver.lt(getReactNativeVersion(appRoot), "0.74.0")) {
gradleArgs.push(
"--init-script", // configureReactNativeOverrides init script is used to patch React Android project, see comments in configureReactNativeOverrides.gradle for more details
path.join(
Expand Down Expand Up @@ -186,6 +187,6 @@ export async function buildAndroid(

await buildProcess;
Logger.debug("Android build successful");
const apkInfo = await getAndroidBuildPaths(appRootFolder, cancelToken, productFlavor, buildType);
const apkInfo = await getAndroidBuildPaths(appRoot, cancelToken, productFlavor, buildType);
return { ...apkInfo, platform: DevicePlatform.Android };
}
15 changes: 8 additions & 7 deletions packages/vscode-extension/src/builders/buildIOS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ function buildProject(
}

export async function buildIos(
appRootFolder: string,
appRoot: string,
forceCleanBuild: boolean,
cancelToken: CancelToken,
outputChannel: OutputChannel,
Expand All @@ -97,7 +97,8 @@ export async function buildIos(
cancelToken,
customBuild.ios.buildCommand,
env,
DevicePlatform.IOS
DevicePlatform.IOS,
appRoot
);
if (!appPath) {
throw new Error("Failed to build iOS app using custom script.");
Expand All @@ -114,7 +115,7 @@ export async function buildIos(
getTelemetryReporter().sendTelemetryEvent("build:eas-build-requested", {
platform: DevicePlatform.IOS,
});
const appPath = await fetchEasBuild(cancelToken, eas.ios, DevicePlatform.IOS);
const appPath = await fetchEasBuild(cancelToken, eas.ios, DevicePlatform.IOS, appRoot);
if (!appPath) {
throw new Error("Failed to build iOS app using EAS build.");
}
Expand All @@ -126,11 +127,11 @@ export async function buildIos(
};
}

if (await isExpoGoProject()) {
if (await isExpoGoProject(appRoot)) {
getTelemetryReporter().sendTelemetryEvent("build:expo-go-requested", {
platform: DevicePlatform.IOS,
});
const appPath = await downloadExpoGo(DevicePlatform.IOS, cancelToken);
const appPath = await downloadExpoGo(DevicePlatform.IOS, cancelToken, appRoot);
return { appPath, bundleID: EXPO_GO_BUNDLE_ID, platform: DevicePlatform.IOS };
}

Expand All @@ -140,11 +141,11 @@ export async function buildIos(
);
}

const sourceDir = getIosSourceDir(appRootFolder);
const sourceDir = getIosSourceDir(appRoot);

await installPodsIfNeeded();

const xcodeProject = await findXcodeProject(appRootFolder);
const xcodeProject = await findXcodeProject(appRoot);

if (!xcodeProject) {
throw new Error(`Could not find Xcode project files in "${sourceDir}" folder`);
Expand Down
19 changes: 12 additions & 7 deletions packages/vscode-extension/src/builders/customBuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { mkdtemp } from "fs/promises";
import { Logger } from "../Logger";
import { command, lineReader } from "../utilities/subprocess";
import { CancelToken } from "./cancelToken";
import { getAppRootFolder } from "../utilities/extensionContext";
import { extractTarApp, isApkFile, isAppFile } from "./utils";
import { DevicePlatform } from "../common/DeviceManager";

Expand All @@ -18,9 +17,10 @@ export async function runExternalBuild(
cancelToken: CancelToken,
buildCommand: string,
env: Env,
platform: DevicePlatform
platform: DevicePlatform,
cwd: string
) {
const output = await runExternalScript(buildCommand, env, cancelToken);
const output = await runExternalScript(buildCommand, env, cwd, cancelToken);

if (!output) {
return undefined;
Expand Down Expand Up @@ -64,16 +64,21 @@ export async function runExternalBuild(
return binaryPath;
}

export async function runfingerprintCommand(externalCommand: string, env: Env) {
const output = await runExternalScript(externalCommand, env);
export async function runfingerprintCommand(externalCommand: string, env: Env, cwd: string) {
const output = await runExternalScript(externalCommand, env, cwd);
if (!output) {
return undefined;
}
return output.lastLine;
}

async function runExternalScript(externalCommand: string, env: Env, cancelToken?: CancelToken) {
let process = command(externalCommand, { cwd: getAppRootFolder(), env });
async function runExternalScript(
externalCommand: string,
env: Env,
cwd: string,
cancelToken?: CancelToken
) {
let process = command(externalCommand, { cwd, env });
process = cancelToken ? cancelToken.adapt(process) : process;
Logger.info(`Running external script: ${externalCommand}`);

Expand Down
Loading

0 comments on commit 9b4ab57

Please sign in to comment.