Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(compiler): compile and type check correctly in watch mode when file content itself changes #2405

Merged
merged 1 commit into from Feb 25, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
38 changes: 31 additions & 7 deletions src/compiler/ts-compiler.spec.ts
Expand Up @@ -374,16 +374,16 @@ const t: string = f(5)
})
})

describe('getResolvedModulesMap', () => {
const fileName = 'foo.ts'
describe('getResolvedModules', () => {
const fileName = join(__dirname, '..', '__mocks__', 'thing.spec.ts')
const fileContent = 'const foo = 1'

test('should return undefined when file name is not known to compiler', () => {
const compiler = makeCompiler({
tsJestConfig: baseTsJestConfig,
})

expect(compiler.getResolvedModulesMap(fileContent, fileName)).toBeUndefined()
expect(compiler.getResolvedModules(fileContent, fileName, new Map())).toEqual([])
})

test('should return undefined when it is isolatedModules true', () => {
Expand All @@ -394,7 +394,7 @@ const t: string = f(5)
},
})

expect(compiler.getResolvedModulesMap(fileContent, fileName)).toBeUndefined()
expect(compiler.getResolvedModules(fileContent, fileName, new Map())).toEqual([])
})

test('should return undefined when file has no resolved modules', () => {
Expand All @@ -407,12 +407,12 @@ const t: string = f(5)
jestCacheFS,
)

expect(compiler.getResolvedModulesMap(fileContent, fileName)).toBeUndefined()
expect(compiler.getResolvedModules(fileContent, fileName, new Map())).toEqual([])
})

test('should return resolved modules when file has resolved modules', () => {
const jestCacheFS = new Map<string, string>()
const fileContentWithModules = readFileSync(join(__dirname, '..', '__mocks__', 'thing.spec.ts'), 'utf-8')
const fileContentWithModules = readFileSync(fileName, 'utf-8')
jestCacheFS.set(fileName, fileContentWithModules)
const compiler = makeCompiler(
{
Expand All @@ -421,7 +421,7 @@ const t: string = f(5)
jestCacheFS,
)

expect(compiler.getResolvedModulesMap(fileContentWithModules, fileName)).toBeDefined()
expect(compiler.getResolvedModules(fileContentWithModules, fileName, new Map())).not.toEqual([])
})
})

Expand Down Expand Up @@ -476,6 +476,30 @@ const t: string = f(5)

expect(() => compiler.getCompiledOutput(source, fileName, false)).toThrowErrorMatchingSnapshot()
})

test('should report correct diagnostics when file content has changed', () => {
const compiler = makeCompiler(
{
tsJestConfig: baseTsJestConfig,
},
jestCacheFS,
)
const fileName = join(process.cwd(), 'src', '__mocks__', 'thing.spec.ts')
const oldSource = `
foo.split('-');
`
const newSource = `
const foo = 'bla-bla'
foo.split('-');
`
jestCacheFS.set(fileName, oldSource)

expect(() => compiler.getCompiledOutput(oldSource, fileName, false)).toThrowError()

jestCacheFS.set(fileName, newSource)

expect(() => compiler.getCompiledOutput(newSource, fileName, false)).not.toThrowError()
})
})

