Skip to content

Commit 7807677

Browse files
fix (rsc): Deep clone currentState in getMutableState() (#2821)
1 parent e1063ce commit 7807677

File tree

3 files changed

+152
-1
lines changed

3 files changed

+152
-1
lines changed

.changeset/twelve-dragons-help.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'ai': patch
3+
---
4+
5+
fix (rsc): Deep clone currentState in getMutableState()

packages/ai/rsc/ai-state.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import {
3+
withAIState,
4+
getAIState,
5+
getMutableAIState,
6+
sealMutableAIState,
7+
getAIStateDeltaPromise,
8+
} from './ai-state';
9+
10+
describe('AI State Management', () => {
11+
beforeEach(() => {
12+
vi.resetAllMocks();
13+
});
14+
15+
it('should get the current AI state', () => {
16+
const initialState = { foo: 'bar' };
17+
const result = withAIState({ state: initialState, options: {} }, () => {
18+
return getAIState();
19+
});
20+
expect(result).toEqual(initialState);
21+
});
22+
23+
it('should get a specific key from the AI state', () => {
24+
const initialState = { foo: 'bar', baz: 'qux' };
25+
const result = withAIState({ state: initialState, options: {} }, () => {
26+
return getAIState('foo');
27+
});
28+
expect(result).toBe('bar');
29+
});
30+
31+
it('should update the AI state', () => {
32+
const initialState = { foo: 'bar' };
33+
withAIState({ state: initialState, options: {} }, () => {
34+
const mutableState = getMutableAIState();
35+
mutableState.update({ foo: 'baz' });
36+
expect(getAIState()).toEqual({ foo: 'baz' });
37+
});
38+
});
39+
40+
it('should update a specific key in the AI state', () => {
41+
const initialState = { foo: 'bar', baz: 'qux' };
42+
withAIState({ state: initialState, options: {} }, () => {
43+
const mutableState = getMutableAIState('foo');
44+
mutableState.update('newValue');
45+
expect(getAIState()).toEqual({ foo: 'newValue', baz: 'qux' });
46+
});
47+
});
48+
49+
it('should throw an error when accessing AI state outside of withAIState', () => {
50+
expect(() => getAIState()).toThrow(
51+
'`getAIState` must be called within an AI Action.',
52+
);
53+
});
54+
55+
it('should throw an error when updating AI state after it has been sealed', () => {
56+
withAIState({ state: {}, options: {} }, () => {
57+
sealMutableAIState();
58+
expect(() => getMutableAIState()).toThrow(
59+
'`getMutableAIState` must be called before returning from an AI Action.',
60+
);
61+
});
62+
});
63+
64+
it('should call onSetAIState when updating state', () => {
65+
const onSetAIState = vi.fn();
66+
const initialState = { foo: 'bar' };
67+
withAIState({ state: initialState, options: { onSetAIState } }, () => {
68+
const mutableState = getMutableAIState();
69+
mutableState.update({ foo: 'baz' });
70+
mutableState.done({ foo: 'baz' });
71+
});
72+
expect(onSetAIState).toHaveBeenCalledWith(
73+
expect.objectContaining({
74+
state: { foo: 'baz' },
75+
done: true,
76+
}),
77+
);
78+
});
79+
80+
it('should handle updates with and without key', async () => {
81+
type Message = { role: string; content: string };
82+
83+
type AIState = {
84+
chatId: string;
85+
messages: Array<Message>;
86+
};
87+
88+
const initialState: AIState = {
89+
chatId: '123',
90+
messages: [],
91+
};
92+
93+
await withAIState({ state: initialState, options: {} }, async () => {
94+
// Test with getMutableState()
95+
const stateWithoutKey = getMutableAIState();
96+
97+
stateWithoutKey.update((current: AIState) => ({
98+
...current,
99+
messages: [...current.messages, { role: 'user', content: 'Hello!' }],
100+
}));
101+
102+
stateWithoutKey.done((current: AIState) => ({
103+
...current,
104+
messages: [
105+
...current.messages,
106+
{ role: 'assistant', content: 'Hello! How can I assist you today?' },
107+
],
108+
}));
109+
110+
const deltaWithoutKey = await getAIStateDeltaPromise();
111+
expect(deltaWithoutKey).toBeDefined();
112+
expect(getAIState()).toEqual({
113+
chatId: '123',
114+
messages: [
115+
{ role: 'user', content: 'Hello!' },
116+
{ role: 'assistant', content: 'Hello! How can I assist you today?' },
117+
],
118+
});
119+
});
120+
121+
await withAIState({ state: initialState, options: {} }, async () => {
122+
// Test with getMutableState('messages')
123+
const stateWithKey = getMutableAIState('messages');
124+
125+
stateWithKey.update((current: Array<Message>) => [
126+
...current,
127+
{ role: 'user', content: 'Hello!' },
128+
]);
129+
130+
stateWithKey.done((current: Array<Message>) => [
131+
...current,
132+
{ role: 'assistant', content: 'Hello! How can I assist you today?' },
133+
]);
134+
135+
const deltaWithKey = await getAIStateDeltaPromise();
136+
expect(deltaWithKey).toBeDefined();
137+
expect(getAIState()).toEqual({
138+
chatId: '123',
139+
messages: [
140+
{ role: 'user', content: 'Hello!' },
141+
{ role: 'assistant', content: 'Hello! How can I assist you today?' },
142+
],
143+
});
144+
});
145+
});
146+
});

packages/ai/rsc/ai-state.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export function withAIState<S, T>(
3535
): T {
3636
return asyncAIStateStorage.run(
3737
{
38-
currentState: state,
38+
currentState: JSON.parse(JSON.stringify(state)), // deep clone object
3939
originalState: state,
4040
sealed: false,
4141
options,

0 commit comments

Comments
 (0)