From 7a72208eb5f89b972ab72299b26002b57d58a7bb Mon Sep 17 00:00:00 2001 From: Ben Snyder Date: Mon, 7 Sep 2026 19:19:48 +0000 Subject: [PATCH 1/5] fix(vite-plugin-angular): emit source-linked workspace packages Promote requested external TypeScript sources before retrying Angular emit. Closes analogjs/analog#2507 Co-Authored-By: OpenAI Codex --- .../src/lib/angular-vite-plugin.spec.ts | 68 +++++++++++++++++++ .../src/lib/angular-vite-plugin.ts | 28 +++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.spec.ts b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.spec.ts index f731495d3c..ad55c60813 100644 --- a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.spec.ts +++ b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.spec.ts @@ -1074,6 +1074,74 @@ export class AppComponent {} } } + it('emits source-linked workspace packages without an explicit include', async () => { + const libDir = path.join(fixtureDir, 'lib'); + realFs.mkdirSync(libDir, { recursive: true }); + realFs.mkdirSync(path.join(fixtureDir, 'node_modules'), { + recursive: true, + }); + realFs.writeFileSync( + path.join(libDir, 'package.json'), + JSON.stringify({ + name: 'linked-lib', + type: 'module', + exports: './index.ts', + }), + ); + realFs.writeFileSync( + path.join(libDir, 'index.ts'), + "export { DemoDirective } from './directive';", + ); + realFs.writeFileSync( + path.join(libDir, 'directive.ts'), + "import { Directive } from '@angular/core'; @Directive({ selector: '[demo]', standalone: true }) export class DemoDirective {}", + ); + realFs.symlinkSync( + libDir, + path.join(fixtureDir, 'node_modules/linked-lib'), + 'junction', + ); + realFs.appendFileSync( + componentPath, + "\nexport { DemoDirective } from 'linked-lib';", + ); + const mainPlugin = createAppBuildPlugin(); + await mainPlugin.config( + { root: fixtureDir, build: {} }, + { command: 'build' }, + ); + mainPlugin.configResolved({ + root: fixtureDir, + mode: 'production', + build: {}, + server: { watch: {} }, + safeModulePaths: new Set(), + }); + const ctx = { warn: vi.fn(), error: vi.fn(), addWatchFile: vi.fn() }; + await mainPlugin.buildStart.call(ctx); + const transform = async (name: string) => { + const id = normalizePath(path.join(libDir, name)); + return mainPlugin.transform.handler.call( + ctx, + realFs.readFileSync(id, 'utf8'), + id, + ); + }; + expect((await transform('index.ts'))?.code).toContain('DemoDirective'); + expect((await transform('directive.ts'))?.code).toContain('ɵdir'); + expect((await transform('index.ts'))?.code).toContain('DemoDirective'); + realFs.appendFileSync( + path.join(libDir, 'directive.ts'), + '\nexport const updated = 42;', + ); + await mainPlugin.handleHotUpdate({ + file: normalizePath(path.join(libDir, 'directive.ts')), + modules: [], + }); + expect((await transform('directive.ts'))?.code).toContain('updated = 42'); + expect(ctx.warn).not.toHaveBeenCalled(); + }, 60_000); + it('waits for the initial compilation before emitting a transform result', async () => { const mainPlugin = createAppBuildPlugin(); diff --git a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts index f21274d579..a64dbc56fd 100644 --- a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts +++ b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts @@ -211,6 +211,7 @@ export function angular(options?: PluginOptions): Plugin[] { let cachedHost: ts.CompilerHost | undefined; let cachedHostKey: string | undefined; let includeCache: string[] = []; + const linkedSourceRoots = new Set(); function invalidateFsCaches() { includeCache = []; } @@ -380,6 +381,7 @@ export function angular(options?: PluginOptions): Plugin[] { server.watcher.on('unlink', invalidateCompilationOnFsChange); server.watcher.on('change', (file) => { if (file.includes('tsconfig')) { + linkedSourceRoots.clear(); invalidateTsconfigCaches(); } }); @@ -733,7 +735,22 @@ export function angular(options?: PluginOptions): Plugin[] { pendingCompilation = null; } - const typescriptResult = fileEmitter(id); + let typescriptResult = fileEmitter(id); + const sourceFile = builder?.getSourceFile(id); + if ( + typescriptResult?.content === '' && + sourceFile && + builder!.getProgram().isSourceFileFromExternalLibrary(sourceFile) && + !linkedSourceRoots.has(normalizePath(sourceFile.fileName)) + ) { + // TypeScript skips emit for source-linked package entries until + // they are roots. Only promote files requested by Vite. + linkedSourceRoots.add(normalizePath(sourceFile.fileName)); + pendingCompilation = performCompilation(resolvedConfig, [id]); + await pendingCompilation; + pendingCompilation = null; + typescriptResult = fileEmitter(id); + } // File not in the Angular program — skip and let other plugins // or Vite's built-in transform handle it. Warn if it looks like @@ -1278,7 +1295,14 @@ export function angular(options?: PluginOptions): Plugin[] { ), ); // Merge + dedupe root names - rootNames = [...new Set([...rootNames, ...includeCache, ...replacements])]; + rootNames = [ + ...new Set([ + ...rootNames, + ...includeCache, + ...replacements, + ...linkedSourceRoots, + ]), + ]; const hostKey = JSON.stringify(tsCompilerOptions); let host: ts.CompilerHost; From 184d6d717ce0164d353dc1b22eef6e299190e236 Mon Sep 17 00:00:00 2001 From: Ben Snyder Date: Mon, 7 Sep 2026 19:49:30 +0000 Subject: [PATCH 2/5] fix(vite-plugin-angular): discard deleted linked-source roots Invalidate promoted roots and cached output on unlink and tsconfig replacement. Co-Authored-By: OpenAI Codex --- .../src/lib/angular-vite-plugin.spec.ts | 20 +++++++++++++++++++ .../src/lib/angular-vite-plugin.ts | 16 +++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.spec.ts b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.spec.ts index ad55c60813..3067d44637 100644 --- a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.spec.ts +++ b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.spec.ts @@ -1139,6 +1139,26 @@ export class AppComponent {} modules: [], }); expect((await transform('directive.ts'))?.code).toContain('updated = 42'); + const watchers = new Map void>(); + mainPlugin.configureServer({ + watcher: { + on: (event: string, handler: (file: string) => void) => + watchers.set(event, handler), + }, + }); + const barrel = normalizePath(path.join(libDir, 'index.ts')); + realFs.rmSync(barrel); + vi.useFakeTimers(); + try { + watchers.get('unlink')!(barrel); + await vi.advanceTimersByTimeAsync(100); + await mainPlugin.buildStart.call(ctx); + expect( + await mainPlugin.transform.handler.call(ctx, '', barrel), + ).toBeUndefined(); + } finally { + vi.useRealTimers(); + } expect(ctx.warn).not.toHaveBeenCalled(); }, 60_000); diff --git a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts index a64dbc56fd..9c7e970be6 100644 --- a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts +++ b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts @@ -377,8 +377,20 @@ export function angular(options?: PluginOptions): Plugin[] { `${normalizePath(resolve(pluginOptions.workspaceRoot))}${glob}`, ), ); - server.watcher.on('add', invalidateCompilationOnFsChange); - server.watcher.on('unlink', invalidateCompilationOnFsChange); + server.watcher.on('add', (file) => { + if (basename(file).includes('tsconfig')) linkedSourceRoots.clear(); + invalidateCompilationOnFsChange(file); + }); + server.watcher.on('unlink', (file) => { + const id = normalizePath(file); + if (linkedSourceRoots.delete(id)) { + outputFiles.delete(id); + fileTransformMap.delete(id); + sourceFileCache.delete(id); + } + if (basename(file).includes('tsconfig')) linkedSourceRoots.clear(); + invalidateCompilationOnFsChange(file); + }); server.watcher.on('change', (file) => { if (file.includes('tsconfig')) { linkedSourceRoots.clear(); From adf00dacf9bfa7d37dda73931cdde85ee54778f9 Mon Sep 17 00:00:00 2001 From: Ben Snyder Date: Mon, 7 Sep 2026 20:01:26 +0000 Subject: [PATCH 3/5] build(vite-plugin-angular): retry distributed CI after missing build output The previous top-bar:test job could not resolve the compiler package before its build output was available. The same target passes locally with the built package. Retry the unchanged tree once. From b243828147b6a3e996c5a074ee0f05d77ff524a5 Mon Sep 17 00:00:00 2001 From: Ben Snyder Date: Mon, 7 Sep 2026 22:55:04 +0000 Subject: [PATCH 4/5] perf(vite-plugin-angular): resolve linked sources before compilation Classify workspace TypeScript sources during module resolution instead of rebuilding the Angular program for each newly requested linked module. Retain TypeScript's cache and per-import resolution mode. Cover AOT/JIT build and serve paths, concurrent transforms, incremental edits, external-package boundaries, and removed sources. The AOT fixture creates one program instead of ten. Refs analogjs/analog#2544. --- .../src/lib/angular-vite-plugin.spec.ts | 249 ++++++++++++------ .../src/lib/angular-vite-plugin.ts | 91 ++++--- 2 files changed, 218 insertions(+), 122 deletions(-) diff --git a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.spec.ts b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.spec.ts index 3067d44637..1c87a93410 100644 --- a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.spec.ts +++ b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.spec.ts @@ -3,6 +3,19 @@ import * as realFs from 'node:fs'; import { tmpdir } from 'node:os'; import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; import { normalizePath, preprocessCSS } from 'vite'; +import { NgtscProgram } from '@angular/compiler-cli'; + +vi.mock('@angular/compiler-cli', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + NgtscProgram: vi.fn(function ( + ...args: ConstructorParameters + ) { + return new actual.NgtscProgram(...args); + }), + }; +}); vi.mock('vite', async () => { const actual = await vi.importActual('vite'); @@ -25,6 +38,7 @@ import { mapTemplateUpdatesToFiles, toAngularCompilationFileReplacements, isTestWatchMode, + type PluginOptions, } from './angular-vite-plugin'; import type { EmitFileResult } from './models'; @@ -1056,7 +1070,7 @@ export class AppComponent {} // The plugin reads these at creation time to pick the AOT/JIT path, so an // app build has to be simulated by clearing Vitest's own markers. - function createAppBuildPlugin() { + function createAppBuildPlugin(options: PluginOptions = {}) { const { VITEST, NODE_ENV } = process.env; delete process.env['VITEST']; delete process.env['NODE_ENV']; @@ -1065,6 +1079,7 @@ export class AppComponent {} return angular({ tsconfig: path.join(fixtureDir, 'tsconfig.json'), workspaceRoot: fixtureDir, + ...options, }).find((p) => p.name === '@analogjs/vite-plugin-angular') as any; } finally { process.env['VITEST'] = VITEST as string; @@ -1074,93 +1089,155 @@ export class AppComponent {} } } - it('emits source-linked workspace packages without an explicit include', async () => { - const libDir = path.join(fixtureDir, 'lib'); - realFs.mkdirSync(libDir, { recursive: true }); - realFs.mkdirSync(path.join(fixtureDir, 'node_modules'), { - recursive: true, - }); - realFs.writeFileSync( - path.join(libDir, 'package.json'), - JSON.stringify({ - name: 'linked-lib', - type: 'module', - exports: './index.ts', - }), - ); - realFs.writeFileSync( - path.join(libDir, 'index.ts'), - "export { DemoDirective } from './directive';", - ); - realFs.writeFileSync( - path.join(libDir, 'directive.ts'), - "import { Directive } from '@angular/core'; @Directive({ selector: '[demo]', standalone: true }) export class DemoDirective {}", - ); - realFs.symlinkSync( - libDir, - path.join(fixtureDir, 'node_modules/linked-lib'), - 'junction', - ); - realFs.appendFileSync( - componentPath, - "\nexport { DemoDirective } from 'linked-lib';", - ); - const mainPlugin = createAppBuildPlugin(); - await mainPlugin.config( - { root: fixtureDir, build: {} }, - { command: 'build' }, - ); - mainPlugin.configResolved({ - root: fixtureDir, - mode: 'production', - build: {}, - server: { watch: {} }, - safeModulePaths: new Set(), - }); - const ctx = { warn: vi.fn(), error: vi.fn(), addWatchFile: vi.fn() }; - await mainPlugin.buildStart.call(ctx); - const transform = async (name: string) => { - const id = normalizePath(path.join(libDir, name)); - return mainPlugin.transform.handler.call( - ctx, - realFs.readFileSync(id, 'utf8'), - id, + it.each([ + { command: 'build', jit: false }, + { command: 'serve', jit: false }, + { command: 'build', jit: true }, + { command: 'serve', jit: true }, + ])( + 'emits source-linked workspace packages in $command mode (jit=$jit)', + async ({ command, jit }) => { + const libDir = path.join(fixtureDir, 'lib'); + realFs.mkdirSync(libDir, { recursive: true }); + realFs.mkdirSync(path.join(fixtureDir, 'node_modules'), { + recursive: true, + }); + realFs.writeFileSync( + path.join(libDir, 'package.json'), + JSON.stringify({ + name: 'linked-lib', + type: 'module', + exports: { + '.': './index.ts', + './types': './types.d.ts', + './*': './*.ts', + }, + }), ); - }; - expect((await transform('index.ts'))?.code).toContain('DemoDirective'); - expect((await transform('directive.ts'))?.code).toContain('ɵdir'); - expect((await transform('index.ts'))?.code).toContain('DemoDirective'); - realFs.appendFileSync( - path.join(libDir, 'directive.ts'), - '\nexport const updated = 42;', - ); - await mainPlugin.handleHotUpdate({ - file: normalizePath(path.join(libDir, 'directive.ts')), - modules: [], - }); - expect((await transform('directive.ts'))?.code).toContain('updated = 42'); - const watchers = new Map void>(); - mainPlugin.configureServer({ - watcher: { - on: (event: string, handler: (file: string) => void) => - watchers.set(event, handler), - }, - }); - const barrel = normalizePath(path.join(libDir, 'index.ts')); - realFs.rmSync(barrel); - vi.useFakeTimers(); - try { - watchers.get('unlink')!(barrel); - await vi.advanceTimersByTimeAsync(100); + realFs.writeFileSync( + path.join(libDir, 'index.ts'), + "export { DemoDirective } from './directive';", + ); + realFs.writeFileSync( + path.join(libDir, 'directive.ts'), + "import { Directive } from '@angular/core'; @Directive({ selector: '[demo]', standalone: true }) export class DemoDirective {}", + ); + const declarationPath = path.join(libDir, 'types.d.ts'); + realFs.writeFileSync( + declarationPath, + 'export interface LinkedType { value: string; }', + ); + const installedDir = path.join(fixtureDir, 'node_modules/installed-lib'); + realFs.mkdirSync(installedDir, { recursive: true }); + realFs.writeFileSync( + path.join(installedDir, 'package.json'), + JSON.stringify({ name: 'installed-lib', exports: './index.ts' }), + ); + const installedPath = path.join(installedDir, 'index.ts'); + realFs.writeFileSync(installedPath, 'export const installed = 42;'); + realFs.symlinkSync( + libDir, + path.join(fixtureDir, 'node_modules/linked-lib'), + 'junction', + ); + realFs.appendFileSync( + componentPath, + "\nexport { DemoDirective } from 'linked-lib';\nexport type { LinkedType } from 'linked-lib/types';\nexport { installed } from 'installed-lib';", + ); + const entries = Array.from({ length: 8 }, (_, index) => `entry${index}`); + for (const entry of entries) { + realFs.writeFileSync( + path.join(libDir, `${entry}.ts`), + `export const ${entry} = 42;`, + ); + realFs.appendFileSync( + componentPath, + `\nexport { ${entry} } from 'linked-lib/${entry}';`, + ); + } + vi.mocked(NgtscProgram).mockClear(); + const mainPlugin = createAppBuildPlugin({ + disableTypeChecking: false, + jit, + }); + await mainPlugin.config({ root: fixtureDir, build: {} }, { command }); + mainPlugin.configResolved({ + root: fixtureDir, + mode: 'production', + build: {}, + server: { watch: {} }, + safeModulePaths: new Set(), + }); + const ctx = { warn: vi.fn(), error: vi.fn(), addWatchFile: vi.fn() }; await mainPlugin.buildStart.call(ctx); - expect( - await mainPlugin.transform.handler.call(ctx, '', barrel), - ).toBeUndefined(); - } finally { - vi.useRealTimers(); - } - expect(ctx.warn).not.toHaveBeenCalled(); - }, 60_000); + if (!jit) { + const program = vi + .mocked(NgtscProgram) + .mock.results[0].value.getTsProgram(); + for (const externalPath of [declarationPath, installedPath]) { + const sourceFile = program.getSourceFile(normalizePath(externalPath)); + expect(sourceFile).toBeDefined(); + expect(program.isSourceFileFromExternalLibrary(sourceFile)).toBe( + true, + ); + } + } + const transform = async (name: string) => { + const id = normalizePath(path.join(libDir, name)); + return mainPlugin.transform.handler.call( + ctx, + realFs.readFileSync(id, 'utf8'), + id, + ); + }; + expect((await transform('index.ts'))?.code).toContain('DemoDirective'); + expect((await transform('directive.ts'))?.code).toContain( + jit ? '__decorate' : 'ɵdir', + ); + expect((await transform('index.ts'))?.code).toContain('DemoDirective'); + const results = await Promise.all( + entries.map((entry) => transform(`${entry}.ts`)), + ); + results.forEach((result, index) => + expect(result?.code).toContain(`${entries[index]} = 42`), + ); + expect(NgtscProgram).toHaveBeenCalledTimes(jit ? 0 : 1); + realFs.appendFileSync( + path.join(libDir, 'directive.ts'), + '\nexport const updated = 42;', + ); + await mainPlugin.handleHotUpdate({ + file: normalizePath(path.join(libDir, 'directive.ts')), + modules: [], + }); + expect((await transform('directive.ts'))?.code).toContain('updated = 42'); + expect(NgtscProgram).toHaveBeenCalledTimes(jit ? 0 : 2); + mainPlugin.buildEnd.call(ctx); + expect(ctx.error).not.toHaveBeenCalled(); + const watchers = new Map void>(); + mainPlugin.configureServer({ + watcher: { + on: (event: string, handler: (file: string) => void) => + watchers.set(event, handler), + }, + }); + const barrel = normalizePath(path.join(libDir, 'index.ts')); + realFs.rmSync(barrel); + vi.useFakeTimers(); + try { + watchers.get('unlink')!(barrel); + await vi.advanceTimersByTimeAsync(100); + await mainPlugin.buildStart.call(ctx); + expect( + await mainPlugin.transform.handler.call(ctx, '', barrel), + ).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + expect(ctx.warn).not.toHaveBeenCalled(); + }, + 60_000, + ); it('waits for the initial compilation before emitting a transform result', async () => { const mainPlugin = createAppBuildPlugin(); diff --git a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts index 9c7e970be6..09a5c88d15 100644 --- a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts +++ b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts @@ -211,7 +211,6 @@ export function angular(options?: PluginOptions): Plugin[] { let cachedHost: ts.CompilerHost | undefined; let cachedHostKey: string | undefined; let includeCache: string[] = []; - const linkedSourceRoots = new Set(); function invalidateFsCaches() { includeCache = []; } @@ -377,23 +376,16 @@ export function angular(options?: PluginOptions): Plugin[] { `${normalizePath(resolve(pluginOptions.workspaceRoot))}${glob}`, ), ); - server.watcher.on('add', (file) => { - if (basename(file).includes('tsconfig')) linkedSourceRoots.clear(); - invalidateCompilationOnFsChange(file); - }); + server.watcher.on('add', invalidateCompilationOnFsChange); server.watcher.on('unlink', (file) => { const id = normalizePath(file); - if (linkedSourceRoots.delete(id)) { - outputFiles.delete(id); - fileTransformMap.delete(id); - sourceFileCache.delete(id); - } - if (basename(file).includes('tsconfig')) linkedSourceRoots.clear(); + outputFiles.delete(id); + fileTransformMap.delete(id); + sourceFileCache.delete(id); invalidateCompilationOnFsChange(file); }); server.watcher.on('change', (file) => { if (file.includes('tsconfig')) { - linkedSourceRoots.clear(); invalidateTsconfigCaches(); } }); @@ -747,22 +739,7 @@ export function angular(options?: PluginOptions): Plugin[] { pendingCompilation = null; } - let typescriptResult = fileEmitter(id); - const sourceFile = builder?.getSourceFile(id); - if ( - typescriptResult?.content === '' && - sourceFile && - builder!.getProgram().isSourceFileFromExternalLibrary(sourceFile) && - !linkedSourceRoots.has(normalizePath(sourceFile.fileName)) - ) { - // TypeScript skips emit for source-linked package entries until - // they are roots. Only promote files requested by Vite. - linkedSourceRoots.add(normalizePath(sourceFile.fileName)); - pendingCompilation = performCompilation(resolvedConfig, [id]); - await pendingCompilation; - pendingCompilation = null; - typescriptResult = fileEmitter(id); - } + const typescriptResult = fileEmitter(id); // File not in the Angular program — skip and let other plugins // or Vite's built-in transform handle it. Warn if it looks like @@ -1307,14 +1284,7 @@ export function angular(options?: PluginOptions): Plugin[] { ), ); // Merge + dedupe root names - rootNames = [ - ...new Set([ - ...rootNames, - ...includeCache, - ...replacements, - ...linkedSourceRoots, - ]), - ]; + rootNames = [...new Set([...rootNames, ...includeCache, ...replacements])]; const hostKey = JSON.stringify(tsCompilerOptions); let host: ts.CompilerHost; @@ -1337,6 +1307,55 @@ export function angular(options?: PluginOptions): Plugin[] { return file; }, }); + const resolutionCache = ts.createModuleResolutionCache( + host.getCurrentDirectory(), + host.getCanonicalFileName.bind(host), + tsCompilerOptions, + ); + host.getModuleResolutionCache = () => resolutionCache; + host.resolveModuleNameLiterals = ( + literals, + containingFile, + redirectedReference, + compilerOptions, + containingSourceFile, + ) => + literals.map((literal) => { + const resolution: ts.ResolvedModuleWithFailedLookupLocations = + ts.resolveModuleName( + literal.text, + containingFile, + compilerOptions, + host, + resolutionCache, + redirectedReference, + ts.getModeForUsageLocation( + containingSourceFile, + literal, + compilerOptions, + ), + ); + const resolvedModule = resolution.resolvedModule; + if ( + resolvedModule?.isExternalLibraryImport && + TS_EXT_REGEX.test(resolvedModule.resolvedFileName) && + !EXCLUDED_TS_EXT_REGEX.test(resolvedModule.resolvedFileName) && + !normalizePath(resolvedModule.resolvedFileName).includes( + '/node_modules/', + ) + ) { + // A workspace symlink resolves to source outside node_modules. + // Classify it before program creation so TypeScript emits it. + return { + ...resolution, + resolvedModule: { + ...resolvedModule, + isExternalLibraryImport: false, + }, + }; + } + return resolution; + }); cachedHost = host; cachedHostKey = hostKey; From 67d18f8ea667f5f2d018f569c345b372fe5113af Mon Sep 17 00:00:00 2001 From: Ben Snyder Date: Tue, 8 Sep 2026 17:04:51 +0000 Subject: [PATCH 5/5] refactor(vite-plugin-angular): move module resolution into host augmentation --- .../src/lib/angular-vite-plugin.ts | 55 +----------------- packages/vite-plugin-angular/src/lib/host.ts | 56 +++++++++++++++++++ .../src/lib/utils/plugin-config.ts | 4 ++ 3 files changed, 63 insertions(+), 52 deletions(-) diff --git a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts index d9776f289f..f9700ed0b7 100644 --- a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts +++ b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts @@ -33,6 +33,7 @@ import { } from './component-resolvers.js'; import { augmentHostWithCaching, + augmentHostWithModuleResolution, augmentHostWithResources, augmentProgramWithVersioning, mergeTransformers, @@ -77,6 +78,7 @@ import { fastCompilePlugin } from './fast-compile-plugin.js'; import { ANGULAR_DECORATOR_CALL_RE } from './compiler/index.js'; import { TS_EXT_REGEX, + EXCLUDED_TS_EXT_REGEX, createTsConfigGetter, getTsConfigPath, createDepOptimizerConfig, @@ -1387,55 +1389,7 @@ export function angular(options?: PluginOptions): Plugin[] { return file; }, }); - const resolutionCache = ts.createModuleResolutionCache( - host.getCurrentDirectory(), - host.getCanonicalFileName.bind(host), - tsCompilerOptions, - ); - host.getModuleResolutionCache = () => resolutionCache; - host.resolveModuleNameLiterals = ( - literals, - containingFile, - redirectedReference, - compilerOptions, - containingSourceFile, - ) => - literals.map((literal) => { - const resolution: ts.ResolvedModuleWithFailedLookupLocations = - ts.resolveModuleName( - literal.text, - containingFile, - compilerOptions, - host, - resolutionCache, - redirectedReference, - ts.getModeForUsageLocation( - containingSourceFile, - literal, - compilerOptions, - ), - ); - const resolvedModule = resolution.resolvedModule; - if ( - resolvedModule?.isExternalLibraryImport && - TS_EXT_REGEX.test(resolvedModule.resolvedFileName) && - !EXCLUDED_TS_EXT_REGEX.test(resolvedModule.resolvedFileName) && - !normalizePath(resolvedModule.resolvedFileName).includes( - '/node_modules/', - ) - ) { - // A workspace symlink resolves to source outside node_modules. - // Classify it before program creation so TypeScript emits it. - return { - ...resolution, - resolvedModule: { - ...resolvedModule, - isExternalLibraryImport: false, - }, - }; - } - return resolution; - }); + augmentHostWithModuleResolution(host, tsCompilerOptions); cachedHost = host; cachedHostKey = hostKey; @@ -1718,9 +1672,6 @@ export function angular(options?: PluginOptions): Plugin[] { } const COMPONENT_RESOURCE_EXT_REGEX = /\.(html|htm|css|scss|sass|less)$/; -// Spec files stay included — a newly added spec must join the program's -// root names in Vitest watch mode. -const EXCLUDED_TS_EXT_REGEX = /\.d\.[cm]?ts$/; export function createFsWatcherCacheInvalidator( invalidateFsCaches: () => void, diff --git a/packages/vite-plugin-angular/src/lib/host.ts b/packages/vite-plugin-angular/src/lib/host.ts index 5a12f506c6..0caa796090 100644 --- a/packages/vite-plugin-angular/src/lib/host.ts +++ b/packages/vite-plugin-angular/src/lib/host.ts @@ -6,6 +6,62 @@ import * as ts from 'typescript'; import { createHash } from 'node:crypto'; import path from 'node:path'; import type { SourceFileCache } from './utils/source-file-cache.js'; +import { EXCLUDED_TS_EXT_REGEX, TS_EXT_REGEX } from './utils/plugin-config.js'; + +export function augmentHostWithModuleResolution( + host: ts.CompilerHost, + options: ts.CompilerOptions, +): void { + const resolutionCache = ts.createModuleResolutionCache( + host.getCurrentDirectory(), + host.getCanonicalFileName.bind(host), + options, + ); + host.getModuleResolutionCache = () => resolutionCache; + host.resolveModuleNameLiterals = ( + literals, + containingFile, + redirectedReference, + compilerOptions, + containingSourceFile, + ) => + literals.map((literal) => { + const resolution: ts.ResolvedModuleWithFailedLookupLocations = + ts.resolveModuleName( + literal.text, + containingFile, + compilerOptions, + host, + resolutionCache, + redirectedReference, + ts.getModeForUsageLocation( + containingSourceFile, + literal, + compilerOptions, + ), + ); + const resolvedModule = resolution.resolvedModule; + if ( + resolvedModule?.isExternalLibraryImport && + TS_EXT_REGEX.test(resolvedModule.resolvedFileName) && + !EXCLUDED_TS_EXT_REGEX.test(resolvedModule.resolvedFileName) && + !normalizePath(resolvedModule.resolvedFileName).includes( + '/node_modules/', + ) + ) { + // A workspace symlink resolves to source outside node_modules. + // Classify it before program creation so TypeScript emits it. + return { + ...resolution, + resolvedModule: { + ...resolvedModule, + isExternalLibraryImport: false, + }, + }; + } + return resolution; + }); +} export function augmentHostWithResources( host: ts.CompilerHost, diff --git a/packages/vite-plugin-angular/src/lib/utils/plugin-config.ts b/packages/vite-plugin-angular/src/lib/utils/plugin-config.ts index 5390d04239..a678de0aa7 100644 --- a/packages/vite-plugin-angular/src/lib/utils/plugin-config.ts +++ b/packages/vite-plugin-angular/src/lib/utils/plugin-config.ts @@ -26,6 +26,10 @@ import { */ export const TS_EXT_REGEX = /\.[cm]?ts(?![a-z])/; +// Spec files stay included — a newly added spec must join the program's +// root names in Vitest watch mode. +export const EXCLUDED_TS_EXT_REGEX = /\.d\.[cm]?ts$/; + /** * Resolves whether Angular should be compiled for production. An explicit * `development` mode wins over an ambient production `NODE_ENV` (e.g. set by