Files
vue-tui/packages/runtime-tests/integration/lifecycle/throttle.sequential.test.tsx
T
Yunfei He f3bb5f268a 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).
2026-05-29 20:00:36 +08:00

357 lines
11 KiB
TypeScript

// Sequential: uses vi.useFakeTimers, which mutates the process-global timer
// functions. Run concurrently, a sibling test calling useRealTimers() would
// pull the mocked timers out mid-advanceTimersByTime. Tests are it.sequential.
import { defineComponent, nextTick, shallowRef } from "vue";
import { expect, test, vi } from "vite-plus/test";
import { Box, createApp, Text } from "@vue-tui/runtime";
import ansiEscapes from "ansi-escapes";
import stripAnsi from "strip-ansi";
import {
makeFakeStdin,
makeFakeWritable,
captureWrites,
getContentWrites,
} from "./test-streams.ts";
// Fake timer options: only fake setTimeout/clearTimeout/Date so that
// Vue's internal scheduler (nextTick, queueMicrotask, setImmediate) still
// runs on real clocks. This matches the pattern in use-animation.test.tsx.
const FAKE_TIMER_OPTS = {
shouldAdvanceTime: false,
toFake: ["setTimeout", "clearTimeout", "Date"] as ("setTimeout" | "clearTimeout" | "Date")[],
};
test.sequential("throttle renders to maxFps", async () => {
// Port of Ink's "throttle renders to maxFps" — verifies leading+trailing
// throttle pattern with maxFps=1 (1000ms window).
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const msg = shallowRef("Hello");
const App = defineComponent(() => {
return () => <Text>{msg.value}</Text>;
});
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({
stdout,
stdin,
stderr,
exitOnCtrlC: false,
maxFps: 1, // 1 Hz => ~1000ms throttle window
});
// Flush Vue scheduler so the initial commit fires (leading edge)
await nextTick();
await nextTick();
const initialWriteCount = writes.length;
expect(initialWriteCount).toBeGreaterThanOrEqual(1);
expect(writes.some((w) => w.includes("Hello"))).toBe(true);
// Trigger a rerender inside the throttle window
msg.value = "World";
await nextTick();
await nextTick();
// Throttling should have held — no new writes yet
const midWriteCount = writes.length;
expect(midWriteCount).toBe(initialWriteCount);
// Advance 999ms: still within window, no trailing call yet
vi.advanceTimersByTime(999);
expect(writes.length).toBe(midWriteCount);
// Cross the 1000ms boundary: trailing render fires
vi.advanceTimersByTime(1);
expect(writes.some((w) => w.includes("World"))).toBe(true);
app.unmount();
} finally {
vi.useRealTimers();
}
});
test.sequential("immediate scheduler in debug mode commits every mutation", async () => {
// Counterpart to the throttle test: in debug mode (used by the testing
// helper), the scheduler bypasses throttling and commits synchronously.
// This verifies the immediate path in createCommitScheduler.
const msg = shallowRef("A");
const App = defineComponent(() => {
return () => <Text>{msg.value}</Text>;
});
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({
stdout,
stdin,
stderr,
debug: true,
exitOnCtrlC: false,
});
await nextTick();
await nextTick();
expect(writes.some((w) => w.includes("A"))).toBe(true);
msg.value = "B";
await nextTick();
await nextTick();
expect(writes.some((w) => w.includes("B"))).toBe(true);
msg.value = "C";
await nextTick();
await nextTick();
expect(writes.some((w) => w.includes("C"))).toBe(true);
app.unmount();
});
test.sequential("screen reader mode bypasses throttle (immediate commits)", async () => {
// Port of Ink's screen reader behavior: isScreenReaderEnabled causes
// the scheduler to use immediate mode, ensuring every frame is flushed
// without delay for assistive technology.
const msg = shallowRef("Hello");
const App = defineComponent(() => {
return () => <Text>{msg.value}</Text>;
});
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({
stdout,
stdin,
stderr,
exitOnCtrlC: false,
isScreenReaderEnabled: true,
});
await nextTick();
await nextTick();
expect(writes.some((w) => w.includes("Hello"))).toBe(true);
// Even rapid mutations should commit immediately (no throttle)
msg.value = "World";
await nextTick();
await nextTick();
expect(writes.some((w) => w.includes("World"))).toBe(true);
app.unmount();
});
test.sequential("no throttled renders after unmount", async () => {
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const msg = shallowRef("Foo");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await nextTick();
await nextTick();
const initialCount = getContentWrites(writes).length;
expect(initialCount).toBeGreaterThanOrEqual(1);
msg.value = "Bar";
await nextTick();
msg.value = "Baz";
await nextTick();
app.unmount();
const countAfterUnmount = getContentWrites(writes).length;
vi.advanceTimersByTime(1000);
expect(getContentWrites(writes).length).toBe(countAfterUnmount);
} finally {
vi.useRealTimers();
}
});
test.sequential("unmount forces pending throttled render", async () => {
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const msg = shallowRef("Hello");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, maxFps: 1 });
await nextTick();
await nextTick();
expect(getContentWrites(writes).length).toBe(1);
expect(stripAnsi(getContentWrites(writes)[0]!)).toContain("Hello");
msg.value = "Final";
await nextTick();
await nextTick();
expect(getContentWrites(writes).length).toBe(1);
app.unmount();
const allContent = getContentWrites(writes).map((w) => stripAnsi(w));
expect(allContent.some((c) => c.includes("Final"))).toBe(true);
} finally {
vi.useRealTimers();
}
});
test.sequential("unmount cancels pending throttled log writes when stdout is ended", async () => {
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const { PassThrough } = await import("node:stream");
const stdout = new PassThrough() as unknown as NodeJS.WriteStream;
stdout.columns = 100;
const writeErrors: Error[] = [];
stdout.on("error", (error: Error) => writeErrors.push(error));
const msg = shallowRef("Hello");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, maxFps: 1 });
await nextTick();
await nextTick();
msg.value = "World";
await nextTick();
stdout.end();
app.unmount();
vi.advanceTimersByTime(1000);
const hasWriteAfterEndError = writeErrors.some(
(e) => (e as NodeJS.ErrnoException).code === "ERR_STREAM_WRITE_AFTER_END",
);
expect(hasWriteAfterEndError).toBe(false);
} finally {
vi.useRealTimers();
}
});
test.sequential("unmount cancels pending throttled render when stdout is ended", async () => {
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const { PassThrough } = await import("node:stream");
// Baseline: mount + end + unmount without pending rerender
const baseStdout = new PassThrough() as unknown as NodeJS.WriteStream;
baseStdout.columns = 100;
const BaseApp = defineComponent(() => () => <Text>Hello</Text>);
const baseApp = createApp(BaseApp);
const baseStderr = makeFakeWritable({ columns: 80 });
const { stream: baseStdin } = makeFakeStdin();
baseApp.mount({
stdout: baseStdout,
stdin: baseStdin,
stderr: baseStderr,
exitOnCtrlC: false,
maxFps: 1,
});
await nextTick();
await nextTick();
baseStdout.end();
baseApp.unmount();
const baselineTimers = vi.getTimerCount();
vi.runAllTimers();
// Test: mount + rerender + end + unmount
const stdout = new PassThrough() as unknown as NodeJS.WriteStream;
stdout.columns = 100;
const msg = shallowRef("Hello");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, maxFps: 1 });
await nextTick();
await nextTick();
msg.value = "World";
await nextTick();
stdout.end();
app.unmount();
expect(vi.getTimerCount()).toBe(baselineTimers);
} finally {
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();
}
});