From 58411e7ad9641d7e815c5660077933e25b43562e Mon Sep 17 00:00:00 2001 From: JoostK Date: Mon, 14 Sep 2020 14:08:29 +0200 Subject: [PATCH] perf(ngcc): introduce cache for sharing data across entry-points (#38840) ngcc creates typically two `ts.Program` instances for each entry-point, one for processing sources and another one for processing the typings. The creation of these programs is somewhat expensive, as it concerns module resolution and parsing of source files. This commit implements several layers of caching to optimize the creation of programs: 1. A shared module resolution cache across all entry-points within a single invocation of ngcc. Both the sources and typings program benefit from this cache. 2. Sharing the parsed `ts.SourceFile` for a single entry-point between the sources and typings program. 3. Sharing parsed `ts.SourceFile`s of TypeScript's default libraries across all entry-points within a single invocation. Some of these default library typings are large and therefore expensive to parse, so sharing the parsed source files across all entry-points offers a significant performance improvement. Using a bare CLI app created using `ng new` + `ng add @angular/material`, the above changes offer a 3-4x improvement in ngcc's processing time when running synchronously and ~2x improvement for asynchronous runs. PR Close #38840 --- .../src/execution/create_compile_function.ts | 7 +- .../ngcc/src/packages/entry_point_bundle.ts | 16 +- .../ngcc/src/packages/ngcc_compiler_host.ts | 44 +++- .../ngcc/src/packages/source_file_cache.ts | 197 ++++++++++++++++ .../compiler-cli/ngcc/test/helpers/utils.ts | 6 +- .../test/packages/entry_point_bundle_spec.ts | 31 ++- .../test/packages/source_file_cache_spec.ts | 223 ++++++++++++++++++ .../new_entry_point_file_writer_spec.ts | 5 +- 8 files changed, 509 insertions(+), 20 deletions(-) create mode 100644 packages/compiler-cli/ngcc/src/packages/source_file_cache.ts create mode 100644 packages/compiler-cli/ngcc/test/packages/source_file_cache_spec.ts diff --git a/packages/compiler-cli/ngcc/src/execution/create_compile_function.ts b/packages/compiler-cli/ngcc/src/execution/create_compile_function.ts index c3671e6946a56..6f8d754ed2670 100644 --- a/packages/compiler-cli/ngcc/src/execution/create_compile_function.ts +++ b/packages/compiler-cli/ngcc/src/execution/create_compile_function.ts @@ -14,6 +14,7 @@ import {Logger} from '../../../src/ngtsc/logging'; import {ParsedConfiguration} from '../../../src/perform_compile'; import {getEntryPointFormat} from '../packages/entry_point'; import {makeEntryPointBundle} from '../packages/entry_point_bundle'; +import {createModuleResolutionCache, SharedFileCache} from '../packages/source_file_cache'; import {PathMappings} from '../path_mappings'; import {FileWriter} from '../writing/file_writer'; @@ -30,6 +31,8 @@ export function getCreateCompileFn( return (beforeWritingFiles, onTaskCompleted) => { const {Transformer} = require('../packages/transformer'); const transformer = new Transformer(fileSystem, logger, tsConfig); + const sharedFileCache = new SharedFileCache(fileSystem); + const moduleResolutionCache = createModuleResolutionCache(fileSystem); return (task: Task) => { const {entryPoint, formatProperty, formatPropertiesToMarkAsProcessed, processDts} = task; @@ -54,8 +57,8 @@ export function getCreateCompileFn( logger.info(`Compiling ${entryPoint.name} : ${formatProperty} as ${format}`); const bundle = makeEntryPointBundle( - fileSystem, entryPoint, formatPath, isCore, format, processDts, pathMappings, true, - enableI18nLegacyMessageIdFormat); + fileSystem, entryPoint, sharedFileCache, moduleResolutionCache, formatPath, isCore, + format, processDts, pathMappings, true, enableI18nLegacyMessageIdFormat); const result = transformer.transform(bundle); if (result.success) { diff --git a/packages/compiler-cli/ngcc/src/packages/entry_point_bundle.ts b/packages/compiler-cli/ngcc/src/packages/entry_point_bundle.ts index 68eea9495df18..4acd7f0031de9 100644 --- a/packages/compiler-cli/ngcc/src/packages/entry_point_bundle.ts +++ b/packages/compiler-cli/ngcc/src/packages/entry_point_bundle.ts @@ -6,11 +6,12 @@ * found in the LICENSE file at https://angular.io/license */ import * as ts from 'typescript'; -import {AbsoluteFsPath, FileSystem, NgtscCompilerHost} from '../../../src/ngtsc/file_system'; +import {AbsoluteFsPath, FileSystem} from '../../../src/ngtsc/file_system'; import {PathMappings} from '../path_mappings'; import {BundleProgram, makeBundleProgram} from './bundle_program'; import {EntryPoint, EntryPointFormat} from './entry_point'; -import {NgccSourcesCompilerHost} from './ngcc_compiler_host'; +import {NgccDtsCompilerHost, NgccSourcesCompilerHost} from './ngcc_compiler_host'; +import {EntryPointFileCache, SharedFileCache} from './source_file_cache'; /** * A bundle of files and paths (and TS programs) that correspond to a particular @@ -31,6 +32,8 @@ export interface EntryPointBundle { * Get an object that describes a formatted bundle for an entry-point. * @param fs The current file-system being used. * @param entryPoint The entry-point that contains the bundle. + * @param sharedFileCache The cache to use for source files that are shared across all entry-points. + * @param moduleResolutionCache The module resolution cache to use. * @param formatPath The path to the source files for this bundle. * @param isCore This entry point is the Angular core package. * @param format The underlying format of the bundle. @@ -42,7 +45,8 @@ export interface EntryPointBundle { * component templates. */ export function makeEntryPointBundle( - fs: FileSystem, entryPoint: EntryPoint, formatPath: string, isCore: boolean, + fs: FileSystem, entryPoint: EntryPoint, sharedFileCache: SharedFileCache, + moduleResolutionCache: ts.ModuleResolutionCache, formatPath: string, isCore: boolean, format: EntryPointFormat, transformDts: boolean, pathMappings?: PathMappings, mirrorDtsFromSrc: boolean = false, enableI18nLegacyMessageIdFormat: boolean = true): EntryPointBundle { @@ -50,8 +54,10 @@ export function makeEntryPointBundle( const rootDir = entryPoint.packagePath; const options: ts .CompilerOptions = {allowJs: true, maxNodeModuleJsDepth: Infinity, rootDir, ...pathMappings}; - const srcHost = new NgccSourcesCompilerHost(fs, options, entryPoint.packagePath); - const dtsHost = new NgtscCompilerHost(fs, options); + const entryPointCache = new EntryPointFileCache(fs, sharedFileCache); + const dtsHost = new NgccDtsCompilerHost(fs, options, entryPointCache, moduleResolutionCache); + const srcHost = new NgccSourcesCompilerHost( + fs, options, entryPointCache, moduleResolutionCache, entryPoint.packagePath); // Create the bundle programs, as necessary. const absFormatPath = fs.resolve(entryPoint.path, formatPath); diff --git a/packages/compiler-cli/ngcc/src/packages/ngcc_compiler_host.ts b/packages/compiler-cli/ngcc/src/packages/ngcc_compiler_host.ts index 4c5d62f767e3d..c679a12503e94 100644 --- a/packages/compiler-cli/ngcc/src/packages/ngcc_compiler_host.ts +++ b/packages/compiler-cli/ngcc/src/packages/ngcc_compiler_host.ts @@ -10,6 +10,7 @@ import * as ts from 'typescript'; import {AbsoluteFsPath, FileSystem, NgtscCompilerHost} from '../../../src/ngtsc/file_system'; import {isWithinPackage} from '../analysis/util'; import {isRelativePath} from '../utils'; +import {EntryPointFileCache} from './source_file_cache'; /** * Represents a compiler host that resolves a module import as a JavaScript source file if @@ -18,19 +19,24 @@ import {isRelativePath} from '../utils'; * would otherwise let TypeScript prefer the .d.ts file instead of the JavaScript source file. */ export class NgccSourcesCompilerHost extends NgtscCompilerHost { - private cache = ts.createModuleResolutionCache( - this.getCurrentDirectory(), file => this.getCanonicalFileName(file)); - - constructor(fs: FileSystem, options: ts.CompilerOptions, protected packagePath: AbsoluteFsPath) { + constructor( + fs: FileSystem, options: ts.CompilerOptions, private cache: EntryPointFileCache, + private moduleResolutionCache: ts.ModuleResolutionCache, + protected packagePath: AbsoluteFsPath) { super(fs, options); } + getSourceFile(fileName: string, languageVersion: ts.ScriptTarget): ts.SourceFile|undefined { + return this.cache.getCachedSourceFile(fileName, languageVersion); + } + resolveModuleNames( moduleNames: string[], containingFile: string, reusedNames?: string[], redirectedReference?: ts.ResolvedProjectReference): Array { return moduleNames.map(moduleName => { const {resolvedModule} = ts.resolveModuleName( - moduleName, containingFile, this.options, this, this.cache, redirectedReference); + moduleName, containingFile, this.options, this, this.moduleResolutionCache, + redirectedReference); // If the module request originated from a relative import in a JavaScript source file, // TypeScript may have resolved the module to its .d.ts declaration file if the .js source @@ -59,3 +65,31 @@ export class NgccSourcesCompilerHost extends NgtscCompilerHost { }); } } + +/** + * A compiler host implementation that is used for the typings program. It leverages the entry-point + * cache for source files and module resolution, as these results can be reused across the sources + * program. + */ +export class NgccDtsCompilerHost extends NgtscCompilerHost { + constructor( + fs: FileSystem, options: ts.CompilerOptions, private cache: EntryPointFileCache, + private moduleResolutionCache: ts.ModuleResolutionCache) { + super(fs, options); + } + + getSourceFile(fileName: string, languageVersion: ts.ScriptTarget): ts.SourceFile|undefined { + return this.cache.getCachedSourceFile(fileName, languageVersion); + } + + resolveModuleNames( + moduleNames: string[], containingFile: string, reusedNames?: string[], + redirectedReference?: ts.ResolvedProjectReference): Array { + return moduleNames.map(moduleName => { + const {resolvedModule} = ts.resolveModuleName( + moduleName, containingFile, this.options, this, this.moduleResolutionCache, + redirectedReference); + return resolvedModule; + }); + } +} diff --git a/packages/compiler-cli/ngcc/src/packages/source_file_cache.ts b/packages/compiler-cli/ngcc/src/packages/source_file_cache.ts new file mode 100644 index 0000000000000..d9b0c33637c25 --- /dev/null +++ b/packages/compiler-cli/ngcc/src/packages/source_file_cache.ts @@ -0,0 +1,197 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as ts from 'typescript'; +import {AbsoluteFsPath, FileSystem} from '../../../src/ngtsc/file_system'; + +/** + * A cache that holds on to source files that can be shared for processing all entry-points in a + * single invocation of ngcc. In particular, the following files are shared across all entry-points + * through this cache: + * + * 1. Default library files such as `lib.dom.d.ts` and `lib.es5.d.ts`. These files don't change + * and some are very large, so parsing is expensive. Therefore, the parsed `ts.SourceFile`s for + * the default library files are cached. + * 2. The typings of @angular scoped packages. The typing files for @angular packages are typically + * used in the entry-points that ngcc processes, so benefit from a single source file cache. + * Especially `@angular/core/core.d.ts` is large and expensive to parse repeatedly. In contrast + * to default library files, we have to account for these files to be invalidated during a single + * invocation of ngcc, as ngcc will overwrite the .d.ts files during its processing. + * + * The lifecycle of this cache corresponds with a single invocation of ngcc. Separate invocations, + * e.g. the CLI's synchronous module resolution fallback will therefore all have their own cache. + * This allows for the source file cache to be garbage collected once ngcc processing has completed. + */ +export class SharedFileCache { + private sfCache = new Map(); + + constructor(private fs: FileSystem) {} + + /** + * Loads a `ts.SourceFile` if the provided `fileName` is deemed appropriate to be cached. To + * optimize for memory usage, only files that are generally used in all entry-points are cached. + * If `fileName` is not considered to benefit from caching or the requested file does not exist, + * then `undefined` is returned. + */ + getCachedSourceFile(fileName: string): ts.SourceFile|undefined { + const absPath = this.fs.resolve(fileName); + if (isDefaultLibrary(absPath, this.fs)) { + return this.getStableCachedFile(absPath); + } else if (isAngularDts(absPath, this.fs)) { + return this.getVolatileCachedFile(absPath); + } else { + return undefined; + } + } + + /** + * Attempts to load the source file from the cache, or parses the file into a `ts.SourceFile` if + * it's not yet cached. This method assumes that the file will not be modified for the duration + * that this cache is valid for. If that assumption does not hold, the `getVolatileCachedFile` + * method is to be used instead. + */ + private getStableCachedFile(absPath: AbsoluteFsPath): ts.SourceFile|undefined { + if (!this.sfCache.has(absPath)) { + const content = readFile(absPath, this.fs); + if (content === undefined) { + return undefined; + } + const sf = ts.createSourceFile(absPath, content, ts.ScriptTarget.ES2015); + this.sfCache.set(absPath, sf); + } + return this.sfCache.get(absPath)!; + } + + /** + * In contrast to `getStableCachedFile`, this method always verifies that the cached source file + * is the same as what's stored on disk. This is done for files that are expected to change during + * ngcc's processing, such as @angular scoped packages for which the .d.ts files are overwritten + * by ngcc. If the contents on disk have changed compared to a previously cached source file, the + * content from disk is re-parsed and the cache entry is replaced. + */ + private getVolatileCachedFile(absPath: AbsoluteFsPath): ts.SourceFile|undefined { + const content = readFile(absPath, this.fs); + if (content === undefined) { + return undefined; + } + if (!this.sfCache.has(absPath) || this.sfCache.get(absPath)!.text !== content) { + const sf = ts.createSourceFile(absPath, content, ts.ScriptTarget.ES2015); + this.sfCache.set(absPath, sf); + } + return this.sfCache.get(absPath)!; + } +} + +const DEFAULT_LIB_PATTERN = ['node_modules', 'typescript', 'lib', /^lib\..+\.d\.ts$/]; + +/** + * Determines whether the provided path corresponds with a default library file inside of the + * typescript package. + * + * @param absPath The path for which to determine if it corresponds with a default library file. + * @param fs The filesystem to use for inspecting the path. + */ +export function isDefaultLibrary(absPath: AbsoluteFsPath, fs: FileSystem): boolean { + return isFile(absPath, DEFAULT_LIB_PATTERN, fs); +} + +const ANGULAR_DTS_PATTERN = ['node_modules', '@angular', /./, /\.d\.ts$/]; + +/** + * Determines whether the provided path corresponds with a .d.ts file inside of an @angular + * scoped package. This logic only accounts for the .d.ts files in the root, which is sufficient + * to find the large, flattened entry-point files that benefit from caching. + * + * @param absPath The path for which to determine if it corresponds with an @angular .d.ts file. + * @param fs The filesystem to use for inspecting the path. + */ +export function isAngularDts(absPath: AbsoluteFsPath, fs: FileSystem): boolean { + return isFile(absPath, ANGULAR_DTS_PATTERN, fs); +} + +/** + * Helper function to determine whether a file corresponds with a given pattern of segments. + * + * @param path The path for which to determine if it corresponds with the provided segments. + * @param segments Array of segments; the `path` must have ending segments that match the + * patterns in this array. + * @param fs The filesystem to use for inspecting the path. + */ +function isFile( + path: AbsoluteFsPath, segments: ReadonlyArray, fs: FileSystem): boolean { + for (let i = segments.length - 1; i >= 0; i--) { + const pattern = segments[i]; + const segment = fs.basename(path); + if (typeof pattern === 'string') { + if (pattern !== segment) { + return false; + } + } else { + if (!pattern.test(segment)) { + return false; + } + } + path = fs.dirname(path); + } + return true; +} + +/** + * A cache for processing a single entry-point. This exists to share `ts.SourceFile`s between the + * source and typing programs that are created for a single program. + */ +export class EntryPointFileCache { + private readonly sfCache = new Map(); + + constructor(private fs: FileSystem, private sharedFileCache: SharedFileCache) {} + + /** + * Returns and caches a parsed `ts.SourceFile` for the provided `fileName`. If the `fileName` is + * cached in the shared file cache, that result is used. Otherwise, the source file is cached + * internally. This method returns `undefined` if the requested file does not exist. + * + * @param fileName The path of the file to retrieve a source file for. + * @param languageVersion The language version to use for parsing the file. + */ + getCachedSourceFile(fileName: string, languageVersion: ts.ScriptTarget): ts.SourceFile|undefined { + const staticSf = this.sharedFileCache.getCachedSourceFile(fileName); + if (staticSf !== undefined) { + return staticSf; + } + + const absPath = this.fs.resolve(fileName); + if (this.sfCache.has(absPath)) { + return this.sfCache.get(absPath); + } + + const content = readFile(absPath, this.fs); + if (content === undefined) { + return undefined; + } + const sf = ts.createSourceFile(fileName, content, languageVersion); + this.sfCache.set(absPath, sf); + return sf; + } +} + +function readFile(absPath: AbsoluteFsPath, fs: FileSystem): string|undefined { + if (!fs.exists(absPath) || !fs.stat(absPath).isFile()) { + return undefined; + } + return fs.readFile(absPath); +} + +/** + * Creates a `ts.ModuleResolutionCache` that uses the provided filesystem for path operations. + * + * @param fs The filesystem to use for path operations. + */ +export function createModuleResolutionCache(fs: FileSystem): ts.ModuleResolutionCache { + return ts.createModuleResolutionCache(fs.pwd(), fileName => { + return fs.isCaseSensitive() ? fileName : fileName.toLowerCase(); + }); +} diff --git a/packages/compiler-cli/ngcc/test/helpers/utils.ts b/packages/compiler-cli/ngcc/test/helpers/utils.ts index 75a23805543c5..382758f3c370b 100644 --- a/packages/compiler-cli/ngcc/test/helpers/utils.ts +++ b/packages/compiler-cli/ngcc/test/helpers/utils.ts @@ -14,6 +14,7 @@ import {NgccEntryPointConfig} from '../../src/packages/configuration'; import {EntryPoint, EntryPointFormat} from '../../src/packages/entry_point'; import {EntryPointBundle} from '../../src/packages/entry_point_bundle'; import {NgccSourcesCompilerHost} from '../../src/packages/ngcc_compiler_host'; +import {createModuleResolutionCache, EntryPointFileCache, SharedFileCache} from '../../src/packages/source_file_cache'; export type TestConfig = Pick; @@ -68,7 +69,10 @@ export function makeTestBundleProgram( const rootDir = fs.dirname(entryPointPath); const options: ts.CompilerOptions = {allowJs: true, maxNodeModuleJsDepth: Infinity, checkJs: false, rootDir, rootDirs: [rootDir]}; - const host = new NgccSourcesCompilerHost(fs, options, rootDir); + const moduleResolutionCache = createModuleResolutionCache(fs); + const entryPointFileCache = new EntryPointFileCache(fs, new SharedFileCache(fs)); + const host = + new NgccSourcesCompilerHost(fs, options, entryPointFileCache, moduleResolutionCache, rootDir); return makeBundleProgram( fs, isCore, rootDir, path, 'r3_symbols.js', options, host, additionalFiles); } diff --git a/packages/compiler-cli/ngcc/test/packages/entry_point_bundle_spec.ts b/packages/compiler-cli/ngcc/test/packages/entry_point_bundle_spec.ts index 8eb133ed0a23a..a5878794af169 100644 --- a/packages/compiler-cli/ngcc/test/packages/entry_point_bundle_spec.ts +++ b/packages/compiler-cli/ngcc/test/packages/entry_point_bundle_spec.ts @@ -10,6 +10,7 @@ import {runInEachFileSystem} from '../../../src/ngtsc/file_system/testing'; import {loadTestFiles} from '../../../test/helpers'; import {EntryPoint} from '../../src/packages/entry_point'; import {makeEntryPointBundle} from '../../src/packages/entry_point_bundle'; +import {createModuleResolutionCache, SharedFileCache} from '../../src/packages/source_file_cache'; runInEachFileSystem(() => { describe('entry point bundle', () => { @@ -180,7 +181,10 @@ runInEachFileSystem(() => { ignoreMissingDependencies: false, generateDeepReexports: false, }; - const esm5bundle = makeEntryPointBundle(fs, entryPoint, './index.js', false, 'esm5', true); + const moduleResolutionCache = createModuleResolutionCache(fs); + const esm5bundle = makeEntryPointBundle( + fs, entryPoint, new SharedFileCache(fs), moduleResolutionCache, './index.js', false, + 'esm5', true); expect(esm5bundle.src.program.getSourceFiles().map(sf => sf.fileName)) .toEqual(jasmine.arrayWithExactContents([ @@ -291,8 +295,11 @@ runInEachFileSystem(() => { ignoreMissingDependencies: false, generateDeepReexports: false, }; + const moduleResolutionCache = createModuleResolutionCache(fs); const esm5bundle = makeEntryPointBundle( - fs, entryPoint, './index.js', false, 'esm5', /* transformDts */ true, + fs, entryPoint, new SharedFileCache(fs), moduleResolutionCache, './index.js', false, + 'esm5', + /* transformDts */ true, /* pathMappings */ undefined, /* mirrorDtsFromSrc */ true); expect(esm5bundle.src.program.getSourceFiles().map(sf => _(sf.fileName))) @@ -328,8 +335,11 @@ runInEachFileSystem(() => { ignoreMissingDependencies: false, generateDeepReexports: false, }; + const moduleResolutionCache = createModuleResolutionCache(fs); const esm5bundle = makeEntryPointBundle( - fs, entryPoint, './index.js', false, 'esm5', /* transformDts */ true, + fs, entryPoint, new SharedFileCache(fs), moduleResolutionCache, './index.js', false, + 'esm5', + /* transformDts */ true, /* pathMappings */ undefined, /* mirrorDtsFromSrc */ true); expect(esm5bundle.src.program.getSourceFiles().map(sf => sf.fileName)) .toContain(absoluteFrom('/node_modules/test/internal.js')); @@ -351,8 +361,11 @@ runInEachFileSystem(() => { ignoreMissingDependencies: false, generateDeepReexports: false, }; + const moduleResolutionCache = createModuleResolutionCache(fs); const esm5bundle = makeEntryPointBundle( - fs, entryPoint, './esm2015/index.js', false, 'esm2015', /* transformDts */ true, + fs, entryPoint, new SharedFileCache(fs), moduleResolutionCache, + './esm2015/index.js', false, 'esm2015', + /* transformDts */ true, /* pathMappings */ undefined, /* mirrorDtsFromSrc */ true); expect(esm5bundle.src.program.getSourceFiles().map(sf => sf.fileName)) .toContain(absoluteFrom('/node_modules/internal/esm2015/src/internal.js')); @@ -374,8 +387,11 @@ runInEachFileSystem(() => { ignoreMissingDependencies: false, generateDeepReexports: false, }; + const moduleResolutionCache = createModuleResolutionCache(fs); const esm5bundle = makeEntryPointBundle( - fs, entryPoint, './index.js', false, 'esm5', /* transformDts */ true, + fs, entryPoint, new SharedFileCache(fs), moduleResolutionCache, './index.js', false, + 'esm5', + /* transformDts */ true, /* pathMappings */ undefined, /* mirrorDtsFromSrc */ false); expect(esm5bundle.src.program.getSourceFiles().map(sf => sf.fileName)) .toContain(absoluteFrom('/node_modules/test/internal.js')); @@ -398,8 +414,11 @@ runInEachFileSystem(() => { ignoreMissingDependencies: false, generateDeepReexports: false, }; + const moduleResolutionCache = createModuleResolutionCache(fs); const bundle = makeEntryPointBundle( - fs, entryPoint, './index.js', false, 'esm2015', /* transformDts */ true, + fs, entryPoint, new SharedFileCache(fs), moduleResolutionCache, './index.js', false, + 'esm2015', + /* transformDts */ true, /* pathMappings */ undefined, /* mirrorDtsFromSrc */ true); expect(bundle.rootDirs).toEqual([absoluteFrom('/node_modules/primary')]); }); diff --git a/packages/compiler-cli/ngcc/test/packages/source_file_cache_spec.ts b/packages/compiler-cli/ngcc/test/packages/source_file_cache_spec.ts new file mode 100644 index 0000000000000..8c3ca880897ab --- /dev/null +++ b/packages/compiler-cli/ngcc/test/packages/source_file_cache_spec.ts @@ -0,0 +1,223 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as ts from 'typescript'; + +import {absoluteFrom, FileSystem, getFileSystem} from '../../../src/ngtsc/file_system'; +import {runInEachFileSystem} from '../../../src/ngtsc/file_system/testing'; +import {loadTestFiles} from '../../../test/helpers'; +import {EntryPointFileCache, isAngularDts, isDefaultLibrary, SharedFileCache} from '../../src/packages/source_file_cache'; + +runInEachFileSystem(() => { + describe('caching', () => { + let _: typeof absoluteFrom; + let fs: FileSystem; + beforeEach(() => { + _ = absoluteFrom; + fs = getFileSystem(); + loadTestFiles([ + { + name: _('/node_modules/typescript/lib/lib.es5.d.ts'), + contents: `export declare interface Array {}`, + }, + { + name: _('/node_modules/typescript/lib/lib.dom.d.ts'), + contents: `export declare interface Window {}`, + }, + { + name: _('/node_modules/@angular/core/core.d.ts'), + contents: `export declare interface Component {}`, + }, + { + name: _('/node_modules/@angular/common/common.d.ts'), + contents: `export declare interface NgIf {}`, + }, + { + name: _('/index.ts'), + contents: `export const index = true;`, + }, + { + name: _('/main.ts'), + contents: `export const main = true;`, + }, + ]); + }); + + describe('SharedFileCache', () => { + it('should cache a parsed source file for default libraries', () => { + const cache = new SharedFileCache(fs); + + const libEs5 = cache.getCachedSourceFile('/node_modules/typescript/lib/lib.es5.d.ts')!; + expect(libEs5).not.toBeUndefined(); + expect(libEs5.text).toContain('Array'); + + const libDom = cache.getCachedSourceFile('/node_modules/typescript/lib/lib.dom.d.ts')!; + expect(libDom).not.toBeUndefined(); + expect(libDom.text).toContain('Window'); + + const libEs5_2 = cache.getCachedSourceFile('/node_modules/typescript/lib/lib.es5.d.ts')!; + expect(libEs5_2).toBe(libEs5); + + const libDom_2 = cache.getCachedSourceFile('/node_modules/typescript/lib/lib.dom.d.ts')!; + expect(libDom_2).toBe(libDom); + }); + + it('should cache a parsed source file for @angular scoped packages', () => { + const cache = new SharedFileCache(fs); + + const core = cache.getCachedSourceFile('/node_modules/@angular/core/core.d.ts')!; + expect(core).not.toBeUndefined(); + expect(core.text).toContain('Component'); + + const common = cache.getCachedSourceFile('/node_modules/@angular/common/common.d.ts')!; + expect(common).not.toBeUndefined(); + expect(common.text).toContain('NgIf'); + + const core_2 = cache.getCachedSourceFile('/node_modules/@angular/core/core.d.ts')!; + expect(core_2).toBe(core); + + const common_2 = cache.getCachedSourceFile('/node_modules/@angular/common/common.d.ts')!; + expect(common_2).toBe(common); + }); + + it('should reparse @angular d.ts files when they change', () => { + const cache = new SharedFileCache(fs); + + const core = cache.getCachedSourceFile('/node_modules/@angular/core/core.d.ts')!; + expect(core).not.toBeUndefined(); + expect(core.text).toContain('Component'); + + const common = cache.getCachedSourceFile('/node_modules/@angular/common/common.d.ts')!; + expect(common).not.toBeUndefined(); + expect(common.text).toContain('NgIf'); + + fs.writeFile( + _('/node_modules/@angular/core/core.d.ts'), `export declare interface Directive {}`); + + const core_2 = cache.getCachedSourceFile('/node_modules/@angular/core/core.d.ts')!; + expect(core_2).not.toBe(core); + expect(core_2.text).toContain('Directive'); + + const core_3 = cache.getCachedSourceFile('/node_modules/@angular/core/core.d.ts')!; + expect(core_3).toBe(core_2); + + const common_2 = cache.getCachedSourceFile('/node_modules/@angular/common/common.d.ts')!; + expect(common_2).toBe(common); + }); + + it('should not cache files that are not default library files inside of the typescript package', + () => { + const cache = new SharedFileCache(fs); + + expect(cache.getCachedSourceFile('/node_modules/typescript/lib/typescript.d.ts')) + .toBeUndefined(); + expect(cache.getCachedSourceFile('/typescript/lib.es5.d.ts')).toBeUndefined(); + }); + }); + + describe('isDefaultLibrary()', () => { + it('should accept lib files inside of the typescript package', () => { + expect(isDefaultLibrary(_('/node_modules/typescript/lib/lib.es5.d.ts'), fs)).toBe(true); + expect(isDefaultLibrary(_('/node_modules/typescript/lib/lib.dom.d.ts'), fs)).toBe(true); + expect(isDefaultLibrary(_('/node_modules/typescript/lib/lib.es2015.core.d.ts'), fs)) + .toBe(true); + expect(isDefaultLibrary(_('/root/node_modules/typescript/lib/lib.es5.d.ts'), fs)) + .toBe(true); + }); + it('should reject non lib files inside of the typescript package', () => { + expect(isDefaultLibrary(_('/node_modules/typescript/lib/typescript.d.ts'), fs)).toBe(false); + expect(isDefaultLibrary(_('/node_modules/typescript/lib/lib.es5.ts'), fs)).toBe(false); + expect(isDefaultLibrary(_('/node_modules/typescript/lib/lib.d.ts'), fs)).toBe(false); + expect(isDefaultLibrary(_('/node_modules/typescript/lib.es5.d.ts'), fs)).toBe(false); + }); + it('should reject lib files outside of the typescript package', () => { + expect(isDefaultLibrary(_('/node_modules/ttypescript/lib/lib.es5.d.ts'), fs)).toBe(false); + expect(isDefaultLibrary(_('/node_modules/ttypescript/lib/lib.es5.d.ts'), fs)).toBe(false); + expect(isDefaultLibrary(_('/typescript/lib/lib.es5.d.ts'), fs)).toBe(false); + }); + }); + + describe('isAngularDts()', () => { + it('should accept .d.ts files inside of the @angular scope', () => { + expect(isAngularDts(_('/node_modules/@angular/core/core.d.ts'), fs)).toBe(true); + expect(isAngularDts(_('/node_modules/@angular/common/common.d.ts'), fs)).toBe(true); + }); + it('should reject non-.d.ts files inside @angular scoped packages', () => { + expect(isAngularDts(_('/node_modules/@angular/common/src/common.ts'), fs)).toBe(false); + }); + it('should reject .d.ts files nested deeply inside @angular scoped packages', () => { + expect(isAngularDts(_('/node_modules/@angular/common/src/common.d.ts'), fs)).toBe(false); + }); + it('should reject .d.ts files directly inside the @angular scope', () => { + expect(isAngularDts(_('/node_modules/@angular/common.d.ts'), fs)).toBe(false); + }); + it('should reject files that are not inside node_modules', () => { + expect(isAngularDts(_('/@angular/core/core.d.ts'), fs)).toBe(false); + }); + }); + + describe('EntryPointFileCache', () => { + let sharedFileCache: SharedFileCache; + beforeEach(() => { + sharedFileCache = new SharedFileCache(fs); + }); + + it('should prefer source files cached in SharedFileCache', () => { + const cache1 = new EntryPointFileCache(fs, sharedFileCache); + const libEs5_1 = cache1.getCachedSourceFile( + '/node_modules/typescript/lib/lib.es5.d.ts', ts.ScriptTarget.ESNext)!; + expect(libEs5_1).not.toBeUndefined(); + expect(libEs5_1.text).toContain('Array'); + expect(libEs5_1.languageVersion).toBe(ts.ScriptTarget.ES2015); + + const cache2 = new EntryPointFileCache(fs, sharedFileCache); + const libEs5_2 = cache2.getCachedSourceFile( + '/node_modules/typescript/lib/lib.es5.d.ts', ts.ScriptTarget.ESNext)!; + expect(libEs5_1).toBe(libEs5_2); + }); + + it('should cache source files that are not default library files', () => { + const cache = new EntryPointFileCache(fs, sharedFileCache); + const index = cache.getCachedSourceFile('/index.ts', ts.ScriptTarget.ESNext)!; + expect(index).not.toBeUndefined(); + expect(index.text).toContain('index'); + expect(index.languageVersion).toBe(ts.ScriptTarget.ESNext); + + const main = cache.getCachedSourceFile('/main.ts', ts.ScriptTarget.ESNext)!; + expect(main).not.toBeUndefined(); + expect(main.text).toContain('main'); + expect(main.languageVersion).toBe(ts.ScriptTarget.ESNext); + + const index_2 = cache.getCachedSourceFile('/index.ts', ts.ScriptTarget.ESNext)!; + expect(index_2).toBe(index); + + const main_2 = cache.getCachedSourceFile('/main.ts', ts.ScriptTarget.ESNext)!; + expect(main_2).toBe(main); + }); + + it('should not share non-library files across multiple cache instances', () => { + const cache1 = new EntryPointFileCache(fs, sharedFileCache); + const cache2 = new EntryPointFileCache(fs, sharedFileCache); + + const index1 = cache1.getCachedSourceFile('/index.ts', ts.ScriptTarget.ESNext)!; + const index2 = cache2.getCachedSourceFile('/index.ts', ts.ScriptTarget.ESNext)!; + expect(index1).not.toBe(index2); + }); + + it('should return undefined if the file does not exist', () => { + const cache = new EntryPointFileCache(fs, sharedFileCache); + expect(cache.getCachedSourceFile('/nonexistent.ts', ts.ScriptTarget.ESNext)) + .toBeUndefined(); + }); + + it('should return undefined if the path is a directory', () => { + const cache = new EntryPointFileCache(fs, sharedFileCache); + expect(cache.getCachedSourceFile('/node_modules', ts.ScriptTarget.ESNext)).toBeUndefined(); + }); + }); + }); +}); diff --git a/packages/compiler-cli/ngcc/test/writing/new_entry_point_file_writer_spec.ts b/packages/compiler-cli/ngcc/test/writing/new_entry_point_file_writer_spec.ts index b8fb4aba3382b..255212a20c9d0 100644 --- a/packages/compiler-cli/ngcc/test/writing/new_entry_point_file_writer_spec.ts +++ b/packages/compiler-cli/ngcc/test/writing/new_entry_point_file_writer_spec.ts @@ -12,6 +12,7 @@ import {loadTestFiles} from '../../../test/helpers'; import {NgccConfiguration} from '../../src/packages/configuration'; import {EntryPoint, EntryPointFormat, EntryPointJsonProperty, getEntryPointInfo, isEntryPoint} from '../../src/packages/entry_point'; import {EntryPointBundle, makeEntryPointBundle} from '../../src/packages/entry_point_bundle'; +import {createModuleResolutionCache, SharedFileCache} from '../../src/packages/source_file_cache'; import {FileWriter} from '../../src/writing/file_writer'; import {NewEntryPointFileWriter} from '../../src/writing/new_entry_point_file_writer'; import {DirectPackageJsonUpdater} from '../../src/writing/package_json_updater'; @@ -634,7 +635,9 @@ runInEachFileSystem(() => { function makeTestBundle( fs: FileSystem, entryPoint: EntryPoint, formatProperty: EntryPointJsonProperty, format: EntryPointFormat): EntryPointBundle { + const moduleResolutionCache = createModuleResolutionCache(fs); return makeEntryPointBundle( - fs, entryPoint, entryPoint.packageJson[formatProperty]!, false, format, true); + fs, entryPoint, new SharedFileCache(fs), moduleResolutionCache, + entryPoint.packageJson[formatProperty]!, false, format, true); } });