diff --git a/apps/web/e2e/chat-composer-performance.spec.ts b/apps/web/e2e/chat-composer-performance.spec.ts new file mode 100644 index 000000000..a300a1aa7 --- /dev/null +++ b/apps/web/e2e/chat-composer-performance.spec.ts @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { expect, test, type Page } from '@playwright/test'; + +type InputMode = 'direct' | 'composition'; + +async function measureInputToPaint(page: Page, mode: InputMode) { + return page + .locator('textarea[name="agent-message"]') + .evaluate(async (textarea, inputMode) => { + const input = textarea as HTMLTextAreaElement; + const valueSetter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + 'value', + )?.set; + if (!valueSetter) throw new Error('textarea value setter unavailable'); + + const samples: number[] = []; + for (let index = 0; index < 12; index++) { + const nextCharacter = inputMode === 'composition' ? '你' : 'a'; + const duration = await new Promise((resolve) => { + const start = performance.now(); + if (inputMode === 'composition') { + input.dispatchEvent( + new CompositionEvent('compositionstart', { bubbles: true }), + ); + } + valueSetter.call(input, input.value + nextCharacter); + input.dispatchEvent( + new InputEvent('input', { + bubbles: true, + data: nextCharacter, + inputType: + inputMode === 'composition' + ? 'insertCompositionText' + : 'insertText', + isComposing: inputMode === 'composition', + }), + ); + if (inputMode === 'composition') { + input.dispatchEvent( + new CompositionEvent('compositionend', { + bubbles: true, + data: nextCharacter, + }), + ); + } + requestAnimationFrame(() => resolve(performance.now() - start)); + }); + samples.push(duration); + await new Promise((resolve) => + requestAnimationFrame(() => resolve()), + ); + } + + samples.sort((left, right) => left - right); + return { + median: samples[Math.floor(samples.length / 2)] ?? 0, + p95: samples[Math.ceil(samples.length * 0.95) - 1] ?? 0, + }; + }, mode); +} + +async function openFixture(page: Page, messageCount: number) { + await page.goto(`/playground/chat-performance?messages=${messageCount}`); + await expect( + page.locator(`[data-chat-performance-fixture="${messageCount}"]`), + ).toBeVisible(); + await expect(page.locator('[data-chat-user-message]')).toHaveCount( + Math.ceil(messageCount / 2), + ); + await expect(page.locator('textarea[name="agent-message"]')).toBeEditable(); + await page.waitForTimeout(500); +} + +test('input-to-paint remains stable with 200 historical messages', async ({ + page, +}) => { + await openFixture(page, 0); + const freshDirect = await measureInputToPaint(page, 'direct'); + await openFixture(page, 0); + const freshComposition = await measureInputToPaint(page, 'composition'); + + await openFixture(page, 200); + const longDirect = await measureInputToPaint(page, 'direct'); + await openFixture(page, 200); + const longComposition = await measureInputToPaint(page, 'composition'); + + console.log({ + freshDirect, + longDirect, + freshComposition, + longComposition, + }); + + expect(longDirect.p95 - freshDirect.p95).toBeLessThanOrEqual(8); + expect(longComposition.p95 - freshComposition.p95).toBeLessThanOrEqual(8); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0b74b4b9f..32ed0e691 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -58,6 +58,14 @@ const playgroundRoutes = import.meta.env.DEV .default, }), }, + { + path: '/playground/chat-performance', + lazy: async () => ({ + Component: ( + await import('./pages/playground/ChatPerformancePlaygroundPage') + ).default, + }), + }, { path: '/playground/agent-nodes', lazy: async () => ({ diff --git a/apps/web/src/components/Messages/AIMessage/MilkdownMessageCard.tsx b/apps/web/src/components/Messages/AIMessage/MilkdownMessageCard.tsx index 5747c1001..52371f9c0 100644 --- a/apps/web/src/components/Messages/AIMessage/MilkdownMessageCard.tsx +++ b/apps/web/src/components/Messages/AIMessage/MilkdownMessageCard.tsx @@ -5,6 +5,8 @@ * Milkdown-backed renderer for AI chat messages. */ +import { memo } from 'react'; + import { parseArtifactUrl } from '@huabu/shared'; import { MilkdownPreview } from '@/components/Milkdown'; @@ -13,7 +15,6 @@ import { setDragPayload } from '@/utils/io/dragDrop'; import type { ImageDragPayload, NoteDragPayload } from '@/utils/io/dragDrop'; import type { NodeOrigin } from '@huabu/shared'; -import type { FC } from 'react'; interface MilkdownMessageCardProps { content: string; @@ -87,10 +88,10 @@ export function buildImageDragPayload( }; } -export const MilkdownMessageCard: FC = ({ +export const MilkdownMessageCard = memo(function MilkdownMessageCard({ content, threadId, -}) => { +}: MilkdownMessageCardProps) { const canvasId = useCanvasStore((s) => s.canvasId); return ( @@ -111,4 +112,4 @@ export const MilkdownMessageCard: FC = ({ }} /> ); -}; +}); diff --git a/apps/web/src/components/Messages/AIMessage/index.tsx b/apps/web/src/components/Messages/AIMessage/index.tsx index 9178a425f..5b42a0fed 100644 --- a/apps/web/src/components/Messages/AIMessage/index.tsx +++ b/apps/web/src/components/Messages/AIMessage/index.tsx @@ -2,6 +2,7 @@ // Licensed under the MIT license. import { Copy } from 'lucide-react'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { groupByThinkingPhase, type SegmentGroup } from './groupParts'; @@ -106,12 +107,12 @@ function renderToolGroup( } } -export const AIMessage = ({ +export const AIMessage = memo(function AIMessage({ messageId, segments, isStreaming, hideActions, -}: AIMessageProps) => { +}: AIMessageProps) { const { t } = useTranslation(); const addNode = useCanvasStore((state) => state.addNode); const { threadId } = useChatSession(); @@ -282,4 +283,4 @@ export const AIMessage = ({ ); -}; +}); diff --git a/apps/web/src/components/Messages/MessageList.test.tsx b/apps/web/src/components/Messages/MessageList.test.tsx index a7bd2209e..54a28423f 100644 --- a/apps/web/src/components/Messages/MessageList.test.tsx +++ b/apps/web/src/components/Messages/MessageList.test.tsx @@ -1,41 +1,183 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { act } from 'react'; +import { act, useState } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MessageList } from './MessageList'; import { rememberMessageListScrollPosition } from './messageListScroll'; -vi.mock('./UserMessage', () => ({ - UserMessage: ({ content }: { content: string }) =>
{content}
, +import type { ChatMessage } from '../../store/chatTypes'; + +const renderCounts = vi.hoisted(() => ({ + assistant: new Map(), + user: 0, })); -vi.mock('./AIMessage', () => ({ AIMessage: () =>
})); -vi.mock('./StatusMessage', () => ({ StatusMessage: () =>
})); -vi.mock('../Common/Loading', () => ({ Loading: () => null })); + +vi.mock('./AIMessage', async () => { + const { memo } = await import('react'); + return { + AIMessage: memo(function MockAIMessage({ + messageId, + }: { + messageId: string; + }) { + renderCounts.assistant.set( + messageId, + (renderCounts.assistant.get(messageId) ?? 0) + 1, + ); + return
; + }), + }; +}); + +vi.mock('./UserMessage', async () => { + const { memo } = await import('react'); + return { + UserMessage: memo(function MockUserMessage() { + renderCounts.user++; + return
; + }), + }; +}); + +vi.mock('./StatusMessage', () => ({ + StatusMessage: () =>
, +})); + +vi.mock('../Common/Button', () => ({ + Button: ({ children }: { children: React.ReactNode }) => ( + + ), +})); + +vi.mock('../Common/Loading', () => ({ + Loading: () =>
, +})); + vi.mock('../Common/ThinkingIndicator', () => ({ - ThinkingIndicator: () => null, + ThinkingIndicator: () =>
, })); let root: Root | undefined; -let host: HTMLDivElement | undefined; +let container: HTMLDivElement | undefined; -afterEach(async () => { - await act(async () => root?.unmount()); - host?.remove(); +function mount(element: React.ReactNode): void { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => root?.render(element)); +} + +beforeEach(() => { + renderCounts.assistant.clear(); + renderCounts.user = 0; + vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(() => {}); +}); + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); root = undefined; - host = undefined; + container = undefined; + vi.restoreAllMocks(); +}); + +describe('MessageList render isolation', () => { + it('does not rerender historical messages when a sibling draft changes', () => { + const messages: ChatMessage[] = Array.from( + { length: 100 }, + (_, index): ChatMessage[] => [ + { + id: `user-${index}`, + role: 'user', + content: `Question ${index}`, + }, + { + id: `assistant-${index}`, + role: 'assistant', + segments: [ + { + kind: 'text', + text: `## Answer ${index}\n\nA representative Markdown response.`, + }, + ], + }, + ], + ).flat(); + + function Harness() { + const [draft, setDraft] = useState(''); + return ( + <> +