Skip to content

Commit e727ea9

Browse files
committed
perf(@angular/build): memoize package-level sideEffects checks in compiler plugin
When advancedOptimizations is enabled, the compiler plugin checks each emitted and transformed JavaScript file via hasSideEffects() to determine whether pure annotations should be added during bundling. Previously, hasSideEffects() invoked build.resolve() across the esbuild Go <-> Node.js IPC boundary for every individual file. In an application with hundreds of files loaded from node_modules (e.g. 226 files from rxjs), this resulted in hundreds of redundant IPC round-trips to evaluate the exact same package-level sideEffects configuration. To eliminate redundant resolution calls: - Introduce a dedicated SideEffectsResolver class and createSideEffectsResolver() factory in a separate module. - Memoize resolved sideEffects booleans at both the package level and the individual file level. - When an imported file resides in node_modules, extract the package directory and inspect its package.json sideEffects property once. - If sideEffects is a boolean, memoize it for all files originating from that package. - If sideEffects is an array of globs, string, or omitted, bypass package-level memoization and resolve via build.resolve() per file, memoizing the resolved file result to prevent duplicate calls. - Return an async noop when advancedOptimizations is disabled to avoid overhead. - Provide comprehensive unit tests covering package memoization, non-boolean sideEffects, scoped packages, pnpm virtual stores, and Windows/relative paths. In benchmarks on an application with 253 JS dependency files across 6 packages, build.resolve IPC calls dropped from 253 to 6 (-97.6%) and cumulative resolve duration dropped by 99.7%.
1 parent 6b20983 commit e727ea9

3 files changed

Lines changed: 498 additions & 16 deletions

File tree

packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { ComponentStylesheetBundler } from './component-stylesheets';
3737
import { FileReferenceTracker } from './file-reference-tracker';
3838
import { setupJitPluginCallbacks } from './jit-plugin-callbacks';
3939
import { rewriteForBazel } from './rewrite-bazel-paths';
40+
import { createSideEffectsResolver } from './side-effects-resolver';
4041
import { SourceFileCache } from './source-file-cache';
4142

