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