qwen-code/packages/web-shell/client/hooks/useAnimationFrameTranscriptBlocks.ts
ytahdn 6bbb273a86
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 1/2 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 2/2 (push) Waiting to run
E2E Tests / channel-plugin E2E (nightly) (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
npm cache producer / Save npm cache (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
Security Checks / Secret scan (TruffleHog) (push) Waiting to run
Security Checks / Dependency CVE audit (push) Waiting to run
perf(web-shell): optimize streaming transcript rendering (#9672)
* perf(web-shell): optimize streaming transcript rendering

* test(web-shell): pin streaming fast paths

---------

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
2026-08-22 06:12:54 +00:00

128 lines
4.3 KiB
TypeScript

import {
useCallback,
useDeferredValue,
useMemo,
useSyncExternalStore,
} from 'react';
import type {
DaemonTranscriptBlock,
DaemonTranscriptBlockChangeSummary,
} from '@qwen-code/sdk/daemon';
import {
useConnection,
useTranscriptStore,
} from '@qwen-code/webui/daemon-react-sdk';
// Cap transcript re-renders at ~20fps. During streaming every network chunk
// notifies the store; each render then runs the O(transcript) normalization
// pass, so rendering at 60fps triples that cost per second while the visible
// text (itself throttled at 80ms for markdown) cannot change that fast. A
// 50ms window is still smooth for streaming text.
const TRANSCRIPT_RENDER_THROTTLE_MS = 50;
const INPUT_QUIET_WINDOW_MS = 100;
const MAX_INPUT_DEFERRAL_MS = 250;
function hasPendingInput(): boolean {
const scheduling = (
navigator as Navigator & {
scheduling?: { isInputPending?: () => boolean };
}
).scheduling;
return scheduling?.isInputPending?.() === true;
}
interface AnimationFrameTranscriptSnapshot {
blocks: readonly DaemonTranscriptBlock[];
blockChangeSummary?: DaemonTranscriptBlockChangeSummary;
}
export function useAnimationFrameTranscriptSnapshot(): AnimationFrameTranscriptSnapshot {
const store = useTranscriptStore();
const { sessionId } = useConnection();
const subscribe = useCallback(
(notify: () => void) => {
let frame: number | null = null;
let lastNotifyTs = Number.NEGATIVE_INFINITY;
let lastInputTs = Number.NEGATIVE_INFINITY;
let pendingSinceTs: number | null = null;
const recordInput = () => {
lastInputTs = performance.now();
};
const dispatchWhenDue = (ts: number) => {
frame = null;
if (
ts - lastNotifyTs >= TRANSCRIPT_RENDER_THROTTLE_MS &&
((ts - lastInputTs >= INPUT_QUIET_WINDOW_MS && !hasPendingInput()) ||
(pendingSinceTs !== null &&
ts - pendingSinceTs >= MAX_INPUT_DEFERRAL_MS))
) {
lastNotifyTs = ts;
pendingSinceTs = null;
notify();
} else {
frame = window.requestAnimationFrame(dispatchWhenDue);
}
};
document.addEventListener('beforeinput', recordInput, true);
const unsubscribe = store.subscribe(() => {
if (frame !== null) return;
pendingSinceTs = performance.now();
frame = window.requestAnimationFrame(dispatchWhenDue);
});
return () => {
unsubscribe();
document.removeEventListener('beforeinput', recordInput, true);
if (frame !== null) {
window.cancelAnimationFrame(frame);
}
};
},
[store],
);
const getSnapshot = useMemo(() => {
let cached:
| {
blocks: readonly DaemonTranscriptBlock[];
blockIndexById: Readonly<Record<string, number>>;
blockChangeSummary: DaemonTranscriptBlockChangeSummary | undefined;
}
| undefined;
return () => {
const state = store.getSnapshot();
const blockChangeSummary = store.getBlockChangeSummary?.();
if (
!cached ||
cached.blocks !== state.blocks ||
cached.blockIndexById !== state.blockIndexById ||
cached.blockChangeSummary !== blockChangeSummary
) {
cached = {
blocks: state.blocks,
blockIndexById: state.blockIndexById,
blockChangeSummary,
};
}
return cached;
};
}, [store]);
const live = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
// Defer transcript re-renders so urgent updates — composer keystrokes,
// button presses — are never queued behind a streaming frame. The deferred
// value catches up on the next idle render, so streaming stays smooth while
// typing stays responsive.
//
// Session and block-index identities ride inside the deferred snapshot. The
// session id rejects a previous session, while the index identity rejects a
// same-session store reset without blocking ordinary streamed text updates.
const snapshot = useMemo(() => ({ sessionId, ...live }), [live, sessionId]);
const deferred = useDeferredValue(snapshot);
const current =
deferred.sessionId === sessionId &&
deferred.blockIndexById === live.blockIndexById
? deferred
: live;
return {
blocks: current.blocks,
blockChangeSummary: current.blockChangeSummary,
};
}