Skip to content

Commit e5af3b1

Browse files
committed
Resolve race condition when aiState.done is called before onSetAIState - prevent the UI from refreshing before the db is updated
1 parent b38c49d commit e5af3b1

File tree

5 files changed

+124
-45
lines changed

5 files changed

+124
-45
lines changed

components/chat.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
'use client'
22

3+
import type { AI } from '@/lib/chat/actions'
4+
35
import { cn } from '@/lib/utils'
46
import { ChatList } from '@/components/chat-list'
57
import { ChatPanel } from '@/components/chat-panel'
@@ -24,7 +26,7 @@ export function Chat({ id, className, session, missingKeys }: ChatProps) {
2426
const path = usePathname()
2527
const [input, setInput] = useState('')
2628
const [messages] = useUIState()
27-
const [aiState] = useAIState()
29+
const [aiState] = useAIState<typeof AI>()
2830

2931
const [_, setNewChatId] = useLocalStorage('newChatId', id)
3032

@@ -40,6 +42,8 @@ export function Chat({ id, className, session, missingKeys }: ChatProps) {
4042
const messagesLength = aiState.messages?.length
4143
if (messagesLength === 2) {
4244
router.refresh()
45+
} else if (messagesLength === 3 && aiState.messages[2].role === 'tool') {
46+
router.refresh()
4347
}
4448
}, [aiState.messages, router])
4549

components/stocks/stock-purchase.tsx

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,26 @@
11
'use client'
22

3-
import { useId, useState } from 'react'
3+
import { useEffect, useId, useState } from 'react';
44
import { useActions, useAIState, useUIState } from 'ai/rsc'
5-
import { formatNumber } from '@/lib/utils'
5+
import { formatNumber, unixTsNow } from '@/lib/utils'
66

77
import type { AI } from '@/lib/chat/actions'
88

99
interface Purchase {
1010
numberOfShares?: number
1111
symbol: string
1212
price: number
13+
toolCallId: string
1314
status: 'requires_action' | 'completed' | 'expired'
1415
}
1516

