fix(runtime): cancel pending throttled commit on resize

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).
This commit is contained in:
Yunfei He
2026-05-29 17:26:06 +08:00
parent d3480dc285
commit f3bb5f268a
2 changed files with 64 additions and 2 deletions
@@ -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(() => () => (
<Box flexDirection="column">
<Text>line1</Text>
<Text>line2</Text>
<Text>{msg.value}</Text>
</Box>
));
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();
}
});
+10 -1
View File
@@ -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;
}