Skip to content

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) #63664

Description

@kaiguogit

🔎 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.tscore-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):

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

Metadata

Metadata

Assignees

Labels

Needs InvestigationThis issue needs a team member to investigate its status.

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions