From 35af069bd9808095bd66ed2570416323c79b86e7 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:00:49 -0400 Subject: [PATCH] fix(@angular/build): keep dev-server Vite cache worktree-local When running within a Git worktree, the persistent build cache is shared across worktrees by resolving relative to the common Git directory (main repository root). While this works for location-agnostic compilation caches (such as `ng build`'s SourceFileCache), it breaks `ng serve` when server-side rendering (SSR) is enabled. In SSR mode, Vite prebundles dependencies into `deps_ssr/`. Any external or unbundled dependencies inside those prebundles contain bare imports (e.g., `firebase-admin/data-connect`). Because Node.js resolves bare imports relative to the prebundled file's location, placing `cacheDir` under the main repository root causes Node.js to resolve imports against the main repository's `node_modules` instead of the worktree's `node_modules`, resulting in `Cannot find module` errors and HTTP 500 responses. To resolve this, `NormalizedCachedOptions` now exposes `localBasePath` and `localPath` which always resolve relative to the active workspace root. The dev-server's Vite configuration now uses `localPath` for `cacheDir`, ensuring prebundles remain within the worktree hierarchy where Node.js can resolve the worktree's dependencies. Additionally, cache purging and watcher ignore lists are updated to handle both shared and local cache paths when they diverge. Fixes #33968 --- .../src/builders/application/build-action.ts | 5 ++ .../src/builders/dev-server/vite/server.ts | 6 +- .../build/src/utils/normalize-cache.ts | 19 +++++- .../build/src/utils/normalize-cache_spec.ts | 58 +++++++++++++++++++ .../angular/build/src/utils/purge-cache.ts | 42 +++++++++----- 5 files changed, 114 insertions(+), 16 deletions(-) diff --git a/packages/angular/build/src/builders/application/build-action.ts b/packages/angular/build/src/builders/application/build-action.ts index 3f8d24da6ecc..7cc3437fa323 100644 --- a/packages/angular/build/src/builders/application/build-action.ts +++ b/packages/angular/build/src/builders/application/build-action.ts @@ -123,6 +123,11 @@ export async function* runEsBuildBuildAction( `${toPosixPath(workspaceRoot)}/**/.*/**`, ]; + if (cacheOptions.localBasePath && cacheOptions.localBasePath !== cacheOptions.basePath) { + const normalizedLocalCacheBase = toPosixPath(cacheOptions.localBasePath); + ignored.push(normalizedLocalCacheBase, `${normalizedLocalCacheBase}/**`); + } + // Setup a watcher const { createWatcher } = await import('../../tools/esbuild/watcher'); watcher = await createWatcher({ diff --git a/packages/angular/build/src/builders/dev-server/vite/server.ts b/packages/angular/build/src/builders/dev-server/vite/server.ts index b1826383fccb..7dd62aed37b6 100644 --- a/packages/angular/build/src/builders/dev-server/vite/server.ts +++ b/packages/angular/build/src/builders/dev-server/vite/server.ts @@ -165,7 +165,11 @@ export async function setupServer( */ const preTransformRequests = externalMetadata.explicitBrowser.length === 0 && ssrMode === ServerSsrMode.NoSsr; - const cacheDir = join(serverOptions.cacheOptions.path, serverOptions.buildTarget.project, 'vite'); + const cacheDir = join( + serverOptions.cacheOptions.localPath ?? serverOptions.cacheOptions.path, + serverOptions.buildTarget.project, + 'vite', + ); const configuration: Vite.InlineConfig = { configFile: false, diff --git a/packages/angular/build/src/utils/normalize-cache.ts b/packages/angular/build/src/utils/normalize-cache.ts index c2dc52514992..0c5aedc73684 100644 --- a/packages/angular/build/src/utils/normalize-cache.ts +++ b/packages/angular/build/src/utils/normalize-cache.ts @@ -21,6 +21,18 @@ export interface NormalizedCachedOptions { /** Disk cache base path. Example: `/.angular/cache`. */ basePath: string; + + /** + * Workspace-local disk cache path. Example: `/.angular/cache/v12.0.0`. + * Always resolves relative to the current workspace root, even within a Git worktree. + */ + localPath?: string; + + /** + * Workspace-local disk cache base path. Example: `/.angular/cache`. + * Always resolves relative to the current workspace root, even within a Git worktree. + */ + localBasePath?: string; } interface CacheMetadata { @@ -82,7 +94,7 @@ function getCacheBasePath(workspaceRoot: string, cachePathSetting: string): stri export function normalizeCacheOptions( projectMetadata: unknown, - worspaceRoot: string, + workspaceRoot: string, ): NormalizedCachedOptions { const cacheMetadata = hasCacheMetadata(projectMetadata) ? projectMetadata.cli.cache : {}; @@ -106,11 +118,14 @@ export function normalizeCacheOptions( } } - const cacheBasePath = getCacheBasePath(worspaceRoot, path); + const cacheBasePath = getCacheBasePath(workspaceRoot, path); + const localCacheBasePath = isAbsolute(path) ? path : resolve(workspaceRoot, path); return { enabled: cacheEnabled, basePath: cacheBasePath, path: join(cacheBasePath, VERSION), + localBasePath: localCacheBasePath, + localPath: join(localCacheBasePath, VERSION), }; } diff --git a/packages/angular/build/src/utils/normalize-cache_spec.ts b/packages/angular/build/src/utils/normalize-cache_spec.ts index c8a72870d62a..cfa98ea8a69e 100644 --- a/packages/angular/build/src/utils/normalize-cache_spec.ts +++ b/packages/angular/build/src/utils/normalize-cache_spec.ts @@ -29,6 +29,8 @@ describe('normalizeCacheOptions', () => { const options = normalizeCacheOptions({}, workspaceRoot); expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localBasePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localPath).toBe(resolve(workspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); }); it('should resolve cache path relative to main repository root in a git worktree', async () => { @@ -51,6 +53,58 @@ describe('normalizeCacheOptions', () => { const options = normalizeCacheOptions({}, worktreeRoot); expect(options.basePath).toBe(resolve(mainRepoRoot, '.angular/cache')); + expect(options.path).toBe(resolve(mainRepoRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + expect(options.localBasePath).toBe(resolve(worktreeRoot, '.angular/cache')); + expect(options.localPath).toBe(resolve(worktreeRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + }); + + it('should resolve local cache path relative to worktree root with custom relative path', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + + await mkdir(mainGitDir, { recursive: true }); + + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions( + { cli: { cache: { path: 'custom-cache' } } }, + worktreeRoot, + ); + + expect(options.basePath).toBe(resolve(mainRepoRoot, 'custom-cache')); + expect(options.path).toBe(resolve(mainRepoRoot, 'custom-cache', '0.0.0-PLACEHOLDER')); + expect(options.localBasePath).toBe(resolve(worktreeRoot, 'custom-cache')); + expect(options.localPath).toBe(resolve(worktreeRoot, 'custom-cache', '0.0.0-PLACEHOLDER')); + }); + + it('should preserve absolute cache path for both shared and local paths', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + const absoluteCachePath = join(tempDir, 'absolute-cache'); + + await mkdir(mainGitDir, { recursive: true }); + + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions( + { cli: { cache: { path: absoluteCachePath } } }, + worktreeRoot, + ); + + expect(options.basePath).toBe(absoluteCachePath); + expect(options.path).toBe(resolve(absoluteCachePath, '0.0.0-PLACEHOLDER')); + expect(options.localBasePath).toBe(absoluteCachePath); + expect(options.localPath).toBe(resolve(absoluteCachePath, '0.0.0-PLACEHOLDER')); }); it('should resolve cache path relative to workspace root in a git submodule', async () => { @@ -69,6 +123,8 @@ describe('normalizeCacheOptions', () => { const options = normalizeCacheOptions({}, submoduleRoot); expect(options.basePath).toBe(resolve(submoduleRoot, '.angular/cache')); + expect(options.localBasePath).toBe(resolve(submoduleRoot, '.angular/cache')); + expect(options.localPath).toBe(resolve(submoduleRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); }); it('should resolve cache path relative to workspace root when there is no git repository', async () => { @@ -78,5 +134,7 @@ describe('normalizeCacheOptions', () => { const options = normalizeCacheOptions({}, workspaceRoot); expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localBasePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localPath).toBe(resolve(workspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); }); }); diff --git a/packages/angular/build/src/utils/purge-cache.ts b/packages/angular/build/src/utils/purge-cache.ts index 5851d052d54a..545d2dd97bba 100644 --- a/packages/angular/build/src/utils/purge-cache.ts +++ b/packages/angular/build/src/utils/purge-cache.ts @@ -19,25 +19,41 @@ export async function purgeStaleBuildCache(context: BuilderContext): Promise d.isDirectory()) - .map((d) => join(basePath, d.name)) - .filter((cachePath) => cachePath !== path) - .map((stalePath) => rm(stalePath, { force: true, recursive: true, maxRetries: 3 })); + for (const base of basePaths) { + let baseEntries; + try { + baseEntries = await readdir(base, { withFileTypes: true }); + } catch { + // No purging possible if base path does not exist or cannot otherwise be accessed + continue; + } + + const currentPath = base === localBasePath ? localPath : path; + if (!currentPath) { + // Avoid purging if current path is unavailable to prevent deleting the active cache + continue; + } - await Promise.allSettled(entriesToDelete); + const entriesToDelete = baseEntries + .filter((d) => d.isDirectory()) + .map((d) => join(base, d.name)) + .filter((cachePath) => cachePath !== currentPath) + .map((stalePath) => rm(stalePath, { force: true, recursive: true, maxRetries: 3 })); + + await Promise.allSettled(entriesToDelete); + } }