Skip to content

Commit 7928b18

Browse files
clydinfilipesilva
authored andcommitted
perf(@ngtools/webpack): reduce repeat path mapping analysis during resolution
The internal TypeScript path mapping Webpack resolver plugin is used to adjust module resolution during builds via the TypeScript configuration `paths` option. Prior to a build, the `paths` option is now preprocessed to limit the amount of analysis that is needed within each individual module resolution attempt during a build. Since module resolution attempts can occur frequently during a build, this change offers the potential to reduce the total cost of module resolution especially for applications with a large amount of configured path mappings.
1 parent 4f91816 commit 7928b18

File tree

1 file changed

+137
-91
lines changed

1 file changed

+137
-91
lines changed

packages/ngtools/webpack/src/paths-plugin.ts

Lines changed: 137 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import * as path from 'path';
10-
import { CompilerOptions, MapLike } from 'typescript';
10+
import { CompilerOptions } from 'typescript';
1111
import type { Configuration } from 'webpack';
1212

1313
// eslint-disable-next-line @typescript-eslint/no-empty-interface
@@ -16,21 +16,98 @@ export interface TypeScriptPathsPluginOptions extends Pick<CompilerOptions, 'pat
1616
// Extract Resolver type from Webpack types since it is not directly exported
1717
type Resolver = Exclude<Exclude<Configuration['resolve'], undefined>['resolver'], undefined>;
1818

