Skip to content

Commit dc260df

Browse files
feat: allow subspaces to opt out of the PNPM global virtual store
1 parent f4827ef commit dc260df

8 files changed

Lines changed: 82 additions & 5 deletions

File tree

‎common/changes/@microsoft/rush/support-pnpm-global-virtual-store_2026-06-11-15-20.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"changes": [
33
{
44
"packageName": "@microsoft/rush",
5-
"comment": "Add PNPM global virtual store support for workspace installs via RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE.",
5+
"comment": "Add PNPM global virtual store support for workspace installs via RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE, with a per-subspace \"globalVirtualStore\": false opt-out in pnpm-config.json.",
66
"type": "minor"
77
}
88
],

‎common/reviews/api/rush-lib.api.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,6 +1112,7 @@ export interface _IPnpmOptionsJson extends IPackageManagerOptionsJsonBase {
11121112
globalPackageExtensions?: Record<string, IPnpmPackageExtension>;
11131113
globalPatchedDependencies?: Record<string, string>;
11141114
globalPeerDependencyRules?: IPnpmPeerDependencyRules;
1115+
globalVirtualStore?: boolean;
11151116
// @deprecated (undocumented)
11161117
minimumReleaseAge?: number;
11171118
minimumReleaseAgeExclude?: string[];
@@ -1703,6 +1704,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
17031704
readonly globalPackageExtensions: Record<string, IPnpmPackageExtension> | undefined;
17041705
get globalPatchedDependencies(): Record<string, string> | undefined;
17051706
readonly globalPeerDependencyRules: IPnpmPeerDependencyRules | undefined;
1707+
readonly globalVirtualStore: boolean | undefined;
17061708
// (undocumented)
17071709
readonly jsonFilename: string | undefined;
17081710
// @internal (undocumented)

‎libraries/rush-lib/src/api/LastInstallFlag.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import { JsonFile, type JsonObject, Path, type IPackageJson, Objects } from '@ru
77

88
import type { PackageManagerName } from './packageManager/PackageManager';
99
import type { RushConfiguration } from './RushConfiguration';
10-
import { EnvironmentConfiguration } from './EnvironmentConfiguration';
1110
import * as objectUtilities from '../utilities/objectUtilities';
1211
import type { Subspace } from './Subspace';
1312
import { Selection } from '../logic/Selection';
1413
import { FlagFile } from './FlagFile';
14+
import { isPnpmGlobalVirtualStoreEnabled } from '../logic/pnpm/PnpmGlobalVirtualStore';
1515

1616
const LAST_INSTALL_FLAG_FILE_NAME: string = 'last-install';
1717

@@ -218,7 +218,7 @@ export function getCommonTempFlag(
218218

219219
if (currentState.packageManager === 'pnpm' && rushConfiguration.pnpmOptions) {
220220
currentState.storePath = rushConfiguration.pnpmOptions.pnpmStorePath;
221-
if (EnvironmentConfiguration.enablePnpmGlobalVirtualStore) {
221+
if (isPnpmGlobalVirtualStoreEnabled(subspace)) {
222222
currentState.pnpmGlobalVirtualStore = true;
223223
}
224224
if (rushConfiguration.pnpmOptions.useWorkspaces) {

‎libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { ShrinkwrapFileFactory } from '../ShrinkwrapFileFactory';
4040
import { BaseProjectShrinkwrapFile } from '../base/BaseProjectShrinkwrapFile';
4141
import { type CustomTipId, type ICustomTipInfo, PNPM_CUSTOM_TIPS } from '../../api/CustomTipsConfiguration';
4242
import type { PnpmShrinkwrapFile } from '../pnpm/PnpmShrinkwrapFile';
43+
import { isPnpmGlobalVirtualStoreEnabled } from '../pnpm/PnpmGlobalVirtualStore';
4344
import type { Subspace } from '../../api/Subspace';
4445
import { BaseLinkManager, SymlinkKind } from '../base/BaseLinkManager';
4546
import { FlagFile } from '../../api/FlagFile';
@@ -506,7 +507,7 @@ export class WorkspaceInstallManager extends BaseInstallManager {
506507
// Write the common package.json using the "pnpm" field derived above.
507508
await InstallHelpers.generateCommonPackageJsonAsync(subspace, undefined, pnpmSettings);
508509

509-
if (EnvironmentConfiguration.enablePnpmGlobalVirtualStore) {
510+
if (isPnpmGlobalVirtualStoreEnabled(subspace)) {
510511
const globalVirtualStoreWarning: string | undefined = validateGlobalVirtualStoreOptions({
511512
pnpmVersion: this.rushConfiguration.packageManagerToolVersion,
512513
rushJsonFolder: this.rushConfiguration.rushJsonFolder,
@@ -520,6 +521,9 @@ export class WorkspaceInstallManager extends BaseInstallManager {
520521
this._terminal.writeWarningLine(globalVirtualStoreWarning);
521522
}
522523
workspaceFile.enableGlobalVirtualStore = true;
524+
} else {
525+
// The workspace file was loaded from disk, so clear a value left by a previous install.
526+
workspaceFile.enableGlobalVirtualStore = undefined;
523527
}
524528

525529
// The pnpm-workspace.yaml settings were already populated by resolvePnpmSettings, and the
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration';
5+
import type { Subspace } from '../../api/Subspace';
6+
7+
/**
8+
* Whether the PNPM global virtual store applies to this subspace: the environment variable
9+
* enables it repo-wide, and a subspace's pnpm-config.json can opt out with `"globalVirtualStore": false`.
10+
*/
11+
export function isPnpmGlobalVirtualStoreEnabled(subspace: Subspace): boolean {
12+
return (
13+
EnvironmentConfiguration.enablePnpmGlobalVirtualStore &&
14+
subspace.getPnpmOptions()?.globalVirtualStore !== false
15+
);
16+
}

‎libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,10 @@ export interface IPnpmOptionsJson extends IPackageManagerOptionsJsonBase {
186186
* {@inheritDoc PnpmOptionsConfiguration.alwaysInjectDependenciesFromOtherSubspaces}
187187
*/
188188
alwaysInjectDependenciesFromOtherSubspaces?: boolean;
189+
/**
190+
* {@inheritDoc PnpmOptionsConfiguration.globalVirtualStore}
191+
*/
192+
globalVirtualStore?: boolean;
189193
/**
190194
* {@inheritDoc PnpmOptionsConfiguration.alwaysFullInstall}
191195
*/
@@ -390,6 +394,17 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
390394
*/
391395
public readonly alwaysInjectDependenciesFromOtherSubspaces: boolean | undefined;
392396

397+
/**
398+
* Set to `false` to opt this subspace out of PNPM's global virtual store when the
399+
* `RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE` environment variable is enabled.
400+
*
401+
* @remarks
402+
* Useful for subspaces whose tooling cannot follow dependencies outside the subspace's temp folder,
403+
* such as a Metro bundler restricted to its `watchFolders`. The default value is undefined, which
404+
* follows the environment variable.
405+
*/
406+
public readonly globalVirtualStore: boolean | undefined;
407+
393408
/**
394409
* The "globalOverrides" setting provides a simple mechanism for overriding version selections
395410
* for all dependencies of all projects in the monorepo workspace. The settings are copied
@@ -606,6 +621,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
606621
this.trustPolicyExclude = json.trustPolicyExclude;
607622
this.trustPolicyIgnoreAfterMinutes = json.trustPolicyIgnoreAfterMinutes;
608623
this.alwaysInjectDependenciesFromOtherSubspaces = json.alwaysInjectDependenciesFromOtherSubspaces;
624+
this.globalVirtualStore = json.globalVirtualStore;
609625
this.alwaysFullInstall = json.alwaysFullInstall;
610626
this.pnpmLockfilePolicies = json.pnpmLockfilePolicies;
611627
this.globalCatalogs = json.globalCatalogs;

‎libraries/rush-lib/src/logic/test/WorkspaceInstallManager.test.ts‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
22
// See LICENSE in the project root for license information.
33

4-
import { FileSystem, JsonFile, Path } from '@rushstack/node-core-library';
4+
import { FileSystem, JsonFile, type JsonObject, Path } from '@rushstack/node-core-library';
55
import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal';
66

77
import { EnvironmentConfiguration, EnvironmentVariableNames } from '../../api/EnvironmentConfiguration';
88
import { RushConfiguration } from '../../api/RushConfiguration';
9+
import { getCommonTempFlag, type LastInstallFlag } from '../../api/LastInstallFlag';
910
import { RushGlobalFolder } from '../../api/RushGlobalFolder';
1011
import type { Subspace } from '../../api/Subspace';
1112
import {
@@ -121,6 +122,7 @@ describe(WorkspaceInstallManager.name, () => {
121122

122123
async function prepareFixtureRepoAsync(options: {
123124
pnpmStore?: PnpmStoreLocation;
125+
globalVirtualStore?: boolean;
124126
}): Promise<RushConfiguration> {
125127
const repoPath: string = `${tempFolderPath}/repo`;
126128
await FileSystem.copyFilesAsync({
@@ -141,12 +143,25 @@ describe(WorkspaceInstallManager.name, () => {
141143
delete commonPnpmConfigJson.pnpmStore;
142144
}
143145
await JsonFile.saveAsync(commonPnpmConfigJson, commonPnpmConfigPath, { updateExistingFile: true });
146+
if (options.globalVirtualStore !== undefined) {
147+
await JsonFile.saveAsync(
148+
{ globalVirtualStore: options.globalVirtualStore },
149+
`${repoPath}/common/config/subspaces/default/pnpm-config.json`,
150+
{ ensureFolderExists: true }
151+
);
152+
}
144153

145154
const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonPath);
146155
await FileSystem.ensureFolderAsync(rushConfiguration.defaultSubspace.getSubspaceTempFolderPath());
147156
return rushConfiguration;
148157
}
149158

159+
async function readCommonTempFlagAsync(rushConfiguration: RushConfiguration): Promise<JsonObject> {
160+
const flag: LastInstallFlag = getCommonTempFlag(rushConfiguration, rushConfiguration.defaultSubspace);
161+
await flag.createAsync();
162+
return await JsonFile.loadAsync(flag.path);
163+
}
164+
150165
function createInstallManager(rushConfiguration: RushConfiguration): TestWorkspaceInstallManager {
151166
const terminal: Terminal = new Terminal(new StringBufferTerminalProvider());
152167
const options: IInstallManagerOptions = {
@@ -190,6 +205,25 @@ describe(WorkspaceInstallManager.name, () => {
190205
);
191206
expect(workspaceYaml).toContain('enableGlobalVirtualStore: true');
192207
expect(Path.convertToSlashes(workspaceYaml)).toContain('../../../a');
208+
expect(await readCommonTempFlagAsync(rushConfiguration)).toHaveProperty('pnpmGlobalVirtualStore', true);
209+
});
210+
211+
it('leaves the global virtual store off for a subspace that opts out', async () => {
212+
process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE] = '1';
213+
EnvironmentConfiguration.reset();
214+
const rushConfiguration: RushConfiguration = await prepareFixtureRepoAsync({
215+
pnpmStore: 'global',
216+
globalVirtualStore: false
217+
});
218+
const installManager: TestWorkspaceInstallManager = createInstallManager(rushConfiguration);
219+
220+
await installManager.prepareCommonTempForTestAsync(rushConfiguration.defaultSubspace);
221+
222+
const workspaceYaml: string = await FileSystem.readFileAsync(
223+
`${rushConfiguration.defaultSubspace.getSubspaceTempFolderPath()}/pnpm-workspace.yaml`
224+
);
225+
expect(workspaceYaml).not.toContain('enableGlobalVirtualStore');
226+
expect(await readCommonTempFlagAsync(rushConfiguration)).not.toHaveProperty('pnpmGlobalVirtualStore');
193227
});
194228

195229
it('throws from the workspace install prepare path when using a worktree-local PNPM store', async () => {

‎libraries/rush-lib/src/schemas/pnpm-config.schema.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@
5353
"type": "boolean"
5454
},
5555

56+
"globalVirtualStore": {
57+
"description": "Set to false to opt this subspace out of PNPM's global virtual store when the RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE environment variable is enabled. Useful for subspaces whose tooling cannot follow dependencies outside the subspace's temp folder, such as a Metro bundler restricted to its watchFolders.\n\nThe default value is undefined, which follows the environment variable.",
58+
"type": "boolean"
59+
},
60+
5661
"alwaysInjectDependenciesFromOtherSubspaces": {
5762
"description": "When a project uses `workspace:` to depend on another Rush project, PNPM normally installs it by creating a symlink under `node_modules`. This generally works well, but in certain cases such as differing `peerDependencies` versions, symlinking may cause trouble such as incorrectly satisfied versions. For such cases, the dependency can be declared as \"injected\", causing PNPM to copy its built output into `node_modules` like a real install from a registry. Details here: https://rushjs.io/pages/advanced/injected_deps/\n\nWhen using Rush subspaces, these sorts of versioning problems are much more likely if `workspace:` refers to a project from a different subspace. This is because the symlink would point to a separate `node_modules` tree installed by a different PNPM lockfile. A comprehensive solution is to enable `alwaysInjectDependenciesFromOtherSubspaces`, which automatically treats all projects from other subspaces as injected dependencies without having to manually configure them.\n\nNOTE: Use carefully -- excessive file copying can slow down the `rush install` and `pnpm-sync` operations if too many dependencies become injected.\n\nThe default value is false.",
5863
"type": "boolean"

0 commit comments

Comments
 (0)