Skip to content

Commit 28af968

Browse files
committed
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
1 parent ab6d7df commit 28af968

5 files changed

Lines changed: 109 additions & 16 deletions

File tree

packages/angular/build/src/builders/application/build-action.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ export async function* runEsBuildBuildAction(
123123
`${toPosixPath(workspaceRoot)}/**/.*/**`,
124124
];
125125

126+
if (cacheOptions.localBasePath && cacheOptions.localBasePath !== cacheOptions.basePath) {
127+
const normalizedLocalCacheBase = toPosixPath(cacheOptions.localBasePath);
128+
ignored.push(normalizedLocalCacheBase, `${normalizedLocalCacheBase}/**`);
129+
}
130+
126131
// Setup a watcher
127132
const { createWatcher } = await import('../../tools/esbuild/watcher');
128133
watcher = await createWatcher({

packages/angular/build/src/builders/dev-server/vite/server.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,11 @@ export async function setupServer(
165165
*/
166166
const preTransformRequests =
167167
externalMetadata.explicitBrowser.length === 0 && ssrMode === ServerSsrMode.NoSsr;
168-
const cacheDir = join(serverOptions.cacheOptions.path, serverOptions.buildTarget.project, 'vite');
168+
const cacheDir = join(
169+
serverOptions.cacheOptions.localPath ?? serverOptions.cacheOptions.path,
170+
serverOptions.buildTarget.project,
171+
'vite',
172+
);
169173

170174
const configuration: Vite.InlineConfig = {
171175
configFile: false,

packages/angular/build/src/utils/normalize-cache.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ export interface NormalizedCachedOptions {
2121

2222
/** Disk cache base path. Example: `/.angular/cache`. */
2323
basePath: string;
24+
25+
/**
26+
* Workspace-local disk cache path. Example: `/.angular/cache/v12.0.0`.
27+
* Always resolves relative to the current workspace root, even within a Git worktree.
28+
*/
29+
localPath?: string;
30+
31+
/**
32+
* Workspace-local disk cache base path. Example: `/.angular/cache`.
33+
* Always resolves relative to the current workspace root, even within a Git worktree.
34+
*/
35+
localBasePath?: string;
2436
}
2537

2638
interface CacheMetadata {
@@ -82,7 +94,7 @@ function getCacheBasePath(workspaceRoot: string, cachePathSetting: string): stri
8294

8395
export function normalizeCacheOptions(
8496
projectMetadata: unknown,
85-
worspaceRoot: string,
97+
workspaceRoot: string,
8698
): NormalizedCachedOptions {
8799
const cacheMetadata = hasCacheMetadata(projectMetadata) ? projectMetadata.cli.cache : {};
88100

@@ -106,11 +118,14 @@ export function normalizeCacheOptions(
106118
}
107119
}
108120

109-
const cacheBasePath = getCacheBasePath(worspaceRoot, path);
121+
const cacheBasePath = getCacheBasePath(workspaceRoot, path);
122+
const localCacheBasePath = isAbsolute(path) ? path : resolve(workspaceRoot, path);
110123

111124
return {
112125
enabled: cacheEnabled,
113126
basePath: cacheBasePath,
114127
path: join(cacheBasePath, VERSION),
128+
localBasePath: localCacheBasePath,
129+
localPath: join(localCacheBasePath, VERSION),
115130
};
116131
}

packages/angular/build/src/utils/normalize-cache_spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ describe('normalizeCacheOptions', () => {
2929
const options = normalizeCacheOptions({}, workspaceRoot);
3030

3131
expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache'));
32+
expect(options.localBasePath).toBe(resolve(workspaceRoot, '.angular/cache'));
33+
expect(options.localPath).toBe(resolve(workspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER'));
3234
});
3335

3436
it('should resolve cache path relative to main repository root in a git worktree', async () => {
@@ -51,6 +53,58 @@ describe('normalizeCacheOptions', () => {
5153
const options = normalizeCacheOptions({}, worktreeRoot);
5254

5355
expect(options.basePath).toBe(resolve(mainRepoRoot, '.angular/cache'));
56+
expect(options.path).toBe(resolve(mainRepoRoot, '.angular/cache', '0.0.0-PLACEHOLDER'));
57+
expect(options.localBasePath).toBe(resolve(worktreeRoot, '.angular/cache'));
58+
expect(options.localPath).toBe(resolve(worktreeRoot, '.angular/cache', '0.0.0-PLACEHOLDER'));
59+
});
60+
61+
it('should resolve local cache path relative to worktree root with custom relative path', async () => {
62+
const mainRepoRoot = join(tempDir, 'main-repo');
63+
const mainGitDir = join(mainRepoRoot, '.git');
64+
const worktreeRoot = join(tempDir, 'worktree');
65+
66+
await mkdir(mainGitDir, { recursive: true });
67+
68+
const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1');
69+
await mkdir(worktreeMetadataDir, { recursive: true });
70+
await mkdir(worktreeRoot, { recursive: true });
71+
await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`);
72+
await writeFile(join(worktreeMetadataDir, 'commondir'), '../..');
73+
74+
const options = normalizeCacheOptions(
75+
{ cli: { cache: { path: 'custom-cache' } } },
76+
worktreeRoot,
77+
);
78+
79+
expect(options.basePath).toBe(resolve(mainRepoRoot, 'custom-cache'));
80+
expect(options.path).toBe(resolve(mainRepoRoot, 'custom-cache', '0.0.0-PLACEHOLDER'));
81+
expect(options.localBasePath).toBe(resolve(worktreeRoot, 'custom-cache'));
82+
expect(options.localPath).toBe(resolve(worktreeRoot, 'custom-cache', '0.0.0-PLACEHOLDER'));
83+
});
84+
85+
it('should preserve absolute cache path for both shared and local paths', async () => {
86+
const mainRepoRoot = join(tempDir, 'main-repo');
87+
const mainGitDir = join(mainRepoRoot, '.git');
88+
const worktreeRoot = join(tempDir, 'worktree');
89+
const absoluteCachePath = join(tempDir, 'absolute-cache');
90+
91+
await mkdir(mainGitDir, { recursive: true });
92+
93+
const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1');
94+
await mkdir(worktreeMetadataDir, { recursive: true });
95+
await mkdir(worktreeRoot, { recursive: true });
96+
await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`);
97+
await writeFile(join(worktreeMetadataDir, 'commondir'), '../..');
98+
99+
const options = normalizeCacheOptions(
100+
{ cli: { cache: { path: absoluteCachePath } } },
101+
worktreeRoot,
102+
);
103+
104+
expect(options.basePath).toBe(absoluteCachePath);
105+
expect(options.path).toBe(resolve(absoluteCachePath, '0.0.0-PLACEHOLDER'));
106+
expect(options.localBasePath).toBe(absoluteCachePath);
107+
expect(options.localPath).toBe(resolve(absoluteCachePath, '0.0.0-PLACEHOLDER'));
54108
});
55109

56110
it('should resolve cache path relative to workspace root in a git submodule', async () => {
@@ -69,6 +123,8 @@ describe('normalizeCacheOptions', () => {
69123
const options = normalizeCacheOptions({}, submoduleRoot);
70124

71125
expect(options.basePath).toBe(resolve(submoduleRoot, '.angular/cache'));
126+
expect(options.localBasePath).toBe(resolve(submoduleRoot, '.angular/cache'));
127+
expect(options.localPath).toBe(resolve(submoduleRoot, '.angular/cache', '0.0.0-PLACEHOLDER'));
72128
});
73129

74130
it('should resolve cache path relative to workspace root when there is no git repository', async () => {
@@ -78,5 +134,7 @@ describe('normalizeCacheOptions', () => {
78134
const options = normalizeCacheOptions({}, workspaceRoot);
79135

80136
expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache'));
137+
expect(options.localBasePath).toBe(resolve(workspaceRoot, '.angular/cache'));
138+
expect(options.localPath).toBe(resolve(workspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER'));
81139
});
82140
});

packages/angular/build/src/utils/purge-cache.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,36 @@ export async function purgeStaleBuildCache(context: BuilderContext): Promise<voi
1919
}
2020

2121
const metadata = await context.getProjectMetadata(projectName);
22-
const { basePath, path, enabled } = normalizeCacheOptions(metadata, context.workspaceRoot);
22+
const { basePath, path, localBasePath, localPath, enabled } = normalizeCacheOptions(
23+
metadata,
24+
context.workspaceRoot,
25+
);
2326

2427
if (!enabled) {
2528
return;
2629
}
2730

28-
let baseEntries;
29-
try {
30-
baseEntries = await readdir(basePath, { withFileTypes: true });
31-
} catch {
32-
// No purging possible if base path does not exist or cannot otherwise be accessed
33-
return;
31+
const basePaths = new Set([basePath]);
32+
if (localBasePath) {
33+
basePaths.add(localBasePath);
3434
}
3535

36-
const entriesToDelete = baseEntries
37-
.filter((d) => d.isDirectory())
38-
.map((d) => join(basePath, d.name))
39-
.filter((cachePath) => cachePath !== path)
40-
.map((stalePath) => rm(stalePath, { force: true, recursive: true, maxRetries: 3 }));
36+
for (const base of basePaths) {
37+
let baseEntries;
38+
try {
39+
baseEntries = await readdir(base, { withFileTypes: true });
40+
} catch {
41+
// No purging possible if base path does not exist or cannot otherwise be accessed
42+
continue;
43+
}
44+
45+
const currentPath = (base === localBasePath ? localPath : path) ?? path;
46+
const entriesToDelete = baseEntries
47+
.filter((d) => d.isDirectory())
48+
.map((d) => join(base, d.name))
49+
.filter((cachePath) => cachePath !== currentPath)
50+
.map((stalePath) => rm(stalePath, { force: true, recursive: true, maxRetries: 3 }));
4151

42-
await Promise.allSettled(entriesToDelete);
52+
await Promise.allSettled(entriesToDelete);
53+
}
4354
}

0 commit comments

Comments
 (0)