🔎 Search Terms
incremental builder re-emit affected files, exportedModulesMap, handleDtsMayChangeOfReferencingExportOfAffectedFile, useFileVersionAsSignature, const enum invalidateJsFiles, watch first rebuild slow, signature version hash
🕗 Version & Regression Information
- Regression in TypeScript 5.5 (5.4.5 behaves correctly; 5.5.4, 5.6.3, 5.7.3, 5.8.3, 5.9.3 and 6.0.3 all reproduce)
- Coincides with the removal of
exportedModulesMap in 5.5 (22 references in the 5.4.5 bundle, 0 in 5.5.4)
💻 Code
Standalone repro driving the public incremental-builder API (createEmitAndSemanticDiagnosticsBuilderProgram), the way build tools such as the Angular CLI do. Project shape: core.ts ← core-barrel.ts (export *) ← mid.ts (exports a const enum) ← index.ts (export *) ← 500 leaf files importing from the barrel.
// node repro.js <path-to-typescript-package>
const fs = require('fs');
const path = require('path');
const ts = require(path.resolve(process.argv[2] ?? 'typescript'));
const N = 500;
const work = path.join(__dirname, 'work-' + ts.version);
const src = path.join(work, 'src');
fs.rmSync(work, { recursive: true, force: true });
fs.mkdirSync(src, { recursive: true });
fs.writeFileSync(path.join(src, 'core.ts'),
'export interface CoreThing { a: number }\nexport const CORE = 1;\n');
fs.writeFileSync(path.join(src, 'core-barrel.ts'), "export * from './core';\n");
fs.writeFileSync(path.join(src, 'mid.ts'),
"import { CORE, CoreThing } from './core-barrel';\n" +
'export const MID: number = CORE + 1;\n' +
'export function useCore(x: CoreThing): number { return x.a + MID; }\n' +
'export const enum MidMode { A = 1, B = 2 }\n');
fs.writeFileSync(path.join(src, 'index.ts'), "export * from './mid';\n");
for (let i = 0; i < N; i++)
fs.writeFileSync(path.join(src, `leaf${i}.ts`),
"import { MID } from './index';\n" + `export const L${i}: number = MID + ${i};\n`);
fs.writeFileSync(path.join(work, 'tsconfig.json'), JSON.stringify({
compilerOptions: {
target: 'es2022', module: 'esnext', moduleResolution: 'bundler', strict: true,
incremental: true, tsBuildInfoFile: path.join(work, 'proj.tsbuildinfo'),
outDir: path.join(work, 'out')
},
include: ['src/**/*.ts']
}));
function createBuilder(oldProgram) {
const parsed = ts.getParsedCommandLineOfConfigFile(path.join(work, 'tsconfig.json'), {}, {
...ts.sys,
onUnRecoverableConfigFileDiagnostic: (d) => { throw new Error(String(d.messageText)); }
});
const host = ts.createIncrementalCompilerHost(parsed.options);
return ts.createEmitAndSemanticDiagnosticsBuilderProgram(parsed.fileNames, parsed.options, host, oldProgram);
}
function walk(builder) {
let affected = 0, emittedJs = 0;
while (builder.getSemanticDiagnosticsOfNextAffectedFile()) affected++;
while (builder.emitNextAffectedFile((f, text) => {
if (f.endsWith('.tsbuildinfo')) fs.writeFileSync(f, text);
else if (f.endsWith('.js')) emittedJs++;
}));
return { affected, emittedJs };
}
const touch = (f) => fs.appendFileSync(path.join(src, f), '\n');
let b = createBuilder(undefined);
console.log('initial ', walk(b));
touch('core.ts'); b = createBuilder(b);
console.log('whitespace edit core ', walk(b));
touch('mid.ts'); b = createBuilder(b);
console.log('whitespace edit mid ', walk(b)); // <-- BUG: re-emits all 502 files
touch('mid.ts'); b = createBuilder(b);
console.log('same edit again ', walk(b)); // <-- 1 file, correct
🙁 Actual behavior
With TypeScript >= 5.5, the first whitespace-only edit of mid.ts re-emits the entire transitive import closure:
TS 5.4.5: whitespace edit mid { affected: 2, emittedJs: 1 } same edit again { affected: 1, emittedJs: 1 }
TS 5.5.4: whitespace edit mid { affected: 2, emittedJs: 502 } same edit again { affected: 1, emittedJs: 1 }
TS 5.9.3: whitespace edit mid { affected: 2, emittedJs: 502 } same edit again { affected: 1, emittedJs: 1 }
TS 6.0.3: whitespace edit mid { affected: 2, emittedJs: 502 } same edit again { affected: 1, emittedJs: 1 }
The identical edit performed immediately afterwards emits 1 file — so this is not a legitimate invalidation; it is state-dependent.
🙂 Expected behavior
A whitespace-only edit (unchanged shape, unchanged const-enum values) should re-emit ~1 file, as TypeScript 5.4 does and as the second identical edit does.
Analysis (probe-instrumented)
Three interacting mechanisms (all confirmed by instrumenting BuilderState.updateShapeSignature and handleDtsMayChangeOfReferencingExportOfAffectedFile):
- Fresh builds store text version hashes as shape signatures (
BuilderState.create: useFileVersionAsSignature: !disableUseFileVersionAsSignature && !useOldState; the public builder API hardcodes disableUseFileVersionAsSignature: false). These persist into .tsbuildinfo.
- On the file's first subsequent edit, the freshly computed d.ts shape hash is compared against the stored text hash — a guaranteed mismatch — so
isChangedSignature() is falsely true and handleDtsMayChangeOfReferencingExportOfAffectedFile runs even though the shape did not change.
- Since 5.5 that walk is over-broad: with
exportedModulesMap removed, it iterates referencedMap.getKeys() of the referencing files and recurses via handleDtsMayChangeOfFileAndExportsOfFile — reaching the entire transitive import closure through export * barrels rather than only genuine re-exporters as in 5.4. Because the edited file exports a const enum (invalidateJsFiles), the whole closure is queued for full JS re-emit. Along the way, handleDtsMayChangeOf calls updateShapeSignature(..., /*useFileVersionAsSignature*/ true), overwriting each visited file's (possibly real) shape signature with its text hash — so every closure member's own first future edit repeats the explosion, and the poisoned state persists across process restarts via buildinfo.
Real-world impact
In a production Angular workspace (~7,800 files, barrel-heavy, 42 exported const enums), every first edit of each file per watch session costs a 70–95s rebuild (~1,150 files re-emitted + their semantic diagnostics recomputed) for whitespace-level changes; subsequent edits of the same file take 7–10s. Experimentally forcing real d.ts signatures everywhere (never storing version hashes as signatures) fixes it end-to-end with no measurable initial-build cost in our project. Cross-reference with full measurement data: angular/angular-cli#33619.
Possible directions
- Never downgrade a computed d.ts signature to a text version hash in
updateShapeSignature when an actual signature is already stored (keeps the emit-path shortcut for files that never had one).
- And/or expose
disableUseFileVersionAsSignature through the public builder-program API (it exists internally and is used by the language service) so tools like the Angular CLI can opt in to accurate signatures.
- And/or restore export-scoped precision to the
handleDtsMayChangeOfReferencingExportOfAffectedFile walk (pre-5.5 exportedModulesMap behavior).
🔎 Search Terms
incremental builder re-emit affected files, exportedModulesMap, handleDtsMayChangeOfReferencingExportOfAffectedFile, useFileVersionAsSignature, const enum invalidateJsFiles, watch first rebuild slow, signature version hash
🕗 Version & Regression Information
exportedModulesMapin 5.5 (22 references in the 5.4.5 bundle, 0 in 5.5.4)💻 Code
Standalone repro driving the public incremental-builder API (
createEmitAndSemanticDiagnosticsBuilderProgram), the way build tools such as the Angular CLI do. Project shape:core.ts←core-barrel.ts(export *) ←mid.ts(exports aconst enum) ←index.ts(export *) ← 500 leaf files importing from the barrel.🙁 Actual behavior
With TypeScript >= 5.5, the first whitespace-only edit of
mid.tsre-emits the entire transitive import closure:The identical edit performed immediately afterwards emits 1 file — so this is not a legitimate invalidation; it is state-dependent.
🙂 Expected behavior
A whitespace-only edit (unchanged shape, unchanged const-enum values) should re-emit ~1 file, as TypeScript 5.4 does and as the second identical edit does.
Analysis (probe-instrumented)
Three interacting mechanisms (all confirmed by instrumenting
BuilderState.updateShapeSignatureandhandleDtsMayChangeOfReferencingExportOfAffectedFile):BuilderState.create:useFileVersionAsSignature: !disableUseFileVersionAsSignature && !useOldState; the public builder API hardcodesdisableUseFileVersionAsSignature: false). These persist into.tsbuildinfo.isChangedSignature()is falsely true andhandleDtsMayChangeOfReferencingExportOfAffectedFileruns even though the shape did not change.exportedModulesMapremoved, it iteratesreferencedMap.getKeys()of the referencing files and recurses viahandleDtsMayChangeOfFileAndExportsOfFile— reaching the entire transitive import closure throughexport *barrels rather than only genuine re-exporters as in 5.4. Because the edited file exports aconst enum(invalidateJsFiles), the whole closure is queued for full JS re-emit. Along the way,handleDtsMayChangeOfcallsupdateShapeSignature(..., /*useFileVersionAsSignature*/ true), overwriting each visited file's (possibly real) shape signature with its text hash — so every closure member's own first future edit repeats the explosion, and the poisoned state persists across process restarts via buildinfo.Real-world impact
In a production Angular workspace (~7,800 files, barrel-heavy, 42 exported const enums), every first edit of each file per watch session costs a 70–95s rebuild (~1,150 files re-emitted + their semantic diagnostics recomputed) for whitespace-level changes; subsequent edits of the same file take 7–10s. Experimentally forcing real d.ts signatures everywhere (never storing version hashes as signatures) fixes it end-to-end with no measurable initial-build cost in our project. Cross-reference with full measurement data: angular/angular-cli#33619.
Possible directions
updateShapeSignaturewhen an actual signature is already stored (keeps the emit-path shortcut for files that never had one).disableUseFileVersionAsSignaturethrough the public builder-program API (it exists internally and is used by the language service) so tools like the Angular CLI can opt in to accurate signatures.handleDtsMayChangeOfReferencingExportOfAffectedFilewalk (pre-5.5exportedModulesMapbehavior).