From a99f82f409128069fe8f58524db4ab218c56ff30 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:48:06 +0000 Subject: [PATCH] perf(@angular/build): wire module resolution and package.json caches in Angular host - Always wire `host.resolveModuleNameLiterals` using `resolutionCache` (backed by `packageJsonCache`) so `NgCompilerHost.wrap` delegates to it rather than creating an uncached internal `ModuleResolutionCache` without `packageJsonCache`. - Compute missing `ts.SourceFile` versions upfront in `ensureSourceFileVersions` instead of wrapping `program.getSourceFiles` in a closure that retains `previousProgram` across watch rebuilds. --- .../build/src/tools/angular/angular-host.ts | 104 ++++--- .../src/tools/angular/angular-host_spec.ts | 259 ++++++++++++++++++ 2 files changed, 322 insertions(+), 41 deletions(-) create mode 100644 packages/angular/build/src/tools/angular/angular-host_spec.ts diff --git a/packages/angular/build/src/tools/angular/angular-host.ts b/packages/angular/build/src/tools/angular/angular-host.ts index f30df5bab64d..b42235c8c22f 100644 --- a/packages/angular/build/src/tools/angular/angular-host.ts +++ b/packages/angular/build/src/tools/angular/angular-host.ts @@ -30,34 +30,27 @@ export interface AngularHostOptions { } /** - * Patches in-place the `getSourceFiles` function on an instance of a TypeScript - * `Program` to ensure that all returned SourceFile instances have a `version` - * field. The `version` field is required when used with a TypeScript BuilderProgram. - * @param program The TypeScript Program instance to patch. + * Ensures that all `SourceFile` instances in a TypeScript `Program` have a `version` + * field. The `version` field is required when used with a TypeScript `BuilderProgram`. + * @param program The TypeScript Program instance. + * @param previousProgram Optional previous BuilderProgram to reuse existing source file versions. */ export function ensureSourceFileVersions( program: ts.Program, previousProgram?: ts.BuilderProgram, ): void { - const baseGetSourceFiles = program.getSourceFiles; // TODO: Update Angular compiler to add versions to all internal files and remove this - program.getSourceFiles = function (...parameters) { - const files: readonly (ts.SourceFile & { version?: string })[] = baseGetSourceFiles( - ...parameters, - ); - - for (const file of files) { - if (file.version === undefined) { - const previousFile: (ts.SourceFile & { version?: string }) | undefined = - previousProgram && !file.fileName.endsWith('.ngtypecheck.ts') - ? previousProgram.getSourceFile(file.fileName) - : undefined; - file.version = previousFile?.version ?? calculateHash(file.text); - } + const files: readonly (ts.SourceFile & { version?: string })[] = program.getSourceFiles(); + + for (const file of files) { + if (file.version === undefined) { + const previousFile: (ts.SourceFile & { version?: string }) | undefined = + previousProgram && !file.fileName.endsWith('.ngtypecheck.ts') + ? previousProgram.getSourceFile(file.fileName) + : undefined; + file.version = previousFile?.version ?? calculateHash(file.text); } - - return files; - }; + } } function augmentHostWithCaching(host: ts.CompilerHost, cache: Map): void { @@ -94,39 +87,46 @@ function augmentResolveModuleNames( typescript: typeof ts, host: ts.CompilerHost, resolvedModuleModifier: ( - resolvedModule: ts.ResolvedModule | undefined, + resolvedModule: ts.ResolvedModuleFull | undefined, moduleName: string, - ) => ts.ResolvedModule | undefined, + ) => ts.ResolvedModuleFull | undefined, moduleResolutionCache?: ts.ModuleResolutionCache, ): void { - if (host.resolveModuleNames) { - const baseResolveModuleNames = host.resolveModuleNames; - host.resolveModuleNames = function (moduleNames: string[], ...parameters) { - return moduleNames.map((name) => { - const result = baseResolveModuleNames.call(host, [name], ...parameters); - - return resolvedModuleModifier(result[0], name); + if (host.resolveModuleNameLiterals) { + const baseResolveModuleNameLiterals = host.resolveModuleNameLiterals; + host.resolveModuleNameLiterals = function (moduleLiterals, ...parameters) { + return moduleLiterals.map((name) => { + const result = baseResolveModuleNameLiterals.call(host, [name], ...parameters); + + return { + ...result[0], + resolvedModule: resolvedModuleModifier(result[0].resolvedModule, name.text), + }; }); }; } else { - host.resolveModuleNames = function ( - moduleNames: string[], - containingFile: string, - _reusedNames: string[] | undefined, - redirectedReference: ts.ResolvedProjectReference | undefined, - options: ts.CompilerOptions, + host.resolveModuleNameLiterals = function ( + moduleLiterals, + containingFile, + redirectedReference, + options, + containingSourceFile, ) { - return moduleNames.map((name) => { + return moduleLiterals.map((name) => { const result = typescript.resolveModuleName( - name, + name.text, containingFile, options, host, moduleResolutionCache, redirectedReference, - ).resolvedModule; + containingSourceFile.impliedNodeFormat, + ); - return resolvedModuleModifier(result, name); + return { + ...result, + resolvedModule: resolvedModuleModifier(result.resolvedModule, name.text), + }; }); }; } @@ -151,10 +151,13 @@ function augmentHostWithReplacements( normalizedReplacements[normalizePath(key)] = normalizePath(value); } - const tryReplace = (resolvedModule: ts.ResolvedModule | undefined) => { + const tryReplace = ( + resolvedModule: ts.ResolvedModuleFull | undefined, + ): ts.ResolvedModuleFull | undefined => { const replacement = resolvedModule && normalizedReplacements[resolvedModule.resolvedFileName]; if (replacement) { return { + ...resolvedModule, resolvedFileName: replacement, isExternalLibraryImport: /[/\\]node_modules[/\\]/.test(replacement), }; @@ -254,6 +257,25 @@ export function createAngularCompilerHost( packageJsonCache, ); host.getModuleResolutionCache = () => resolutionCache; + host.resolveModuleNameLiterals = ( + moduleLiterals, + containingFile, + redirectedReference, + options, + containingSourceFile, + ) => { + return moduleLiterals.map((moduleLiteral) => { + return typescript.resolveModuleName( + moduleLiteral.text, + containingFile, + options, + host, + resolutionCache, + redirectedReference, + containingSourceFile.impliedNodeFormat, + ); + }); + }; // Augment TypeScript Host for file replacements option if (hostOptions.fileReplacements) { diff --git a/packages/angular/build/src/tools/angular/angular-host_spec.ts b/packages/angular/build/src/tools/angular/angular-host_spec.ts new file mode 100644 index 000000000000..5ebfbb0deaef --- /dev/null +++ b/packages/angular/build/src/tools/angular/angular-host_spec.ts @@ -0,0 +1,259 @@ +/** + * @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 ts from 'typescript'; +import { + type AngularHostOptions, + createAngularCompilerHost, + ensureSourceFileVersions, +} from './angular-host'; + +describe('angular-host', () => { + const baseHostOptions: AngularHostOptions = { + transformStylesheet: async () => null, + processWebWorker: (workerFile) => workerFile, + }; + + describe('createAngularCompilerHost', () => { + it('wires resolveModuleNameLiterals with resolutionCache and packageJsonCache when fileReplacements is undefined', () => { + const compilerOptions: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ES2022, + }; + const packageJsonCache = ts + .createModuleResolutionCache('/workspace', (fileName) => fileName) + .getPackageJsonInfoCache(); + const resolveSpy = jasmine.createSpy('resolveModuleName').and.returnValue({ + resolvedModule: { + resolvedFileName: '/workspace/src/dep.ts', + isExternalLibraryImport: false, + extension: ts.Extension.Ts, + }, + }); + const mockTs = { ...ts, resolveModuleName: resolveSpy } as unknown as typeof ts; + + const host = createAngularCompilerHost( + mockTs, + compilerOptions, + baseHostOptions, + packageJsonCache, + ); + const resolutionCache = host.getModuleResolutionCache?.(); + + expect(resolutionCache).toBeDefined(); + expect(resolutionCache?.getPackageJsonInfoCache()).toBe(packageJsonCache); + expect(host.resolveModuleNameLiterals).toBeDefined(); + + const containingSourceFile = ts.createSourceFile( + '/workspace/src/index.ts', + "import './dep';", + ts.ScriptTarget.ES2022, + ); + containingSourceFile.impliedNodeFormat = ts.ModuleKind.ESNext; + + const resolved = host.resolveModuleNameLiterals?.( + [ts.factory.createStringLiteral('./dep')], + '/workspace/src/index.ts', + undefined, + compilerOptions, + containingSourceFile, + undefined, + ); + + expect(resolved).toEqual([ + { + resolvedModule: { + resolvedFileName: '/workspace/src/dep.ts', + isExternalLibraryImport: false, + extension: ts.Extension.Ts, + }, + }, + ]); + expect(resolveSpy).toHaveBeenCalledOnceWith( + './dep', + '/workspace/src/index.ts', + compilerOptions, + host, + resolutionCache, + undefined, + ts.ModuleKind.ESNext, + ); + }); + + it('wires resolveModuleNameLiterals with resolutionCache when fileReplacements is empty', () => { + const compilerOptions: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + }; + const resolveSpy = jasmine.createSpy('resolveModuleName').and.returnValue({ + resolvedModule: undefined, + }); + const mockTs = { ...ts, resolveModuleName: resolveSpy } as unknown as typeof ts; + + const host = createAngularCompilerHost( + mockTs, + compilerOptions, + { ...baseHostOptions, fileReplacements: {} }, + undefined, + ); + const resolutionCache = host.getModuleResolutionCache?.(); + const containingSourceFile = ts.createSourceFile( + '/workspace/src/index.ts', + "import './missing';", + ts.ScriptTarget.ES2022, + ); + + expect(host.resolveModuleNameLiterals).toBeDefined(); + host.resolveModuleNameLiterals?.( + [ts.factory.createStringLiteral('./missing')], + '/workspace/src/index.ts', + undefined, + compilerOptions, + containingSourceFile, + undefined, + ); + + expect(resolveSpy).toHaveBeenCalledOnceWith( + './missing', + '/workspace/src/index.ts', + compilerOptions, + host, + resolutionCache, + undefined, + undefined, + ); + }); + + it('applies fileReplacements while still using resolutionCache', () => { + const compilerOptions: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + }; + const resolveSpy = jasmine.createSpy('resolveModuleName').and.returnValue({ + resolvedModule: { + resolvedFileName: '/workspace/src/env.ts', + isExternalLibraryImport: false, + extension: ts.Extension.Ts, + }, + }); + const mockTs = { ...ts, resolveModuleName: resolveSpy } as unknown as typeof ts; + + const host = createAngularCompilerHost( + mockTs, + compilerOptions, + { + ...baseHostOptions, + fileReplacements: { + '/workspace/src/env.ts': '/workspace/src/env.prod.ts', + }, + }, + undefined, + ); + const resolutionCache = host.getModuleResolutionCache?.(); + const containingSourceFile = ts.createSourceFile( + '/workspace/src/index.ts', + "import './env';", + ts.ScriptTarget.ES2022, + ); + + const resolved = host.resolveModuleNameLiterals?.( + [ts.factory.createStringLiteral('./env')], + '/workspace/src/index.ts', + undefined, + compilerOptions, + containingSourceFile, + undefined, + ); + + expect(resolved).toEqual([ + { + resolvedModule: { + resolvedFileName: '/workspace/src/env.prod.ts', + isExternalLibraryImport: false, + extension: ts.Extension.Ts, + }, + }, + ]); + expect(resolveSpy).toHaveBeenCalledOnceWith( + './env', + '/workspace/src/index.ts', + compilerOptions, + host, + resolutionCache, + undefined, + undefined, + ); + }); + }); + + describe('ensureSourceFileVersions', () => { + it('computes missing versions upfront and does not retain previousProgram on program', () => { + const existingSf = ts.createSourceFile( + '/src/existing.ts', + 'export const a = 1;', + ts.ScriptTarget.ES2022, + ) as ts.SourceFile & { version?: string }; + existingSf.version = 'existing-version'; + + const reusedFromPrevSf = ts.createSourceFile( + '/src/reused.ts', + 'export const b = 2;', + ts.ScriptTarget.ES2022, + ) as ts.SourceFile & { version?: string }; + + const freshSf = ts.createSourceFile( + '/src/fresh.ts', + 'export const c = 3;', + ts.ScriptTarget.ES2022, + ) as ts.SourceFile & { version?: string }; + + const ttcSf = ts.createSourceFile( + '/src/reused.ngtypecheck.ts', + 'export const ttc = 1;', + ts.ScriptTarget.ES2022, + ) as ts.SourceFile & { version?: string }; + + const getSourceFiles = () => [existingSf, reusedFromPrevSf, freshSf, ttcSf]; + const program = { + getSourceFiles, + } as unknown as ts.Program; + + const getSourceFileSpy = jasmine + .createSpy('getSourceFile') + .and.callFake((fileName: string) => { + if (fileName === '/src/reused.ts') { + return { version: 'previous-version' } as unknown as ts.SourceFile; + } + + return undefined; + }); + const previousProgram = { + getSourceFile: getSourceFileSpy, + } as unknown as ts.BuilderProgram; + + ensureSourceFileVersions(program, previousProgram); + + // Versions should be populated immediately without requiring another getSourceFiles() call + expect(existingSf.version).toBe('existing-version'); + expect(reusedFromPrevSf.version).toBe('previous-version'); + expect(freshSf.version).toBeDefined(); + expect(ttcSf.version).toBeDefined(); + expect(ttcSf.version).not.toBe('previous-version'); + + // previousProgram should only be queried for non-.ngtypecheck.ts files lacking a version + expect(getSourceFileSpy).toHaveBeenCalledTimes(2); + expect(getSourceFileSpy).toHaveBeenCalledWith('/src/reused.ts'); + expect(getSourceFileSpy).toHaveBeenCalledWith('/src/fresh.ts'); + + // program.getSourceFiles must not be wrapped in a closure retaining previousProgram + expect(program.getSourceFiles).toBe(getSourceFiles); + + getSourceFileSpy.calls.reset(); + program.getSourceFiles(); + expect(getSourceFileSpy).not.toHaveBeenCalled(); + }); + }); +});