From 9f900033aef62e3bac4284a008edd99ac86aa495 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:15:08 -0400 Subject: [PATCH] refactor(@angular/build): bypass worker dispatch for untransformed files in JS transformer Make transformData symmetrical to accept both string and Uint8Array inputs, allowing transformFile to directly delegate to transformData after reading from disk or cache. When no transformations are required, untransformed files bypass worker pool dispatch, thread synchronization, and string decoding overhead. In addition, introduce a fast byte-level check on raw ASCII bytes using a pre-allocated comment buffer to immediately return untouched buffers when no sourcemap comment exists. --- .../esbuild/javascript-transformer-worker.ts | 4 + .../tools/esbuild/javascript-transformer.ts | 93 ++++++++++++------- .../esbuild/javascript-transformer_spec.ts | 52 +++++++++++ 3 files changed, 115 insertions(+), 34 deletions(-) diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts index 7dfefbb2de55..49ba241d99b3 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts @@ -89,6 +89,10 @@ export default async function transformJavaScript( const transformedData = await transformJavaScriptImpl(filename, textData, options); // Transfer the data via `move` instead of cloning + if (transformedData === textData && typeof data !== 'string') { + return Piscina.move(data); + } + return Piscina.move(textEncoder.encode(transformedData)); } diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts index de4103f57559..bb5c432c25b8 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts @@ -13,6 +13,8 @@ import { removeSourceMappingURL } from '../../utils/source-map'; import { WorkerPool, WorkerPoolOptions } from '../../utils/worker-pool'; import { Cache } from './cache'; +const SOURCEMAP_COMMENT_BYTES = Buffer.from('sourceMappingURL='); + /** * Transformation options that should apply to all transformed files and data. */ @@ -132,12 +134,10 @@ export class JavaScriptTransformer { return this.#runWithThrottle(async () => { const data = await readFile(filename); - let result; - let cacheKey; + 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. - // TODO: Consider xxhash or similar instead of SHA256 const hash = createHash('sha256'); hash.update(`${!!skipLinker}--${!!sideEffects}`); hash.update(data); @@ -145,37 +145,28 @@ export class JavaScriptTransformer { cacheKey = hash.digest('hex'); try { - result = await this.cache?.get(cacheKey); + const cached = await this.cache.get(cacheKey); + if (cached !== undefined) { + return cached; + } } catch { // Failure to get the value should not fail the transform } } - if (result === undefined) { - // If there is no cache or no cached entry, process the file - result = (await this.#ensureWorkerPool().run( - { - filename, - data, - skipLinker, - sideEffects, - instrumentForCoverage, - ...this.#commonOptions, - }, - { - // The below is disable as with Yarn PNP this causes build failures with the below message - // `Unable to deserialize cloned data`. - transferList: process.versions.pnp ? undefined : [data.buffer], - }, - )) as Uint8Array; - - // If there is a cache then store the result - 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 - } + 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 } } @@ -194,7 +185,7 @@ export class JavaScriptTransformer { */ async transformData( filename: string, - data: string, + data: string | Uint8Array, skipLinker: boolean, sideEffects?: boolean, instrumentForCoverage?: boolean, @@ -206,18 +197,52 @@ export class JavaScriptTransformer { this.#commonOptions.sourcemap && (!!this.#commonOptions.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename)); - return Buffer.from(keepSourcemap ? data : removeSourceMappingURL(data), 'utf-8'); + if (typeof data === 'string') { + return Buffer.from(keepSourcemap ? data : removeSourceMappingURL(data), 'utf-8'); + } + + if (keepSourcemap) { + return data; + } + + const dataBuffer = Buffer.isBuffer(data) + ? data + : Buffer.from(data.buffer, data.byteOffset, data.byteLength); + + // Fast check on raw ASCII bytes to avoid UTF-8 string decoding if no comment exists. + if (dataBuffer.indexOf(SOURCEMAP_COMMENT_BYTES) === -1) { + return data; + } + + const text = dataBuffer.toString('utf-8'); + const stripped = removeSourceMappingURL(text); + + return stripped === text ? data : Buffer.from(stripped, 'utf-8'); } - return this.#runWithThrottle(() => - this.#ensureWorkerPool().run({ + // 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. + // In addition, SharedArrayBuffers cannot be transferred, and Yarn PnP has deserialization issues. + const isTransferable = + typeof data !== 'string' && + data.buffer instanceof ArrayBuffer && + data.byteOffset === 0 && + data.byteLength === data.buffer.byteLength && + !process.versions.pnp; + + return this.#ensureWorkerPool().run( + { filename, data, skipLinker, sideEffects, instrumentForCoverage, ...this.#commonOptions, - }), + }, + { + transferList: isTransferable ? [data.buffer] : undefined, + }, ); } 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 5fa0c708675b..5cf7383ab7d0 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts @@ -239,4 +239,56 @@ describe('JavaScriptTransformer sourcemaps', () => { expect(typeof map?.['mappings']).toBe('string'); expect((map?.['mappings'] as string).length).toBeGreaterThan(0); }); + + it('should accept a Uint8Array input in transformData', async () => { + transformer = new JavaScriptTransformer( + { + sourcemap: true, + advancedOptimizations: true, + }, + 1, + ); + + const inputBuffer = Buffer.from('var x = new SomeClass();', 'utf-8'); + const result = await transformer.transformData('src/app.js', inputBuffer, true); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('src/app.js'); + expect(typeof map?.['mappings']).toBe('string'); + }); + + it('should strip trailing sourcemap comments from Uint8Array input on fast-path', async () => { + transformer = new JavaScriptTransformer( + { + sourcemap: false, + }, + 1, + ); + + const inputBuffer = Buffer.from( + 'console.log("hello");\n//# sourceMappingURL=app.js.map', + 'utf-8', + ); + const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, true); + const text = Buffer.from(result).toString('utf-8'); + + expect(text).toBe('console.log("hello");\n'); + }); + + it('should return Uint8Array input untouched on fast-path when no sourcemap comment is present', async () => { + transformer = new JavaScriptTransformer( + { + sourcemap: false, + }, + 1, + ); + + 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); + + expect(result).toBe(inputBuffer); + }); });