diff --git a/packages/compiler-cli/src/ngtsc/program_driver/src/ts_create_program_driver.ts b/packages/compiler-cli/src/ngtsc/program_driver/src/ts_create_program_driver.ts index b07ce198984..8d8dabc4267 100644 --- a/packages/compiler-cli/src/ngtsc/program_driver/src/ts_create_program_driver.ts +++ b/packages/compiler-cli/src/ngtsc/program_driver/src/ts_create_program_driver.ts @@ -272,5 +272,12 @@ export class TsCreateProgramDriver implements ProgramDriver { // TS 5.5 not having the files tagged while producing diagnostics can lead to errors. See: // https://github.com/microsoft/TypeScript/pull/58398 untagAllTsFiles(oldProgram); + + // Re-tag the new program's files. Since TS reuses SourceFile objects between old and new + // programs, untagging the old program also untags shared files in the new program. + // Without re-tagging, getSemanticDiagnostics() crashes with "Cannot destructure property + // 'pos' of 'file.referencedFiles[index]'" because fileProcessingDiagnostics recorded + // indices into the tagged (longer) referencedFiles array during program creation. + retagAllTsFiles(this.program); } } diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/program_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/program_spec.ts index 8ab433802b5..400f95c0569 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/program_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/program_spec.ts @@ -11,7 +11,7 @@ import ts from 'typescript'; import {absoluteFrom, AbsoluteFsPath, getSourceFileOrError} from '../../file_system'; import {runInEachFileSystem} from '../../file_system/testing'; import {FileUpdate, TsCreateProgramDriver, UpdateMode} from '../../program_driver'; -import {sfExtensionData, ShimReferenceTagger} from '../../shims'; +import {sfExtensionData, ShimReferenceTagger, untagAllTsFiles} from '../../shims'; import {expectCompleteReuse, makeProgram} from '../../testing'; import {OptimizeFor} from '../api'; @@ -64,9 +64,89 @@ runInEachFileSystem(() => { expectCompleteReuse(programStrategy.getProgram()); }); + + it('should retain shim tags on shared SourceFiles after updateFiles', () => { + assertShimTagsRetainedAfterUpdate(UpdateMode.Complete); + }); + + it('should retain shim tags on shared SourceFiles after incremental updateFiles', () => { + const {program, host, options, typecheckPath, mainPath} = + makeProgramWhereShimIsIncludedViaReference(); + + const programStrategy = new TsCreateProgramDriver(program, host, options, ['ngtypecheck']); + + programStrategy.updateFiles( + new Map([[typecheckPath, createUpdate('export const VERSION = 2;')]]), + UpdateMode.Complete, + ); + programStrategy.updateFiles( + new Map([[typecheckPath, createUpdate('export const VERSION = 3;')]]), + UpdateMode.Incremental, + ); + + assertSharedSourceFileShimTags(programStrategy.getProgram(), mainPath); + }); + + it('should allow untagging the input program for emit after updateFiles', () => { + const {program, host, options, typecheckPath, mainPath} = + makeProgramWhereShimIsIncludedViaReference(); + + const programStrategy = new TsCreateProgramDriver(program, host, options, ['ngtypecheck']); + + // Update only the shim file so /main.ts remains a shared SourceFile between programs. + programStrategy.updateFiles( + new Map([[typecheckPath, createUpdate('export const VERSION = 2;')]]), + UpdateMode.Complete, + ); + + const mainSf = getSourceFileOrError(program, mainPath); + const ext = sfExtensionData(mainSf); + + expect(ext.taggedReferenceFiles).not.toBeNull(); + expect(mainSf.referencedFiles.length).toEqual(ext.taggedReferenceFiles!.length); + + // prepareEmit() untags the input program before emit (#56945). + untagAllTsFiles(program); + + expect(ext.originalReferencedFiles).not.toBeNull(); + expect(mainSf.referencedFiles).toEqual(ext.originalReferencedFiles!); + for (const ref of mainSf.referencedFiles) { + expect(ref.fileName).not.toContain('ngtypecheck'); + } + }); }); }); +function assertShimTagsRetainedAfterUpdate(updateMode: UpdateMode): void { + const {program, host, options, typecheckPath, mainPath} = + makeProgramWhereShimIsIncludedViaReference(); + + const programStrategy = new TsCreateProgramDriver(program, host, options, ['ngtypecheck']); + + // Update only the shim file so /main.ts remains a shared SourceFile between programs. + programStrategy.updateFiles( + new Map([[typecheckPath, createUpdate('export const VERSION = 2;')]]), + updateMode, + ); + + assertSharedSourceFileShimTags(programStrategy.getProgram(), mainPath); +} + +function assertSharedSourceFileShimTags(program: ts.Program, mainPath: AbsoluteFsPath): void { + const mainSf = getSourceFileOrError(program, mainPath); + const ext = sfExtensionData(mainSf); + + // Without the fix, untagging the old program also untags shared SourceFiles in the new + // program, leaving referencedFiles shorter than taggedReferenceFiles. + expect(ext.taggedReferenceFiles).not.toBeNull(); + expect(mainSf.referencedFiles.length).toEqual(ext.taggedReferenceFiles!.length); + + // Replicates the TS 5.5+ crash from #68164: with composite, a shim included only via + // referencedFiles gets a lazy "not listed in project" diagnostic. Expanding that diagnostic + // walks the ReferenceFile include reason and reads main.referencedFiles[index]. + expect(() => program.getSemanticDiagnostics(mainSf)).not.toThrow(); +} + function createUpdate(text: string): FileUpdate { return { newText: text, @@ -116,3 +196,76 @@ function makeSingleFileProgramWithTypecheckShim(): { return {program, host, options, mainPath, typecheckPath}; } + +/** + * Build a program where the ngtypecheck shim is pulled in only through a tagged + * `referencedFiles` entry (not as a root). Combined with `composite`, TypeScript records a lazy + * project-file-list diagnostic whose expansion exercises `referencedFiles[index]` — the path that + * crashes when shared SourceFiles are untagged after `updateFiles()`. + */ +function makeProgramWhereShimIsIncludedViaReference(): { + program: ts.Program; + host: ts.CompilerHost; + options: ts.CompilerOptions; + mainPath: AbsoluteFsPath; + typecheckPath: AbsoluteFsPath; +} { + const mainPath = absoluteFrom('/main.ts'); + const typecheckPath = absoluteFrom('/main.ngtypecheck.ts'); + const {host, options} = makeProgram( + [ + { + name: mainPath, + contents: 'export const NOT_A_COMPONENT = true;', + }, + { + name: typecheckPath, + contents: 'export const VERSION = 1;', + isRoot: false, + }, + ], + { + composite: true, + declaration: true, + }, + /* host */ undefined, + /* checkForErrors */ false, + ); + + const shimTagger = new ShimReferenceTagger(['ngtypecheck']); + // Inherit prototype methods from the real host; object spread would drop them. + const taggingHost = Object.create(host) as ts.CompilerHost; + taggingHost.getSourceFile = ( + fileName: string, + languageVersionOrOptions: ts.ScriptTarget | ts.CreateSourceFileOptions, + onError?: (message: string) => void, + shouldCreateNewSourceFile?: boolean, + ): ts.SourceFile | undefined => { + const sf = host.getSourceFile( + fileName, + languageVersionOrOptions, + onError, + shouldCreateNewSourceFile, + ); + if (sf !== undefined) { + shimTagger.tag(sf); + } + return sf; + }; + + const program = ts.createProgram({ + rootNames: [host.getCanonicalFileName(mainPath)], + options, + host: taggingHost, + }); + shimTagger.finalize(); + + const typecheckSf = getSourceFileOrError(program, typecheckPath); + sfExtensionData(typecheckSf).fileShim = { + extension: 'ngtypecheck', + generatedFrom: mainPath, + }; + + // Return the unwrapped host; TsCreateProgramDriver installs its own tagging host on updates. + return {program, host, options, mainPath, typecheckPath}; +}