prevent preloads from firing twice when in React strictMode

This commit is contained in:
Eric Allam
2026-04-14 15:00:05 +01:00
parent 16a604bc38
commit fb360be0d2
5 changed files with 61 additions and 21 deletions
+30 -17
View File
@@ -393,6 +393,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
private sessions: Map<string, ChatSessionState> = new Map();
private activeStreams: Map<string, AbortController> = new Map();
private pendingPreloads: Map<string, Promise<void>> = new Map();
constructor(options: TriggerChatTransportOptions) {
this.taskId = options.task;
@@ -800,26 +801,38 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
// Don't preload if session already exists
if (this.sessions.get(chatId)?.runId) return;
const mergedMetadata =
this.defaultMetadata || options?.metadata
? { ...(this.defaultMetadata ?? {}), ...(options?.metadata ?? {}) }
: undefined;
// Deduplicate concurrent preload calls (e.g. React strict mode double-firing effects)
const pending = this.pendingPreloads.get(chatId);
if (pending) return pending;
const payload = {
messages: [] as never[],
chatId,
trigger: "preload" as const,
metadata: mergedMetadata,
...(options?.idleTimeoutInSeconds !== undefined
? { idleTimeoutInSeconds: options.idleTimeoutInSeconds }
: {}),
const doPreload = async () => {
const mergedMetadata =
this.defaultMetadata || options?.metadata
? { ...(this.defaultMetadata ?? {}), ...(options?.metadata ?? {}) }
: undefined;
const payload = {
messages: [] as never[],
chatId,
trigger: "preload" as const,
metadata: mergedMetadata,
...(options?.idleTimeoutInSeconds !== undefined
? { idleTimeoutInSeconds: options.idleTimeoutInSeconds }
: {}),
};
const { runId, publicAccessToken } = await this.triggerNewRun(chatId, payload, "preload");
const newSession: ChatSessionState = { runId, publicAccessToken };
this.sessions.set(chatId, newSession);
this.notifySessionChange(chatId, newSession);
};
const { runId, publicAccessToken } = await this.triggerNewRun(chatId, payload, "preload");
const newSession: ChatSessionState = { runId, publicAccessToken };
this.sessions.set(chatId, newSession);
this.notifySessionChange(chatId, newSession);
const promise = doPreload().finally(() => {
this.pendingPreloads.delete(chatId);
});
this.pendingPreloads.set(chatId, promise);
return promise;
}
private async resolveAccessToken(params: ResolveChatAccessTokenParams): Promise<string> {
+5
View File
@@ -98,6 +98,11 @@ export async function deleteChat(chatId: string) {
await prisma.chatSession.delete({ where: { id: chatId } }).catch(() => { });
}
export async function deleteAllChats() {
await prisma.chatSession.deleteMany();
await prisma.chat.deleteMany();
}
export async function updateChatTitle(chatId: string, title: string) {
await prisma.chat.update({ where: { id: chatId }, data: { title } }).catch(() => { });
}
@@ -5,7 +5,7 @@ import { ChatSidebar } from "@/components/chat-sidebar";
import { useChatSettings } from "@/components/chat-settings-context";
import { useState, useCallback, useEffect } from "react";
import { generateId } from "ai";
import { getChatList, deleteChat as deleteChatAction } from "@/app/actions";
import { getChatList, deleteChat as deleteChatAction, deleteAllChats } from "@/app/actions";
type ChatMeta = {
id: string;
@@ -68,6 +68,13 @@ export function ChatSidebarWrapper({
}
}
async function handleWipeAll() {
if (!confirm("Delete ALL chats? This cannot be undone.")) return;
await deleteAllChats();
setChatList([]);
router.push("/chats");
}
return (
<ChatSidebar
chats={chatList}
@@ -75,6 +82,7 @@ export function ChatSidebarWrapper({
onSelectChat={handleSelectChat}
onNewChat={handleNewChat}
onDeleteChat={handleDeleteChat}
onWipeAll={handleWipeAll}
preloadEnabled={preloadEnabled}
onPreloadChange={setPreloadEnabled}
idleTimeoutInSeconds={idleTimeoutInSeconds}
@@ -24,6 +24,7 @@ type ChatSidebarProps = {
onSelectChat: (id: string) => void;
onNewChat: () => void;
onDeleteChat: (id: string) => void;
onWipeAll: () => void;
preloadEnabled: boolean;
onPreloadChange: (enabled: boolean) => void;
idleTimeoutInSeconds: number;
@@ -38,6 +39,7 @@ export function ChatSidebar({
onSelectChat,
onNewChat,
onDeleteChat,
onWipeAll,
preloadEnabled,
onPreloadChange,
idleTimeoutInSeconds,
@@ -124,6 +126,13 @@ export function ChatSidebar({
<option value="ai-chat-session">ai-chat-session (session)</option>
</select>
</div>
<button
type="button"
onClick={onWipeAll}
className="w-full rounded border border-red-300 px-2 py-1 text-xs text-red-600 hover:bg-red-50"
>
Wipe all chats
</button>
</div>
</div>
);
@@ -12,7 +12,7 @@ import {
deleteSessionAction,
renewRunAccessTokenForChat,
} from "@/app/actions";
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
type SessionInfo = {
@@ -39,13 +39,18 @@ export function ChatView({
const router = useRouter();
const { taskMode, preloadEnabled, idleTimeoutInSeconds } = useChatSettings();
const [currentSession, setCurrentSession] = useState<SessionInfo | null>(initialSession);
const sessions: Record<string, SessionInfo> = {};
if (initialSession) {
sessions[chatId] = initialSession;
}
const handleSessionChange = useCallback((_id: string, session: SessionInfo | null) => {
if (!session) {
if (session) {
setCurrentSession(session);
} else {
setCurrentSession(null);
deleteSessionAction(_id);
}
}, []);
@@ -86,7 +91,7 @@ export function ChatView({
[router]
);
const activeSession = initialSession ?? undefined;
const activeSession = currentSession ?? undefined;
return (
<Chat