From 7a41b3dc8302d3ac03066921e1148aeb7b52c6e3 Mon Sep 17 00:00:00 2001 From: Jupiter Jayna Date: Fri, 25 Sep 2026 20:36:58 +0100 Subject: [PATCH 1/3] fix(typescript): clear the pending program update when a build closes the watch program TypeScript's `createWatchProgram().close()` stops the file watchers but does not clear `timerToUpdateProgram`. The plugin closes the program in `buildEnd` when Rollup is not in watch mode, so a change to any watched path in the last 250ms of the build leaves the timer armed; it fires after the close, rebuilds the program with a full set of file watchers, and nothing closes those again. `rollup -c` then prints its output and never exits. Track the timers the program arms through the host and cancel the pending ones when the program is closed. Watch mode is unchanged: the program is not closed there until the watcher stops. --- packages/typescript/src/watchProgram.ts | 36 +++++++++++++++- packages/typescript/test/test.js | 56 +++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/packages/typescript/src/watchProgram.ts b/packages/typescript/src/watchProgram.ts index f4b47c7cd..001348795 100644 --- a/packages/typescript/src/watchProgram.ts +++ b/packages/typescript/src/watchProgram.ts @@ -229,5 +229,39 @@ 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); + 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..907985105 100644 --- a/packages/typescript/test/test.js +++ b/packages/typescript/test/test.js @@ -560,6 +560,62 @@ 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 () => { + 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() {}, + setTimeout, + clearTimeout + }; + }, + 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; + }, + 50, + 'timerToUpdateProgram' + ); + return { + close() { + closed = true; + } + }; + } + }) + }) + ] + }); + await getCode(bundle, outputOptions); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(closed).toBe(true); + expect(updateTimer).not.toBeNull(); + expect(updateFired).toBe(false); +}); test.sequential('should not resolve .d.ts files', async () => { const bundle = await rollup({ input: 'fixtures/dts/main.ts', From 7e8298b2e120e41c64461d9d1dcabc43c7154dc3 Mon Sep 17 00:00:00 2001 From: Jupiter Jayna Date: Fri, 25 Sep 2026 21:11:19 +0100 Subject: [PATCH 2/3] test(typescript): make the update-timer regression test independent of build speed --- packages/typescript/test/test.js | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/typescript/test/test.js b/packages/typescript/test/test.js index 907985105..dbadde532 100644 --- a/packages/typescript/test/test.js +++ b/packages/typescript/test/test.js @@ -561,6 +561,8 @@ test.sequential('supports overriding the TypeScript version', async () => { 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; @@ -574,8 +576,16 @@ test.sequential('clears a pending program update when the watch program is close createWatchCompilerHost() { return { afterProgramCreate() {}, - setTimeout, - clearTimeout + // 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) { @@ -596,7 +606,7 @@ test.sequential('clears a pending program update when the watch program is close () => { updateFired = true; }, - 50, + 250, 'timerToUpdateProgram' ); return { @@ -610,10 +620,13 @@ test.sequential('clears a pending program update when the watch program is close ] }); await getCode(bundle, outputOptions); - await new Promise((resolve) => setTimeout(resolve, 200)); expect(closed).toBe(true); - expect(updateTimer).not.toBeNull(); + 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 () => { From 25db6b9e0868f0eac0afb95fbb1b1b644e032395 Mon Sep 17 00:00:00 2001 From: Jupiter Jayna Date: Fri, 25 Sep 2026 21:21:23 +0100 Subject: [PATCH 3/3] fix(typescript): forward timer arguments to the host when wrapping setTimeout --- packages/typescript/src/watchProgram.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/typescript/src/watchProgram.ts b/packages/typescript/src/watchProgram.ts index 001348795..83605b859 100644 --- a/packages/typescript/src/watchProgram.ts +++ b/packages/typescript/src/watchProgram.ts @@ -240,10 +240,14 @@ export default function createWatchProgram( const { setTimeout: schedule, clearTimeout: cancel } = host; if (schedule && cancel) { host.setTimeout = (callback, ms, ...args) => { - const timer: unknown = schedule(() => { - timers.delete(timer); - callback(...args); - }, ms); + const timer: unknown = schedule( + () => { + timers.delete(timer); + callback(...args); + }, + ms, + ...args + ); timers.add(timer); return timer; };