Skip to content

Commit 064bf3a

Browse files
authored
fix(compiler): type check correctly in watch mode when a file content itself has changed (#2405)
Closes #2118
1 parent 8615306 commit 064bf3a

6 files changed

+198
-107
lines changed

src/compiler/ts-compiler.spec.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -374,16 +374,16 @@ const t: string = f(5)
374374
})
375375
})
376376

377-
describe('getResolvedModulesMap', () => {
378-
const fileName = 'foo.ts'
377+
describe('getResolvedModules', () => {
378+
const fileName = join(__dirname, '..', '__mocks__', 'thing.spec.ts')
379379
const fileContent = 'const foo = 1'
380380

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

386-
expect(compiler.getResolvedModulesMap(fileContent, fileName)).toBeUndefined()
386+
expect(compiler.getResolvedModules(fileContent, fileName, new Map())).toEqual([])
387387
})
388388

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

397-
expect(compiler.getResolvedModulesMap(fileContent, fileName)).toBeUndefined()
397+
expect(compiler.getResolvedModules(fileContent, fileName, new Map())).toEqual([])
398398
})
399399

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

410-
expect(compiler.getResolvedModulesMap(fileContent, fileName)).toBeUndefined()
410+
expect(compiler.getResolvedModules(fileContent, fileName, new Map())).toEqual([])
411411
})
412412

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

424-
expect(compiler.getResolvedModulesMap(fileContentWithModules, fileName)).toBeDefined()
424+
expect(compiler.getResolvedModules(fileContentWithModules, fileName, new Map())).not.toEqual([])
425425
})
426426
})
427427

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

477477
expect(() => compiler.getCompiledOutput(source, fileName, false)).toThrowErrorMatchingSnapshot()
478478
})
479+
480+
test('should report correct diagnostics when file content has changed', () => {
481+
const compiler = makeCompiler(
482+
{
483+
tsJestConfig: baseTsJestConfig,
484+
},
485+
jestCacheFS,
486+
)
487+
const fileName = join(process.cwd(), 'src', '__mocks__', 'thing.spec.ts')
488+
const oldSource = `
489+
foo.split('-');
490+
`
491+
const newSource = `
492+
const foo = 'bla-bla'
493+
foo.split('-');
494+
`
495+
jestCacheFS.set(fileName, oldSource)
496+
497+
expect(() => compiler.getCompiledOutput(oldSource, fileName, false)).toThrowError()
498+
499+
jestCacheFS.set(fileName, newSource)
500+
501+
expect(() => compiler.getCompiledOutput(newSource, fileName, false)).not.toThrowError()
502+
})
479503
})
480504