19+
interface PathPattern {
20+
starIndex: number;
21+
prefix: string;
22+
suffix?: string;
23+
potentials: { hasStar: boolean; prefix: string; suffix?: string }[];
24+
}
25+
1926
export class TypeScriptPathsPlugin {
20-
constructor(private options?: TypeScriptPathsPluginOptions) {}
27+
private baseUrl?: string;
28+
private patterns?: PathPattern[];
29+
30+
constructor(options?: TypeScriptPathsPluginOptions) {
31+
if (options) {
32+
this.update(options);
33+
}
34+
}
2135

36+
/**
37+
* Update the plugin with new path mapping option values.
38+
* The options will also be preprocessed to reduce the overhead of individual resolve actions
39+
* during a build.
40+
*
41+
* @param options The `paths` and `baseUrl` options from TypeScript's `CompilerOptions`.
42+
*/
2243
update(options: TypeScriptPathsPluginOptions): void {
23-
this.options = options;
44+
this.baseUrl = options.baseUrl;
45+
this.patterns = undefined;
46+
47+
if (options.paths) {
48+
for (const [pattern, potentials] of Object.entries(options.paths)) {
49+
// Ignore any entries that would not result in a new mapping
50+
if (potentials.length === 0 || potentials.every((potential) => potential === '*')) {
51+
continue;
52+
}
53+
54+
const starIndex = pattern.indexOf('*');
55+
let prefix = pattern;
56+
let suffix;
57+
if (starIndex > -1) {
58+
prefix = pattern.slice(0, starIndex);
59+
if (starIndex < pattern.length - 1) {
60+
suffix = pattern.slice(starIndex + 1);
61+
}
62+
}
63+
64+
this.patterns ??= [];
65+
this.patterns.push({
66+
starIndex,
67+
prefix,
68+
suffix,
69+
potentials: potentials.map((potential) => {
70+
const potentialStarIndex = potential.indexOf('*');
71+
if (potentialStarIndex === -1) {
72+
return { hasStar: false, prefix: potential };
73+
}
74+
75+
return {
76+
hasStar: true,
77+
prefix: potential.slice(0, potentialStarIndex),
78+
suffix:
79+
potentialStarIndex < potential.length - 1
80+
? potential.slice(potentialStarIndex + 1)
81+
: undefined,
82+
};
83+
}),
84+
});
85+
}
86+
87+
// Sort patterns so that exact matches take priority then largest prefix match
88+
this.patterns?.sort((a, b) => {
89+
if (a.starIndex === -1) {
90+
return -1;
91+
} else if (b.starIndex === -1) {
92+
return 1;
93+
} else {
94+
return b.starIndex - a.starIndex;
95+
}
96+
});
97+
}
2498
}
2599

26100
apply(resolver: Resolver): void {
27101
const target = resolver.ensureHook('resolve');
28102

103+
// To support synchronous resolvers this hook cannot be promise based.
104+
// Webpack supports synchronous resolution with `tap` and `tapAsync` hooks.
29105
resolver.getHook('described-resolve').tapAsync(
30106
'TypeScriptPathsPlugin',
31107
// eslint-disable-next-line @typescript-eslint/no-explicit-any
32108
(request: any, resolveContext, callback) => {
33-
if (!this.options) {
109+
// Preprocessing of the options will ensure that `patterns` is either undefined or has elements to check
110+
if (!this.patterns) {
34111
callback();
35112

36113
return;
@@ -50,39 +127,45 @@ export class TypeScriptPathsPlugin {
50127
}
51128

52129
// Only work on Javascript/TypeScript issuers.
53-
if (!request.context.issuer || !request.context.issuer.match(/\.[jt]sx?$/)) {
130+
if (!request.context.issuer || !request.context.issuer.match(/\.[cm]?[jt]sx?$/)) {
54131
callback();
55132

56133
return;
57134
}
58135

59-
// Relative or absolute requests are not mapped
60-
if (originalRequest.startsWith('.') || originalRequest.startsWith('/')) {
61-
callback();
62-
63-
return;
64-
}
65-
66-
// Ignore all webpack special requests
67-
if (originalRequest.startsWith('!!')) {
68-
callback();
136+
switch (originalRequest[0]) {
137+
case '.':
138+
case '/':
139+
// Relative or absolute requests are not mapped
140+
callback();
69141

70-
return;
142+
return;
143+
case '!':
144+
// Ignore all webpack special requests
145+
if (originalRequest.length > 1 && originalRequest[1] === '!') {
146+
callback();
147+
148+
return;
149+
}
150+
break;
71151
}
72152

73-
const replacements = findReplacements(originalRequest, this.options.paths || {});
153+
// A generator is used to limit the amount of replacements that need to be created.
154+
// For example, if the first one resolves, any others are not needed and do not need
155+
// to be created.
156+
const replacements = findReplacements(originalRequest, this.patterns);
74157

75158
const tryResolve = () => {
76-
const potential = replacements.shift();
77-
if (!potential) {
159+
const next = replacements.next();
160+
if (next.done) {
78161
callback();
79162

80163
return;
81164
}
82165

83166
const potentialRequest = {
84167
...request,
85-
request: path.resolve(this.options?.baseUrl || '', potential),
168+
request: path.resolve(this.baseUrl ?? '', next.value),
86169
typescriptPathMapped: true,
87170
};
88171

@@ -110,89 +193,52 @@ export class TypeScriptPathsPlugin {
110193
}
111194
}
112195

113-
function findReplacements(originalRequest: string, paths: MapLike<string[]>): string[] {
196+
function* findReplacements(
197+
originalRequest: string,
198+
patterns: PathPattern[],
199+
): IterableIterator<string> {
114200
// check if any path mapping rules are relevant
115-
const pathMapOptions = [];
116-
for (const pattern in paths) {
117-
// get potentials and remove duplicates; JS Set maintains insertion order
118-
const potentials = Array.from(new Set(paths[pattern]));
119-
if (potentials.length === 0) {
120-
// no potential replacements so skip
121-
continue;
122-
}
201+
for (const { starIndex, prefix, suffix, potentials } of patterns) {
202+
let partial;
123203

124-
// can only contain zero or one
125-
const starIndex = pattern.indexOf('*');
126204
if (starIndex === -1) {
127-
if (pattern === originalRequest) {
128-
pathMapOptions.push({
129-
starIndex,
130-
partial: '',
131-
potentials,
132-
});
133-
}
134-
} else if (starIndex === 0 && pattern.length === 1) {
135-
if (potentials.length === 1 && potentials[0] === '*') {
136-
// identity mapping -> noop
137-
continue;
205+
// No star means an exact match is required
206+
if (prefix === originalRequest) {
207+
partial = '';
138208
}
139-
pathMapOptions.push({
140-
starIndex,
141-
partial: originalRequest,
142-
potentials,
143-
});
144-
} else if (starIndex === pattern.length - 1) {
145-
if (originalRequest.startsWith(pattern.slice(0, -1))) {
146-
pathMapOptions.push({
147-
starIndex,
148-
partial: originalRequest.slice(pattern.length - 1),
149-
potentials,
150-
});
209+
} else if (starIndex === 0 && !suffix) {
210+
// Everything matches a single wildcard pattern ("*")
211+
partial = originalRequest;
212+
} else if (!suffix) {
213+
// No suffix means the star is at the end of the pattern
214+
if (originalRequest.startsWith(prefix)) {
215+
partial = originalRequest.slice(prefix.length);
151216
}
152217
} else {
153-
const [prefix, suffix] = pattern.split('*');
218+
// Star was in the middle of the pattern
154219
if (originalRequest.startsWith(prefix) && originalRequest.endsWith(suffix)) {
155-
pathMapOptions.push({
156-
starIndex,
157-
partial: originalRequest.slice(prefix.length).slice(0, -suffix.length),
158-
potentials,
159-
});
220+
partial = originalRequest.substring(prefix.length, originalRequest.length - suffix.length);
160221
}
161222
}
162-
}
163-
164-
if (pathMapOptions.length === 0) {
165-
return [];
166-
}
167223

168-
// exact matches take priority then largest prefix match
169-
pathMapOptions.sort((a, b) => {
170-
if (a.starIndex === -1) {
171-
return -1;
172-
} else if (b.starIndex === -1) {
173-
return 1;
174-
} else {
175-
return b.starIndex - a.starIndex;
224+
// If request was not matched, move on to the next pattern
225+
if (partial === undefined) {
226+
continue;
176227
}
177-
});
178-
179-
const replacements: string[] = [];
180-
pathMapOptions.forEach((option) => {
181-
for (const potential of option.potentials) {
182-
let replacement;
183-
const starIndex = potential.indexOf('*');
184-
if (starIndex === -1) {
185-
replacement = potential;
186-
} else if (starIndex === potential.length - 1) {
187-
replacement = potential.slice(0, -1) + option.partial;
188-
} else {
189-
const [prefix, suffix] = potential.split('*');
190-
replacement = prefix + option.partial + suffix;
228+
229+
// Create the full replacement values based on the original request and the potentials
230+
// for the successfully matched pattern.
231+
for (const { hasStar, prefix, suffix } of potentials) {
232+
let replacement = prefix;
233+
234+
if (hasStar) {
235+
replacement += partial;
236+
if (suffix) {
237+
replacement += suffix;
238+
}
191239
}
192240

193-
replacements.push(replacement);
241+
yield replacement;
194242
}
195-
});
196-
197-
return replacements;
243+
}
198244
}

0 commit comments

Comments
 (0)