Skip to content

Commit dac8e5c

Browse files
authored
[rush] Optimize setPreferredVersions (#5312)
* [rush] Optimize setPreferredVersions * Revise shim * Tune * Add comment --------- Co-authored-by: David Michon <dmichon-msft@users.noreply.github.com>
1 parent 3a779a9 commit dac8e5c

2 files changed

Lines changed: 82 additions & 21 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/rush",
5+
"comment": "Optimize `setPreferredVersions` in install setup.",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@microsoft/rush"
10+
}

libraries/rush-lib/src/logic/pnpm/PnpmfileShim.ts

Lines changed: 72 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,23 @@ import type { IPnpmfile, IPnpmfileShimSettings, IPnpmfileContext, IPnpmfileHooks
1616

1717
let settings: IPnpmfileShimSettings | undefined;
1818
let allPreferredVersions: Map<string, string> | undefined;
19+
let rangeParseCache: Map<string, TSemver.Range | false> | undefined;
1920
let allowedAlternativeVersions: Map<string, Set<string>> | undefined;
21+
let workspaceVersions: Map<string, string> | undefined;
2022
let userPnpmfile: IPnpmfile | undefined;
2123
let semver: typeof TSemver | undefined;
2224

25+
// All calls to new semver.Range and the comparison functions need to have the same options
26+
// It can be a different object with the same properties, but reusing this const avoids allocations
27+
const SEMVER_COMPARE_OPTIONS: TSemver.RangeOptions = { includePrerelease: true };
28+
2329
// Resets the internal state of the pnpmfile
2430
export function reset(): void {
2531
settings = undefined;
2632
allPreferredVersions = undefined;
2733
allowedAlternativeVersions = undefined;
34+
workspaceVersions = undefined;
35+
rangeParseCache = undefined;
2836
userPnpmfile = undefined;
2937
semver = undefined;
3038
}
@@ -56,6 +64,7 @@ function init(context: IPnpmfileContext | any): IPnpmfileContext {
5664
}
5765
if (!allPreferredVersions && settings.allPreferredVersions) {
5866
allPreferredVersions = new Map(Object.entries(settings.allPreferredVersions));
67+
rangeParseCache = new Map();
5968
}
6069
if (!allowedAlternativeVersions && settings.allowedAlternativeVersions) {
6170
allowedAlternativeVersions = new Map(
@@ -64,6 +73,9 @@ function init(context: IPnpmfileContext | any): IPnpmfileContext {
6473
})
6574
);
6675
}
76+
if (!workspaceVersions && settings.workspaceVersions) {
77+
workspaceVersions = new Map(Object.entries(settings.workspaceVersions));
78+
}
6779
// If a userPnpmfilePath is provided, we expect it to exist
6880
if (!userPnpmfile && settings.userPnpmfilePath) {
6981
userPnpmfile = require(settings.userPnpmfilePath);
@@ -76,48 +88,87 @@ function init(context: IPnpmfileContext | any): IPnpmfileContext {
7688
return context as IPnpmfileContext;
7789
}
7890

91+
function parseRange(range: string): TSemver.Range | false {
92+
if (!rangeParseCache || !semver) {
93+
return false;
94+
}
95+
96+
const entry: TSemver.Range | false | undefined = rangeParseCache.get(range);
97+
if (entry !== undefined) {
98+
return entry;
99+
}
100+
101+
if (range.includes(':')) {
102+
// This version specifier has a protocol (e.g. npm:, workspace:, etc.), so it is not a normal semver range.
103+
rangeParseCache.set(range, false);
104+
return false;
105+
}
106+
107+
try {
108+
const parsedRange: TSemver.Range = new semver.Range(range, SEMVER_COMPARE_OPTIONS);
109+
rangeParseCache.set(range, parsedRange);
110+
return parsedRange;
111+
} catch {
112+
rangeParseCache.set(range, false);
113+
return false;
114+
}
115+
}
116+
79117
// Set the preferred versions on the dependency map. If the version on the map is an allowedAlternativeVersion
80118
// then skip it. Otherwise, check to ensure that the common version is a subset of the specified version. If
81119
// it is, then replace the specified version with the preferredVersion
82120
function setPreferredVersions(dependencies: { [dependencyName: string]: string } | undefined): void {
83-
for (const [name, version] of Object.entries(dependencies || {})) {
84-
const preferredVersion: string | undefined = allPreferredVersions?.get(name);
121+
if (!dependencies || !semver || !allPreferredVersions) {
122+
return;
123+
}
124+
125+
// Needed for control flow analyzer.
126+
const definitelyDefinedAllPreferredVersions: Map<string, string> = allPreferredVersions;
127+
const definiteSemver: typeof TSemver = semver;
128+
129+
Object.entries(dependencies).forEach(([name, version]: [string, string]) => {
130+
const preferredVersion: string | undefined = definitelyDefinedAllPreferredVersions.get(name);
131+
// If preferredVersionRange is valid and the current version is not an allowed alternative, proceed to check subsets
85132
if (preferredVersion && !allowedAlternativeVersions?.get(name)?.has(version)) {
86-
let preferredVersionRange: TSemver.Range | undefined;
87-
let versionRange: TSemver.Range | undefined;
88-
try {
89-
preferredVersionRange = new semver!.Range(preferredVersion);
90-
versionRange = new semver!.Range(version);
91-
} catch {
92-
// Swallow invalid range errors
133+
const preferredVersionRange: TSemver.Range | false = parseRange(preferredVersion);
134+
if (!preferredVersionRange) {
135+
return;
136+
}
137+
138+
const versionRange: TSemver.Range | false = parseRange(version);
139+
if (!versionRange) {
140+
return;
93141
}
94-
if (
95-
preferredVersionRange &&
96-
versionRange &&
97-
semver!.subset(preferredVersionRange, versionRange, { includePrerelease: true })
98-
) {
99-
dependencies![name] = preferredVersion;
142+
143+
if (definiteSemver.subset(preferredVersionRange, versionRange, SEMVER_COMPARE_OPTIONS)) {
144+
dependencies[name] = preferredVersion;
100145
}
101146
}
102-
}
147+
});
103148
}
104149

105150
export const hooks: IPnpmfileHooks = {
106151
// Call the original pnpmfile (if it exists)
107152
afterAllResolved: (lockfile: IPnpmShrinkwrapYaml, context: IPnpmfileContext) => {
108153
context = init(context);
109-
return userPnpmfile?.hooks?.afterAllResolved
110-
? userPnpmfile.hooks.afterAllResolved(lockfile, context)
111-
: lockfile;
154+
return userPnpmfile?.hooks?.afterAllResolved?.(lockfile, context) ?? lockfile;
112155
},
113156

114157
// Set the preferred versions in the package, then call the original pnpmfile (if it exists)
115158
readPackage: (pkg: IPackageJson, context: IPnpmfileContext) => {
116159
context = init(context);
160+
// Apply the user pnpmfile readPackage hook first, in case it moves dependencies around, and so that it sees the true package.json
161+
pkg = userPnpmfile?.hooks?.readPackage?.(pkg, context) ?? pkg;
162+
163+
// Then do version refinement of preferredVersions, since this is just supposed to act as if pnpm "prefers" these resolutions during
164+
// calculation.
117165
setPreferredVersions(pkg.dependencies);
118-
setPreferredVersions(pkg.devDependencies);
166+
if (workspaceVersions && workspaceVersions.get(pkg.name) === pkg.version) {
167+
// devDependencies are only installed for workspace packages, so the rest of the time we can save the trouble of scanning.
168+
setPreferredVersions(pkg.devDependencies);
169+
}
119170
setPreferredVersions(pkg.optionalDependencies);
120-
return userPnpmfile?.hooks?.readPackage ? userPnpmfile.hooks.readPackage(pkg, context) : pkg;
171+
return pkg;
121172
},
122173

123174
// Call the original pnpmfile (if it exists)

0 commit comments

Comments
 (0)