Skip to content

Commit 657a07b

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. (cherry picked from commit 7b9b7fe)
1 parent 7e12fdf commit 657a07b

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

Lines changed: 20 additions & 0 deletions
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

Lines changed: 5 additions & 6 deletions
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

Lines changed: 15 additions & 2 deletions
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
}
Lines changed: 49 additions & 0 deletions
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)