perf(chat): debounce persisted writes and re-render only the streaming message
- the Zustand persist middleware serialized every saved session and wrote to AsyncStorage on each streamed token (appendToMessage -> set); writes are now debounced (400 ms) and flushed when the app leaves the foreground - every token re-rendered every MessageBubble, re-parsing the Markdown of the whole conversation; rows are memoized so only the changed message renders (covered by MessageList.test.tsx)
This commit is contained in:
1 parent
200fb6afd7
commit
0e5b38fd15
6 files changed
+252
-13
No files matched your search
@@ -0,0 +1,78 @@
|
||||
import { render, screen } from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
import { MessageList } from '../components/chat/MessageList';
|
||||
import { ChatMessage } from '../store/useAppStore';
|
||||
|
||||
jest.mock('react-native-reanimated', () => {
|
||||
const { View } = require('react-native');
|
||||
const chain: any = new Proxy(() => chain, { get: () => chain });
|
||||
return { __esModule: true, default: { View }, FadeInDown: chain };
|
||||
});
|
||||
|
||||
const mockRenders: Record<string, number> = {};
|
||||
jest.mock('../components/chat/MessageBubble', () => {
|
||||
const { Text } = require('react-native');
|
||||
return {
|
||||
MessageBubble: ({ message }: any) => {
|
||||
mockRenders[message.id] = (mockRenders[message.id] ?? 0) + 1;
|
||||
return <Text>{`${message.id}:${message.content}`}</Text>;
|
||||
},
|
||||
};
|
||||
});
|
||||
jest.mock('../components/chat/TypingIndicator', () => {
|
||||
const { Text } = require('react-native');
|
||||
return { TypingIndicator: () => <Text>typing-indicator</Text> };
|
||||
});
|
||||
|
||||
const msg = (id: string, role: ChatMessage['role'], content: string): ChatMessage => ({
|
||||
id,
|
||||
role,
|
||||
content,
|
||||
timestamp: 0,
|
||||
});
|
||||
|
||||
const handlers = { onEdit: jest.fn(), onDelete: jest.fn(), onRegenerate: jest.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
Object.keys(mockRenders).forEach((k) => delete mockRenders[k]);
|
||||
});
|
||||
|
||||
describe('MessageList', () => {
|
||||
it('renders every message', () => {
|
||||
render(
|
||||
<MessageList
|
||||
messages={[msg('u1', 'user', 'q'), msg('a1', 'assistant', 'a')]}
|
||||
isStreaming={false}
|
||||
{...handlers}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('u1:q')).toBeTruthy();
|
||||
expect(screen.getByText('a1:a')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('re-renders only the message that changed while an answer streams in', () => {
|
||||
const user = msg('u1', 'user', 'question');
|
||||
const earlier = msg('a0', 'assistant', 'earlier answer');
|
||||
const streaming = msg('a1', 'assistant', 'He');
|
||||
|
||||
const { rerender } = render(
|
||||
<MessageList messages={[user, earlier, streaming]} isStreaming {...handlers} />,
|
||||
);
|
||||
expect(mockRenders).toEqual({ u1: 1, a0: 1, a1: 1 });
|
||||
|
||||
rerender(
|
||||
<MessageList messages={[user, earlier, { ...streaming, content: 'Hello' }]} isStreaming {...handlers} />,
|
||||
);
|
||||
expect(screen.getByText('a1:Hello')).toBeTruthy();
|
||||
expect(mockRenders.u1).toBe(1);
|
||||
expect(mockRenders.a0).toBe(1);
|
||||
expect(mockRenders.a1).toBe(2);
|
||||
});
|
||||
|
||||
it('shows the typing indicator while the last assistant message is still empty', () => {
|
||||
render(
|
||||
<MessageList messages={[msg('u1', 'user', 'q'), msg('a1', 'assistant', '')]} isStreaming {...handlers} />,
|
||||
);
|
||||
expect(screen.getByText('typing-indicator')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user