Skip to content

Commit

Permalink
fix (react): infinite re-render caused by fillMessageParts (#5013)
Browse files Browse the repository at this point in the history
  • Loading branch information
iteratetograceness authored Mar 1, 2025
1 parent 9594b97 commit da5c734
Show file tree
Hide file tree
Showing 3 changed files with 91 additions and 9 deletions.
5 changes: 5 additions & 0 deletions .changeset/tricky-walls-poke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ai-sdk/react': patch
---

fix (react): infinite re-render caused by fillMessageParts
16 changes: 8 additions & 8 deletions packages/react/src/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
shouldResubmitMessages,
updateToolCallResult,
} from '@ai-sdk/ui-utils';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import useSWR from 'swr';
import { throttle } from './throttle';

Expand Down Expand Up @@ -178,20 +178,20 @@ By default, it's set to 1, which means that only a single LLM call is made.
const chatId = id ?? hookId;
const chatKey = typeof api === 'string' ? [api, chatId] : chatId;

// Store a empty array as the initial messages
// (instead of using a default parameter value that gets re-created each time)
// Store array of the initial messages
// (processed with messaged parts if applicable)
// instead of using a default parameter value that gets re-created each time
// to avoid re-renders:
const [initialMessagesFallback] = useState([]);
const [initialMessagesFallback] = useState(
initialMessages != null ? fillMessageParts(initialMessages) : [],
);

// Store the chat state in SWR, using the chatId as the key to share states.
const { data: messages, mutate } = useSWR<UIMessage[]>(
[chatKey, 'messages'],
null,
{
fallbackData:
initialMessages != null
? fillMessageParts(initialMessages)
: initialMessagesFallback,
fallbackData: initialMessagesFallback,
},
);

Expand Down
79 changes: 78 additions & 1 deletion packages/react/src/use-chat.ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import '@testing-library/jest-dom/vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useChat } from './use-chat';

describe('data protocol stream', () => {
Expand Down Expand Up @@ -1888,3 +1888,80 @@ describe('test sending additional fields during message submission', () => {
),
);
});

describe('initialMessages stability', () => {
let renderCount = 0;

const TestComponent = () => {
renderCount++;
const [derivedState, setDerivedState] = useState<string[]>([]);

const { messages } = useChat({
api: '/api/chat',
id: 'test-stability',
initialMessages: [
{
id: 'test-msg-1',
content: 'Test message',
role: 'user',
},
{
id: 'test-msg-2',
content: 'Test response',
role: 'assistant',
},
],
});

useEffect(() => {
setDerivedState(messages.map(m => m.content));
}, [messages]);

if (renderCount > 10) {
throw new Error('Excessive renders detected; likely an infinite loop!');
}

return (
<div>
<div data-testid="render-count">{renderCount}</div>
<div data-testid="derived-state">{derivedState.join(', ')}</div>
{messages.map(m => (
<div key={m.id} data-testid={`message-${m.role}`}>
{m.content}
</div>
))}
</div>
);
};

beforeEach(() => {
renderCount = 0;
render(<TestComponent />);
});

afterEach(() => {
cleanup();
});

it('should not cause infinite rerenders when initialMessages is defined and messages is a dependency of useEffect', async () => {
// wait for initial render to complete
await waitFor(() => {
expect(screen.getByTestId('message-user')).toHaveTextContent(
'Test message',
);
});

// confirm useEffect ran
await waitFor(() => {
expect(screen.getByTestId('derived-state')).toHaveTextContent(
'Test message, Test response',
);
});

const renderCount = parseInt(
screen.getByTestId('render-count').textContent!,
);

expect(renderCount).toBe(2);
});
});

0 comments on commit da5c734

Please sign in to comment.