test(frontend): cover streaming markdown scheduling

This commit is contained in:
Dixing Xu 2026-07-10 18:36:19 +01:00
parent 9f8960f222
commit 1b5039e35a
4 changed files with 108 additions and 16 deletions

View file

@ -35,6 +35,7 @@
import { user } from '$lib/stores';
import MarkdownTokens from './Markdown/MarkdownTokens.svelte';
import { createMarkdownUpdateScheduler } from './markdown-update-scheduler';
export let id = '';
export let content;
@ -59,7 +60,6 @@
export let onTaskClick = () => {};
let tokens = [];
let pendingUpdate = null;
let lastContent = '';
let lastParsedContent = '';
@ -74,21 +74,12 @@
tokens = marked.lexer(processed);
};
const updateHandler = (content) => {
if (content && !pendingUpdate) {
pendingUpdate = requestAnimationFrame(() => {
pendingUpdate = null;
parseTokens();
});
}
};
const updateScheduler = createMarkdownUpdateScheduler(parseTokens);
$: updateHandler(content);
$: updateScheduler.update(content);
// Throttle parsing to once per animation frame while streaming
onDestroy(() => {
cancelAnimationFrame(pendingUpdate);
});
onDestroy(updateScheduler.cancel);
</script>
{#key id}

View file

@ -65,6 +65,7 @@
import FullHeightIframe from '$lib/components/common/FullHeightIframe.svelte';
import OutputEditView from './OutputEditView.svelte';
import { getOutputText, replaceOutputMessageText, type OutputItem } from './structuredOutput';
import { resolveMarkdownDone } from './markdown-update-scheduler';
interface MessageType {
id: string;
@ -839,9 +840,7 @@
preview={!readOnly}
{editCodeBlock}
{topPadding}
done={($settings?.chatFadeStreamingText ?? true)
? (message?.done ?? false)
: true}
done={resolveMarkdownDone($settings?.chatFadeStreamingText, message?.done)}
{model}
onTaskClick={async (e) => {
console.log(e);

View file

@ -0,0 +1,67 @@
import { describe, expect, it, vi } from 'vitest';
import { createMarkdownUpdateScheduler, resolveMarkdownDone } from './markdown-update-scheduler';
describe('createMarkdownUpdateScheduler', () => {
it('coalesces the fade-disabled done path onto the pending frame', () => {
let content = '';
let done = resolveMarkdownDone(false, false);
const parsed: Array<{ content: string; done: boolean }> = [];
const frames: FrameRequestCallback[] = [];
const requestFrame = vi.fn((callback: FrameRequestCallback) => {
frames.push(callback);
return frames.length;
});
const scheduler = createMarkdownUpdateScheduler(
() => parsed.push({ content, done }),
requestFrame,
vi.fn()
);
scheduler.update(content);
expect(requestFrame).not.toHaveBeenCalled();
content = 'partial';
scheduler.update(content);
content = 'final';
done = resolveMarkdownDone(false, true);
scheduler.update(content);
expect(requestFrame).toHaveBeenCalledTimes(1);
expect(parsed).toEqual([]);
frames[0](16);
expect(parsed).toEqual([{ content: 'final', done: true }]);
});
it('cancels a pending frame on teardown', () => {
const cancelFrame = vi.fn();
const scheduler = createMarkdownUpdateScheduler(
vi.fn(),
vi.fn(() => 42),
cancelFrame
);
scheduler.update('streaming');
scheduler.cancel();
scheduler.cancel();
expect(cancelFrame).toHaveBeenCalledOnce();
expect(cancelFrame).toHaveBeenCalledWith(42);
});
});
describe('resolveMarkdownDone', () => {
it.each([
{ fadeStreamingText: false, messageDone: false, expected: true },
{ fadeStreamingText: false, messageDone: true, expected: true },
{ fadeStreamingText: true, messageDone: false, expected: false },
{ fadeStreamingText: true, messageDone: true, expected: true },
{ fadeStreamingText: undefined, messageDone: false, expected: false }
])(
'returns $expected when fadeStreamingText=$fadeStreamingText and messageDone=$messageDone',
({ fadeStreamingText, messageDone, expected }) => {
expect(resolveMarkdownDone(fadeStreamingText, messageDone)).toBe(expected);
}
);
});

View file

@ -0,0 +1,35 @@
type RequestFrame = (callback: FrameRequestCallback) => number;
type CancelFrame = (handle: number) => void;
const requestBrowserFrame: RequestFrame = (callback) => requestAnimationFrame(callback);
const cancelBrowserFrame: CancelFrame = (handle) => cancelAnimationFrame(handle);
export const resolveMarkdownDone = (
fadeStreamingText: boolean | null | undefined,
messageDone: boolean | null | undefined
) => ((fadeStreamingText ?? true) ? (messageDone ?? false) : true);
export const createMarkdownUpdateScheduler = (
parseTokens: () => void,
requestFrame: RequestFrame = requestBrowserFrame,
cancelFrame: CancelFrame = cancelBrowserFrame
) => {
let pendingUpdate: number | null = null;
return {
update(content: unknown) {
if (!content || pendingUpdate !== null) return;
pendingUpdate = requestFrame(() => {
pendingUpdate = null;
parseTokens();
});
},
cancel() {
if (pendingUpdate === null) return;
cancelFrame(pendingUpdate);
pendingUpdate = null;
}
};
};