feat: add time-based render throttle (~30fps) matching Ink

Add leading+trailing throttle to the commit scheduler in production mode.
In debug/test mode (immediate: true), commits fire without delay to
preserve test determinism. Adjusted the non-debug static test to account
for the throttle timer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-26 00:55:02 +08:00
parent b8778c1847
commit 8565f41827
4 changed files with 85 additions and 10 deletions
@@ -111,6 +111,8 @@ test("Static flush clears the dynamic frame first (non-debug mode)", async () =>
stdout.chunks.length = 0;
items.value = ["STATIC_ITEM"];
await nextTick();
// Wait for the render throttle trailing timer (~32ms) to fire in production mode.
await new Promise((r) => setTimeout(r, 50));
// Collect everything written during this render cycle
const renderOutput = stdout.chunks.join("");
@@ -0,0 +1,24 @@
import { defineComponent, nextTick, shallowRef } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Text } from "@vue-tui/runtime";
test("rapid mutations in debug mode produce immediate frames (no throttle)", async () => {
const count = shallowRef(0);
const App = defineComponent(() => {
return () => <Text>{String(count.value)}</Text>;
});
const { frames } = await render(App);
const before = frames.length;
// Each mutation + tick should produce a frame in debug mode
for (let i = 1; i <= 5; i++) {
count.value = i;
await nextTick();
await nextTick();
}
// In debug mode (used by testing), each mutation produces a frame
expect(frames.length - before).toBe(5);
});
+1 -1
View File
@@ -170,7 +170,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
writer.write(frame);
}
const scheduler = createCommitScheduler(commit);
const scheduler = createCommitScheduler(commit, { immediate: debug });
scheduledCommit = scheduler.schedule;
// Internal provides — set before the actual mount so components can inject
+58 -9
View File
@@ -5,27 +5,76 @@ export interface CommitScheduler {
flush: () => Promise<void>;
}
export function createCommitScheduler(commit: () => void): CommitScheduler {
export interface CommitSchedulerOptions {
/** Disable time-based throttle (used in tests / debug mode). */
immediate?: boolean;
}
/** Minimum interval between commits in production (~30fps). */
const THROTTLE_MS = 32;
export function createCommitScheduler(
commit: () => void,
options: CommitSchedulerOptions = {},
): CommitScheduler {
const immediate = options.immediate ?? false;
let scheduled = false;
let resolveFlush: (() => void) | null = null;
// Throttle state (production only): leading+trailing pattern.
// The leading call fires immediately, subsequent calls within the window
// are collapsed into a single trailing call at the end of the window.
let lastCommitTime = 0;
let trailingTimer: ReturnType<typeof setTimeout> | null = null;
let hasPending = false;
function doCommit() {
scheduled = false;
hasPending = false;
lastCommitTime = Date.now();
try {
commit();
} finally {
const r = resolveFlush;
resolveFlush = null;
r?.();
}
}
function schedule() {
if (scheduled) return;
scheduled = true;
queuePostFlushCb(() => {
scheduled = false;
try {
commit();
} finally {
const r = resolveFlush;
resolveFlush = null;
r?.();
if (immediate) {
doCommit();
return;
}
// Leading+trailing throttle: fire immediately if enough time has
// passed since last commit (leading edge). Otherwise mark pending
// and let the trailing timer handle it.
const elapsed = Date.now() - lastCommitTime;
if (elapsed >= THROTTLE_MS) {
// Leading edge: fire immediately
if (trailingTimer) {
clearTimeout(trailingTimer);
trailingTimer = null;
}
doCommit();
} else {
// Within throttle window: schedule trailing edge
hasPending = true;
if (!trailingTimer) {
trailingTimer = setTimeout(() => {
trailingTimer = null;
if (hasPending) doCommit();
}, THROTTLE_MS - elapsed);
}
}
});
}
function flush(): Promise<void> {
if (!scheduled) return Promise.resolve();
if (!scheduled && !hasPending) return Promise.resolve();
return new Promise<void>((resolve) => {
resolveFlush = resolve;
});