diff --git a/packages/typescript/src/watchProgram.ts b/packages/typescript/src/watchProgram.ts index f4b47c7cd..83605b859 100644 --- a/packages/typescript/src/watchProgram.ts +++ b/packages/typescript/src/watchProgram.ts @@ -229,5 +229,43 @@ export default function createWatchProgram( context: PluginContext, options: CreateProgramOptions ) { - return ts.createWatchProgram(createWatchHost(ts, context, options)); + const host = createWatchHost(ts, context, options); + + // TypeScript schedules program updates through the host's timers and does not + // clear a pending update when the program is closed. Track the timers the + // program arms so `close()` can cancel whatever is still pending; otherwise a + // file change in the last 250ms of a one-shot build fires after `buildEnd`, + // recreates every file watcher on the closed program and keeps Rollup alive. + const timers = new Set(); + const { setTimeout: schedule, clearTimeout: cancel } = host; + if (schedule && cancel) { + host.setTimeout = (callback, ms, ...args) => { + const timer: unknown = schedule( + () => { + timers.delete(timer); + callback(...args); + }, + ms, + ...args + ); + timers.add(timer); + return timer; + }; + host.clearTimeout = (timer) => { + timers.delete(timer); + cancel(timer); + }; + } + + const program = ts.createWatchProgram(host); + const { close } = program; + program.close = () => { + close.call(program); + if (cancel) { + timers.forEach((timer) => cancel(timer)); + } + timers.clear(); + }; + + return program; } diff --git a/packages/typescript/test/test.js b/packages/typescript/test/test.js index f4f29e8a2..dbadde532 100644 --- a/packages/typescript/test/test.js +++ b/packages/typescript/test/test.js @@ -560,6 +560,75 @@ test.sequential('supports overriding the TypeScript version', async () => { const result = await evaluateBundle(bundle); expect(result).toBe(1337); }); +test.sequential('clears a pending program update when the watch program is closed', async () => { + const scheduled = []; + const cancelled = []; + let updateTimer = null; + let updateFired = false; + let closed = false; + const bundle = await rollup({ + input: 'fixtures/overriding-typescript/main.ts', + onwarn, + plugins: [ + typescript({ + tsconfig: false, + typescript: fakeTypescript({ + createWatchCompilerHost() { + return { + afterProgramCreate() {}, + // Record the timers instead of arming them so the outcome does not depend on + // how quickly the build reaches `buildEnd`. + setTimeout(callback, ms) { + const timer = { callback, ms }; + scheduled.push(timer); + return timer; + }, + clearTimeout(timer) { + cancelled.push(timer); + } + }; + }, + createWatchProgram(host) { + const program = { + emit(_, writeFile) { + writeFile( + path.join(__dirname, 'fixtures/overriding-typescript/main.js'), + 'export default 1337;' + ); + } + }; + host.afterProgramCreate(program); + program.emit(); + + // A file change in the last 250ms of the build arms TypeScript's update timer + // and `close()` leaves it running. + updateTimer = host.setTimeout( + () => { + updateFired = true; + }, + 250, + 'timerToUpdateProgram' + ); + return { + close() { + closed = true; + } + }; + } + }) + }) + ] + }); + await getCode(bundle, outputOptions); + + expect(closed).toBe(true); + expect(scheduled).toHaveLength(1); + expect(scheduled[0].ms).toBe(250); + expect(updateTimer).toBe(scheduled[0]); + expect(cancelled).toHaveLength(1); + expect(cancelled[0]).toBe(updateTimer); + expect(updateFired).toBe(false); +}); test.sequential('should not resolve .d.ts files', async () => { const bundle = await rollup({ input: 'fixtures/dts/main.ts',