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 e907313014..ae581396b8 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,7 +3,20 @@ import * as realFs from 'node:fs'; import { SourceMap } from 'node:module'; import { tmpdir } from 'node:os'; import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; -import { normalizePath } from 'vite'; +import { normalizePath, resolveConfig } 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); + }), + }; +}); import type ts from 'typescript'; import * as tsModule from 'typescript'; @@ -18,8 +31,10 @@ import { mapTemplateUpdatesToFiles, toAngularCompilationFileReplacements, isTestWatchMode, + type PluginOptions, } from './angular-vite-plugin'; import type { EmitFileResult } from './models'; +import { releaseCssPreprocessorWorkers } from './utils/css-preprocessor-workers'; describe('angularVitePlugin', () => { it('should work', () => { @@ -1057,12 +1072,13 @@ export class AppComponent {} }); afterEach(() => { + releaseCssPreprocessorWorkers(); realFs.rmSync(fixtureDir, { recursive: true, force: true }); }); // 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']; @@ -1071,6 +1087,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; @@ -1080,6 +1097,162 @@ export class AppComponent {} } } + it.each([ + { command: 'build', jit: false }, + { command: 'serve', jit: false }, + { command: 'build', jit: true }, + { command: 'serve', jit: true }, + ] as const)( + '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', + }, + }), + ); + 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 }); + const resolvedConfig = await resolveConfig( + { configFile: false, root: fixtureDir, mode: 'production' }, + command, + ); + mainPlugin.configResolved(resolvedConfig); + const ctx = { + environment: { config: resolvedConfig }, + warn: vi.fn(), + error: vi.fn(), + addWatchFile: vi.fn(), + }; + await mainPlugin.buildStart.call(ctx); + 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); + await 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 { + const unlink = watchers.get('unlink'); + expect(unlink).toBeDefined(); + unlink?.(barrel); + await vi.advanceTimersByTimeAsync(100); + await mainPlugin.buildStart.call(ctx); + expect( + await mainPlugin.transform.handler.call(ctx, '', barrel), + ).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + await mainPlugin.buildEnd.call(ctx); + expect(ctx.warn).not.toHaveBeenCalled(); + }, + 60_000, + ); + it('waits for the initial compilation before emitting a transform result', async () => { const mainPlugin = createAppBuildPlugin(); @@ -1087,14 +1260,10 @@ export class AppComponent {} { root: fixtureDir, build: {} }, { command: 'build' }, ); - const resolvedConfig = { - root: fixtureDir, - mode: 'production', - build: {}, - server: { watch: {} }, - safeModulePaths: new Set(), - css: {}, - }; + const resolvedConfig = await resolveConfig( + { configFile: false, root: fixtureDir, mode: 'production' }, + 'build', + ); mainPlugin.configResolved(resolvedConfig); const ctx = { @@ -1138,14 +1307,15 @@ export class AppComponent {} { root: fixtureDir, build: { sourcemap: true } }, { command: 'build' }, ); - const resolvedConfig = { - root: fixtureDir, - mode: 'production', - build: { sourcemap: true }, - server: { watch: {} }, - safeModulePaths: new Set(), - css: {}, - }; + const resolvedConfig = await resolveConfig( + { + configFile: false, + root: fixtureDir, + mode: 'production', + build: { sourcemap: true }, + }, + 'build', + ); mainPlugin.configResolved(resolvedConfig); const ctx = { @@ -1188,14 +1358,10 @@ export class AppComponent {} { root: fixtureDir, build: {} }, { command: 'build' }, ); - const resolvedConfig = { - root: fixtureDir, - mode: 'production', - build: {}, - server: { watch: {} }, - safeModulePaths: new Set(), - css: {}, - }; + const resolvedConfig = await resolveConfig( + { configFile: false, root: fixtureDir, mode: 'production' }, + 'build', + ); mainPlugin.configResolved(resolvedConfig); const ctx = { environment: { config: resolvedConfig }, @@ -1229,15 +1395,10 @@ export class AppComponent {} { root: fixtureDir, build: {} }, { command: 'build' }, ); - const resolvedConfig = { - root: fixtureDir, - mode: 'production', - build: {}, - server: { watch: {} }, - safeModulePaths: new Set(), - css: {}, - plugins: [], - }; + const resolvedConfig = await resolveConfig( + { configFile: false, root: fixtureDir, mode: 'production' }, + 'build', + ); mainPlugin.configResolved(resolvedConfig); // Context without this.environment (e.g. older Vite or minimal test harness) 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 9e22100551..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, @@ -410,7 +412,13 @@ export function angular(options?: PluginOptions): Plugin[] { ), ); server.watcher.on('add', invalidateCompilationOnFsChange); - server.watcher.on('unlink', invalidateCompilationOnFsChange); + server.watcher.on('unlink', (file) => { + const id = normalizePath(file); + outputFiles.delete(id); + fileTransformMap.delete(id); + sourceFileCache.delete(id); + invalidateCompilationOnFsChange(file); + }); server.watcher.on('change', (file) => { if (file.includes('tsconfig')) { invalidateTsconfigCaches(); @@ -1381,6 +1389,7 @@ export function angular(options?: PluginOptions): Plugin[] { return file; }, }); + augmentHostWithModuleResolution(host, tsCompilerOptions); cachedHost = host; cachedHostKey = hostKey; @@ -1663,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