From f3bb5f268ae08733fbd8645fc957a57ef3dd2ac0 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Fri, 29 May 2026 17:26:06 +0800 Subject: [PATCH] fix(runtime): cancel pending throttled commit on resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resize handler painted synchronously via commit() but left the commit scheduler's pending trailing throttle timer armed. If an update was sitting in that timer, it fired a second doCommit() right after the resize paint — and because shouldClearTerminalForFrame clears whenever the previous frame overflowed the viewport, the second commit emitted a duplicate clearTerminal. Cancel the pending trailing commit before the synchronous paint; the paint already reflects the current tree, so the pending commit is redundant. Regression test (test-first) in throttle.sequential.test.tsx reproduces the double-clear (2 clears) and verifies the fix (1 clear). Closes #26 (item 1). --- .../lifecycle/throttle.sequential.test.tsx | 55 ++++++++++++++++++- packages/runtime/src/render.ts | 11 +++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/runtime-tests/integration/lifecycle/throttle.sequential.test.tsx b/packages/runtime-tests/integration/lifecycle/throttle.sequential.test.tsx index 2ae580f..951c21c 100644 --- a/packages/runtime-tests/integration/lifecycle/throttle.sequential.test.tsx +++ b/packages/runtime-tests/integration/lifecycle/throttle.sequential.test.tsx @@ -4,7 +4,8 @@ import { defineComponent, nextTick, shallowRef } from "vue"; import { expect, test, vi } from "vite-plus/test"; -import { createApp, Text } from "@vue-tui/runtime"; +import { Box, createApp, Text } from "@vue-tui/runtime"; +import ansiEscapes from "ansi-escapes"; import stripAnsi from "strip-ansi"; import { makeFakeStdin, @@ -301,3 +302,55 @@ test.sequential("unmount cancels pending throttled render when stdout is ended", vi.useRealTimers(); } }); + +test.sequential("resize does not double-clear when a throttled commit is pending", async () => { + // Regression for issue #26: the resize handler paints synchronously, but if + // a trailing throttled commit is still pending, that timer fires a second + // doCommit() right after. Because shouldClearTerminalForFrame clears whenever + // the previous frame overflowed the viewport, the second commit emits a + // duplicate clearTerminal. onResize must cancel the pending commit first. + vi.useFakeTimers(FAKE_TIMER_OPTS); + try { + const msg = shallowRef("A"); + // Three rows of content into a 2-row viewport => the frame overflows, so + // every commit after the first takes the clearTerminal branch. + const App = defineComponent(() => () => ( + + line1 + line2 + {msg.value} + + )); + const app = createApp(App); + const stdout = makeFakeWritable({ columns: 80, rows: 2 }); + const stderr = makeFakeWritable({ columns: 80, rows: 2 }); + const { stream: stdin } = makeFakeStdin(); + const writes = captureWrites(stdout); + + app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, interactive: true, maxFps: 1 }); + await nextTick(); + await nextTick(); + + const countClears = () => writes.join("").split(ansiEscapes.clearTerminal).length - 1; + + // Leading commit only: previous height was 0, so no clear yet. + expect(countClears()).toBe(0); + + // Mutate inside the throttle window so a trailing commit is armed. + msg.value = "B"; + await nextTick(); + expect(vi.getTimerCount()).toBeGreaterThanOrEqual(1); + + // Resize while the trailing commit is pending: paints synchronously (1 clear) + // and must cancel the pending timer so it doesn't paint (and clear) again. + stdout.emit("resize"); + vi.advanceTimersByTime(1000); + + expect(countClears()).toBe(1); + expect(stripAnsi(writes.join(""))).toContain("B"); + + app.unmount(); + } finally { + vi.useRealTimers(); + } +}); diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index c666db2..f8a97e4 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -665,7 +665,16 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp // and makes the clearTerminal-on-overflow behavior depend on wall-clock // timing rather than the resize itself. if (interactive) { - const onResize = () => commit(); + const onResize = () => { + // Cancel any pending trailing commit before painting synchronously. + // Otherwise the throttle timer fires a second doCommit() right after + // this paint, and because shouldClearTerminalForFrame clears whenever + // the previous frame overflowed, that second commit emits a duplicate + // clearTerminal (issue #26). The synchronous commit below already + // reflects the current tree, so the pending commit is redundant. + scheduler.cancel(); + commit(); + }; stdout.on("resize", onResize); mountedResizeHandler = onResize; }