fix(runtime): render synchronously on resize, matching Ink

The resize handler routed through scheduler.schedule(), deferring the repaint
through the ~32ms commit throttle. Ink's resized() calls onRender() directly,
and a resize is a discrete viewport change that should repaint immediately —
deferring it can leave stale/overlapping content on screen for a frame.

It also made the clearTerminal-on-overflow behavior depend on wall-clock
timing: the #450 "shrink into overflow" test passed only because the throttled
resize emitted ZERO clears (its trailing timer never fired within the test's
nextTicks) and the single clear came entirely from unmount. The test asserted
the right number for the wrong reason, and the dependency on real elapsed time
made it flaky under CPU contention.

Change the resize handler to commit() directly. Now the resize itself emits the
overflow clear deterministically. Update the test to assert the clear happens
ON the resize (clearsAfterResize - clearsBeforeResize === 1) after a single
nextTick — no longer dependent on throttle timing.
This commit is contained in:
Yunfei He
2026-05-29 13:33:54 +08:00
parent 02f9cfa98a
commit bc61d57a11
2 changed files with 16 additions and 5 deletions
@@ -56,7 +56,7 @@ it("#450: non-TTY overflow transitions should never clear terminal", async () =>
expect(clearCount).toBe(0);
});
it("#450: viewport shrink into overflow should clear once", async () => {
it("#450: viewport shrink into overflow clears exactly once on resize", async () => {
const stdout = makeFakeWritable({ rows: 10 });
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
@@ -69,15 +69,20 @@ it("#450: viewport shrink into overflow should clear once", async () => {
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await nextTick();
await nextTick();
const clearsBeforeResize = writes.filter((w) => w.includes(ansiEscapes.clearTerminal)).length;
expect(clearsBeforeResize).toBe(0); // 8 lines fit in 10 rows — no clear yet
// Shrink so the content overflows the viewport. The resize renders
// synchronously (matching Ink), so the overflow clear is emitted right here,
// not deferred through the commit throttle.
stdout.rows = 4;
stdout.emit("resize");
await nextTick();
await nextTick();
const clearsAfterResize = writes.filter((w) => w.includes(ansiEscapes.clearTerminal)).length;
expect(clearsAfterResize - clearsBeforeResize).toBe(1);
app.unmount();
const clearCount = writes.filter((w) => w.includes(ansiEscapes.clearTerminal)).length;
expect(clearCount).toBe(1);
});
it("#450: non-TTY grow-to-overflow rerender should not clear terminal", async () => {
+7 -1
View File
@@ -658,8 +658,14 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
}
// Only listen for resize in interactive mode (matching Ink).
// Render synchronously on resize rather than through the commit throttle:
// a resize is a discrete event that changes the viewport, and Ink's
// resized() handler calls onRender() directly. Deferring it through the
// ~32ms throttle can leave stale/overlapping content on screen for a frame
// and makes the clearTerminal-on-overflow behavior depend on wall-clock
// timing rather than the resize itself.
if (interactive) {
const onResize = () => scheduler.schedule();
const onResize = () => commit();
stdout.on("resize", onResize);
mountedResizeHandler = onResize;
}