microsoft / microsoft/TypeScript

Incremental builder re-emits entire transitive import closure on a whitespace-only edit (regression in 5.5: exportedModulesMap removal + text version hashes stored as shape signatures)

Open
#63,664 0 comments 0 reactions 1 assignee Claimed by @johnfav03 View on GitHub
Needs Investigation
Dominant language
Go
Stars
111k
Forks
14.3k
PR merge metrics
PR metrics pending

Description

### 🔎 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.

```js
// node repro.js
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`):

1. **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`.
2. 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.
3. **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).

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.