test('should pass Program instance into custom transformers', () => {
Expand Down
151 changes: 108 additions & 43 deletions src/compiler/ts-compiler.ts
Expand Up @@ -16,11 +16,13 @@ import type {
Bundle,
CustomTransformerFactory,
CustomTransformers,
ModuleResolutionHost,
ModuleResolutionCache,
} from 'typescript'

import { ConfigSet, TS_JEST_OUT_DIR } from '../config/config-set'
import { LINE_FEED } from '../constants'
import type { ResolvedModulesMap, StringMap, TsCompilerInstance, TsJestAstTransformer, TTypeScript } from '../types'
import type { StringMap, TsCompilerInstance, TsJestAstTransformer, TTypeScript } from '../types'
import { rootLogger } from '../utils/logger'
import { Errors, interpolate } from '../utils/messages'

Expand All @@ -31,18 +33,26 @@ export class TsCompiler implements TsCompilerInstance {
protected readonly _ts: TTypeScript
protected readonly _initialCompilerOptions: CompilerOptions
protected _compilerOptions: CompilerOptions
/**
* @private
*/
private _runtimeCacheFS: StringMap
/**
* @private
*/
private _fileContentCache: StringMap | undefined
/**
* @internal
*/
private readonly _parsedTsConfig: ParsedCommandLine
/**
* @internal
*/
private readonly _compilerCacheFS: Map<string, number> = new Map<string, number>()
private readonly _fileVersionCache: Map<string, number> | undefined
/**
* @internal
*/
private _cachedReadFile: ((fileName: string) => string | undefined) | undefined
private readonly _cachedReadFile: ((fileName: string) => string | undefined) | undefined
/**
* @internal
*/
Expand All @@ -51,15 +61,50 @@ export class TsCompiler implements TsCompilerInstance {
* @internal
*/
private _languageService: LanguageService | undefined
/**
* @internal
*/
private readonly _moduleResolutionHost: ModuleResolutionHost | undefined
/**
* @internal
*/
private readonly _moduleResolutionCache: ModuleResolutionCache | undefined

program: Program | undefined

constructor(readonly configSet: ConfigSet, readonly jestCacheFS: StringMap) {
constructor(readonly configSet: ConfigSet, readonly runtimeCacheFS: StringMap) {
this._ts = configSet.compilerModule
this._logger = rootLogger.child({ namespace: 'ts-compiler' })
this._parsedTsConfig = this.configSet.parsedTsConfig as ParsedCommandLine
this._initialCompilerOptions = { ...this._parsedTsConfig.options }
this._compilerOptions = { ...this._initialCompilerOptions }
this._runtimeCacheFS = runtimeCacheFS
if (!this.configSet.isolatedModules) {
this._fileContentCache = new Map<string, string>()
this._fileVersionCache = new Map<string, number>()
this._cachedReadFile = this._logger.wrap(
{
namespace: 'ts:serviceHost',
call: null,
[LogContexts.logLevel]: LogLevels.trace,
},
'readFile',
memoize(this._ts.sys.readFile),
)
/* istanbul ignore next */
this._moduleResolutionHost = {
fileExists: memoize(this._ts.sys.fileExists),
readFile: this._cachedReadFile,
directoryExists: memoize(this._ts.sys.directoryExists),
getCurrentDirectory: () => this.configSet.cwd,
realpath: this._ts.sys.realpath && memoize(this._ts.sys.realpath),
getDirectories: memoize(this._ts.sys.getDirectories),
}
this._moduleResolutionCache = this._ts.createModuleResolutionCache(
this.configSet.cwd,
(x) => x,
this._compilerOptions,
)
this._createLanguageService()
}
}
Expand All @@ -68,11 +113,6 @@ export class TsCompiler implements TsCompilerInstance {
* @internal
*/
private _createLanguageService(): void {
const serviceHostTraceCtx = {
namespace: 'ts:serviceHost',
call: null,
[LogContexts.logLevel]: LogLevels.trace,
}
// Initialize memory cache for typescript compiler
this._parsedTsConfig.fileNames
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
Expand All @@ -81,29 +121,17 @@ export class TsCompiler implements TsCompilerInstance {
!this.configSet.isTestFile(fileName) &&
!fileName.includes(this._parsedTsConfig.options.outDir ?? TS_JEST_OUT_DIR),
)
.forEach((fileName) => this._compilerCacheFS.set(fileName, 0))
this._cachedReadFile = this._logger.wrap(serviceHostTraceCtx, 'readFile', memoize(this._ts.sys.readFile))
/* istanbul ignore next */
const moduleResolutionHost = {
fileExists: memoize(this._ts.sys.fileExists),
readFile: this._cachedReadFile,
directoryExists: memoize(this._ts.sys.directoryExists),
getCurrentDirectory: () => this.configSet.cwd,
realpath: this._ts.sys.realpath && memoize(this._ts.sys.realpath),
getDirectories: memoize(this._ts.sys.getDirectories),
}
const moduleResolutionCache = this._ts.createModuleResolutionCache(
this.configSet.cwd,
(x) => x,
this._compilerOptions,
)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.forEach((fileName) => this._fileVersionCache!.set(fileName, 0))
/* istanbul ignore next */
const serviceHost: LanguageServiceHost = {
getProjectVersion: () => String(this._projectVersion),
getScriptFileNames: () => [...this._compilerCacheFS.keys()],
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getScriptFileNames: () => [...this._fileVersionCache!.keys()],
getScriptVersion: (fileName: string) => {
const normalizedFileName = normalize(fileName)
const version = this._compilerCacheFS.get(normalizedFileName)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const version = this._fileVersionCache!.get(normalizedFileName)

// We need to return `undefined` and not a string here because TypeScript will use
// `getScriptVersion` and compare against their own version - which can be `undefined`.
Expand All @@ -122,13 +150,20 @@ export class TsCompiler implements TsCompilerInstance {
// Read contents from TypeScript memory cache.
if (!hit) {
const fileContent =
this.jestCacheFS.get(normalizedFileName) ?? this._cachedReadFile?.(normalizedFileName) ?? undefined
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this._fileContentCache!.get(normalizedFileName) ??
this._runtimeCacheFS.get(normalizedFileName) ??
this._cachedReadFile?.(normalizedFileName) ??
undefined
if (fileContent) {
this.jestCacheFS.set(normalizedFileName, fileContent)
this._compilerCacheFS.set(normalizedFileName, 1)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this._fileContentCache!.set(normalizedFileName, fileContent)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this._fileVersionCache!.set(normalizedFileName, 1)
}
}
const contents = this.jestCacheFS.get(normalizedFileName)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const contents = this._fileContentCache!.get(normalizedFileName)

if (contents === undefined) return

Expand All @@ -151,8 +186,10 @@ export class TsCompiler implements TsCompilerInstance {
moduleName,
containingFile,
this._compilerOptions,
moduleResolutionHost,
moduleResolutionCache,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this._moduleResolutionHost!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this._moduleResolutionCache!,
)

return resolvedModule
Expand All @@ -165,12 +202,29 @@ export class TsCompiler implements TsCompilerInstance {
this.program = this._languageService.getProgram()
}

getResolvedModulesMap(fileContent: string, fileName: string): ResolvedModulesMap {
this._updateMemoryCache(fileContent, fileName)
getResolvedModules(fileContent: string, fileName: string, runtimeCacheFS: StringMap): string[] {
// In watch mode, it is possible that the initial cacheFS becomes empty
if (!this.runtimeCacheFS.size) {
this._runtimeCacheFS = runtimeCacheFS
}

return this._ts
.preProcessFile(fileContent, true, true)
.importedFiles.map((importedFile) => {
const { resolvedModule } = this._ts.resolveModuleName(
importedFile.fileName,
fileName,
this._compilerOptions,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this._moduleResolutionHost!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this._moduleResolutionCache!,
)

// See https://github.com/microsoft/TypeScript/blob/master/src/compiler/utilities.ts#L164
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (this._languageService?.getProgram()?.getSourceFile(fileName) as any)?.resolvedModules
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return resolvedModule?.resolvedFileName ?? ''
})
.filter((resolvedFileName) => !!resolvedFileName)
}

getCompiledOutput(fileContent: string, fileName: string, supportsStaticESM: boolean): string {
Expand Down Expand Up @@ -261,7 +315,12 @@ export class TsCompiler implements TsCompilerInstance {
*/
private _isFileInCache(fileName: string): boolean {
return (
this.jestCacheFS.has(fileName) && this._compilerCacheFS.has(fileName) && this._compilerCacheFS.get(fileName) !== 0
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this._fileContentCache!.has(fileName) &&
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this._fileVersionCache!.has(fileName) &&
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this._fileVersionCache!.get(fileName) !== 0
)
}

Expand All @@ -275,14 +334,20 @@ export class TsCompiler implements TsCompilerInstance {
let shouldIncrementProjectVersion = false
const hit = this._isFileInCache(fileName)
if (!hit) {
this._compilerCacheFS.set(fileName, 1)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this._fileVersionCache!.set(fileName, 1)
shouldIncrementProjectVersion = true
} else {
const prevVersion = this._compilerCacheFS.get(fileName) ?? 0
const previousContents = this.jestCacheFS.get(fileName)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const prevVersion = this._fileVersionCache!.get(fileName) ?? 0
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const previousContents = this._fileContentCache!.get(fileName)
// Avoid incrementing cache when nothing has changed.
if (previousContents !== contents) {
this._compilerCacheFS.set(fileName, prevVersion + 1)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this._fileVersionCache!.set(fileName, prevVersion + 1)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this._fileContentCache!.set(fileName, contents)
// Only bump project version when file is modified in cache, not when discovered for the first time
if (hit) shouldIncrementProjectVersion = true
}
Expand Down
10 changes: 5 additions & 5 deletions src/compiler/ts-jest-compiler.ts
@@ -1,5 +1,5 @@
import type { ConfigSet } from '../config/config-set'
import type { CompilerInstance, ResolvedModulesMap, StringMap } from '../types'
import type { CompilerInstance, StringMap } from '../types'

import { TsCompiler } from './ts-compiler'

Expand All @@ -9,13 +9,13 @@ import { TsCompiler } from './ts-compiler'
export class TsJestCompiler implements CompilerInstance {
private readonly _compilerInstance: CompilerInstance

constructor(readonly configSet: ConfigSet, readonly jestCacheFS: StringMap) {
constructor(configSet: ConfigSet, runtimeCacheFS: StringMap) {
// Later we can add swc/esbuild or other typescript compiler instance here
this._compilerInstance = new TsCompiler(configSet, jestCacheFS)
this._compilerInstance = new TsCompiler(configSet, runtimeCacheFS)
}

getResolvedModulesMap(fileContent: string, fileName: string): ResolvedModulesMap {
return this._compilerInstance.getResolvedModulesMap(fileContent, fileName)
getResolvedModules(fileContent: string, fileName: string, runtimeCacheFS: StringMap): string[] {
return this._compilerInstance.getResolvedModules(fileContent, fileName, runtimeCacheFS)
}

getCompiledOutput(fileContent: string, fileName: string, supportsStaticESM: boolean): string {
Expand Down