From 3ca09e1564fd722c0f5fd7ba81c83851782494cd Mon Sep 17 00:00:00 2001 From: boulea7 Date: Mon, 26 Oct 2026 22:29:48 +0800 Subject: [PATCH] fix(typescript): recover watch builds after errors --- packages/typescript/src/index.ts | 21 ++- packages/typescript/src/watchProgram.ts | 21 ++- packages/typescript/test/watch.js | 183 ++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 packages/typescript/test/watch.js diff --git a/packages/typescript/src/index.ts b/packages/typescript/src/index.ts index 5caaae3c7..25b97906d 100644 --- a/packages/typescript/src/index.ts +++ b/packages/typescript/src/index.ts @@ -5,11 +5,12 @@ import * as os from 'os'; import { createFilter } from '@rollup/pluginutils'; import type { Plugin, SourceDescription } from 'rollup'; -import type { Watch } from 'typescript'; +import type { Diagnostic, Watch } from 'typescript'; import type { RollupTypescriptOptions } from '../types'; import createFormattingHost from './diagnostics/host'; +import { emitDiagnostics } from './diagnostics/emit'; import createModuleResolver from './moduleResolution'; import { getPluginOptions } from './options/plugin'; import { emitParsedOptionsErrors, parseTypescriptConfig } from './options/tsconfig'; @@ -42,6 +43,8 @@ export default function typescript(options: RollupTypescriptOptions = {}): Plugi } = getPluginOptions(options); const tsCache = new TSCache(cacheDir); const emittedFiles = new Map(); + const diagnostics: Diagnostic[] = []; + const watchFiles = new Set(); const watchProgramHelper = new WatchProgramHelper(); let autoOutDir: string | null = null; // Centralize temp outDir cleanup to avoid duplication/drift across hooks @@ -149,7 +152,7 @@ export default function typescript(options: RollupTypescriptOptions = {}): Plugi return { name: 'typescript', - buildStart(rollupOptions) { + async buildStart(rollupOptions) { emitParsedOptionsErrors(ts, this, parsedOptions); preflight({ @@ -169,6 +172,8 @@ export default function typescript(options: RollupTypescriptOptions = {}): Plugi } if (!program) { program = createWatchProgram(ts, this, { + diagnostics, + watchFiles, formatHost, resolveModule, parsedOptions, @@ -185,10 +190,17 @@ export default function typescript(options: RollupTypescriptOptions = {}): Plugi recreateTransformersOnRebuild }); } + + await watchProgramHelper.wait(); + // An error can abort before Rollup loads imported or type-only dependencies. + if (this.meta.watchMode && parsedOptions.options.noEmitOnError) { + watchFiles.forEach((fileName) => this.addWatchFile(fileName)); + } + emitDiagnostics(ts, this, formatHost, diagnostics); }, watchChange(id) { - if (!filter(id)) return; + if (!filter(id) && !watchFiles.has(normalizePath(id))) return; watchProgramHelper.watch(); }, @@ -244,11 +256,10 @@ export default function typescript(options: RollupTypescriptOptions = {}): Plugi return null; }, - async load(id) { + load(id) { if (!filter(id)) return null; this.addWatchFile(id); - await watchProgramHelper.wait(); const fileName = normalizePath(id); if (!parsedOptions.fileNames.includes(fileName)) { diff --git a/packages/typescript/src/watchProgram.ts b/packages/typescript/src/watchProgram.ts index 83605b859..ba2f0f123 100644 --- a/packages/typescript/src/watchProgram.ts +++ b/packages/typescript/src/watchProgram.ts @@ -17,6 +17,7 @@ import { buildDiagnosticReporter } from './diagnostics/emit'; import type { DiagnosticsHost } from './diagnostics/host'; import type { Resolver } from './moduleResolution'; import { mergeTransformers } from './customTransformers'; +import { normalizePath } from './outputFile'; const { DiagnosticCategory } = typescript; type BuilderProgram = EmitAndSemanticDiagnosticsBuilderProgram; @@ -30,6 +31,10 @@ enum DiagnosticCode { } interface CreateProgramOptions { + /** Diagnostics from the current compilation, reported from Rollup hooks. */ + diagnostics: Diagnostic[]; + /** Compiler inputs to watch when an error prevents Rollup from loading them. */ + watchFiles: Set; /** Formatting host used to get some system functions and emit type errors. */ formatHost: DiagnosticsHost; /** Parsed Typescript compiler options. */ @@ -143,6 +148,8 @@ function createWatchHost( ts: typeof typescript, context: PluginContext, { + diagnostics, + watchFiles, formatHost, parsedOptions, writeFile, @@ -159,7 +166,9 @@ function createWatchHost( parsedOptions.options, ts.sys, createProgram, - buildDiagnosticReporter(ts, context, formatHost), + parsedOptions.options.noEmitOnError + ? (diagnostic) => diagnostics.push(diagnostic) + : buildDiagnosticReporter(ts, context, formatHost), status, parsedOptions.projectReferences ); @@ -169,6 +178,16 @@ function createWatchHost( ...baseHost, /** Override the created program so an in-memory emit is used */ afterProgramCreate(program) { + diagnostics.splice(0); + if (parsedOptions.options.noEmitOnError) { + const currentProgram = program.getProgram(); + watchFiles.clear(); + currentProgram.getSourceFiles().forEach((file) => { + if (!currentProgram.isSourceFileDefaultLibrary(file)) { + watchFiles.add(normalizePath(file.fileName)); + } + }); + } // Optionally recompute custom transformers for each new builder program in watch mode // so factories capture the current Program/TypeChecker and any provided getters can // return the latest values. When disabled (default), legacy behavior reuses the diff --git a/packages/typescript/test/watch.js b/packages/typescript/test/watch.js new file mode 100644 index 000000000..30ed386e5 --- /dev/null +++ b/packages/typescript/test/watch.js @@ -0,0 +1,183 @@ +/* eslint-disable no-await-in-loop -- Each watch edit depends on the previous build. */ +const fs = require('fs'); +const path = require('path'); + +const { watch } = require('rollup'); + +const typescript = require('..'); + +function waitForBuild(watcher, buildNumber) { + return new Promise((resolve, reject) => { + let result; + const timer = setTimeout(() => { + watcher.off('event', handleEvent); + reject(new Error(`Timed out waiting for watch build ${buildNumber}`)); + }, 10000); + function handleEvent(event) { + if (event.code === 'ERROR' || event.code === 'BUNDLE_END') result = event; + if (event.code === 'END') { + clearTimeout(timer); + watcher.off('event', handleEvent); + resolve(result); + } + } + watcher.on('event', handleEvent); + }); +} + +for (const { startsWithError, dependency } of [ + { startsWithError: true, dependency: 'root' }, + { startsWithError: false, dependency: 'root' }, + { startsWithError: true, dependency: 'imported' }, + { startsWithError: true, dependency: 'excluded type-only' } +]) { + test.sequential( + `recovers from consecutive type errors in watch mode (${ + startsWithError ? 'invalid' : 'valid' + } initial build, ${dependency} file)`, + async () => { + const outputDir = path.join(__dirname, 'output'); + fs.mkdirSync(outputDir, { recursive: true }); + const dir = fs.mkdtempSync(path.join(outputDir, 'watch-no-emit-')); + const input = path.join(dir, 'main.ts'); + const edited = dependency === 'root' ? input : path.join(dir, 'dep.ts'); + const typeOnly = dependency === 'excluded type-only'; + const output = path.join(dir, 'bundle.js'); + const tsconfig = path.join(dir, 'tsconfig.json'); + const builds = [ + ...(startsWithError ? [] : [{ source: 'export const value: number = 1;', valid: true }]), + { + source: typeOnly + ? 'export type Value = string;' + : "export const value: number = 'invalid-one';", + valid: false + }, + { + source: typeOnly + ? 'export type Value = boolean;' + : "export const value: number = 'invalid-two';", + valid: false + }, + { + source: typeOnly ? 'export type Value = number;' : 'export const value: number = 42;', + valid: true + } + ]; + if (typeOnly) { + fs.writeFileSync( + input, + "import type { Value } from './dep'; export const value: Value = 42;" + ); + } else if (dependency === 'imported') { + fs.writeFileSync(input, "export { value } from './dep';"); + } + fs.writeFileSync(edited, builds[0].source); + fs.writeFileSync( + tsconfig, + JSON.stringify({ + compilerOptions: { + module: 'esnext', + target: 'es2015', + noEmitOnError: true, + types: [], + skipLibCheck: true + }, + files: ['main.ts'] + }) + ); + const watcher = watch({ + input, + output: { file: output, format: 'es' }, + plugins: [typescript({ tsconfig, ...(typeOnly ? { exclude: [edited] } : {}) })] + }); + let previousOutput; + let previousMtime; + try { + let nextBuild = waitForBuild(watcher, 0); + for (let i = 0; i < builds.length; i += 1) { + const build = builds[i]; + const event = await nextBuild; + expect(event.code).toBe(build.valid ? 'BUNDLE_END' : 'ERROR'); + if (build.valid) { + await event.result.close(); + const code = fs.readFileSync(output, 'utf8'); + expect(code).toContain(i === builds.length - 1 ? 'value = 42' : 'value = 1'); + previousOutput = code; + previousMtime = fs.statSync(output).mtimeMs; + } else { + expect(event.error.plugin).toBe('typescript'); + expect(event.error.pluginCode).toBe('TS2322'); + if (previousOutput) { + expect(fs.readFileSync(output, 'utf8')).toBe(previousOutput); + expect(fs.statSync(output).mtimeMs).toBe(previousMtime); + } else { + expect(fs.existsSync(output)).toBe(false); + } + } + if (i + 1 < builds.length) { + nextBuild = waitForBuild(watcher, i + 1); + await fs.promises.writeFile(edited, builds[i + 1].source); + } + } + } finally { + await watcher.close(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }, + 30000 + ); +} + +test.sequential( + 'reports type-only watch warnings when noEmitOnError is false', + async () => { + const outputDir = path.join(__dirname, 'output'); + fs.mkdirSync(outputDir, { recursive: true }); + const dir = fs.mkdtempSync(path.join(outputDir, 'watch-warnings-')); + const input = path.join(dir, 'main.ts'); + const dependency = path.join(dir, 'dep.ts'); + const output = path.join(dir, 'bundle.js'); + const tsconfig = path.join(dir, 'tsconfig.json'); + fs.writeFileSync(input, "import type { Value } from './dep'; export const value: Value = 42;"); + fs.writeFileSync(dependency, 'export type Value = number;'); + fs.writeFileSync( + tsconfig, + JSON.stringify({ + compilerOptions: { module: 'esnext', target: 'es2015', noEmitOnError: false, types: [] }, + files: ['main.ts'] + }) + ); + let receiveWarning; + const watcher = watch({ + input, + output: { file: output, format: 'es' }, + plugins: [typescript({ tsconfig, exclude: [dependency] })], + onwarn(warning) { + if (receiveWarning) receiveWarning(warning); + } + }); + try { + const initial = await waitForBuild(watcher, 0); + expect(initial.code).toBe('BUNDLE_END'); + await initial.result.close(); + const nextWarning = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('Timed out waiting for a type warning')), + 10000 + ); + receiveWarning = (warning) => { + clearTimeout(timer); + resolve(warning); + }; + }); + await fs.promises.writeFile(dependency, 'export type Value = string;'); + const warning = await nextWarning; + expect(warning.plugin).toBe('typescript'); + expect(warning.pluginCode).toBe('TS2322'); + } finally { + await watcher.close(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }, + 30000 +);