fix(FN-3838): suppress suspended-tab chat load failure toasts

- Add visibility suspension hook to detect tab-hidden periods during chat loading
- Update useChat and useQuickChat to suppress load-failed toasts triggered by tab suspension resumes
- Expand hook test coverage for suspension timing and toast suppression behavior
- Add plugin authoring documentation updates and include FN-3838 changeset

Fusion-Task-Id: FN-3838
This commit is contained in:
Fusion
2026-05-09 09:12:52 -07:00
committed by gsxdsm
parent 865f2eba4f
commit a2258b811f
7 changed files with 466 additions and 8 deletions

View File

@@ -0,0 +1,8 @@
---
"@runfusion/fusion": patch
---
Main chat no longer surfaces a confusing "Load failed" error banner when the
browser tab is backgrounded during a streaming reply. Tab-suspension network
errors are now treated as benign interruptions and the conversation silently
reconciles with the server on tab return.

View File

@@ -3,7 +3,7 @@
* search/filter, and pagination. * search/filter, and pagination.
*/ */
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, fireEvent, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useChat } from "../useChat"; import { useChat } from "../useChat";
import * as apiModule from "../../api"; import * as apiModule from "../../api";
@@ -79,6 +79,14 @@ function makeMessage(overrides: Partial<ChatMessage> & Pick<ChatMessage, "id" |
}; };
} }
const setDocumentVisibilityState = (state: DocumentVisibilityState) => {
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => state,
});
fireEvent(document, new Event("visibilitychange"));
};
describe("useChat", () => { describe("useChat", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -97,6 +105,7 @@ describe("useChat", () => {
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.useRealTimers();
}); });
it("loads sessions on mount", async () => { it("loads sessions on mount", async () => {
@@ -636,6 +645,157 @@ describe("useChat", () => {
}); });
}); });
it("suppresses Load failed toast when tab is hidden and reconciles messages", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const addToast = vi.fn();
let errorHandler: ((data: string) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
errorHandler = handlers.onError;
return { close: vi.fn(), isConnected: () => true };
});
setDocumentVisibilityState("hidden");
const { result } = renderHook(() => useChat(undefined, addToast));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(result.current.activeSession?.id).toBe("session-001");
});
act(() => {
result.current.sendMessage("Hello!");
errorHandler?.("Load failed");
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(false);
expect(addToast).not.toHaveBeenCalledWith("Load failed", "error");
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-001", { limit: 50 }, undefined);
});
});
it("shows Load failed toast when tab stays visible", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const addToast = vi.fn();
let errorHandler: ((data: string) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
errorHandler = handlers.onError;
return { close: vi.fn(), isConnected: () => true };
});
setDocumentVisibilityState("visible");
const { result } = renderHook(() => useChat(undefined, addToast));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(result.current.activeSession?.id).toBe("session-001");
});
act(() => {
result.current.sendMessage("Hello!");
errorHandler?.("Load failed");
});
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Load failed", "error");
});
});
it("suppresses Failed to fetch shortly after hidden to visible transition", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const addToast = vi.fn();
let errorHandler: ((data: string) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
errorHandler = handlers.onError;
return { close: vi.fn(), isConnected: () => true };
});
setDocumentVisibilityState("hidden");
const { result } = renderHook(() => useChat(undefined, addToast));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
setDocumentVisibilityState("visible");
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(result.current.activeSession?.id).toBe("session-001");
});
act(() => {
result.current.sendMessage("Hello!");
errorHandler?.("Failed to fetch");
});
await waitFor(() => {
expect(addToast).not.toHaveBeenCalledWith("Failed to fetch", "error");
});
});
it("still shows toast for non-suspension errors regardless of visibility", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const addToast = vi.fn();
let errorHandler: ((data: string) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
errorHandler = handlers.onError;
return { close: vi.fn(), isConnected: () => true };
});
setDocumentVisibilityState("hidden");
const { result } = renderHook(() => useChat(undefined, addToast));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(result.current.activeSession?.id).toBe("session-001");
});
act(() => {
result.current.sendMessage("Hello!");
errorHandler?.("Request failed: 500");
});
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Request failed: 500", "error");
});
});
it("onFallback updates the selected session model, persists fallback metadata, and shows a warning toast", async () => { it("onFallback updates the selected session model, persists fallback metadata, and shows a warning toast", async () => {
const session = makeSession({ const session = makeSession({
id: "session-001", id: "session-001",

View File

@@ -1,5 +1,5 @@
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, fireEvent, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatSession } from "@fusion/core"; import type { ChatSession } from "@fusion/core";
import * as apiModule from "../../api"; import * as apiModule from "../../api";
import { FN_AGENT_ID, useQuickChat } from "../useQuickChat"; import { FN_AGENT_ID, useQuickChat } from "../useQuickChat";
@@ -36,6 +36,14 @@ function makeSession(overrides: Partial<ChatSession> & Pick<ChatSession, "id" |
}; };
} }
const setDocumentVisibilityState = (state: DocumentVisibilityState) => {
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => state,
});
fireEvent(document, new Event("visibilitychange"));
};
describe("useQuickChat", () => { describe("useQuickChat", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -52,6 +60,11 @@ describe("useQuickChat", () => {
mockCancelChatResponse.mockResolvedValue({ success: true }); mockCancelChatResponse.mockResolvedValue({ success: true });
}); });
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
it("sendMessage returns a promise that resolves on stream completion", async () => { it("sendMessage returns a promise that resolves on stream completion", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" }); const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchResumeChatSession.mockResolvedValue({ session }); mockFetchResumeChatSession.mockResolvedValue({ session });
@@ -874,6 +887,131 @@ describe("useQuickChat", () => {
}); });
}); });
it("suppresses Load failed toast when tab is hidden", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
const addToast = vi.fn();
let onErrorHandler: ((data: string) => void) | undefined;
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
onErrorHandler = handlers.onError;
return { close: vi.fn(), isConnected: () => true };
});
setDocumentVisibilityState("hidden");
const { result } = renderHook(() => useQuickChat("proj-123", addToast));
await act(async () => {
await result.current.switchSession("agent-001");
});
await act(async () => {
const sendPromise = result.current.sendMessage("Hello");
onErrorHandler?.("Load failed");
await sendPromise;
});
await waitFor(() => {
expect(addToast).not.toHaveBeenCalledWith("Load failed", "error");
});
});
it("shows Load failed toast when tab remains visible", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
const addToast = vi.fn();
let onErrorHandler: ((data: string) => void) | undefined;
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
onErrorHandler = handlers.onError;
return { close: vi.fn(), isConnected: () => true };
});
setDocumentVisibilityState("visible");
const { result } = renderHook(() => useQuickChat("proj-123", addToast));
await act(async () => {
await result.current.switchSession("agent-001");
});
await expect(act(async () => {
const sendPromise = result.current.sendMessage("Hello");
onErrorHandler?.("Load failed");
await sendPromise;
})).rejects.toThrow("Load failed");
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Load failed", "error");
});
});
it("suppresses Failed to fetch shortly after hidden to visible transition", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
const addToast = vi.fn();
let onErrorHandler: ((data: string) => void) | undefined;
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
onErrorHandler = handlers.onError;
return { close: vi.fn(), isConnected: () => true };
});
setDocumentVisibilityState("hidden");
const { result } = renderHook(() => useQuickChat("proj-123", addToast));
await act(async () => {
await result.current.switchSession("agent-001");
});
await act(async () => {
setDocumentVisibilityState("visible");
const sendPromise = result.current.sendMessage("Hello");
onErrorHandler?.("Failed to fetch");
await sendPromise;
});
await waitFor(() => {
expect(addToast).not.toHaveBeenCalledWith("Failed to fetch", "error");
});
});
it("still shows toast for non-suspension errors regardless of visibility", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
const addToast = vi.fn();
let onErrorHandler: ((data: string) => void) | undefined;
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
onErrorHandler = handlers.onError;
return { close: vi.fn(), isConnected: () => true };
});
setDocumentVisibilityState("hidden");
const { result } = renderHook(() => useQuickChat("proj-123", addToast));
await act(async () => {
await result.current.switchSession("agent-001");
});
await expect(act(async () => {
const sendPromise = result.current.sendMessage("Hello");
onErrorHandler?.("Request failed: 500");
await sendPromise;
})).rejects.toThrow("Request failed: 500");
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Request failed: 500", "error");
});
});
it("onFallback updates the active model, persists fallback metadata, and shows a warning toast", async () => { it("onFallback updates the active model, persists fallback metadata, and shows a warning toast", async () => {
const existingSession = makeSession({ const existingSession = makeSession({
id: "session-existing", id: "session-existing",

View File

@@ -0,0 +1,57 @@
import { act, fireEvent, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { isLikelyTabSuspensionError, useTabVisibilitySuspension } from "../visibilitySuspension";
describe("visibilitySuspension", () => {
it.each([
"Load failed",
"Failed to fetch",
"NetworkError when attempting to fetch resource.",
"Connection aborted",
"Connection closed unexpectedly",
"network error",
])("matches known tab-suspension transport errors: %s", (message) => {
expect(isLikelyTabSuspensionError(message)).toBe(true);
});
it("rejects unrelated backend errors", () => {
expect(isLikelyTabSuspensionError("Request failed: 500")).toBe(false);
expect(isLikelyTabSuspensionError("Validation error: missing key")).toBe(false);
});
it("tracks recently hidden window", () => {
vi.useFakeTimers();
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "visible",
});
const { result } = renderHook(() => useTabVisibilitySuspension());
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "hidden",
});
act(() => {
fireEvent(document, new Event("visibilitychange"));
});
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "visible",
});
act(() => {
fireEvent(document, new Event("visibilitychange"));
});
expect(result.current.wasRecentlyHidden(5000)).toBe(true);
act(() => {
vi.advanceTimersByTime(6000);
});
expect(result.current.wasRecentlyHidden(5000)).toBe(false);
vi.useRealTimers();
});
});

View File

@@ -35,6 +35,7 @@ export interface ChatSessionInfo {
export type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes"; export type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
import type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes"; import type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
import { createChatStreamHandlers } from "./createChatStreamHandlers"; import { createChatStreamHandlers } from "./createChatStreamHandlers";
import { isLikelyTabSuspensionError, useTabVisibilitySuspension } from "./visibilitySuspension";
export interface UseChatReturn { export interface UseChatReturn {
// Session state // Session state
@@ -488,6 +489,7 @@ export function useChat(
const sendMessageRef = useRef<(content: string, attachments?: File[]) => void>(() => { const sendMessageRef = useRef<(content: string, attachments?: File[]) => void>(() => {
// no-op until sendMessage is defined // no-op until sendMessage is defined
}); });
const visibilitySuspension = useTabVisibilitySuspension();
const sendMessage = useCallback( const sendMessage = useCallback(
(content: string, attachments?: File[]) => { (content: string, attachments?: File[]) => {
@@ -591,7 +593,19 @@ export function useChat(
isStreamingRef.current = false; isStreamingRef.current = false;
streamRef.current = null; streamRef.current = null;
console.error("[useChat] Stream error:", data); console.error("[useChat] Stream error:", data);
addToast?.(typeof data === "string" && data.trim() ? data : "Failed to get response", "error"); const errorMessage = typeof data === "string" && data.trim() ? data : "Failed to get response";
const shouldSuppressSuspensionError = typeof data === "string"
&& isLikelyTabSuspensionError(data)
&& (visibilitySuspension.isHiddenNow() || visibilitySuspension.wasRecentlyHidden(5000));
if (shouldSuppressSuspensionError) {
console.info("[useChat] Suppressed tab-suspension stream error:", data);
if (activeSession?.id) {
void loadMessages(activeSession.id);
}
} else {
addToast?.(errorMessage, "error");
}
if (!cancelledByUserRef.current) { if (!cancelledByUserRef.current) {
const queuedMessage = pendingMessageRef.current.trim(); const queuedMessage = pendingMessageRef.current.trim();
@@ -606,7 +620,7 @@ export function useChat(
streamRef.current = streamChatResponse(activeSession.id, content, handlers, attachments, projectId); streamRef.current = streamChatResponse(activeSession.id, content, handlers, attachments, projectId);
}, },
[activeSession, projectId, refreshSessions, addToast], [activeSession, projectId, refreshSessions, addToast, loadMessages, visibilitySuspension],
); );
sendMessageRef.current = sendMessage; sendMessageRef.current = sendMessage;

View File

@@ -20,6 +20,7 @@ export const FN_AGENT_ID = "__fn_agent__";
export type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes"; export type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
import type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes"; import type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
import { createChatStreamHandlers } from "./createChatStreamHandlers"; import { createChatStreamHandlers } from "./createChatStreamHandlers";
import { isLikelyTabSuspensionError, useTabVisibilitySuspension } from "./visibilitySuspension";
interface ModelSelection { interface ModelSelection {
modelProvider?: string; modelProvider?: string;
@@ -539,6 +540,7 @@ export function useQuickChat(
}, []); }, []);
const sendMessageRef = useRef<(content: string, attachments?: File[]) => Promise<void>>(() => Promise.resolve()); const sendMessageRef = useRef<(content: string, attachments?: File[]) => Promise<void>>(() => Promise.resolve());
const visibilitySuspension = useTabVisibilitySuspension();
/** /**
* Send a message using SSE streaming. * Send a message using SSE streaming.
@@ -647,8 +649,19 @@ export function useQuickChat(
isStreamingRef.current = false; isStreamingRef.current = false;
streamRef.current = null; streamRef.current = null;
console.error("[useQuickChat] Stream error:", data); console.error("[useQuickChat] Stream error:", data);
addToast?.(typeof data === "string" && data.trim() ? data : "Failed to get response", "error");
sendCompletionRef.current?.reject(new Error(typeof data === "string" ? data : "Failed to get response")); const errorMessage = typeof data === "string" && data.trim() ? data : "Failed to get response";
const shouldSuppressSuspensionError = typeof data === "string"
&& isLikelyTabSuspensionError(data)
&& (visibilitySuspension.isHiddenNow() || visibilitySuspension.wasRecentlyHidden(5000));
if (shouldSuppressSuspensionError) {
console.info("[useQuickChat] Suppressed tab-suspension stream error:", data);
sendCompletionRef.current?.resolve();
} else {
addToast?.(errorMessage, "error");
sendCompletionRef.current?.reject(new Error(errorMessage));
}
sendCompletionRef.current = null; sendCompletionRef.current = null;
if (!cancelledByUserRef.current) { if (!cancelledByUserRef.current) {
@@ -672,7 +685,7 @@ export function useQuickChat(
void completionPromise.catch(() => {}); void completionPromise.catch(() => {});
return completionPromise; return completionPromise;
}, },
[activeSession, projectId, addToast, reloadMessages], [activeSession, projectId, addToast, reloadMessages, visibilitySuspension],
); );
sendMessageRef.current = sendMessage; sendMessageRef.current = sendMessage;

View File

@@ -0,0 +1,68 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
const SUSPENSION_ERROR_PATTERNS = [
"load failed",
"failed to fetch",
"networkerror when attempting to fetch resource.",
"connection aborted",
"connection closed unexpectedly",
"network error",
];
export function isLikelyTabSuspensionError(message: string): boolean {
const normalized = message.trim().toLowerCase();
if (!normalized) {
return false;
}
return SUSPENSION_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern));
}
export function useTabVisibilitySuspension() {
const lastHiddenAtRef = useRef<number | null>(null);
const lastVisibleAtRef = useRef<number | null>(null);
useEffect(() => {
if (typeof document === "undefined") {
return;
}
const handleVisibilityChange = () => {
const now = Date.now();
if (document.visibilityState === "hidden") {
lastHiddenAtRef.current = now;
return;
}
if (document.visibilityState === "visible") {
lastVisibleAtRef.current = now;
}
};
handleVisibilityChange();
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
}, []);
const isHiddenNow = useCallback(() => typeof document !== "undefined" && document.visibilityState === "hidden", []);
const wasRecentlyHidden = useCallback((windowMs = 5000): boolean => {
const hiddenAt = lastHiddenAtRef.current;
if (hiddenAt === null) {
return false;
}
const now = Date.now();
if (isHiddenNow()) {
return now - hiddenAt <= windowMs;
}
const visibleAt = lastVisibleAtRef.current;
if (visibleAt === null || visibleAt < hiddenAt) {
return false;
}
return now - visibleAt <= windowMs;
}, [isHiddenNow]);
return useMemo(() => ({
isHiddenNow,
wasRecentlyHidden,
}), [isHiddenNow, wasRecentlyHidden]);
}