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:
@@ -111,6 +111,8 @@ test("Static flush clears the dynamic frame first (non-debug mode)", async () =>
|
|||||||
stdout.chunks.length = 0;
|
stdout.chunks.length = 0;
|
||||||
items.value = ["STATIC_ITEM"];
|
items.value = ["STATIC_ITEM"];
|
||||||
await nextTick();
|
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
|
// Collect everything written during this render cycle
|
||||||
const renderOutput = stdout.chunks.join("");
|
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);
|
||||||
|
});
|
||||||
@@ -170,7 +170,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
|||||||
writer.write(frame);
|
writer.write(frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduler = createCommitScheduler(commit);
|
const scheduler = createCommitScheduler(commit, { immediate: debug });
|
||||||
scheduledCommit = scheduler.schedule;
|
scheduledCommit = scheduler.schedule;
|
||||||
|
|
||||||
// Internal provides — set before the actual mount so components can inject
|
// Internal provides — set before the actual mount so components can inject
|
||||||
|
|||||||
@@ -5,15 +5,33 @@ export interface CommitScheduler {
|
|||||||
flush: () => Promise<void>;
|
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 scheduled = false;
|
||||||
let resolveFlush: (() => void) | null = null;
|
let resolveFlush: (() => void) | null = null;
|
||||||
|
|
||||||
function schedule() {
|
// Throttle state (production only): leading+trailing pattern.
|
||||||
if (scheduled) return;
|
// The leading call fires immediately, subsequent calls within the window
|
||||||
scheduled = true;
|
// are collapsed into a single trailing call at the end of the window.
|
||||||
queuePostFlushCb(() => {
|
let lastCommitTime = 0;
|
||||||
|
let trailingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let hasPending = false;
|
||||||
|
|
||||||
|
function doCommit() {
|
||||||
scheduled = false;
|
scheduled = false;
|
||||||
|
hasPending = false;
|
||||||
|
lastCommitTime = Date.now();
|
||||||
try {
|
try {
|
||||||
commit();
|
commit();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -21,11 +39,42 @@ export function createCommitScheduler(commit: () => void): CommitScheduler {
|
|||||||
resolveFlush = null;
|
resolveFlush = null;
|
||||||
r?.();
|
r?.();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
if (scheduled) return;
|
||||||
|
scheduled = true;
|
||||||
|
queuePostFlushCb(() => {
|
||||||
|
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> {
|
function flush(): Promise<void> {
|
||||||
if (!scheduled) return Promise.resolve();
|
if (!scheduled && !hasPending) return Promise.resolve();
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
resolveFlush = resolve;
|
resolveFlush = resolve;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user