481505
test('should pass Program instance into custom transformers', () => {

src/compiler/ts-compiler.ts

Lines changed: 108 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ import type {
1616
Bundle,
1717
CustomTransformerFactory,
1818
CustomTransformers,
19+
ModuleResolutionHost,
20+
ModuleResolutionCache,
1921
} from 'typescript'
2022

2123
import { ConfigSet, TS_JEST_OUT_DIR } from '../config/config-set'
2224
import { LINE_FEED } from '../constants'
23-
import type { ResolvedModulesMap, StringMap, TsCompilerInstance, TsJestAstTransformer, TTypeScript } from '../types'
25+
import type { StringMap, TsCompilerInstance, TsJestAstTransformer, TTypeScript } from '../types'
2426
import { rootLogger } from '../utils/logger'
2527
import { Errors, interpolate } from '../utils/messages'
2628

@@ -31,18 +33,26 @@ export class TsCompiler implements TsCompilerInstance {
3133
protected readonly _ts: TTypeScript
3234
protected readonly _initialCompilerOptions: CompilerOptions
3335
protected _compilerOptions: CompilerOptions
36+
/**
37+
* @private
38+
*/
39+
private _runtimeCacheFS: StringMap
40+
/**
41+
* @private
42+
*/
43+
private _fileContentCache: StringMap | undefined
3444
/**
3545
* @internal
3646
*/
3747
private readonly _parsedTsConfig: ParsedCommandLine
3848
/**
3949
* @internal
4050
*/
41-
private readonly _compilerCacheFS: Map<string, number> = new Map<string, number>()
51+
private readonly _fileVersionCache: Map<string, number> | undefined
4252
/**
4353
* @internal
4454
*/
45-
private _cachedReadFile: ((fileName: string) => string | undefined) | undefined
55+
private readonly _cachedReadFile: ((fileName: string) => string | undefined) | undefined
4656
/**
4757
* @internal
4858
*/
@@ -51,15 +61,50 @@ export class TsCompiler implements TsCompilerInstance {
5161
* @internal
5262
*/
5363
private _languageService: LanguageService | undefined
64+
/**
65+
* @internal
66+
*/
67+
private readonly _moduleResolutionHost: ModuleResolutionHost | undefined
68+
/**
69+
* @internal
70+
*/
71+
private readonly _moduleResolutionCache: ModuleResolutionCache | undefined
72+
5473
program: Program | undefined
5574

56-
constructor(readonly configSet: ConfigSet, readonly jestCacheFS: StringMap) {
75+
constructor(readonly configSet: ConfigSet, readonly runtimeCacheFS: StringMap) {
5776
this._ts = configSet.compilerModule
5877
this._logger = rootLogger.child({ namespace: 'ts-compiler' })
5978
this._parsedTsConfig = this.configSet.parsedTsConfig as ParsedCommandLine
6079
this._initialCompilerOptions = { ...this._parsedTsConfig.options }
6180
this._compilerOptions = { ...this._initialCompilerOptions }
81+
this._runtimeCacheFS = runtimeCacheFS
6282
if (!this.configSet.isolatedModules) {
83+
this._fileContentCache = new Map<string, string>()
84+
this._fileVersionCache = new Map<string, number>()
85+
this._cachedReadFile = this._logger.wrap(
86+
{
87+
namespace: 'ts:serviceHost',
88+
call: null,
89+
[LogContexts.logLevel]: LogLevels.trace,
90+
},
91+
'readFile',
92+
memoize(this._ts.sys.readFile),
93+
)
94+
/* istanbul ignore next */
95+
this._moduleResolutionHost = {
96+
fileExists: memoize(this._ts.sys.fileExists),
97+
readFile: this._cachedReadFile,
98+
directoryExists: memoize(this._ts.sys.directoryExists),
99+
getCurrentDirectory: () => this.configSet.cwd,
100+
realpath: this._ts.sys.realpath && memoize(this._ts.sys.realpath),
101+
getDirectories: memoize(this._ts.sys.getDirectories),
102+
}
103+
this._moduleResolutionCache = this._ts.createModuleResolutionCache(
104+
this.configSet.cwd,
105+
(x) => x,
106+
this._compilerOptions,
107+
)
63108
this._createLanguageService()
64109
}
65110
}
@@ -68,11 +113,6 @@ export class TsCompiler implements TsCompilerInstance {
68113
* @internal
69114
*/
70115
private _createLanguageService(): void {
71-
const serviceHostTraceCtx = {
72-
namespace: 'ts:serviceHost',
73-
call: null,
74-
[LogContexts.logLevel]: LogLevels.trace,
75-
}
76116
// Initialize memory cache for typescript compiler
77117
this._parsedTsConfig.fileNames
78118
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -81,29 +121,17 @@ export class TsCompiler implements TsCompilerInstance {
81121
!this.configSet.isTestFile(fileName) &&
82122
!fileName.includes(this._parsedTsConfig.options.outDir ?? TS_JEST_OUT_DIR),
83123
)
84-
.forEach((fileName) => this._compilerCacheFS.set(fileName, 0))
85-
this._cachedReadFile = this._logger.wrap(serviceHostTraceCtx, 'readFile', memoize(this._ts.sys.readFile))
86-
/* istanbul ignore next */
87-
const moduleResolutionHost = {
88-
fileExists: memoize(this._ts.sys.fileExists),
89-
readFile: this._cachedReadFile,
90-
directoryExists: memoize(this._ts.sys.directoryExists),
91-
getCurrentDirectory: () => this.configSet.cwd,
92-
realpath: this._ts.sys.realpath && memoize(this._ts.sys.realpath),
93-
getDirectories: memoize(this._ts.sys.getDirectories),
94-
}
95-
const moduleResolutionCache = this._ts.createModuleResolutionCache(
96-
this.configSet.cwd,
97-
(x) => x,
98-
this._compilerOptions,
99-
)
124+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
125+
.forEach((fileName) => this._fileVersionCache!.set(fileName, 0))
100126
/* istanbul ignore next */
101127
const serviceHost: LanguageServiceHost = {
102128
getProjectVersion: () => String(this._projectVersion),
103-
getScriptFileNames: () => [...this._compilerCacheFS.keys()],
129+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
130+
getScriptFileNames: () => [...this._fileVersionCache!.keys()],
104131
getScriptVersion: (fileName: string) => {
105132
const normalizedFileName = normalize(fileName)
106-
const version = this._compilerCacheFS.get(normalizedFileName)
133+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
134+
const version = this._fileVersionCache!.get(normalizedFileName)
107135

108136
// We need to return `undefined` and not a string here because TypeScript will use
109137
// `getScriptVersion` and compare against their own version - which can be `undefined`.
@@ -122,13 +150,20 @@ export class TsCompiler implements TsCompilerInstance {
122150
// Read contents from TypeScript memory cache.
123151
if (!hit) {
124152
const fileContent =
125-
this.jestCacheFS.get(normalizedFileName) ?? this._cachedReadFile?.(normalizedFileName) ?? undefined
153+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
154+
this._fileContentCache!.get(normalizedFileName) ??
155+
this._runtimeCacheFS.get(normalizedFileName) ??
156+
this._cachedReadFile?.(normalizedFileName) ??
157+
undefined
126158
if (fileContent) {
127-
this.jestCacheFS.set(normalizedFileName, fileContent)
128-
this._compilerCacheFS.set(normalizedFileName, 1)
159+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
160+
this._fileContentCache!.set(normalizedFileName, fileContent)
161+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
162+
this._fileVersionCache!.set(normalizedFileName, 1)
129163
}
130164
}
131-
const contents = this.jestCacheFS.get(normalizedFileName)
165+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
166+
const contents = this._fileContentCache!.get(normalizedFileName)
132167

133168
if (contents === undefined) return
134169

@@ -151,8 +186,10 @@ export class TsCompiler implements TsCompilerInstance {
151186
moduleName,
152187
containingFile,
153188
this._compilerOptions,
154-
moduleResolutionHost,
155-
moduleResolutionCache,
189+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
190+
this._moduleResolutionHost!,
191+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
192+
this._moduleResolutionCache!,
156193
)
157194

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

168-
getResolvedModulesMap(fileContent: string, fileName: string): ResolvedModulesMap {
169-
this._updateMemoryCache(fileContent, fileName)
205+
getResolvedModules(fileContent: string, fileName: string, runtimeCacheFS: StringMap): string[] {
206+
// In watch mode, it is possible that the initial cacheFS becomes empty
207+
if (!this.runtimeCacheFS.size) {
208+
this._runtimeCacheFS = runtimeCacheFS
209+
}
210+
211+
return this._ts
212+
.preProcessFile(fileContent, true, true)
213+
.importedFiles.map((importedFile) => {
214+
const { resolvedModule } = this._ts.resolveModuleName(
215+
importedFile.fileName,
216+
fileName,
217+
this._compilerOptions,
218+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
219+
this._moduleResolutionHost!,
220+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
221+
this._moduleResolutionCache!,
222+
)
170223

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

176230
getCompiledOutput(fileContent: string, fileName: string, supportsStaticESM: boolean): string {
@@ -261,7 +315,12 @@ export class TsCompiler implements TsCompilerInstance {
261315
*/
262316
private _isFileInCache(fileName: string): boolean {
263317
return (
264-
this.jestCacheFS.has(fileName) && this._compilerCacheFS.has(fileName) && this._compilerCacheFS.get(fileName) !== 0
318+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
319+
this._fileContentCache!.has(fileName) &&
320+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
321+
this._fileVersionCache!.has(fileName) &&
322+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
323+
this._fileVersionCache!.get(fileName) !== 0
265324
)
266325
}
267326

@@ -275,14 +334,20 @@ export class TsCompiler implements TsCompilerInstance {
275334
let shouldIncrementProjectVersion = false
276335
const hit = this._isFileInCache(fileName)
277336
if (!hit) {
278-
this._compilerCacheFS.set(fileName, 1)
337+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
338+
this._fileVersionCache!.set(fileName, 1)
279339
shouldIncrementProjectVersion = true
280340
} else {
281-
const prevVersion = this._compilerCacheFS.get(fileName) ?? 0
282-
const previousContents = this.jestCacheFS.get(fileName)
341+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
342+
const prevVersion = this._fileVersionCache!.get(fileName) ?? 0
343+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
344+
const previousContents = this._fileContentCache!.get(fileName)
283345
// Avoid incrementing cache when nothing has changed.
284346
if (previousContents !== contents) {
285-
this._compilerCacheFS.set(fileName, prevVersion + 1)
347+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
348+
this._fileVersionCache!.set(fileName, prevVersion + 1)
349+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
350+
this._fileContentCache!.set(fileName, contents)
286351
// Only bump project version when file is modified in cache, not when discovered for the first time
287352
if (hit) shouldIncrementProjectVersion = true
288353
}

src/compiler/ts-jest-compiler.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ConfigSet } from '../config/config-set'
2-
import type { CompilerInstance, ResolvedModulesMap, StringMap } from '../types'
2+
import type { CompilerInstance, StringMap } from '../types'
33

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

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

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

17-
getResolvedModulesMap(fileContent: string, fileName: string): ResolvedModulesMap {
18-
return this._compilerInstance.getResolvedModulesMap(fileContent, fileName)
17+
getResolvedModules(fileContent: string, fileName: string, runtimeCacheFS: StringMap): string[] {
18+
return this._compilerInstance.getResolvedModules(fileContent, fileName, runtimeCacheFS)
1919
}
2020

2121
getCompiledOutput(fileContent: string, fileName: string, supportsStaticESM: boolean): string {

0 commit comments

Comments
 (0)