diff --git a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts index bcebf9b110a0..92af13dfe497 100644 --- a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts +++ b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts @@ -526,15 +526,11 @@ export function createCompilerPlugin( } else if (typeof contents === 'string' && (useTypeScriptTranspilation || isJS)) { // A string indicates untransformed output from the TS/NG compiler. // This step is unneeded when using esbuild transpilation. - const sideEffects = await hasSideEffects(request); - const instrumentForCoverage = pluginOptions.instrumentForCoverage?.(request); - contents = await javascriptTransformer.transformData( - request, - contents, - true /* skipLinker */, - sideEffects, - instrumentForCoverage, - ); + contents = await javascriptTransformer.transformData(request, contents, { + skipLinker: true, + sideEffects: () => hasSideEffects(request), + instrumentForCoverage: pluginOptions.instrumentForCoverage?.(request), + }); // Store as the returned Uint8Array to allow caching the fully transformed code typeScriptFileCache.set(request, contents); @@ -573,12 +569,10 @@ export function createCompilerPlugin( return profileAsync( 'NG_EMIT_JS*', async () => { - const sideEffects = await hasSideEffects(request); - const contents = await javascriptTransformer.transformFile( - request, - pluginOptions.jit, - sideEffects, - ); + const contents = await javascriptTransformer.transformFile(request, { + skipLinker: pluginOptions.jit, + sideEffects: () => hasSideEffects(request), + }); return { contents, diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts index 824ee5817606..7cd4ffd1bf3f 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts @@ -33,9 +33,7 @@ const DECORATOR_TOKENS = ['__decorate', '__esDecorate'] as const; const DECORATOR_TOKEN_BYTES = DECORATOR_TOKENS.map((token) => Buffer.from(token, 'utf-8')); const ADVANCED_OPTIMIZATION_REGEX = new RegExp(ADVANCED_OPTIMIZATION_TOKENS.join('|')); -const ADVANCED_OPTIMIZATION_WITH_DECORATORS_REGEX = new RegExp( - [...ADVANCED_OPTIMIZATION_TOKENS, ...DECORATOR_TOKENS].join('|'), -); +const DECORATOR_REGEX = new RegExp(DECORATOR_TOKENS.join('|')); /** * Determines whether JavaScript code contains potential candidate constructs for advanced optimizations. @@ -43,43 +41,44 @@ const ADVANCED_OPTIMIZATION_WITH_DECORATORS_REGEX = new RegExp( * * @param filename The full path to the file. * @param data The data (string or Buffer) of the file. - * @param sideEffects If false, indicates the file is considered side-effect free. + * @param sideEffects An optional lazy resolver callback that returns whether the file is considered side-effect free. * @returns True if the code may contain constructs that advanced optimizations can mutate. */ -function hasAdvancedOptimizationCandidates( +async function hasAdvancedOptimizationCandidates( filename: string, data: string | Uint8Array, - sideEffects?: boolean, -): boolean { + sideEffects?: () => Promise, +): Promise { // Side-effect-free @angular/ packages undergo top-level pure function annotations - if (sideEffects === false && /[\\/]node_modules[\\/]@angular[\\/]/.test(filename)) { + if (/[\\/]node_modules[\\/]@angular[\\/]/.test(filename) && (await sideEffects?.()) === false) { return true; } if (typeof data === 'string') { - const regex = - sideEffects === false - ? ADVANCED_OPTIMIZATION_WITH_DECORATORS_REGEX - : ADVANCED_OPTIMIZATION_REGEX; + const hasDecorators = DECORATOR_REGEX.test(data); + if (hasDecorators && (await sideEffects?.()) === false) { + return true; + } - return regex.test(data); + return ADVANCED_OPTIMIZATION_REGEX.test(data); } const dataBuffer = Buffer.isBuffer(data) ? data : Buffer.from(data.buffer, data.byteOffset, data.byteLength); - for (const tokenBytes of ADVANCED_OPTIMIZATION_TOKEN_BYTES) { + for (const tokenBytes of DECORATOR_TOKEN_BYTES) { if (dataBuffer.includes(tokenBytes)) { - return true; + if ((await sideEffects?.()) === false) { + return true; + } + break; } } - if (sideEffects === false) { - for (const tokenBytes of DECORATOR_TOKEN_BYTES) { - if (dataBuffer.includes(tokenBytes)) { - return true; - } + for (const tokenBytes of ADVANCED_OPTIMIZATION_TOKEN_BYTES) { + if (dataBuffer.includes(tokenBytes)) { + return true; } } @@ -121,6 +120,23 @@ export interface JavaScriptTransformerOptions { jit?: boolean; } +/** + * Transformation options for an individual file or data transform request. + */ +export interface TransformOptions { + /** If true, bypass all Angular linker processing; if false, attempt linking. */ + skipLinker?: boolean; + + /** + * An optional lazy resolver callback that returns whether the file has side-effects. + * If it resolves to false, top-level pure function annotations and decorator wrapping may be applied. + */ + sideEffects?: () => Promise; + + /** If true, instrument the code for test coverage. */ + instrumentForCoverage?: boolean; +} + /** * A class that performs transformation of JavaScript files and raw data. * A worker pool is used to distribute the transformation actions and allow @@ -220,56 +236,14 @@ export class JavaScriptTransformer { * Performs JavaScript transformations on a file from the filesystem. * If no transformations are required, the data for the original file will be returned. * @param filename The full path to the file. - * @param skipLinker If true, bypass all Angular linker processing; if false, attempt linking. - * @param sideEffects If false, and `advancedOptimizations` is enabled tslib decorators are wrapped. + * @param options Transformation options specific to this file. * @returns A promise that resolves to a UTF-8 encoded Uint8Array containing the result. */ - async transformFile( - filename: string, - skipLinker?: boolean, - sideEffects?: boolean, - instrumentForCoverage?: boolean, - ): Promise { + async transformFile(filename: string, options?: TransformOptions): Promise { return this.#runWithThrottle(async () => { const data = await readFile(filename); - let cacheKey: string | undefined; - if (this.cache) { - // Create a cache key from the file data and options that effect the output. - // NOTE: If additional options are added, this may need to be updated. - const hasher = createContentHash(); - hasher.update(`${!!skipLinker}--${!!sideEffects}`); - hasher.update(data); - hasher.update(this.#fileCacheKeyBase); - cacheKey = hasher.digest(); - - try { - const cached = await this.cache.get(cacheKey); - if (cached !== undefined) { - return cached; - } - } catch { - // Failure to get the value should not fail the transform - } - } - - const result = await this.transformData( - filename, - data, - !!skipLinker, - sideEffects, - instrumentForCoverage, - ); - - if (this.cache && cacheKey) { - try { - await this.cache.put(cacheKey, result); - } catch { - // Failure to store the value in the cache should not fail the transform - } - } - - return result; + return this.transformData(filename, data, options); }); } @@ -278,25 +252,36 @@ export class JavaScriptTransformer { * to exist on the filesystem. * @param filename The full path of the file represented by the data. * @param data The data of the file that should be transformed. - * @param skipLinker If true, bypass all Angular linker processing; if false, attempt linking. - * @param sideEffects If false, and `advancedOptimizations` is enabled tslib decorators are wrapped. + * @param options Transformation options specific to this file data. * @returns A promise that resolves to a UTF-8 encoded Uint8Array containing the result. */ async transformData( filename: string, data: string | Uint8Array, - skipLinker: boolean, - sideEffects?: boolean, - instrumentForCoverage?: boolean, + options?: TransformOptions, ): Promise { - const shouldLink = !skipLinker && requiresLinking(filename, data); + let resolvedSideEffects: boolean | undefined; + let sideEffectsQueried = false; + + const sideEffectsGetter = options?.sideEffects + ? async () => { + if (!sideEffectsQueried) { + sideEffectsQueried = true; + resolvedSideEffects = await options.sideEffects?.(); + } + + return resolvedSideEffects; + } + : undefined; + + const shouldLink = !options?.skipLinker && requiresLinking(filename, data); const shouldOptimize = this.#commonOptions.advancedOptimizations && - hasAdvancedOptimizationCandidates(filename, data, sideEffects); + (await hasAdvancedOptimizationCandidates(filename, data, sideEffectsGetter)); // Perform a quick test to determine if the data needs any transformations. // This allows directly returning the data without the worker communication overhead. - if (!shouldLink && !shouldOptimize && !instrumentForCoverage) { + if (!shouldLink && !shouldOptimize && !options?.instrumentForCoverage) { const keepSourcemap = this.#commonOptions.sourcemap && (!!this.#commonOptions.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename)); @@ -308,6 +293,28 @@ export class JavaScriptTransformer { return keepSourcemap ? data : removeSourceMappingURL(data); } + let cacheKey: string | undefined; + if (this.cache) { + // Create a cache key from the file data and options that affect the output. + // NOTE: If additional options are added, this may need to be updated. + const hasher = createContentHash(); + hasher.update( + `${!options?.skipLinker}--${resolvedSideEffects === false}--${!!options?.instrumentForCoverage}`, + ); + hasher.update(data); + hasher.update(this.#fileCacheKeyBase); + cacheKey = hasher.digest(); + + try { + const cached = await this.cache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + } catch { + // Failure to get the value should not fail the transform + } + } + // Only standalone (non-pooled) ArrayBuffers can be transferred across worker threads. // Node.js shares an internal 8KB ArrayBuffer pool for small buffers, and transferring // a pooled buffer will throw a DataCloneError because detaching it invalidates other slices. @@ -319,18 +326,28 @@ export class JavaScriptTransformer { data.byteLength === data.buffer.byteLength && !process.versions.pnp; - return this.#ensureWorkerPool().run( + const result = (await this.#ensureWorkerPool().run( { filename, data, skipLinker: !shouldLink, - sideEffects, - instrumentForCoverage, + sideEffects: resolvedSideEffects, + instrumentForCoverage: options?.instrumentForCoverage, }, { transferList: isTransferable ? [data.buffer] : undefined, }, - ); + )) as Uint8Array; + + if (this.cache && cacheKey) { + try { + await this.cache.put(cacheKey, result); + } catch { + // Failure to store the value in the cache should not fail the transform + } + } + + return result; } /** diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts index 3949c9efbbdc..b818077be49b 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts @@ -45,7 +45,7 @@ describe('JavaScriptTransformer sourcemaps', () => { const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); const input = `export class MyClass { static ɵprov = 42; }\n//# sourceMappingURL=data:application/json;base64,${base64Map}`; - const result = await transformer.transformData('src/app.js', input, true); + const result = await transformer.transformData('src/app.js', input, { skipLinker: true }); const text = Buffer.from(result).toString('utf-8'); const map = extractSourcemap(text); @@ -86,11 +86,9 @@ describe('JavaScriptTransformer sourcemaps', () => { //# sourceMappingURL=data:application/json;base64,${base64Map} `; - const result = await transformer.transformData( - 'node_modules/my-lib/directive.js', - input, - false, - ); + const result = await transformer.transformData('node_modules/my-lib/directive.js', input, { + skipLinker: false, + }); const text = Buffer.from(result).toString('utf-8'); const map = extractSourcemap(text); @@ -133,11 +131,9 @@ describe('JavaScriptTransformer sourcemaps', () => { //# sourceMappingURL=data:application/json;base64,${base64Map} `; - const result = await transformer.transformData( - 'node_modules/my-lib/component.js', - input, - false, - ); + const result = await transformer.transformData('node_modules/my-lib/component.js', input, { + skipLinker: false, + }); const text = Buffer.from(result).toString('utf-8'); const map = extractSourcemap(text); @@ -158,7 +154,7 @@ describe('JavaScriptTransformer sourcemaps', () => { ); const input = 'export class MyClass { static ɵprov = 42; }'; - const result = await transformer.transformData('src/app.js', input, true); + const result = await transformer.transformData('src/app.js', input, { skipLinker: true }); const text = Buffer.from(result).toString('utf-8'); const map = extractSourcemap(text); @@ -187,13 +183,10 @@ describe('JavaScriptTransformer sourcemaps', () => { const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); const input = `export function add(a, b) { return a + b; }\n//# sourceMappingURL=data:application/json;base64,${base64Map}`; - const result = await transformer.transformData( - 'src/counter.js', - input, - true, - undefined, - true /* instrumentForCoverage */, - ); + const result = await transformer.transformData('src/counter.js', input, { + skipLinker: true, + instrumentForCoverage: true, + }); const text = Buffer.from(result).toString('utf-8'); const map = extractSourcemap(text); @@ -223,13 +216,10 @@ describe('JavaScriptTransformer sourcemaps', () => { const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); const input = `var x = new SomeClass();\n//# sourceMappingURL=data:application/json;base64,${base64Map}`; - const result = await transformer.transformData( - 'src/app.js', - input, - true, - undefined, - true /* instrumentForCoverage */, - ); + const result = await transformer.transformData('src/app.js', input, { + skipLinker: true, + instrumentForCoverage: true, + }); const text = Buffer.from(result).toString('utf-8'); const map = extractSourcemap(text); @@ -250,7 +240,7 @@ describe('JavaScriptTransformer sourcemaps', () => { ); const inputBuffer = Buffer.from('export class MyClass { static ɵprov = 42; }', 'utf-8'); - const result = await transformer.transformData('src/app.js', inputBuffer, true); + const result = await transformer.transformData('src/app.js', inputBuffer, { skipLinker: true }); const text = Buffer.from(result).toString('utf-8'); const map = extractSourcemap(text); @@ -272,7 +262,9 @@ describe('JavaScriptTransformer sourcemaps', () => { 'console.log("hello");\n//# sourceMappingURL=app.js.map', 'utf-8', ); - const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, true); + const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, { + skipLinker: true, + }); const text = Buffer.from(result).toString('utf-8'); expect(text).toBe('console.log("hello");\n'); @@ -287,7 +279,9 @@ describe('JavaScriptTransformer sourcemaps', () => { ); const inputBuffer = Buffer.from('console.log("hello");\nconst x = 1;', 'utf-8'); - const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, true); + const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, { + skipLinker: true, + }); expect(result).toBe(inputBuffer); }); @@ -301,11 +295,9 @@ describe('JavaScriptTransformer sourcemaps', () => { ); const inputBuffer = Buffer.from('console.log("no linking required");\nconst x = 1;', 'utf-8'); - const result = await transformer.transformData( - 'node_modules/my-lib/lib.js', - inputBuffer, - false, // skipLinker: false - ); + const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, { + skipLinker: false, + }); expect(result).toBe(inputBuffer); }); @@ -322,7 +314,7 @@ describe('JavaScriptTransformer sourcemaps', () => { const result = await transformer.transformData( 'node_modules/@angular/core/fesm2022/core.mjs', inputBuffer, - false, + { skipLinker: false }, ); expect(result).toBe(inputBuffer); @@ -339,7 +331,9 @@ describe('JavaScriptTransformer sourcemaps', () => { const inputBuffer = Buffer.from('export const ɵɵngDeclareDirective = () => {};', 'utf-8'); for (const ext of ['.ts', '.tsx', '.mts', '.cts']) { - const result = await transformer.transformData(`src/app/directive${ext}`, inputBuffer, false); + const result = await transformer.transformData(`src/app/directive${ext}`, inputBuffer, { + skipLinker: false, + }); expect(result).toBe(inputBuffer); } @@ -368,7 +362,7 @@ describe('JavaScriptTransformer sourcemaps', () => { const result = await transformer.transformData( 'node_modules/@angular/compiler-cli/test.js', input, - false, + { skipLinker: false }, ); const text = Buffer.from(result).toString('utf-8'); @@ -389,7 +383,9 @@ describe('JavaScriptTransformer sourcemaps', () => { 'function add(a, b) { return a + b; }\nconst result = add(1, 2);', 'utf-8', ); - const result = await transformer.transformData('src/math.js', inputBuffer, true); + const result = await transformer.transformData('src/math.js', inputBuffer, { + skipLinker: true, + }); expect(result).toBe(inputBuffer); }); @@ -410,7 +406,9 @@ describe('JavaScriptTransformer sourcemaps', () => { }`, 'utf-8', ); - const result = await transformer.transformData('src/user.service.js', inputBuffer, true); + const result = await transformer.transformData('src/user.service.js', inputBuffer, { + skipLinker: true, + }); expect(result).toBe(inputBuffer); }); @@ -431,7 +429,9 @@ describe('JavaScriptTransformer sourcemaps', () => { }`, 'utf-8', ); - const result = await transformer.transformData('src/user.service.js', inputBuffer, true); + const result = await transformer.transformData('src/user.service.js', inputBuffer, { + skipLinker: true, + }); expect(result).toBe(inputBuffer); }); @@ -446,7 +446,9 @@ describe('JavaScriptTransformer sourcemaps', () => { ); const inputBuffer = Buffer.from('export class MyComponent { static prop = 42; }', 'utf-8'); - const result = await transformer.transformData('src/component.js', inputBuffer, true); + const result = await transformer.transformData('src/component.js', inputBuffer, { + skipLinker: true, + }); expect(result).toBe(inputBuffer); }); @@ -461,7 +463,7 @@ describe('JavaScriptTransformer sourcemaps', () => { ); const input = 'export class MyService { static ɵprov = true; }'; - const result = await transformer.transformData('src/service.js', input, true); + const result = await transformer.transformData('src/service.js', input, { skipLinker: true }); const text = Buffer.from(result).toString('utf-8'); expect(text).toContain('let MyService = /*#__PURE__*/ (() => {'); @@ -477,7 +479,10 @@ describe('JavaScriptTransformer sourcemaps', () => { ); const inputBuffer = Buffer.from('const MyClass = __decorate([], class {});', 'utf-8'); - const result = await transformer.transformData('src/class.js', inputBuffer, true, false); + const result = await transformer.transformData('src/class.js', inputBuffer, { + skipLinker: true, + sideEffects: async () => false, + }); expect(result).not.toBe(inputBuffer); }); @@ -492,7 +497,9 @@ describe('JavaScriptTransformer sourcemaps', () => { ); const inputString = 'function multiply(a, b) { return a * b; }'; - const result = await transformer.transformData('src/math.js', inputString, true); + const result = await transformer.transformData('src/math.js', inputString, { + skipLinker: true, + }); expect(Buffer.from(result).toString('utf-8')).toBe(inputString); }); @@ -507,10 +514,168 @@ describe('JavaScriptTransformer sourcemaps', () => { ); const inputString = 'export class MyService { static ɵprov = true; }'; - const result = await transformer.transformData('src/service.js', inputString, true); + const result = await transformer.transformData('src/service.js', inputString, { + skipLinker: true, + }); const text = Buffer.from(result).toString('utf-8'); expect(text).toContain('let MyService = /*#__PURE__*/ (() => {'); }); + + it('should not query sideEffects when no candidate tokens are present', async () => { + transformer = new JavaScriptTransformer( + { + sourcemap: false, + advancedOptimizations: true, + }, + 1, + ); + + let queried = false; + const inputBuffer = Buffer.from('function add(a, b) { return a + b; }', 'utf-8'); + await transformer.transformData('src/math.js', inputBuffer, { + skipLinker: true, + sideEffects: async () => { + queried = true; + + return false; + }, + }); + + expect(queried).toBeFalse(); + }); + + it('should not query sideEffects when primary tokens are present', async () => { + transformer = new JavaScriptTransformer( + { + sourcemap: false, + advancedOptimizations: true, + }, + 1, + ); + + let queried = false; + const input = 'export class MyService { static ɵprov = true; }'; + await transformer.transformData('src/service.js', input, { + skipLinker: true, + sideEffects: async () => { + queried = true; + + return false; + }, + }); + + expect(queried).toBeFalse(); + }); + + it('should query sideEffects for @angular/ packages and dispatch to worker when sideEffects is false', async () => { + transformer = new JavaScriptTransformer( + { + sourcemap: false, + advancedOptimizations: true, + }, + 1, + ); + + let queryCount = 0; + const inputBuffer = Buffer.from('export const foo = someCall();', 'utf-8'); + const result = await transformer.transformData( + '/node_modules/@angular/core/fesm2022/index.mjs', + inputBuffer, + { + skipLinker: true, + sideEffects: async () => { + queryCount++; + + return false; + }, + }, + ); + + expect(queryCount).toBe(1); + expect(result).not.toBe(inputBuffer); + }); + + it('should query sideEffects for decorator tokens and evaluate at most once', async () => { + transformer = new JavaScriptTransformer( + { + sourcemap: false, + advancedOptimizations: true, + }, + 1, + ); + + let queryCount = 0; + const inputBuffer = Buffer.from('const MyClass = __decorate([], class {});', 'utf-8'); + const result = await transformer.transformData('src/class.js', inputBuffer, { + skipLinker: true, + sideEffects: async () => { + queryCount++; + + return false; + }, + }); + + expect(queryCount).toBe(1); + expect(result).not.toBe(inputBuffer); + }); + + it('should query sideEffects and wrap decorators when both primary and decorator tokens are present', async () => { + transformer = new JavaScriptTransformer( + { + sourcemap: false, + advancedOptimizations: true, + }, + 1, + ); + + let queryCount = 0; + const input = ` + let MyService = class MyService { static ɵprov = true; }; + MyService = __decorate([], MyService); + `; + const result = await transformer.transformData('src/service.js', input, { + skipLinker: true, + sideEffects: async () => { + queryCount++; + + return false; + }, + }); + + expect(queryCount).toBe(1); + const text = Buffer.from(result).toString('utf-8'); + expect(text).toContain('let MyService = /*#__PURE__*/ (() => {'); + expect(text).toContain('__decorate'); + }); + + it('should query sideEffects when both primary and decorator tokens are present in Buffer', async () => { + transformer = new JavaScriptTransformer( + { + sourcemap: false, + advancedOptimizations: true, + }, + 1, + ); + + let queryCount = 0; + const inputBuffer = Buffer.from( + 'let MyService = class MyService { static ɵprov = true; };\nMyService = __decorate([], MyService);', + 'utf-8', + ); + const result = await transformer.transformData('src/service.js', inputBuffer, { + skipLinker: true, + sideEffects: async () => { + queryCount++; + + return false; + }, + }); + + expect(queryCount).toBe(1); + const text = Buffer.from(result).toString('utf-8'); + expect(text).toContain('let MyService = /*#__PURE__*/ (() => {'); + expect(text).toContain('__decorate'); + }); }); });