Skip to content

Commit 7b9b7fe

Browse files
committed
fix(@angular-devkit/build-angular): treeshake unused class that use custom decorators
This changes enables wrapping classes in side-effect free modules that make use of custom decorators when using the esbuild based builders so that when such classes are unused they can be treeshaken.
1 parent 6473b01 commit 7b9b7fe

File tree

4 files changed

+89
-8
lines changed

4 files changed

+89
-8
lines changed

packages/angular_devkit/build_angular/src/tools/esbuild/angular/compiler-plugin.ts

+20
Original file line numberDiff line numberDiff line change
@@ -358,10 +358,12 @@ export function createCompilerPlugin(
358358
};
359359
} else if (typeof contents === 'string') {
360360
// A string indicates untransformed output from the TS/NG compiler
361+
const sideEffects = await hasSideEffects(request);
361362
contents = await javascriptTransformer.transformData(
362363
request,
363364
contents,
364365
true /* skipLinker */,
366+
sideEffects,
365367
);
366368

367369
// Store as the returned Uint8Array to allow caching the fully transformed code
@@ -380,9 +382,11 @@ export function createCompilerPlugin(
380382
return profileAsync(
381383
'NG_EMIT_JS*',
382384
async () => {
385+
const sideEffects = await hasSideEffects(args.path);
383386
const contents = await javascriptTransformer.transformFile(
384387
args.path,
385388
pluginOptions.jit,
389+
sideEffects,
386390
);
387391

388392
return {
@@ -430,6 +434,22 @@ export function createCompilerPlugin(
430434
void stylesheetBundler.dispose();
431435
void compilation.close?.();
432436
});
437+
438+
/**
439+
* Checks if the file has side-effects when `advancedOptimizations` is enabled.
440+
*/
441+
async function hasSideEffects(path: string): Promise<boolean | undefined> {
442+
if (!pluginOptions.advancedOptimizations) {
443+
return undefined;
444+
}
445+
446+
const { sideEffects } = await build.resolve(path, {
447+
kind: 'import-statement',
448+
resolveDir: build.initialOptions.absWorkingDir ?? '',
449+
});
450+
451+
return sideEffects;
452+
}
433453
},
434454
};
435455
}

packages/angular_devkit/build_angular/src/tools/esbuild/javascript-transformer-worker.ts

+5-6
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ interface JavaScriptTransformRequest {
1717
sourcemap: boolean;
1818
thirdPartySourcemaps: boolean;
1919
advancedOptimizations: boolean;
20-
skipLinker: boolean;
20+
skipLinker?: boolean;
21+
sideEffects?: boolean;
2122
jit: boolean;
2223
}
2324

@@ -50,11 +51,8 @@ async function transformWithBabel({
5051
return useInputSourcemap ? data : data.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, '');
5152
}
5253

53-
// `@angular/platform-server/init` and `@angular/common/locales/global` entry-points are side effectful.
54-
const safeAngularPackage =
55-
/[\\/]node_modules[\\/]@angular[\\/]/.test(filename) &&
56-
!/@angular[\\/]platform-server[\\/]f?esm2022[\\/]init/.test(filename) &&
57-
!/@angular[\\/]common[\\/]locales[\\/]global/.test(filename);
54+
const sideEffectFree = options.sideEffects === false;
55+
const safeAngularPackage = sideEffectFree && /[\\/]node_modules[\\/]@angular[\\/]/.test(filename);
5856

5957
// Lazy load the linker plugin only when linking is required
6058
if (shouldLink) {
@@ -85,6 +83,7 @@ async function transformWithBabel({
8583
},
8684
optimize: options.advancedOptimizations && {
8785
pureTopLevel: safeAngularPackage,
86+
wrapDecorators: sideEffectFree,
8887
},
8988
},
9089
],

packages/angular_devkit/build_angular/src/tools/esbuild/javascript-transformer.ts

+15-2
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,14 @@ export class JavaScriptTransformer {
7272
* If no transformations are required, the data for the original file will be returned.
7373
* @param filename The full path to the file.
7474
* @param skipLinker If true, bypass all Angular linker processing; if false, attempt linking.
75+
* @param sideEffects If false, and `advancedOptimizations` is enabled tslib decorators are wrapped.
7576
* @returns A promise that resolves to a UTF-8 encoded Uint8Array containing the result.
7677
*/
77-
transformFile(filename: string, skipLinker?: boolean): Promise<Uint8Array> {
78+
transformFile(
79+
filename: string,
80+
skipLinker?: boolean,
81+
sideEffects?: boolean,
82+
): Promise<Uint8Array> {
7883
const pendingKey = `${!!skipLinker}--${filename}`;
7984
let pending = this.#pendingfileResults?.get(pendingKey);
8085
if (pending === undefined) {
@@ -83,6 +88,7 @@ export class JavaScriptTransformer {
8388
pending = this.#ensureWorkerPool().run({
8489
filename,
8590
skipLinker,
91+
sideEffects,
8692
...this.#commonOptions,
8793
});
8894

@@ -98,9 +104,15 @@ export class JavaScriptTransformer {
98104
* @param filename The full path of the file represented by the data.
99105
* @param data The data of the file that should be transformed.
100106
* @param skipLinker If true, bypass all Angular linker processing; if false, attempt linking.
107+
* @param sideEffects If false, and `advancedOptimizations` is enabled tslib decorators are wrapped.
101108
* @returns A promise that resolves to a UTF-8 encoded Uint8Array containing the result.
102109
*/
103-
async transformData(filename: string, data: string, skipLinker: boolean): Promise<Uint8Array> {
110+
async transformData(
111+
filename: string,
112+
data: string,
113+
skipLinker: boolean,
114+
sideEffects?: boolean,
115+
): Promise<Uint8Array> {
104116
// Perform a quick test to determine if the data needs any transformations.
105117
// This allows directly returning the data without the worker communication overhead.
106118
if (skipLinker && !this.#commonOptions.advancedOptimizations) {
@@ -118,6 +130,7 @@ export class JavaScriptTransformer {
118130
filename,
119131
data,
120132
skipLinker,
133+
sideEffects,
121134
...this.#commonOptions,
122135
});
123136
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import assert from 'assert';
2+
import { appendToFile, expectFileToExist, expectFileToMatch, readFile } from '../../../utils/fs';
3+
import { ng } from '../../../utils/process';
4+
import { libraryConsumptionSetup } from './setup';
5+
import { updateJsonFile } from '../../../utils/project';
6+
import { expectToFail } from '../../../utils/utils';
7+
8+
export default async function () {
9+
await ng('cache', 'off');
10+
await libraryConsumptionSetup();
11+
12+
// Add an unused class as part of the public api.
13+
await appendToFile(
14+
'projects/my-lib/src/public-api.ts',
15+
`
16+
function something() {
17+
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
18+
console.log("someDecorator");
19+
};
20+
}
21+
22+
export class ExampleClass {
23+
@something()
24+
method() {}
25+
}
26+
`,
27+
);
28+
29+
// build the lib
30+
await ng('build', 'my-lib', '--configuration=production');
31+
const packageJson = JSON.parse(await readFile('dist/my-lib/package.json'));
32+
assert.equal(packageJson.sideEffects, false);
33+
34+
// build the app
35+
await ng('build', 'test-project', '--configuration=production', '--output-hashing=none');
36+
// Output should not contain `ExampleClass` as the library is marked as side-effect free.
37+
await expectFileToExist('dist/test-project/browser/main.js');
38+
await expectToFail(() => expectFileToMatch('dist/test-project/browser/main.js', 'someDecorator'));
39+
40+
// Mark library as side-effectful.
41+
await updateJsonFile('dist/my-lib/package.json', (packageJson) => {
42+
packageJson.sideEffects = true;
43+
});
44+
45+
// build the app
46+
await ng('build', 'test-project', '--configuration=production', '--output-hashing=none');
47+
// Output should contain `ExampleClass` as the library is marked as side-effectful.
48+
await expectFileToMatch('dist/test-project/browser/main.js', 'someDecorator');
49+
}

0 commit comments

Comments
 (0)