Skip to content

Commit d9c4f51

Browse files
JS-1505 Fix S1874 crash on TSX files with react-jsx in monorepos (#6695)
Co-authored-by: Victor Diez <victor.diez@sonarsource.com>
1 parent 7b8d5cc commit d9c4f51

10 files changed

Lines changed: 244 additions & 37 deletions

File tree

packages/analysis/src/jsts/program/cache/sourceFileCache.ts

Lines changed: 38 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -26,32 +26,32 @@ const sourceFileContentCache = new Map<string, string>();
2626
/**
2727
* Global cache for parsed TypeScript SourceFile ASTs
2828
*
29-
* This cache stores parsed ASTs keyed by (fileName, scriptTarget).
29+
* This cache stores parsed ASTs keyed by (fileName, scriptTarget, jsx, importHelpers).
3030
* The contentHash is stored as a value to validate cache freshness.
31-
* Multiple programs can share the same parsed AST if they have the same target.
31+
* Multiple programs can share the same parsed AST if they have the same options.
3232
*
33-
* IMPLEMENTATION NOTE: We cache SourceFiles per ScriptTarget to preserve the
34-
* languageVersion metadata stored in the SourceFile object. While our testing shows
35-
* that the AST structure is identical across targets and type checking uses the
36-
* program's target (not the SourceFile's languageVersion), we keep target-specific
37-
* caching for these reasons:
38-
*
39-
* 1. Performance gain of target-agnostic caching may be marginal since most analysis
40-
* runs use the same target configuration
41-
* 2. Preserves metadata integrity - SourceFile.languageVersion matches the intended target
42-
* 3. Future-proofing - allows filtering rules based on configured target if needed
43-
* 4. TypeScript API contract - creating SourceFiles with their intended target is the
44-
* documented usage pattern
45-
*
46-
* If profiling shows that different targets are commonly used and parsing becomes a
47-
* bottleneck, we could simplify to target-agnostic caching (always use ESNext) with
48-
* minimal impact on functionality.
33+
* IMPLEMENTATION NOTE: We key on scriptTarget, jsx, and importHelpers because all
34+
* three affect the structure of SourceFile.imports (the synthesized JSX runtime import
35+
* prepended by TypeScript when jsx=react-jsx, and tslib helpers when importHelpers=true).
36+
* Sharing a SourceFile between programs with different values for these options causes
37+
* internal TypeScript assertion failures (e.g. in getSuggestionDiagnostics).
4938
*/
5039
const parsedSourceFileCache = new Map<
5140
string, // normalized file path
52-
Map<ts.ScriptTarget, { contentHash: string; sourceFile: ts.SourceFile }>
41+
Map<string, { contentHash: string; sourceFile: ts.SourceFile }> // compound key → entry
5342
>();
5443

44+
/**
45+
* Build a compound cache key from the options that affect SourceFile.imports structure.
46+
*/
47+
function makeParsedSourceFileCacheKey(
48+
scriptTarget: ts.ScriptTarget,
49+
jsx: ts.JsxEmit | undefined,
50+
importHelpers = false,
51+
): string {
52+
return `${scriptTarget}:${jsx ?? -1}:${importHelpers ? 1 : 0}`;
53+
}
54+
5555
/**
5656
* Current files context for lazy loading
5757
*/
@@ -79,23 +79,28 @@ export function getCurrentFilesContext(): Record<string, { fileContent?: string
7979
}
8080

8181
/**
82-
* Get a cached parsed SourceFile for a given file and target
82+
* Get a cached parsed SourceFile for a given file and compiler options
8383
* @param fileName Normalized file path
8484
* @param scriptTarget TypeScript ScriptTarget (e.g., ES5, ES2020, ESNext)
8585
* @param contentHash Hash/version of the file content
86+
* @param jsx The jsx compiler option (affects SourceFile.imports structure)
87+
* @param importHelpers The importHelpers compiler option (affects SourceFile.imports structure)
8688
* @returns Cached SourceFile if available and content matches, undefined otherwise
8789
*/
8890
export function getCachedSourceFile(
8991
fileName: string,
9092
scriptTarget: ts.ScriptTarget,
9193
contentHash: string,
94+
jsx?: ts.JsxEmit,
95+
importHelpers = false,
9296
): ts.SourceFile | undefined {
93-
const targetCache = parsedSourceFileCache.get(fileName);
94-
if (!targetCache) {
97+
const fileCache = parsedSourceFileCache.get(fileName);
98+
if (!fileCache) {
9599
return undefined;
96100
}
97101

98-
const cached = targetCache.get(scriptTarget);
102+
const key = makeParsedSourceFileCacheKey(scriptTarget, jsx, importHelpers);
103+
const cached = fileCache.get(key);
99104
// If hash doesn't match, return undefined (cache miss). The caller will parse
100105
// the file and call setCachedSourceFile, which overwrites the stale entry.
101106
// This makes the cache self-healing - stale entries cause one miss, then get fixed.
@@ -112,20 +117,25 @@ export function getCachedSourceFile(
112117
* @param scriptTarget TypeScript ScriptTarget used to parse the file
113118
* @param contentHash Hash/version of the file content
114119
* @param sourceFile The parsed TypeScript SourceFile
120+
* @param jsx The jsx compiler option (affects SourceFile.imports structure)
121+
* @param importHelpers The importHelpers compiler option (affects SourceFile.imports structure)
115122
*/
116123
export function setCachedSourceFile(
117124
fileName: string,
118125
scriptTarget: ts.ScriptTarget,
119126
contentHash: string,
120127
sourceFile: ts.SourceFile,
128+
jsx?: ts.JsxEmit,
129+
importHelpers = false,
121130
): void {
122-
let targetCache = parsedSourceFileCache.get(fileName);
123-
if (!targetCache) {
124-
targetCache = new Map();
125-
parsedSourceFileCache.set(fileName, targetCache);
131+
let fileCache = parsedSourceFileCache.get(fileName);
132+
if (!fileCache) {
133+
fileCache = new Map();
134+
parsedSourceFileCache.set(fileName, fileCache);
126135
}
127136

128-
targetCache.set(scriptTarget, { contentHash, sourceFile });
137+
const key = makeParsedSourceFileCacheKey(scriptTarget, jsx, importHelpers);
138+
fileCache.set(key, { contentHash, sourceFile });
129139
}
130140

131141
/**

packages/analysis/src/jsts/program/compilerHost.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export class IncrementalCompilerHost implements ts.CompilerHost {
5050
private readonly baseHost: ts.CompilerHost;
5151

5252
constructor(
53-
compilerOptions: ts.CompilerOptions,
53+
private readonly compilerOptions: ts.CompilerOptions,
5454
private readonly baseDir: NormalizedAbsolutePath,
5555
private readonly skipNodeModuleLookupOutsideBaseDir = false,
5656
) {
@@ -218,9 +218,11 @@ export class IncrementalCompilerHost implements ts.CompilerHost {
218218
? languageVersionOrOptions
219219
: languageVersionOrOptions.languageVersion;
220220

221+
const { jsx, importHelpers } = this.compilerOptions;
222+
221223
// Check global cache (unless forced to create new)
222224
if (!shouldCreateNewSourceFile) {
223-
const cached = getCachedSourceFile(normalized, scriptTarget, contentHash);
225+
const cached = getCachedSourceFile(normalized, scriptTarget, contentHash, jsx, importHelpers);
224226
if (cached) {
225227
// Set version for builder programs
226228
(cached as ts.SourceFile & { version?: string }).version = contentHash;
@@ -241,7 +243,7 @@ export class IncrementalCompilerHost implements ts.CompilerHost {
241243
);
242244

243245
// Store in global cache for reuse across programs
244-
setCachedSourceFile(normalized, scriptTarget, contentHash, sourceFile);
246+
setCachedSourceFile(normalized, scriptTarget, contentHash, sourceFile, jsx, importHelpers);
245247
} else {
246248
// Fallback to base host for files not in our cache/context (e.g., TypeScript lib files).
247249
// readFile returns undefined for these, but baseHost can resolve them.

packages/analysis/tests/analyzeProject-sonarqube.test.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -816,7 +816,7 @@ describe('SonarQube project analysis', () => {
816816
}
817817
});
818818

819-
it('should reproduce JSX cache collision with mixed jsx compiler options', async () => {
819+
it('should analyze JSX cache collision fixture with mixed jsx compiler options', async () => {
820820
const baseDir = join(fixtures, 'jsx-cache-collision');
821821
const initialFile = join(baseDir, 'initial.ts');
822822
const triggerFile = join(baseDir, 'trigger.tsx');
@@ -842,11 +842,9 @@ describe('SonarQube project analysis', () => {
842842

843843
const triggerResult = result.files[normalizeToAbsolutePath(triggerFile)];
844844
expect(triggerResult).toBeDefined();
845-
expect('error' in triggerResult!).toBe(true);
846-
if ('error' in triggerResult!) {
847-
expect(triggerResult.error).toContain(
848-
'Expected sourceFile.imports[0] to be the synthesized JSX runtime import',
849-
);
845+
expect('issues' in triggerResult!).toBe(true);
846+
if ('issues' in triggerResult!) {
847+
expect(triggerResult.issues.some(issue => issue.ruleId === 'S1874')).toBe(true);
850848
}
851849

852850
expect(result.meta.telemetry?.compilerOptions.jsx).toEqual(

packages/analysis/tests/jsts/program/compilerHost.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
import { describe, it, beforeEach } from 'node:test';
1818
import { expect } from 'expect';
1919
import ts from 'typescript';
20+
import path from 'node:path';
21+
import { readFileSync } from 'node:fs';
2022
import { IncrementalCompilerHost } from '../../../src/jsts/program/compilerHost.js';
2123
import {
2224
setSourceFilesContext,
@@ -412,4 +414,86 @@ describe('IncrementalCompilerHost', () => {
412414
host.writeFile(normalizeToAbsolutePath('/project/dist/index.js'), 'content', false);
413415
});
414416
});
417+
418+
describe('cross-program SourceFile cache with different jsx options (JS-1505)', () => {
419+
// Fixture: server.ts (jsx:preserve tsconfig) imports Component.tsx, which is also
420+
// included in a jsx:react-jsx tsconfig. This reproduces the cross-batch crash scenario:
421+
//
422+
// Batch 1: server.ts is in the analysis context. Program A (jsx:preserve) is created.
423+
// TypeScript follows the import and calls getSourceFile(Component.tsx). Component.tsx
424+
// is NOT in the context → read from disk → SourceFile cached without synthesized import.
425+
//
426+
// Batch 2: Component.tsx is in the analysis context (same content as on disk).
427+
// Program B (jsx:react-jsx) is created. getSourceFile(Component.tsx) is called.
428+
// updateFile is a no-op (same content → same contentHash) → cache HIT → stale SourceFile.
429+
// getSuggestionDiagnostics on the stale SourceFile triggers TypeScript's internal assertion:
430+
// "Expected sourceFile.imports[0] to be the synthesized JSX runtime import"
431+
//
432+
// The fix: jsx is now part of the cache key, so Program B gets a cache miss and a fresh
433+
// SourceFile with the correct synthesized import at file.imports[0].
434+
const fixtureDir = normalizeToAbsolutePath(
435+
path.join(import.meta.dirname, 'fixtures', 'tsx-cache-collision'),
436+
);
437+
const serverFile = path.join(fixtureDir, 'server.ts') as ReturnType<
438+
typeof normalizeToAbsolutePath
439+
>;
440+
const componentFile = path.join(fixtureDir, 'Component.tsx') as ReturnType<
441+
typeof normalizeToAbsolutePath
442+
>;
443+
444+
it('should not serve a stale SourceFile to a jsx:react-jsx program when Component.tsx was first read from disk by a jsx:preserve program', () => {
445+
// Read the actual file content so the "same content" invariant holds.
446+
const serverContent = readFileSync(serverFile, 'utf-8');
447+
const componentContent = readFileSync(componentFile, 'utf-8');
448+
449+
// --- Batch 1: only server.ts is in the analysis context ---
450+
// Component.tsx is NOT in the context → getSourceFile reads it from disk.
451+
setSourceFilesContext({ [serverFile]: { fileContent: serverContent } });
452+
453+
const noJsxConfig = ts.readConfigFile(
454+
path.join(fixtureDir, 'tsconfig-no-jsx.json'),
455+
ts.sys.readFile,
456+
);
457+
const noJsxOptions = ts.parseJsonConfigFileContent(
458+
noJsxConfig.config,
459+
ts.sys,
460+
fixtureDir,
461+
).options;
462+
const hostA = new IncrementalCompilerHost(noJsxOptions, fixtureDir);
463+
// Creating Program A causes TypeScript to call getSourceFile(Component.tsx) transitively.
464+
ts.createProgram({ rootNames: [serverFile], options: noJsxOptions, host: hostA });
465+
466+
// --- Batch 2: Component.tsx is now in the analysis context ---
467+
// Same content as on disk → updateFile is a no-op → contentHash unchanged.
468+
// Without the fix, Program B would receive Program A's stale SourceFile (wrong imports).
469+
setSourceFilesContext({ [componentFile]: { fileContent: componentContent } });
470+
471+
const jsxConfig = ts.readConfigFile(
472+
path.join(fixtureDir, 'tsconfig-jsx.json'),
473+
ts.sys.readFile,
474+
);
475+
const jsxOptions = ts.parseJsonConfigFileContent(
476+
jsxConfig.config,
477+
ts.sys,
478+
fixtureDir,
479+
).options;
480+
const hostB = new IncrementalCompilerHost(jsxOptions, fixtureDir);
481+
const programB = ts.createProgram({
482+
rootNames: [componentFile],
483+
options: jsxOptions,
484+
host: hostB,
485+
});
486+
const checker = programB.getTypeChecker();
487+
const sourceFile = programB.getSourceFile(componentFile);
488+
489+
// getSuggestionDiagnostics is the non-public API called by rule S1874.
490+
// Without the fix it throws:
491+
// "Debug Failure. False expression: Expected sourceFile.imports[0] to be
492+
// the synthesized JSX runtime import"
493+
expect(() => {
494+
// @ts-ignore: TypeChecker#getSuggestionDiagnostics is not publicly exposed
495+
checker.getSuggestionDiagnostics(sourceFile);
496+
}).not.toThrow();
497+
});
498+
});
415499
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { deprecated } from './deprecated';
2+
3+
// JSX + user import: the user import is at file.imports[0].
4+
// When jsx=react-jsx, TypeScript synthesizes a react/jsx-runtime import that should be at
5+
// file.imports[0]. If the SourceFile is reused from a non-react-jsx program (stale cache),
6+
// file.imports[0] is this real import instead, triggering a TypeScript internal assertion
7+
// in getSuggestionDiagnostics: "Expected sourceFile.imports[0] to be the synthesized JSX
8+
// runtime import"
9+
export function Component() {
10+
return <span>{deprecated()}</span>;
11+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/** @deprecated Use newFunction instead */
2+
export function deprecated(): string {
3+
return 'deprecated';
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// This file's import forces Program A (jsx:preserve) to process Component.tsx as a
2+
// transitive dependency, caching its SourceFile without the synthesized JSX runtime import.
3+
import type { Component } from './Component';
4+
void 0 as unknown as typeof Component;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"compilerOptions": {
3+
"jsx": "react-jsx"
4+
},
5+
"include": ["Component.tsx"]
6+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"compilerOptions": {
3+
"jsx": "preserve"
4+
},
5+
"include": ["server.ts"]
6+
}

packages/analysis/tests/jsts/program/sourceFileCache.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,57 @@ describe('sourceFileCache', () => {
133133
const result = getCachedSourceFile(testFileName, ts.ScriptTarget.ES2020, '1');
134134
expect(result).toBeUndefined();
135135
});
136+
137+
it('should return undefined when jsx option does not match', () => {
138+
const sourceFile = createSourceFile(testFileName, testContent, ts.ScriptTarget.ESNext);
139+
140+
setCachedSourceFile(testFileName, ts.ScriptTarget.ESNext, '1', sourceFile, ts.JsxEmit.None);
141+
142+
const result = getCachedSourceFile(
143+
testFileName,
144+
ts.ScriptTarget.ESNext,
145+
'1',
146+
ts.JsxEmit.ReactJSX,
147+
);
148+
expect(result).toBeUndefined();
149+
});
150+
151+
it('should return undefined when importHelpers option does not match', () => {
152+
const sourceFile = createSourceFile(testFileName, testContent, ts.ScriptTarget.ESNext);
153+
154+
setCachedSourceFile(testFileName, ts.ScriptTarget.ESNext, '1', sourceFile, undefined, true);
155+
156+
const result = getCachedSourceFile(
157+
testFileName,
158+
ts.ScriptTarget.ESNext,
159+
'1',
160+
undefined,
161+
false,
162+
);
163+
expect(result).toBeUndefined();
164+
});
165+
166+
it('should return cached file when jsx and importHelpers match', () => {
167+
const sourceFile = createSourceFile(testFileName, testContent, ts.ScriptTarget.ESNext);
168+
169+
setCachedSourceFile(
170+
testFileName,
171+
ts.ScriptTarget.ESNext,
172+
'1',
173+
sourceFile,
174+
ts.JsxEmit.ReactJSX,
175+
true,
176+
);
177+
178+
const result = getCachedSourceFile(
179+
testFileName,
180+
ts.ScriptTarget.ESNext,
181+
'1',
182+
ts.JsxEmit.ReactJSX,
183+
true,
184+
);
185+
expect(result).toBe(sourceFile);
186+
});
136187
});
137188

138189
describe('setCachedSourceFile', () => {
@@ -159,6 +210,37 @@ describe('sourceFileCache', () => {
159210
);
160211
});
161212

213+
it('should store separate entries for different jsx options', () => {
214+
const sourceFileNoJsx = createSourceFile(testFileName, testContent, ts.ScriptTarget.ESNext);
215+
const sourceFileReactJsx = createSourceFile(
216+
testFileName,
217+
testContent,
218+
ts.ScriptTarget.ESNext,
219+
);
220+
221+
setCachedSourceFile(
222+
testFileName,
223+
ts.ScriptTarget.ESNext,
224+
'1',
225+
sourceFileNoJsx,
226+
ts.JsxEmit.None,
227+
);
228+
setCachedSourceFile(
229+
testFileName,
230+
ts.ScriptTarget.ESNext,
231+
'1',
232+
sourceFileReactJsx,
233+
ts.JsxEmit.ReactJSX,
234+
);
235+
236+
expect(
237+
getCachedSourceFile(testFileName, ts.ScriptTarget.ESNext, '1', ts.JsxEmit.None),
238+
).toBe(sourceFileNoJsx);
239+
expect(
240+
getCachedSourceFile(testFileName, ts.ScriptTarget.ESNext, '1', ts.JsxEmit.ReactJSX),
241+
).toBe(sourceFileReactJsx);
242+
});
243+
162244
it('should overwrite cached source file when content hash changes', () => {
163245
const sourceFile1 = createSourceFile(testFileName, testContent, ts.ScriptTarget.ESNext);
164246
const sourceFile2 = createSourceFile(testFileName, 'const y = 2;', ts.ScriptTarget.ESNext);

0 commit comments

Comments
 (0)