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)

Đang mở
#63,664 0 bình luận 0 reaction 1 người được giao Xem trên GitHub

@johnfav03 đang làm issue này rồi.

Từ ngày 22/7/2026.

Needs Investigation
Ngôn ngữ chính
Go
Star
111k
Fork
14.3k
Merge trung bình
2 ngày 4 giờ
Pull request đã merge (30 ngày)
132

Mô tả

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

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Đánh giá

Issue này chưa được đánh giá.

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.