4243
export interface CompilerPluginOptions {
@@ -116,6 +117,8 @@ export function createCompilerPlugin(
116117
cacheStore?.createCache('jstransformer'),
117118
);
118119

120+
const hasSideEffects = createSideEffectsResolver(build, pluginOptions.advancedOptimizations);
121+
119122
// Setup defines based on the values used by the Angular compiler-cli
120123
build.initialOptions.define ??= {};
121124
build.initialOptions.define['ngI18nClosureMode'] ??= 'false';
@@ -647,22 +650,6 @@ export function createCompilerPlugin(
647650
void javascriptTransformer.close();
648651
void cacheStore?.close();
649652
});
650-
651-
/**
652-
* Checks if the file has side-effects when `advancedOptimizations` is enabled.
653-
*/
654-
async function hasSideEffects(path: string): Promise<boolean | undefined> {
655-
if (!pluginOptions.advancedOptimizations) {
656-
return undefined;
657-
}
658-
659-
const { sideEffects } = await build.resolve(path, {
660-
kind: 'import-statement',
661-
resolveDir: build.initialOptions.absWorkingDir ?? '',
662-
});
663-
664-
return sideEffects;
665-
}
666653
},
667654
};
668655
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import type { PluginBuild } from 'esbuild';
10+
import { readFile } from 'node:fs/promises';
11+
import * as path from 'node:path';
12+
13+
/**
14+
* Extracts the root package directory containing package.json for a file inside node_modules.
15+
* Supports standard packages (node_modules/pkg/...) and scoped packages (node_modules/@scope/pkg/...).
16+
*/
17+
export function getPackageDirectory(filePath: string): string | undefined {
18+
const normalizedPath = filePath.includes('\\') ? filePath.replace(/\\/g, '/') : filePath;
19+
let index = normalizedPath.lastIndexOf('/node_modules/');
20+
let offset = 14;
21+
if (index === -1) {
22+
if (normalizedPath.startsWith('node_modules/')) {
23+
index = 0;
24+
offset = 13;
25+
} else {
26+
return undefined;
27+
}
28+
}
29+
30+
const afterNodeModules = normalizedPath.slice(index + offset);
31+
const firstSlash = afterNodeModules.indexOf('/');
32+
if (firstSlash === -1) {
33+
return undefined;
34+
}
35+
36+
let end = index + offset;
37+
if (afterNodeModules.startsWith('@')) {
38+
const secondSlash = afterNodeModules.indexOf('/', firstSlash + 1);
39+
end += secondSlash === -1 ? afterNodeModules.length : secondSlash;
40+
} else {
41+
end += firstSlash;
42+
}
43+
44+
return filePath.slice(0, end);
45+
}
46+
47+
/**
48+
* Resolves and memoizes package-level and file-level side-effects for bundling optimizations.
49+
*/
50+
export class SideEffectsResolver {
51+
readonly #build: PluginBuild;
52+
readonly #advancedOptimizations: boolean;
53+
readonly #workingDirectory: string;
54+
55+
/**
56+
* Memoizes package-level sideEffects values.
57+
* - `true` or `false` when package.json specifies a boolean `sideEffects`.
58+
* - `null` when package.json has non-boolean (e.g. array of globs, string), omitted sideEffects, or fails to read.
59+
* - `Promise<boolean | null>` while package.json is being read and parsed.
60+
*/
61+
readonly #packageSideEffectsCache = new Map<string, Promise<boolean | null> | boolean | null>();
62+
63+
/**
64+
* Memoizes file-level sideEffects results.
65+
* - `true` or `false` once resolved.
66+
* - `Promise<boolean>` while esbuild resolution is in-flight.
67+
*/
68+
readonly #fileSideEffectsCache = new Map<string, Promise<boolean> | boolean>();
69+
70+
constructor(build: PluginBuild, advancedOptimizations: boolean = true) {
71+
this.#build = build;
72+
this.#advancedOptimizations = advancedOptimizations;
73+
this.#workingDirectory = build.initialOptions.absWorkingDir ?? '';
74+
}
75+
76+
/**
77+
* Determines if a file has side-effects.
78+
* Returns `undefined` when `advancedOptimizations` is disabled.
79+
*/
80+
async resolve(filePath: string): Promise<boolean | undefined> {
81+
if (!this.#advancedOptimizations) {
82+
return undefined;
83+
}
84+
85+
const cachedFileSideEffects = this.#fileSideEffectsCache.get(filePath);
86+
if (cachedFileSideEffects !== undefined) {
87+
return cachedFileSideEffects;
88+
}
89+
90+
const packageDir = getPackageDirectory(filePath);
91+
if (packageDir !== undefined) {
92+
let packageSideEffects = this.#packageSideEffectsCache.get(packageDir);
93+
if (packageSideEffects === undefined) {
94+
packageSideEffects = this.#resolvePackageSideEffects(packageDir);
95+
this.#packageSideEffectsCache.set(packageDir, packageSideEffects);
96+
}
97+
98+
if (packageSideEffects instanceof Promise) {
99+
packageSideEffects = await packageSideEffects;
100+
this.#packageSideEffectsCache.set(packageDir, packageSideEffects);
101+
}
102+
103+
if (packageSideEffects !== null) {
104+
this.#fileSideEffectsCache.set(filePath, packageSideEffects);
105+
106+
return packageSideEffects;
107+
}
108+
}
109+
110+
// Fallback: per-file resolution via esbuild when outside node_modules,
111+
// or when the package sideEffects is non-boolean (array of globs, string, omitted).
112+
const resolutionPromise = (async () => {
113+
try {
114+
const { sideEffects } = await this.#build.resolve(filePath, {
115+
kind: 'import-statement',
116+
resolveDir: this.#workingDirectory,
117+
});
118+
119+
this.#fileSideEffectsCache.set(filePath, sideEffects);
120+
121+
return sideEffects;
122+
} catch (error) {
123+
this.#fileSideEffectsCache.delete(filePath);
124+
throw error;
125+
}
126+
})();
127+
128+
this.#fileSideEffectsCache.set(filePath, resolutionPromise);
129+
130+
return resolutionPromise;
131+
}
132+
133+
async #resolvePackageSideEffects(packageDir: string): Promise<boolean | null> {
134+
try {
135+
const packageJsonPath = path.join(packageDir, 'package.json');
136+
const packageJsonContent = await readFile(packageJsonPath, 'utf-8');
137+
const { sideEffects } = JSON.parse(packageJsonContent) as { sideEffects?: unknown };
138+
139+
return typeof sideEffects === 'boolean' ? sideEffects : null;
140+
} catch {
141+
return null;
142+
}
143+
}
144+
145+
/**
146+
* Clears all memoized package and file-level side-effects caches.
147+
*/
148+
clear(): void {
149+
this.#packageSideEffectsCache.clear();
150+
this.#fileSideEffectsCache.clear();
151+
}
152+
}
153+
154+
/**
155+
* Creates a side-effects resolver function that determines whether a file has side-effects.
156+
*
157+
* @param build The esbuild PluginBuild instance.
158+
* @param advancedOptimizations Whether advanced optimizations are enabled.
159+
* @returns An async function accepting a file path and returning whether the file has side-effects,
160+
* or `undefined` when `advancedOptimizations` is false.
161+
*/
162+
export function createSideEffectsResolver(
163+
build: PluginBuild,
164+
advancedOptimizations?: boolean,
165+
): (filePath: string) => Promise<boolean | undefined> {
166+
if (!advancedOptimizations) {
167+
return async () => undefined;
168+
}
169+
170+
const resolver = new SideEffectsResolver(build, advancedOptimizations);
171+
172+
return (filePath: string) => resolver.resolve(filePath);
173+
}

0 commit comments

Comments
 (0)