From 6a3537023a7ce92e87a9bb98c1f21c897be7464b Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Fri, 12 Jun 2026 03:21:35 +0800 Subject: [PATCH] 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 --- .agents/docs/ink-divergences.md | 13 +- .../lifecycle/throttle.sequential.test.tsx | 111 ++++++++++++++++++ packages/runtime/src/scheduler.ts | 79 +++++++++---- 3 files changed, 177 insertions(+), 26 deletions(-) diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md index e997768..602f0b8 100644 --- a/.agents/docs/ink-divergences.md +++ b/.agents/docs/ink-divergences.md @@ -588,6 +588,13 @@ mechanics so they are not mistaken for parity gaps. itself. The observable `` literal-`false` edge is documented above as a **model-implied divergence**. - Commit timing is deliberately Ink-aligned: leading+trailing throttle at - `ceil(1000/maxFps)` ms (34ms at the default `maxFps=30`, matching Ink's - `renderThrottleMs`), synchronous resize. This remains true even though re-renders come - from Vue's fine-grained reactivity, not a React subtree re-render. + `Math.max(1, Math.ceil(1000/maxFps))` ms behind a `maxFps > 0` guard (34ms at the + default `maxFps=30` — both engines compute exactly this), synchronous resize. The + scheduler mirrors the observable timing of Ink's es-toolkit throttle (run-verified vs + v7.0.4): the trailing timer re-arms on every deferred call, so the trailing commit + fires at `lastCall+wait` (not `windowStart+wait`), and a call arriving a full window + after the first deferral commits synchronously (es-toolkit's `maxWait`), keeping a + ~`wait` cadence under sustained updates. This remains true even though re-renders come + from Vue's fine-grained reactivity, not a React subtree re-render. One deliberate + exception: resize cancels the pending trailing commit — see the divergence entry + "Resize unconditionally cancels the pending trailing commit". diff --git a/packages/runtime-tests/integration/lifecycle/throttle.sequential.test.tsx b/packages/runtime-tests/integration/lifecycle/throttle.sequential.test.tsx index d872997..545318d 100644 --- a/packages/runtime-tests/integration/lifecycle/throttle.sequential.test.tsx +++ b/packages/runtime-tests/integration/lifecycle/throttle.sequential.test.tsx @@ -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(() => () => {msg.value}); + 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(() => () => {msg.value}); + 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. diff --git a/packages/runtime/src/scheduler.ts b/packages/runtime/src/scheduler.ts index 4d16ba5..513126b 100644 --- a/packages/runtime/src/scheduler.ts +++ b/packages/runtime/src/scheduler.ts @@ -39,17 +39,27 @@ export function createCommitScheduler( for (const resolve of resolvers) resolve(); } - // 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; + // Throttle state (production only). This mirrors the OBSERVABLE timing of + // Ink's render throttle — es-toolkit/compat `throttle(fn, wait, {leading, + // trailing})`, i.e. `debounce(fn, wait, {leading, trailing, maxWait: wait})` + // — not its implementation (verified against real Ink v7.0.4, audit e29): + // - leading: a call with no active trailing window commits synchronously + // and arms the window; + // - trailing: EVERY deferred call re-arms the window, so the trailing + // commit fires at lastCall+wait (NOT windowStart+wait); + // - maxWait: when a call arrives a full window after the first deferral + // (`pendingAt`), it commits synchronously — sustained updates hold a + // ~wait cadence instead of debounce-starving forever. let trailingTimer: ReturnType | null = null; let hasPendingFlag = false; + // Time of the first call since the last leading/trailing commit + // (es-toolkit compat-debounce `pendingAt`); drives the maxWait edge. + let pendingAt: number | null = null; function doCommit() { scheduled = false; hasPendingFlag = false; - lastCommitTime = Date.now(); + pendingAt = null; try { commit(); } finally { @@ -57,6 +67,18 @@ export function createCommitScheduler( } } + // (Re-)arm the trailing window at now+wait. Like es-toolkit's debounce + // `schedule()`, the timer is armed even when nothing is deferred yet (after + // a leading/maxWait commit): an "empty" expiry is a no-op, but while armed + // it marks the window as active so calls inside it defer. + function armTrailingWindow() { + if (trailingTimer) clearTimeout(trailingTimer); + trailingTimer = setTimeout(() => { + trailingTimer = null; + if (hasPendingFlag) doCommit(); + }, throttleMs); + } + function schedule() { if (scheduled) return; scheduled = true; @@ -66,30 +88,38 @@ export function createCommitScheduler( // it — bail here so it doesn't commit on a torn-down tree or re-arm a // trailing timer that nothing will cancel. if (!scheduled) return; + // Reset eagerly (not only in doCommit): a deferred call must leave + // `scheduled` false so the NEXT call re-enters this callback and + // re-arms the trailing window — `lastCall+wait` only works if every + // call reaches the throttle, as every Ink `onRender` call does. + scheduled = false; 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 >= throttleMs) { - // Leading edge: fire immediately - if (trailingTimer) { - clearTimeout(trailingTimer); - trailingTimer = null; - } + const now = Date.now(); + if (pendingAt === null) pendingAt = now; + if (now - pendingAt >= throttleMs) { + // maxWait edge: deferred calls have been pushing the trailing edge + // for a full window — commit now, then re-arm an (empty) window so + // the next call defers instead of double-committing as leading. + // pendingAt is stamped AFTER the commit (es-toolkit does the same), + // so paint time doesn't eat into the next window. doCommit(); - } else { - // Within throttle window: schedule trailing edge + pendingAt = Date.now(); + armTrailingWindow(); + return; + } + const isWindowActive = trailingTimer !== null; + // Ink parity (audit e29): every call re-anchors the trailing edge to + // now+wait, exactly like es-toolkit's debounce re-arming per call. + armTrailingWindow(); + if (isWindowActive) { + // Inside an active window: defer to the trailing edge. hasPendingFlag = true; - if (!trailingTimer) { - trailingTimer = setTimeout(() => { - trailingTimer = null; - if (hasPendingFlag) doCommit(); - }, throttleMs - elapsed); - } + } else { + // Leading edge: no active window — commit synchronously. + doCommit(); } }); } @@ -112,6 +142,9 @@ export function createCommitScheduler( } hasPendingFlag = false; scheduled = false; + // Clean slate: the next schedule() after a cancel commits on the leading + // edge (no live window, no deferral history). + pendingAt = null; // Resolve any waiters blocked on flush() — the pending commit will never // fire now, so leaving them unsettled would hang waitUntilRenderFlush. drainFlushResolvers();