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); }); });