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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user