From 85c641db9e6e7fb409b44e29bb18e46f3a0aa997 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:19:49 -0400 Subject: [PATCH] refactor(@angular/build): defer sourcemap remapping in javascript transformer worker Refactor the JavaScript transformer worker to collect intermediate decoded source maps in memory across pipeline passes and execute sourcemap remapping only once at the end of transformation. In addition, cache the oxc-transform module at module scope to avoid allocating dynamic import promises for every transformed file, and update oxc-linker and oxc-transform to directly return raw DecodedSourceMap objects without internal input sourcemap loading or intermediate remapping. --- .../src/tools/angular/linker/oxc-linker.ts | 16 +- .../tools/angular/linker/oxc-linker_spec.ts | 36 +-- .../esbuild/javascript-transformer-worker.ts | 73 ++++-- .../esbuild/javascript-transformer_spec.ts | 242 ++++++++++++++++++ .../build/src/tools/oxc/oxc-transform.ts | 18 +- .../build/src/tools/oxc/oxc-transform_spec.ts | 29 +-- 6 files changed, 305 insertions(+), 109 deletions(-) create mode 100644 packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts diff --git a/packages/angular/build/src/tools/angular/linker/oxc-linker.ts b/packages/angular/build/src/tools/angular/linker/oxc-linker.ts index 6743fb720c21..7d085502978b 100644 --- a/packages/angular/build/src/tools/angular/linker/oxc-linker.ts +++ b/packages/angular/build/src/tools/angular/linker/oxc-linker.ts @@ -7,7 +7,6 @@ */ import type { DecodedSourceMap } from '@ampproject/remapping'; -import remapping from '@ampproject/remapping'; import { ConsoleLogger, LogLevel } from '@angular/compiler-cli'; import type { DeclarationScope } from '@angular/compiler-cli/linker'; import { FileLinker, LinkerEnvironment, needsLinking } from '@angular/compiler-cli/linker'; @@ -18,7 +17,6 @@ import type { import type { CallExpression, Node } from '@oxc-project/types'; import MagicString from 'magic-string'; import { parseSync, visitorKeys } from 'oxc-parser'; -import { loadInputSourceMap } from '../../../utils/source-map'; import { OxcAstHost } from './oxc-ast-host'; import { StringAstFactory } from './string-ast-factory'; @@ -168,18 +166,10 @@ export function linkWithOxc(filename: string, code: string, options: OxcLinkerOp return { code, map: undefined }; } - let map: string | undefined; + let map: DecodedSourceMap | undefined; if (options.sourcemap) { - const inputMap = loadInputSourceMap(filename, code); - if (inputMap) { - const rawMap = s.generateDecodedMap({ hires: true, source: filename }); - map = remapping( - [{ ...rawMap, version: 3 } satisfies DecodedSourceMap, inputMap], - () => null, - ).toString(); - } else { - map = s.generateMap({ hires: true, source: filename }).toString(); - } + const rawMap = s.generateDecodedMap({ hires: true, source: filename }); + map = { ...rawMap, version: 3 }; } return { diff --git a/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts b/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts index 6b1ae742581a..5f5cf6d84fe9 100644 --- a/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts +++ b/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts @@ -54,7 +54,7 @@ describe('linkWithOxc', () => { expect(result.code).not.toContain('i0.ɵɵngDeclareComponent'); }); - it('should generate a sourcemap when sourcemap option is enabled', () => { + it('should generate a decoded sourcemap when sourcemap option is enabled', () => { const input = ` import * as i0 from "@angular/core"; export class MyDirective {} @@ -69,37 +69,7 @@ describe('linkWithOxc', () => { const result = linkWithOxc('test.js', input, { sourcemap: true }); expect(result.map).toBeDefined(); - const parsedMap = JSON.parse(result.map as string); - expect(parsedMap.version).toBe(3); - expect(parsedMap.sources).toContain('test.js'); - }); - - it('should remap with input sourcemap when sourcemap option is enabled and inputMap is present', () => { - const inputMap = { - version: 3, - sources: ['original.ts'], - sourcesContent: ['// original content'], - mappings: 'AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA', - names: [], - }; - const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); - const input = ` - import * as i0 from "@angular/core"; - export class MyDirective {} - MyDirective.ɵdir = i0.ɵɵngDeclareDirective({ - minVersion: "12.0.0", - version: "14.0.0", - ngImport: i0, - type: MyDirective, - selector: "[my-dir]" - }); - //# sourceMappingURL=data:application/json;base64,${base64Map} - `; - - const result = linkWithOxc('test.js', input, { sourcemap: true }); - expect(result.map).toBeDefined(); - const parsedMap = JSON.parse(result.map as string); - expect(parsedMap.version).toBe(3); - expect(parsedMap.sources).toContain('original.ts'); + expect(result.map?.version).toBe(3); + expect(result.map?.sources).toContain('test.js'); }); }); 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 ffc5feb6e008..7dfefbb2de55 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import remapping, { type DecodedSourceMap, type EncodedSourceMap } from '@ampproject/remapping'; import { type PluginItem, transformAsync } from '@babel/core'; import { createRequire } from 'node:module'; import Piscina from 'piscina'; @@ -39,7 +40,7 @@ async function instrumentCoverage( filename: string, data: string, useInputSourcemap: boolean, -): Promise { +): Promise<{ code: string; map?: EncodedSourceMap }> { try { let resolvedPath = 'istanbul-lib-instrument'; try { @@ -63,15 +64,14 @@ async function instrumentCoverage( filename, inputSourceMap as Parameters[2], ); - const lastMap = instrumenter.lastSourceMap(); - - if (useInputSourcemap && lastMap) { - const inlineMap = Buffer.from(JSON.stringify(lastMap)).toString('base64'); - - return instrumentedCode + `\n//# sourceMappingURL=data:application/json;base64,${inlineMap}`; - } - - return removeSourceMappingURL(instrumentedCode); + const lastMap = useInputSourcemap + ? (instrumenter.lastSourceMap() as EncodedSourceMap) + : undefined; + + return { + code: instrumentedCode, + map: lastMap ?? undefined, + }; } catch (error) { throw new Error( `The 'istanbul-lib-instrument' package is required for code coverage but was not found. Please install the package.`, @@ -97,6 +97,11 @@ export default async function transformJavaScript( */ let oxcLinkerModule: typeof import('../angular/linker/oxc-linker.js') | undefined; +/** + * Cached instance of the OXC transform module. + */ +let oxcTransformModule: typeof import('../oxc/oxc-transform.js') | undefined; + async function transformJavaScriptImpl( filename: string, data: string, @@ -108,9 +113,13 @@ async function transformJavaScriptImpl( (!!options.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename)); let code = data; + const maps: (DecodedSourceMap | EncodedSourceMap)[] = []; + let coverageMap: EncodedSourceMap | undefined; if (options.instrumentForCoverage) { - code = await instrumentCoverage(filename, code, useInputSourcemap); + const result = await instrumentCoverage(filename, code, useInputSourcemap); + code = result.code; + coverageMap = result.map; } if (shouldLink) { @@ -120,8 +129,8 @@ async function transformJavaScriptImpl( const result = await transformAsync(code, { filename, - inputSourceMap: (useInputSourcemap ? undefined : false) as undefined, - sourceMaps: useInputSourcemap ? 'inline' : false, + inputSourceMap: false, + sourceMaps: !!useInputSourcemap, compact: false, configFile: false, babelrc: false, @@ -144,6 +153,9 @@ async function transformJavaScriptImpl( }); code = result?.code ?? code; + if (result?.map) { + maps.push(result.map as EncodedSourceMap); + } } else { oxcLinkerModule ??= await import('../angular/linker/oxc-linker.js'); const result = oxcLinkerModule.linkWithOxc(filename, code, { @@ -152,39 +164,52 @@ async function transformJavaScriptImpl( skipCheck: true, }); code = result.code; - if (useInputSourcemap && result.map) { - code = removeSourceMappingURL(code); - const base64Map = Buffer.from(result.map).toString('base64'); - code += `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64Map}`; + if (result.map) { + maps.push(result.map); } } } // Run advanced optimizations using our fast oxc-transform if (options.advancedOptimizations) { - const { transform } = await import('../oxc/oxc-transform.js'); + oxcTransformModule ??= await import('../oxc/oxc-transform.js'); const sideEffectFree = options.sideEffects === false; const safeAngularPackage = sideEffectFree && /[\\/]node_modules[\\/]@angular[\\/]/.test(filename); const topLevelSafeMode = !safeAngularPackage; - const result = transform(filename, code, { + const result = oxcTransformModule.transform(filename, code, { sourcemap: useInputSourcemap, sideEffects: options.sideEffects, topLevelSafeMode, }); code = result.code; + if (result.map) { + maps.push(result.map); + } + } - if (useInputSourcemap && result.map) { - // Strip old source map comment if Babel added one + if (useInputSourcemap) { + const baseMap = coverageMap ?? loadInputSourceMap(filename, data); + if (maps.length > 0 || coverageMap) { code = removeSourceMappingURL(code); - const base64Map = Buffer.from(result.map).toString('base64'); - code += `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64Map}`; + const remappingChain: (DecodedSourceMap | EncodedSourceMap)[] = maps.reverse(); + if (baseMap) { + remappingChain.push(baseMap); + } + + if (remappingChain.length > 0) { + const finalMap = remapping(remappingChain, () => null).toString(); + const base64Map = Buffer.from(finalMap).toString('base64'); + code += `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64Map}`; + } } + + return code; } // Strip sourcemaps if they should not be used - return useInputSourcemap ? code : removeSourceMappingURL(code); + return removeSourceMappingURL(code); } function requiresLinking(path: string, source: string): boolean { diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts new file mode 100644 index 000000000000..5fa0c708675b --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts @@ -0,0 +1,242 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { JavaScriptTransformer } from './javascript-transformer'; + +describe('JavaScriptTransformer sourcemaps', () => { + let transformer: JavaScriptTransformer; + + afterEach(async () => { + await transformer?.close(); + }); + + function extractSourcemap(code: string): Record | null { + const match = code.match( + /\/\/# sourceMappingURL=data:application\/json;charset=utf-8;base64,(.+)/, + ); + if (!match) { + return null; + } + + return JSON.parse(Buffer.from(match[1], 'base64').toString('utf-8')); + } + + it('should remap correctly when only advanced optimizations are applied', async () => { + transformer = new JavaScriptTransformer( + { + sourcemap: true, + advancedOptimizations: true, + }, + 1, + ); + + const inputMap = { + version: 3, + sources: ['src/app.ts'], + sourcesContent: ['const x = new SomeClass();'], + mappings: 'AAAA', + names: [], + }; + 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); + 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.ts'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should remap correctly when only linking is applied', async () => { + transformer = new JavaScriptTransformer( + { + sourcemap: true, + thirdPartySourcemaps: true, + }, + 1, + ); + + const inputMap = { + version: 3, + sources: ['node_modules/my-lib/directive.ts'], + sourcesContent: ['export class MyDirective {}'], + mappings: 'AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA', + names: [], + }; + const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); + const input = ` + import * as i0 from "@angular/core"; + export class MyDirective {} + MyDirective.ɵdir = i0.ɵɵngDeclareDirective({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyDirective, + selector: "[my-dir]" + }); + //# sourceMappingURL=data:application/json;base64,${base64Map} + `; + + const result = await transformer.transformData( + 'node_modules/my-lib/directive.js', + input, + false, + ); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('node_modules/my-lib/directive.ts'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should defer and chain remapping when both linking and advanced optimizations are applied', async () => { + transformer = new JavaScriptTransformer( + { + sourcemap: true, + thirdPartySourcemaps: true, + advancedOptimizations: true, + }, + 1, + ); + + const inputMap = { + version: 3, + sources: ['node_modules/my-lib/component.ts'], + sourcesContent: ['export class MyComponent {}'], + mappings: 'AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA', + names: [], + }; + const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); + const input = ` + import * as i0 from "@angular/core"; + export class MyComponent {} + MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyComponent, + selector: "my-cmp", + template: "
" + }); + //# sourceMappingURL=data:application/json;base64,${base64Map} + `; + + const result = await transformer.transformData( + 'node_modules/my-lib/component.js', + input, + false, + ); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('node_modules/my-lib/component.ts'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should produce a valid sourcemap when no input sourcemap is present', async () => { + transformer = new JavaScriptTransformer( + { + sourcemap: true, + advancedOptimizations: true, + }, + 1, + ); + + const input = 'var x = new SomeClass();'; + const result = await transformer.transformData('src/app.js', input, 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'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should remap correctly when coverage instrumentation is applied with an input sourcemap', async () => { + transformer = new JavaScriptTransformer( + { + sourcemap: true, + }, + 1, + ); + + const inputMap = { + version: 3, + sources: ['src/counter.ts'], + sourcesContent: ['export function add(a: number, b: number) { return a + b; }'], + mappings: 'AAAA', + names: [], + }; + 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 text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('src/counter.ts'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should defer and chain remapping when coverage instrumentation and advanced optimizations are applied', async () => { + transformer = new JavaScriptTransformer( + { + sourcemap: true, + advancedOptimizations: true, + }, + 1, + ); + + const inputMap = { + version: 3, + sources: ['src/app.ts'], + sourcesContent: ['const x = new SomeClass();'], + mappings: 'AAAA', + names: [], + }; + 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 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.ts'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); +}); diff --git a/packages/angular/build/src/tools/oxc/oxc-transform.ts b/packages/angular/build/src/tools/oxc/oxc-transform.ts index 7f373ff09f08..2033cd836a05 100644 --- a/packages/angular/build/src/tools/oxc/oxc-transform.ts +++ b/packages/angular/build/src/tools/oxc/oxc-transform.ts @@ -6,11 +6,10 @@ * found in the LICENSE file at https://angular.dev/license */ -import remapping, { type DecodedSourceMap } from '@ampproject/remapping'; +import type { DecodedSourceMap } from '@ampproject/remapping'; import type { BindingIdentifier, Class, Node } from '@oxc-project/types'; import { MagicString } from 'magic-string'; import { Visitor, parseSync } from 'oxc-parser'; -import { loadInputSourceMap } from '../../utils/source-map'; export interface OxcTransformOptions { sourcemap?: boolean; @@ -747,19 +746,10 @@ export function transform(filename: string, code: string, options: OxcTransformO visitor.visit(program); - let map: string | undefined; + let map: DecodedSourceMap | undefined; if (options.sourcemap) { - const inputMap = loadInputSourceMap(filename, code); - - if (inputMap) { - const rawMap = s.generateDecodedMap({ hires: true, source: filename }); - map = remapping( - [{ ...rawMap, version: 3 } satisfies DecodedSourceMap, inputMap], - () => null, - ).toString(); - } else { - map = s.generateMap({ hires: true, source: filename }).toString(); - } + const rawMap = s.generateDecodedMap({ hires: true, source: filename }); + map = { ...rawMap, version: 3 }; } return { diff --git a/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts b/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts index ee37c7fec1fb..daa4cf554634 100644 --- a/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts +++ b/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts @@ -9,34 +9,13 @@ import { transform } from './oxc-transform'; describe('oxc-transform sourcemaps', () => { - it('should generate a sourcemap when sourcemap option is enabled without inputMap', () => { + it('should generate a decoded sourcemap when sourcemap option is enabled', () => { const input = 'var result = new SomeClass();'; const result = transform('test.js', input, { sourcemap: true }); expect(result.map).toBeDefined(); - const parsedMap = JSON.parse(result.map as string); - expect(parsedMap.version).toBe(3); - expect(parsedMap.sources).toContain('test.js'); - expect(parsedMap.mappings.length).toBeGreaterThan(0); - }); - - it('should remap with input sourcemap when sourcemap option is enabled and inputMap is present', () => { - const inputMap = { - version: 3, - sources: ['original.ts'], - sourcesContent: ['const result = new SomeClass();'], - mappings: 'AAAA', - names: [], - }; - const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); - const input = `var result = new SomeClass();\n//# sourceMappingURL=data:application/json;base64,${base64Map}`; - - const result = transform('test.js', input, { sourcemap: true }); - - expect(result.map).toBeDefined(); - const parsedMap = JSON.parse(result.map as string); - expect(parsedMap.version).toBe(3); - expect(parsedMap.sources).toContain('original.ts'); - expect(parsedMap.mappings.length).toBeGreaterThan(0); + expect(result.map?.version).toBe(3); + expect(result.map?.sources).toContain('test.js'); + expect(result.map?.mappings.length).toBeGreaterThan(0); }); });