Skip to content

Commit fe9ebf5

Browse files
alan-agius4atscott
authored andcommitted
fix(compiler-cli): support extendedConfigCache in readConfiguration
Pass `extendedConfigCache` to `ts.parseJsonConfigFileContent` and consult the cache in `readConfigFile` when reading configuration files. In Angular CLI watch mode, re-reading and re-parsing extended tsconfig files on every file change causes unnecessary disk I/O and CPU overhead. Reusing TypeScript's `extendedConfigCache` avoids adding extended config files to watchers and eliminates redundant configuration parsing on rebuilds. Additionally, simplify `readConfiguration` by invoking `ts.parseJsonConfigFileContent` before `readAngularCompilerOptions`, caching merged `angularCompilerOptions` on `extendedConfigCache` entries, and utilizing the resolved `extendedConfigPath` to bypass redundant module resolution. (cherry picked from commit 0f1e216)
1 parent dcf4ab8 commit fe9ebf5

2 files changed

Lines changed: 220 additions & 84 deletions

File tree

‎packages/compiler-cli/src/perform_compile.ts‎

Lines changed: 148 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -76,54 +76,13 @@ export function readConfiguration(
7676
project: string,
7777
existingOptions?: api.CompilerOptions,
7878
host: ConfigurationHost = getFileSystem(),
79+
extendedConfigCache = new Map<string, ts.ExtendedConfigCacheEntry>(),
7980
): ParsedConfiguration {
8081
try {
8182
const fs = getFileSystem();
82-
83-
const readConfigFile = (configFile: string) =>
84-
ts.readConfigFile(configFile, (file) => host.readFile(host.resolve(file)));
85-
const readAngularCompilerOptions = (
86-
configFile: string,
87-
parentOptions: NgCompilerOptions = {},
88-
): NgCompilerOptions => {
89-
const {config, error} = readConfigFile(configFile);
90-
91-
if (error) {
92-
// Errors are handled later on by 'parseJsonConfigFileContent'
93-
return parentOptions;
94-
}
95-
96-
// Note: In Google, `angularCompilerOptions` are stored in `bazelOptions`.
97-
// This function typically doesn't run for actual Angular compilations, but
98-
// tooling like Tsurge, or schematics may leverage this helper, so we account
99-
// for this here.
100-
const angularCompilerOptions =
101-
config.angularCompilerOptions ?? config.bazelOptions?.angularCompilerOptions;
102-
103-
// we are only interested into merging 'angularCompilerOptions' as
104-
// other options like 'compilerOptions' are merged by TS
105-
let existingNgCompilerOptions = {...angularCompilerOptions, ...parentOptions};
106-
if (!config.extends) {
107-
return existingNgCompilerOptions;
108-
}
109-
110-
const extendsPaths: string[] =
111-
typeof config.extends === 'string' ? [config.extends] : config.extends;
112-
113-
// Call readAngularCompilerOptions recursively to merge NG Compiler options
114-
// Reverse the array so the overrides happen from right to left.
115-
return [...extendsPaths].reverse().reduce((prevOptions, extendsPath) => {
116-
const extendedConfigPath = getExtendedConfigPath(configFile, extendsPath, host, fs);
117-
118-
return extendedConfigPath === null
119-
? prevOptions
120-
: readAngularCompilerOptions(extendedConfigPath, prevOptions);
121-
}, existingNgCompilerOptions);
122-
};
123-
12483
const {projectFile, basePath} = calcProjectFileAndBasePath(project, host);
125-
const configFileName = host.resolve(host.pwd(), projectFile);
126-
const {config, error} = readConfigFile(projectFile);
84+
const parseConfigHost = createParseConfigHost(host, fs);
85+
const {config, error} = readConfigFile(projectFile, host, fs, extendedConfigCache);
12786

12887
if (error) {
12988
return {
@@ -138,31 +97,45 @@ export function readConfiguration(
13897
const existingCompilerOptions: api.CompilerOptions = {
13998
genDir: basePath,
14099
basePath,
141-
...readAngularCompilerOptions(configFileName),
142100
...existingOptions,
143101
};
144102

145-
const parseConfigHost = createParseConfigHost(host, fs);
146103
const {
147104
options,
148105
errors,
149106
fileNames: rootNames,
150107
projectReferences,
151108
} = ts.parseJsonConfigFileContent(
109+
/* json */ config,
110+
/* host */ parseConfigHost,
111+
/* basePath */ basePath,
112+
/* existingOptions */ existingCompilerOptions,
113+
/* configFileName */ projectFile,
114+
/* resolutionStack */ undefined,
115+
/* extraFileExtensions */ undefined,
116+
/* extendedConfigCache */ extendedConfigCache,
117+
);
118+
119+
const angularCompilerOptions = readAngularCompilerOptions(
120+
projectFile,
152121
config,
122+
host,
123+
fs,
153124
parseConfigHost,
154-
basePath,
155-
existingCompilerOptions,
156-
configFileName,
125+
extendedConfigCache,
157126
);
158127

128+
Object.assign(options, angularCompilerOptions, existingOptions);
129+
159130
let emitFlags = api.EmitFlags.Default;
160-
if (!(options['skipMetadataEmit'] || options['flatModuleOutFile'])) {
131+
if (!options['skipMetadataEmit'] && !options['flatModuleOutFile']) {
161132
emitFlags |= api.EmitFlags.Metadata;
162133
}
134+
163135
if (options['skipTemplateCodegen']) {
164136
emitFlags = emitFlags & ~api.EmitFlags.Codegen;
165137
}
138+
166139
return {project: projectFile, rootNames, projectReferences, options, errors, emitFlags};
167140
} catch (e) {
168141
const errors: ts.Diagnostic[] = [
@@ -180,6 +153,113 @@ export function readConfiguration(
180153
}
181154
}
182155

156+
function readConfigFile(
157+
configFile: string,
158+
host: ConfigurationHost,
159+
fs: FileSystem,
160+
extendedConfigCache?: Map<string, ts.ExtendedConfigCacheEntry>,
161+
): {config?: any; error?: ts.Diagnostic} {
162+
const cacheKey = fs.isCaseSensitive() ? configFile : configFile.toLowerCase();
163+
const cacheEntry = extendedConfigCache?.get(cacheKey);
164+
165+
if (cacheEntry) {
166+
return {
167+
config: cacheEntry.extendedConfig?.raw,
168+
error: (cacheEntry.extendedResult as {parseDiagnostics?: ts.Diagnostic[]})
169+
.parseDiagnostics?.[0],
170+
};
171+
}
172+
173+
return ts.readConfigFile(configFile, (file) => host.readFile(host.resolve(file)));
174+
}
175+
176+
function readAngularCompilerOptions(
177+
configFile: string,
178+
config: any,
179+
host: ConfigurationHost,
180+
fs: FileSystem,
181+
parseConfigHost: ts.ParseConfigHost,
182+
extendedConfigCache: Map<string, ts.ExtendedConfigCacheEntry>,
183+
): NgCompilerOptions {
184+
const cacheKey = fs.isCaseSensitive() ? configFile : configFile.toLowerCase();
185+
const cacheEntry = extendedConfigCache.get(cacheKey);
186+
const cachedExtendedConfig = cacheEntry?.extendedConfig as
187+
| (ts.ExtendedConfigCacheEntry['extendedConfig'] & {
188+
angularCompilerOptions?: NgCompilerOptions;
189+
})
190+
| undefined;
191+
192+
// If the merged angularCompilerOptions for this config are already cached, reuse them.
193+
if (cachedExtendedConfig?.angularCompilerOptions) {
194+
return cachedExtendedConfig.angularCompilerOptions;
195+
}
196+
197+
// Note: In Google, `angularCompilerOptions` are stored in `bazelOptions`.
198+
// This function typically doesn't run for actual Angular compilations, but
199+
// tooling like Tsurge, or schematics may leverage this helper, so we account
200+
// for this here.
201+
const angularCompilerOptions =
202+
config.angularCompilerOptions ?? config.bazelOptions?.angularCompilerOptions ?? {};
203+
204+
if (!config.extends) {
205+
if (cachedExtendedConfig) {
206+
cachedExtendedConfig.angularCompilerOptions = angularCompilerOptions;
207+
}
208+
return angularCompilerOptions;
209+
}
210+
211+
const resolvedExtendedConfigPath = cachedExtendedConfig?.extendedConfigPath;
212+
213+
const extendsPaths: string[] = resolvedExtendedConfigPath
214+
? typeof resolvedExtendedConfigPath === 'string'
215+
? [resolvedExtendedConfigPath]
216+
: resolvedExtendedConfigPath
217+
: typeof config.extends === 'string'
218+
? [config.extends]
219+
: Array.isArray(config.extends)
220+
? config.extends
221+
: [];
222+
223+
// Recursively merge extended configurations from base to leaf (left-to-right).
224+
const inheritedOptions = extendsPaths.reduce((prevOptions, extendsPath) => {
225+
const extendedConfigPath = resolvedExtendedConfigPath
226+
? absoluteFrom(extendsPath)
227+
: getExtendedConfigPath(configFile, extendsPath, host, fs, parseConfigHost);
228+
if (extendedConfigPath === null) {
229+
return prevOptions;
230+
}
231+
232+
const {config: extendedConfig, error} = readConfigFile(
233+
extendedConfigPath,
234+
host,
235+
fs,
236+
extendedConfigCache,
237+
);
238+
239+
if (error || !extendedConfig) {
240+
return prevOptions;
241+
}
242+
243+
const options = readAngularCompilerOptions(
244+
extendedConfigPath,
245+
extendedConfig,
246+
host,
247+
fs,
248+
parseConfigHost,
249+
extendedConfigCache,
250+
);
251+
252+
return {...prevOptions, ...options};
253+
}, {} as NgCompilerOptions);
254+
255+
const mergedOptions = {...inheritedOptions, ...angularCompilerOptions};
256+
if (cachedExtendedConfig) {
257+
cachedExtendedConfig.angularCompilerOptions = mergedOptions;
258+
}
259+
260+
return mergedOptions;
261+
}
262+
183263
function createParseConfigHost(host: ConfigurationHost, fs = getFileSystem()): ts.ParseConfigHost {
184264
return {
185265
fileExists: host.exists.bind(host),
@@ -194,41 +274,25 @@ function getExtendedConfigPath(
194274
extendsValue: string,
195275
host: ConfigurationHost,
196276
fs: FileSystem,
277+
parseConfigHost: ts.ParseConfigHost,
197278
): AbsoluteFsPath | null {
198-
const result = getExtendedConfigPathWorker(configFile, extendsValue, host, fs);
199-
if (result !== null) {
200-
return result;
201-
}
202-
203-
// Try to resolve the paths with a json extension append a json extension to the file in case if
204-
// it is missing and the resolution failed. This is to replicate TypeScript behaviour, see:
205-
// https://github.com/microsoft/TypeScript/blob/294a5a7d784a5a95a8048ee990400979a6bc3a1c/src/compiler/commandLineParser.ts#L2806
206-
return getExtendedConfigPathWorker(configFile, `${extendsValue}.json`, host, fs);
207-
}
208-
209-
function getExtendedConfigPathWorker(
210-
configFile: string,
211-
extendsValue: string,
212-
host: ConfigurationHost,
213-
fs: FileSystem,
214-
): AbsoluteFsPath | null {
215-
if (extendsValue.startsWith('.') || fs.isRooted(extendsValue)) {
216-
const extendedConfigPath = host.resolve(host.dirname(configFile), extendsValue);
217-
if (host.exists(extendedConfigPath)) {
218-
return extendedConfigPath;
219-
}
220-
} else {
221-
const parseConfigHost = createParseConfigHost(host, fs);
222-
223-
// Path isn't a rooted or relative path, resolve like a module.
224-
const {resolvedModule} = ts.nodeModuleNameResolver(
225-
extendsValue,
226-
configFile,
227-
{moduleResolution: ts.ModuleResolutionKind.NodeNext, resolveJsonModule: true},
228-
parseConfigHost,
229-
);
230-
if (resolvedModule) {
231-
return absoluteFrom(resolvedModule.resolvedFileName);
279+
for (const candidate of [extendsValue, `${extendsValue}.json`]) {
280+
if (candidate[0] === '.' || fs.isRooted(candidate)) {
281+
const extendedConfigPath = host.resolve(host.dirname(configFile), candidate);
282+
if (host.exists(extendedConfigPath)) {
283+
return extendedConfigPath;
284+
}
285+
} else {
286+
// Path isn't a rooted or relative path, resolve like a module.
287+
const {resolvedModule} = ts.nodeModuleNameResolver(
288+
candidate,
289+
configFile,
290+
{moduleResolution: ts.ModuleResolutionKind.NodeNext, resolveJsonModule: true},
291+
parseConfigHost,
292+
);
293+
if (resolvedModule) {
294+
return absoluteFrom(resolvedModule.resolvedFileName);
295+
}
232296
}
233297
}
234298

‎packages/compiler-cli/test/perform_compile_spec.ts‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,4 +298,76 @@ describe('perform_compile', () => {
298298
}),
299299
);
300300
});
301+
302+
it('should reuse cached configuration populated by extendedConfigCache across calls', () => {
303+
writeSomeConfigs();
304+
305+
const extendedConfigCache = new Map<string, ts.ExtendedConfigCacheEntry>();
306+
const {options: firstRunOptions} = readConfiguration(
307+
path.resolve(basePath, 'tsconfig-level-1.json'),
308+
undefined,
309+
undefined,
310+
extendedConfigCache,
311+
);
312+
313+
expect(firstRunOptions.annotateForClosureCompiler).toBeTrue();
314+
expect(firstRunOptions.skipMetadataEmit).toBeTrue();
315+
expect(extendedConfigCache.size).toBeGreaterThan(0);
316+
317+
// Overwrite the file on disk with invalid JSON. If readConfiguration uses cache, it will not fail.
318+
support.writeFiles({
319+
'tsconfig-level-2.json': `INVALID_JSON`,
320+
});
321+
322+
const {options: secondRunOptions} = readConfiguration(
323+
path.resolve(basePath, 'tsconfig-level-1.json'),
324+
undefined,
325+
undefined,
326+
extendedConfigCache,
327+
);
328+
329+
expect(secondRunOptions.annotateForClosureCompiler).toBeTrue();
330+
expect(secondRunOptions.skipMetadataEmit).toBeTrue();
331+
});
332+
333+
it('should honor changed angularCompilerOptions in an extended tsconfig across calls when cache is invalidated', () => {
334+
writeSomeConfigs();
335+
336+
const extendedConfigCache = new Map<string, ts.ExtendedConfigCacheEntry>();
337+
const level1Path = path.resolve(basePath, 'tsconfig-level-1.json');
338+
const level2Path = path.resolve(basePath, 'tsconfig-level-2.json');
339+
340+
// First run: initial values
341+
const {options: firstRunOptions} = readConfiguration(
342+
level1Path,
343+
undefined,
344+
undefined,
345+
extendedConfigCache,
346+
);
347+
348+
expect(firstRunOptions.skipMetadataEmit).toBeTrue();
349+
350+
// Modify the angular-specific option in the extended config file
351+
support.writeFiles({
352+
'tsconfig-level-2.json': `{
353+
"extends": "./tsconfig-level-3.json",
354+
"angularCompilerOptions": {
355+
"skipMetadataEmit": false
356+
}
357+
}`,
358+
});
359+
360+
// Invalidate the cache entry for the modified extended configuration (mimicking watch mode file watcher behavior)
361+
extendedConfigCache.delete(level2Path);
362+
363+
// Second run: verify updated option is picked up and honored
364+
const {options: secondRunOptions} = readConfiguration(
365+
level1Path,
366+
undefined,
367+
undefined,
368+
extendedConfigCache,
369+
);
370+
371+
expect(secondRunOptions.skipMetadataEmit).toBeFalse();
372+
});
301373
});

0 commit comments

Comments
 (0)