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:
evandro committed 2026-10-10 04:28:38 -03:00
1 parent 200fb6afd7
commit 0e5b38fd15
6 files changed
+252 -13

No files matched your search

+78
View File
@@ -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();
});
});
+72
View File
@@ -0,0 +1,72 @@
import { createDebouncedStorage } from '../store/debouncedStorage';
const makeBase = () => ({
getItem: jest.fn(async (_name: string): Promise<string | null> => 'from-disk'),
setItem: jest.fn(async (_name: string, _value: string) => {}),
removeItem: jest.fn(async (_name: string) => {}),
});
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
describe('createDebouncedStorage', () => {
it('collapses a burst of writes into one trailing write with the latest value', () => {
const base = makeBase();
const storage = createDebouncedStorage(base, 400);
storage.setItem('k', '1');
jest.advanceTimersByTime(300);
storage.setItem('k', '2');
jest.advanceTimersByTime(300);
storage.setItem('k', '3');
expect(base.setItem).not.toHaveBeenCalled();
jest.advanceTimersByTime(400);
expect(base.setItem).toHaveBeenCalledTimes(1);
expect(base.setItem).toHaveBeenCalledWith('k', '3');
});
it('reads a pending value before it reaches the disk, and falls back to the base storage', async () => {
const base = makeBase();
const storage = createDebouncedStorage(base);
await expect(storage.getItem('k')).resolves.toBe('from-disk');
storage.setItem('k', 'fresh');
await expect(storage.getItem('k')).resolves.toBe('fresh');
});
it('flush writes immediately and cancels the timer', async () => {
const base = makeBase();
const storage = createDebouncedStorage(base);
storage.setItem('a', '1');
storage.setItem('b', '2');
await storage.flush();
expect(base.setItem.mock.calls).toEqual([['a', '1'], ['b', '2']]);
jest.advanceTimersByTime(1000);
expect(base.setItem).toHaveBeenCalledTimes(2);
});
it('flush with nothing pending does not write', async () => {
const base = makeBase();
await createDebouncedStorage(base).flush();
expect(base.setItem).not.toHaveBeenCalled();
});
it('removeItem drops a pending write and removes from the base storage', async () => {
const base = makeBase();
const storage = createDebouncedStorage(base);
storage.setItem('k', '1');
await storage.removeItem('k');
jest.advanceTimersByTime(1000);
expect(base.setItem).not.toHaveBeenCalled();
expect(base.removeItem).toHaveBeenCalledWith('k');
});
});
+2 -1
View File
@@ -1,4 +1,5 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { appStorage } from '../store/debouncedStorage';
import { useAppStore } from '../store/useAppStore';
const state = () => useAppStore.getState();
@@ -172,7 +173,7 @@ describe('persistence', () => {
});
state().addMessage({ role: 'user', content: 'persist me' });
await new Promise((resolve) => setTimeout(resolve, 0));
await appStorage.flush();
const raw = await AsyncStorage.getItem('localaiapp-storage');
const persisted = JSON.parse(raw as string).state;