Skip to content

Commit

Permalink
perf(@ngtools/webpack): reduce repeat path mapping analysis during re…
Browse files Browse the repository at this point in the history
…solution

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.
  • Loading branch information
clydin authored and filipesilva committed Aug 23, 2021
1 parent 4f91816 commit 7928b18
Showing 1 changed file with 137 additions and 91 deletions.
228 changes: 137 additions & 91 deletions packages/ngtools/webpack/src/paths-plugin.ts
Expand Up @@ -7,7 +7,7 @@
*/

import * as path from 'path';
import { CompilerOptions, MapLike } from 'typescript';
import { CompilerOptions } from 'typescript';
import type { Configuration } from 'webpack';

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

interface PathPattern {
starIndex: number;
prefix: string;
suffix?: string;
potentials: { hasStar: boolean; prefix: string; suffix?: string }[];
}

export class TypeScriptPathsPlugin {
constructor(private options?: TypeScriptPathsPluginOptions) {}
private baseUrl?: string;
private patterns?: PathPattern[];

constructor(options?: TypeScriptPathsPluginOptions) {
if (options) {
this.update(options);
}
}

/**
* Update the plugin with new path mapping option values.
* The options will also be preprocessed to reduce the overhead of individual resolve actions
* during a build.
*
* @param options The `paths` and `baseUrl` options from TypeScript's `CompilerOptions`.
*/
update(options: TypeScriptPathsPluginOptions): void {
this.options = options;
this.baseUrl = options.baseUrl;
this.patterns = undefined;

if (options.paths) {
for (const [pattern, potentials] of Object.entries(options.paths)) {
// Ignore any entries that would not result in a new mapping
if (potentials.length === 0 || potentials.every((potential) => potential === '*')) {
continue;
}

const starIndex = pattern.indexOf('*');
let prefix = pattern;
let suffix;
if (starIndex > -1) {
prefix = pattern.slice(0, starIndex);
if (starIndex < pattern.length - 1) {
suffix = pattern.slice(starIndex + 1);
}
}

this.patterns ??= [];
this.patterns.push({
starIndex,
prefix,
suffix,
potentials: potentials.map((potential) => {
const potentialStarIndex = potential.indexOf('*');
if (potentialStarIndex === -1) {
return { hasStar: false, prefix: potential };
}

return {
hasStar: true,
prefix: potential.slice(0, potentialStarIndex),
suffix:
potentialStarIndex < potential.length - 1
? potential.slice(potentialStarIndex + 1)
: undefined,
};
}),
});
}

// Sort patterns so that exact matches take priority then largest prefix match
this.patterns?.sort((a, b) => {
if (a.starIndex === -1) {
return -1;
} else if (b.starIndex === -1) {
return 1;
} else {
return b.starIndex - a.starIndex;
}
});
}
}

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

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

return;
Expand All @@ -50,39 +127,45 @@ export class TypeScriptPathsPlugin {
}

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

return;
}

// Relative or absolute requests are not mapped
if (originalRequest.startsWith('.') || originalRequest.startsWith('/')) {
callback();

return;
}

// Ignore all webpack special requests
if (originalRequest.startsWith('!!')) {
callback();
switch (originalRequest[0]) {
case '.':
case '/':
// Relative or absolute requests are not mapped
callback();

return;
return;
case '!':
// Ignore all webpack special requests
if (originalRequest.length > 1 && originalRequest[1] === '!') {
callback();

return;
}
break;
}

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

const tryResolve = () => {
const potential = replacements.shift();
if (!potential) {
const next = replacements.next();
if (next.done) {
callback();

return;
}

const potentialRequest = {
...request,
request: path.resolve(this.options?.baseUrl || '', potential),
request: path.resolve(this.baseUrl ?? '', next.value),
typescriptPathMapped: true,
};

Expand Down Expand Up @@ -110,89 +193,52 @@ export class TypeScriptPathsPlugin {
}
}

function findReplacements(originalRequest: string, paths: MapLike<string[]>): string[] {
function* findReplacements(
originalRequest: string,
patterns: PathPattern[],
): IterableIterator<string> {
// check if any path mapping rules are relevant
const pathMapOptions = [];
for (const pattern in paths) {
// get potentials and remove duplicates; JS Set maintains insertion order
const potentials = Array.from(new Set(paths[pattern]));
if (potentials.length === 0) {
// no potential replacements so skip
continue;
}
for (const { starIndex, prefix, suffix, potentials } of patterns) {
let partial;

// can only contain zero or one
const starIndex = pattern.indexOf('*');
if (starIndex === -1) {
if (pattern === originalRequest) {
pathMapOptions.push({
starIndex,
partial: '',
potentials,
});
}
} else if (starIndex === 0 && pattern.length === 1) {
if (potentials.length === 1 && potentials[0] === '*') {
// identity mapping -> noop
continue;
// No star means an exact match is required
if (prefix === originalRequest) {
partial = '';
}
pathMapOptions.push({
starIndex,
partial: originalRequest,
potentials,
});
} else if (starIndex === pattern.length - 1) {
if (originalRequest.startsWith(pattern.slice(0, -1))) {
pathMapOptions.push({
starIndex,
partial: originalRequest.slice(pattern.length - 1),
potentials,
});
} else if (starIndex === 0 && !suffix) {
// Everything matches a single wildcard pattern ("*")
partial = originalRequest;
} else if (!suffix) {
// No suffix means the star is at the end of the pattern
if (originalRequest.startsWith(prefix)) {
partial = originalRequest.slice(prefix.length);
}
} else {
const [prefix, suffix] = pattern.split('*');
// Star was in the middle of the pattern
if (originalRequest.startsWith(prefix) && originalRequest.endsWith(suffix)) {
pathMapOptions.push({
starIndex,
partial: originalRequest.slice(prefix.length).slice(0, -suffix.length),
potentials,
});
partial = originalRequest.substring(prefix.length, originalRequest.length - suffix.length);
}
}
}

if (pathMapOptions.length === 0) {
return [];
}

// exact matches take priority then largest prefix match
pathMapOptions.sort((a, b) => {
if (a.starIndex === -1) {
return -1;
} else if (b.starIndex === -1) {
return 1;
} else {
return b.starIndex - a.starIndex;
// If request was not matched, move on to the next pattern
if (partial === undefined) {
continue;
}
});

const replacements: string[] = [];
pathMapOptions.forEach((option) => {
for (const potential of option.potentials) {
let replacement;
const starIndex = potential.indexOf('*');
if (starIndex === -1) {
replacement = potential;
} else if (starIndex === potential.length - 1) {
replacement = potential.slice(0, -1) + option.partial;
} else {
const [prefix, suffix] = potential.split('*');
replacement = prefix + option.partial + suffix;

// Create the full replacement values based on the original request and the potentials
// for the successfully matched pattern.
for (const { hasStar, prefix, suffix } of potentials) {
let replacement = prefix;

if (hasStar) {
replacement += partial;
if (suffix) {
replacement += suffix;
}
}

replacements.push(replacement);
yield replacement;
}
});

return replacements;
}
}

0 comments on commit 7928b18

Please sign in to comment.