fix(runtime): re-arm trailing commit per deferred call to match Ink's throttle anchor (#154)

The commit scheduler armed one fixed trailing timer at the start of a
throttle window, firing the trailing commit at windowStart+wait. Ink's
es-toolkit throttle re-arms on every throttled call: trailing fires at
lastCall+wait. Deterministic probe at maxFps=10 (updates t0/t0+43/
t0+86): Ink trailing median 192.5ms, vue-tui 103.6ms (audit e29).

Mirror the observable timing of es-toolkit's throttle: leading commit
when no window is active, per-call trailing re-arm (lastCall+wait), and
the maxWait edge (a call a full window after the first deferral commits
synchronously) so sustained updates keep the ~wait cadence instead of
debounce-starving. Resize cancellation (a separate blessed divergence)
is preserved: the post-fix cancel probe is byte-identical.

Red test is the discriminating multi-deferred-call shape: a single-
deferred-call test goes green under the wrong firstDeferredCall anchor.
Post-fix probes land at 188.3-189.2ms, inside Ink's 188.4-195.0 band;
CI=true vp run ci passes alongside vp run ready.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-12 03:21:35 +08:00
committed by GitHub
parent c66cddb676
commit 6a3537023a
3 changed files with 177 additions and 26 deletions
@@ -79,6 +79,117 @@ test.sequential("throttle renders to maxFps", async () => {
}
});
// Audit e29 (verified vs real Ink v7.0.4): Ink's render throttle is
// es-toolkit/compat `throttle(fn, wait, {leading, trailing})`, i.e.
// `debounce(fn, wait, {leading, trailing, maxWait: wait})`, whose trailing
// timer is RE-ARMED on every call — the trailing commit fires at
// lastCall+wait. A window-anchored timer (windowStart+wait) fires a full
// window early; a first-deferred-call anchor (firstDeferred+wait) is also
// wrong, and only this multi-deferred-call shape discriminates it: with
// wait=1000ms and calls at t0 / t0+400 / t0+800, the three anchors predict
// t0+1000 / t0+1400 / t0+1800 respectively.
test.sequential("trailing commit fires at lastCall+wait, re-armed per deferred call", async () => {
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const msg = shallowRef("v0");
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 }); // wait = 1000ms
await nextTick();
await nextTick();
// Let the mount-time throttle window fully expire so "vA" starts idle.
vi.advanceTimersByTime(2000);
const has = (s: string) => writes.some((w) => w.includes(s));
// t0: leading edge — commits synchronously.
msg.value = "vA";
await nextTick();
await nextTick();
expect(has("vA")).toBe(true);
// t0+400: deferred call #1 (inside the window).
vi.advanceTimersByTime(400);
msg.value = "vB";
await nextTick();
await nextTick();
// t0+800: deferred call #2 — the LAST call; silence afterwards.
vi.advanceTimersByTime(400);
msg.value = "vC";
await nextTick();
await nextTick();
// t0+1799: both wrong anchors (t0+1000 window, t0+1400 first-deferred)
// would have committed by now — Ink's lastCall+wait anchor has not.
vi.advanceTimersByTime(999);
expect(has("vC")).toBe(false);
// t0+1800 = lastCall (t0+800) + wait (1000): the trailing commit fires,
// and the intermediate "vB" collapsed into it (Ink-identical).
vi.advanceTimersByTime(1);
expect(has("vC")).toBe(true);
expect(has("vB")).toBe(false);
app.unmount();
} finally {
vi.useRealTimers();
}
});
test.sequential("sustained deferred calls hold a ~wait cadence (maxWait edge)", async () => {
// Re-arming the trailing timer per call must NOT turn the throttle into a
// debounce that starves forever: es-toolkit's maxWait (= wait) commits
// synchronously when a call arrives a full window after the first deferral.
// Shape verified vs real Ink v7.0.4 (audit e29 sustained-burst cadence):
// calls every 100ms at wait=1000ms — leading at call 1 (t0+100), call 2
// (t0+200) starts the deferral window, so the calls at t0+1200 (k=12) and
// t0+2200 (k=22) hit the maxWait edge and commit; the burst tail lands as
// a trailing commit at lastCall+wait (t0+3500).
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const msg = shallowRef("v0.");
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 }); // wait = 1000ms
await nextTick();
await nextTick();
vi.advanceTimersByTime(2000);
const has = (s: string) => writes.some((w) => w.includes(s));
for (let k = 1; k <= 25; k++) {
vi.advanceTimersByTime(100);
msg.value = `v${k}.`;
await nextTick();
await nextTick();
}
expect(has("v1.")).toBe(true); // leading
expect(has("v12.")).toBe(true); // maxWait edge, one wait after first deferral
expect(has("v22.")).toBe(true); // next maxWait edge
// The burst tail (last call t0+2500) is still pending...
expect(has("v25.")).toBe(false);
// ...and lands at lastCall+wait.
vi.advanceTimersByTime(1000);
expect(has("v25.")).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.