Aari f090f01806
perf(frontend): coalesce streaming renders to a frame budget instead of per chunk (#4425)
* perf(frontend): coalesce streaming renders to a frame budget instead of per chunk

While a run streams, the merge/group/render pipeline consumed every SSE chunk
as its own React update (~60/s), re-rendering the whole thread tree per token.
Enable the SDK's same-tick batching (throttle: true) and publish the
render-facing messages snapshot at most once per 80 ms with a leading edge and
a trailing flush, keyed through a memoized merge so identities stay stable
between flushes. Lifecycle consumers (optimistic clearing, summarization
capture, usage baselines) keep reading the per-chunk array.

* perf(frontend): keep the transient bridge order array identity stable

mergeTransientHistoryBridgeOrder cloned unconditionally, so the render-time
call handed the coalesced merge memo a fresh array identity on every render
while the transient history bridge was open, re-running mergeMessages between
flushes. Clone lazily and return the input order when nothing is appended; the
merge only ever appends, so an unchanged length means unchanged content.

Consumers only read the returned order, so reusing the input is safe.

* perf(frontend): drive the render coalescer from a monotonic clock

The coalescing interval was measured with Date.now(). A backward wall-clock
step (NTP correction, sleep/wake) turns the elapsed term negative, so the
scheduled delay becomes interval + jump and the rendered snapshot stalls for
the length of the jump. Read performance.now() once per effect invocation
instead; the timer callback re-reads it because timers fire late and the next
interval must start from the real flush.

Seed the last-flush marker with -Infinity so the first update of a stream
still takes the leading edge under a page-load-relative clock.

* perf(frontend): reset the coalescer flush baseline when a stream ends

The leading-edge flush was scoped to the hook instance rather than to each
stream: a run starting within one interval of the previous one found a recent
flush baseline and deferred its first frame. Drop the baseline when leaving
the streaming state so every stream opens on the leading edge.

* perf(frontend): disarm the trailing flush when the leading edge wins

decideCoalesce checks the elapsed interval before the pending-timer flag, so
an update arriving past the interval takes the leading edge while a trailing
timer is still armed. Timers fire late under main-thread load -- exactly the
regime this coalescer targets -- so that timer then publishes a second time
and slips the flush baseline forward, breaking the at-most-one-flush-per-
interval property when it matters most.

Disarm the pending timer in the flush-now branch, and cover the previously
untested elapsed >= interval && hasPendingTimer quadrant.

* perf(frontend): stop syncing the render snapshot while idle

The snapshot is only read while streaming, so keeping it current on every
idle messages change costs one wasted render per history refetch or thread
navigation. Dropping that publish outright is not safe either: the leading
edge runs in a passive effect, but the render where isStreaming flips true
paints first and returns the snapshot, so a stale one would be painted --
after a thread switch, another thread's messages, since the chat page
deliberately avoids re-mounting on navigation.

Make the snapshot nullable, where null means no snapshot belongs to the
current stream, and return the live array while it is null. The idle branch
then writes state once per stream end instead of once per idle update, and
the stale-frame window does not exist rather than being short.
2026-07-26 08:19:13 +08:00

98 lines
3.5 KiB
TypeScript

import { describe, expect, it } from "@rstest/core";
import {
decideCoalesce,
STREAM_RENDER_COALESCE_MS,
} from "@/core/threads/hooks";
describe("decideCoalesce", () => {
it("flushes immediately once a full interval has elapsed (leading edge)", () => {
expect(decideCoalesce(1000, 900, 80, false)).toEqual({
action: "flush-now",
});
expect(decideCoalesce(1000, 920, 80, false)).toEqual({
action: "flush-now",
});
});
it("schedules exactly one trailing flush for the interval remainder", () => {
expect(decideCoalesce(1000, 950, 80, false)).toEqual({
action: "schedule",
delayMs: 30,
});
});
it("waits while a trailing flush is already pending", () => {
expect(decideCoalesce(1000, 950, 80, true)).toEqual({ action: "wait" });
});
it("still takes the leading edge when a late trailing flush is pending", () => {
// Timer callbacks queue behind long tasks, so an update can arrive past the
// interval while the trailing flush is still armed. The decision is
// flush-now, which obliges the call site to disarm that timer — otherwise
// it publishes a second time and slips the next interval forward.
expect(decideCoalesce(1000, 900, 80, true)).toEqual({
action: "flush-now",
});
});
it("never delays a flush beyond the interval, unlike a debounce", () => {
// Simulate a dense stream: updates every 10ms. A debounce would keep
// resetting its timer and never fire; here the trailing flush scheduled at
// the first update stays put and every later update just waits on it.
let lastFlush = 0;
let pendingUntil: number | null = null;
const flushes: number[] = [];
for (let now = 10; now <= 400; now += 10) {
if (pendingUntil !== null && now >= pendingUntil) {
flushes.push(pendingUntil);
lastFlush = pendingUntil;
pendingUntil = null;
}
const decision = decideCoalesce(
now,
lastFlush,
80,
pendingUntil !== null,
);
if (decision.action === "flush-now") {
flushes.push(now);
lastFlush = now;
} else if (decision.action === "schedule") {
pendingUntil = now + decision.delayMs;
}
}
expect(flushes.length).toBeGreaterThanOrEqual(4);
for (let i = 1; i < flushes.length; i++) {
const gap = flushes[i]! - flushes[i - 1]!;
expect(gap).toBeGreaterThanOrEqual(80);
expect(gap).toBeLessThanOrEqual(90);
}
});
it("takes the leading edge when nothing has flushed yet", () => {
// The hook seeds lastFlush with -Infinity on a monotonic clock whose epoch
// is page load, so the first update of a stream must not be deferred.
expect(decideCoalesce(5, Number.NEGATIVE_INFINITY, 80, false)).toEqual({
action: "flush-now",
});
});
it("keeps the scheduled delay inside the interval", () => {
// Holds for every elapsed value a monotonic clock can produce, which is why
// the call site needs no clamp on the timeout delay.
for (let elapsed = 0; elapsed < 80; elapsed++) {
const decision = decideCoalesce(1000, 1000 - elapsed, 80, false);
expect(decision.action).toBe("schedule");
if (decision.action !== "schedule") continue;
expect(decision.delayMs).toBeGreaterThan(0);
expect(decision.delayMs).toBeLessThanOrEqual(80);
}
});
it("exports a frame-scale default interval", () => {
expect(STREAM_RENDER_COALESCE_MS).toBeGreaterThanOrEqual(50);
expect(STREAM_RENDER_COALESCE_MS).toBeLessThanOrEqual(100);
});
});