1617
export function Purchase({
17-
props: { numberOfShares, symbol, price, status = 'expired' }
18+
props: { numberOfShares, symbol, price, toolCallId, status = 'requires_action' }
1819
}: {
1920
props: Purchase
2021
}) {
2122
const [value, setValue] = useState(numberOfShares || 100)
23+
const [purchaseStatus, setPurchaseStatus] = useState(status);
2224
const [purchasingUI, setPurchasingUI] = useState<null | React.ReactNode>(null)
2325
const [aiState, setAIState] = useAIState<typeof AI>()
2426
const [, setMessages] = useUIState<typeof AI>()
@@ -59,6 +61,51 @@ export function Purchase({
5961
setAIState({ ...aiState, messages: [...aiState.messages, message] })
6062
}
6163

64+
useEffect(() => {
65+
const checkPurchaseStatus = () => {
66+
if (purchaseStatus === 'requires_action') {
67+
// check for purchase completion
68+
// Find the tool message with the matching toolCallId
69+
const toolMessage = aiState.messages.find(
70+
message =>
71+
message.role === 'tool' &&
72+
message.content.some(part => part.toolCallId === toolCallId)
73+
);
74+
75+
if (toolMessage) {
76+
const toolMessageIndex = aiState.messages.indexOf(toolMessage);
77+
// Check if the next message is a system message containing "purchased"
78+
const nextMessage = aiState.messages[toolMessageIndex + 1];
79+
if (
80+
nextMessage?.role === 'system' &&
81+
nextMessage.content.includes('purchased')
82+
) {
83+
setPurchaseStatus('completed');
84+
} else {
85+
// Check for expiration
86+
const requestedAt = toolMessage.createdAt;
87+
if (!requestedAt || unixTsNow() - requestedAt > 30) {
88+
setPurchaseStatus('expired');
89+
}
90+
}
91+
}
92+
}
93+
};
94+
95+
checkPurchaseStatus();
96+
97+
let intervalId: NodeJS.Timeout | null = null;
98+
if (purchaseStatus === 'requires_action') {
99+
intervalId = setInterval(checkPurchaseStatus, 1000);
100+
}
101+
102+
return () => {
103+
if (intervalId) {
104+
clearInterval(intervalId);
105+
}
106+
};
107+
}, [purchaseStatus, toolCallId, aiState.messages]);
108+
62109
return (
63110
<div className="p-4 text-green-400 border rounded-xl bg-zinc-950">
64111
<div className="inline-block float-right px-2 py-1 text-xs rounded-full bg-white/10">
@@ -68,7 +115,7 @@ export function Purchase({
68115
<div className="text-3xl font-bold">${price}</div>
69116
{purchasingUI ? (
70117
<div className="mt-4 text-zinc-200">{purchasingUI}</div>
71-
) : status === 'requires_action' ? (
118+
) : purchaseStatus === 'requires_action' ? (
72119
<>
73120
<div className="relative pb-6 mt-6">
74121
<p>Shares to purchase</p>
@@ -133,12 +180,12 @@ export function Purchase({
133180
Purchase
134181
</button>
135182
</>
136-
) : status === 'completed' ? (
183+
) : purchaseStatus === 'completed' ? (
137184
<p className="mb-2 text-white">
138185
You have successfully purchased {value} ${symbol}. Total cost:{' '}
139186
{formatNumber(value * price)}
140187
</p>
141-
) : status === 'expired' ? (
188+
) : purchaseStatus === 'expired' ? (
142189
<p className="mb-2 text-white">Your checkout session has expired!</p>
143190
) : null}
144191
</div>

lib/chat/actions.tsx

Lines changed: 53 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'server-only'
22

3+
import type { MutableAIState } from '@/lib/types'
34
import {
45
createAI,
56
createStreamableUI,
@@ -29,7 +30,8 @@ import {
2930
formatNumber,
3031
runAsyncFnWithoutBlocking,
3132
sleep,
32-
nanoid
33+
nanoid,
34+
unixTsNow
3335
} from '@/lib/utils'
3436
import { saveChat } from '@/app/actions'
3537
import { SpinnerMessage, UserMessage } from '@/components/stocks/message'
@@ -82,7 +84,7 @@ async function confirmPurchase(symbol: string, price: number, amount: number) {
8284
</SystemMessage>
8385
)
8486

85-
aiState.done({
87+
aiStateDone(aiState, {
8688
...aiState.get(),
8789
messages: [
8890
...aiState.get().messages,
@@ -159,7 +161,7 @@ async function submitUserMessage(content: string) {
159161

160162
if (done) {
161163
textStream.done()
162-
aiState.done({
164+
aiStateDone(aiState, {
163165
...aiState.get(),
164166
messages: [
165167
...aiState.get().messages,
@@ -199,7 +201,7 @@ async function submitUserMessage(content: string) {
199201

200202
const toolCallId = nanoid()
201203

202-
aiState.done({
204+
aiStateDone(aiState, {
203205
...aiState.get(),
204206
messages: [
205207
...aiState.get().messages,
@@ -259,8 +261,7 @@ async function submitUserMessage(content: string) {
259261
await sleep(1000)
260262

261263
const toolCallId = nanoid()
262-
263-
aiState.done({
264+
aiStateDone(aiState, {
264265
...aiState.get(),
265266
messages: [
266267
...aiState.get().messages,
@@ -319,7 +320,7 @@ async function submitUserMessage(content: string) {
319320
const toolCallId = nanoid()
320321

321322
if (numberOfShares <= 0 || numberOfShares > 1000) {
322-
aiState.done({
323+
aiStateDone(aiState, {
323324
...aiState.get(),
324325
messages: [
325326
...aiState.get().messages,
@@ -362,7 +363,7 @@ async function submitUserMessage(content: string) {
362363

363364
return <BotMessage content={'Invalid amount'} />
364365
} else {
365-
aiState.done({
366+
aiStateDone(aiState, {
366367
...aiState.get(),
367368
messages: [
368369
...aiState.get().messages,
@@ -381,6 +382,7 @@ async function submitUserMessage(content: string) {
381382
{
382383
id: nanoid(),
383384
role: 'tool',
385+
createdAt: unixTsNow(),
384386
content: [
385387
{
386388
type: 'tool-result',
@@ -404,6 +406,7 @@ async function submitUserMessage(content: string) {
404406
numberOfShares,
405407
symbol,
406408
price: +price,
409+
toolCallId,
407410
status: 'requires_action'
408411
}}
409412
/>
@@ -437,7 +440,7 @@ async function submitUserMessage(content: string) {
437440

438441
const toolCallId = nanoid()
439442

440-
aiState.done({
443+
aiStateDone(aiState, {
441444
...aiState.get(),
442445
messages: [
443446
...aiState.get().messages,
@@ -517,36 +520,45 @@ export const AI = createAI<AIState, UIState>({
517520
return
518521
}
519522
},
520-
onSetAIState: async ({ state }) => {
521-
'use server'
523+
})
522524

523-
const session = await auth()
525+
const updateChat = async (state: AIState) => {
526+
'use server'
524527

525-
if (session && session.user) {
526-
const { chatId, messages } = state
527-
528-
const createdAt = new Date()
529-
const userId = session.user.id as string
530-
const path = `/chat/${chatId}`
531-
532-
const firstMessageContent = messages[0].content as string
533-
const title = firstMessageContent.substring(0, 100)
534-
535-
const chat: Chat = {
536-
id: chatId,
537-
title,
538-
userId,
539-
createdAt,
540-
messages,
541-
path
542-
}
528+
const session = await auth()
543529

544-
await saveChat(chat)
545-
} else {
546-
return
530+
if (session && session.user) {
531+
const { chatId, messages } = state
532+
533+
const createdAt = new Date()
534+
const userId = session.user.id as string
535+
const path = `/chat/${chatId}`
536+
537+
const firstMessageContent = messages[0].content as string
538+
const title = firstMessageContent.substring(0, 100)
539+
540+
const chat: Chat = {
541+
id: chatId,
542+
title,
543+
userId,
544+
createdAt,
545+
messages,
546+
path
547547
}
548+
549+
await saveChat(chat)
550+
} else {
551+
return
548552
}
549-
})
553+
};
554+
555+
const aiStateDone = (aiState: MutableAIState<AIState>, newState: AIState) => {
556+
runAsyncFnWithoutBlocking(async () => {
557+
// resolves race condition in aiState.done - the UI refreshed before db was updated
558+
await updateChat(newState);
559+
aiState.done(newState);
560+
});
561+
};
550562

551563
export const getUIStateFromAIState = (aiState: Chat) => {
552564
return aiState.messages
@@ -569,8 +581,13 @@ export const getUIStateFromAIState = (aiState: Chat) => {
569581
</BotCard>
570582
) : tool.toolName === 'showStockPurchase' ? (
571583
<BotCard>
572-
{/* @ts-expect-error */}
573-
<Purchase props={tool.result} />
584+
<Purchase
585+
// @ts-expect-error
586+
props={{
587+
...(tool.result as object),
588+
toolCallId: tool.toolCallId,
589+
}}
590+
/>
574591
</BotCard>
575592
) : tool.toolName === 'getEvents' ? (
576593
<BotCard>

lib/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { CoreMessage } from 'ai'
22

33
export type Message = CoreMessage & {
44
id: string
5+
createdAt?: number;
56
}
67

78
export interface Chat extends Record<string, any> {
@@ -39,3 +40,9 @@ export interface User extends Record<string, any> {
3940
password: string
4041
salt: string
4142
}
43+
44+
export type MutableAIState<AIState> = {
45+
get: () => AIState;
46+
update: (newState: AIState | ((current: AIState) => AIState)) => void;
47+
done: ((newState: AIState) => void) | (() => void);
48+
};

lib/utils.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,10 @@ export const formatNumber = (value: number) =>
5151
export const runAsyncFnWithoutBlocking = (
5252
fn: (...args: any) => Promise<any>
5353
) => {
54-
fn()
55-
}
54+
fn().catch(error => {
55+
console.error('An error occurred in the async function:', error);
56+
});
57+
};
5658

5759
export const sleep = (ms: number) =>
5860
new Promise(resolve => setTimeout(resolve, ms))
@@ -87,3 +89,5 @@ export const getMessageFromCode = (resultCode: string) => {
8789
return 'Logged in!'
8890
}
8991
}
92+
93+
export const unixTsNow = () => Math.floor(Date.now() / 1000);

0 commit comments

Comments
 (0)