fix(web): clear stale banner when hidden-tab reconnect is deferred
scheduleReconnect parks the PTY as "closed" while the tab is hidden or the chat route is inactive, but left any earlier non-rejection banner (e.g. a failed image upload) in place. maybeReconnectOnPageResume refuses to reconnect while a banner sits on a closed PTY and the reconnect overlay hides behind a banner too, so the tab came back disconnected with no way to recover. Clear the banner in the deferral branch like the normal reconnect path does. Test: hidden-tab 1001 close after a failed upload now opens a second socket on visibilitychange (1 -> 2), red before the fix.
This commit is contained in:
@@ -86,6 +86,14 @@ const maybeReloadForLoopbackWsAuthFailure = vi.fn(() => false);
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
buildWsUrl: vi.fn(async () => "ws://localhost/api/pty?channel=chat-1"),
|
||||
}));
|
||||
const uploadChatImage = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ path: "/tmp/pasted.png" })),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/chatImagePaste", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/chatImagePaste")>()),
|
||||
uploadChatImage,
|
||||
}));
|
||||
|
||||
vi.mock("@xterm/addon-fit", () => ({ FitAddon: FakeFitAddon }));
|
||||
vi.mock("@xterm/addon-unicode11", () => ({ Unicode11Addon: class {} }));
|
||||
@@ -317,6 +325,57 @@ describe("ChatPage", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reconnects on tab return after a hidden-tab close even when a stale upload banner is showing", async () => {
|
||||
const { default: ChatPage } = await import("./ChatPage");
|
||||
await render(
|
||||
<MemoryRouter initialEntries={["/chat"]}>
|
||||
<ChatPage isActive />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
|
||||
const socket = FakeWebSocket.instances[0];
|
||||
await act(async () => socket.onopen?.());
|
||||
|
||||
// A failed image paste leaves a non-rejection banner behind.
|
||||
uploadChatImage.mockRejectedValueOnce(new Error("disk full"));
|
||||
const host = container.querySelector(".hermes-chat-xterm-host");
|
||||
expect(host).not.toBeNull();
|
||||
const paste = new Event("paste", { bubbles: true, cancelable: true });
|
||||
const file = new File([new Uint8Array([1, 2, 3])], "shot.png", { type: "image/png" });
|
||||
Object.defineProperty(paste, "clipboardData", {
|
||||
value: {
|
||||
files: [file],
|
||||
items: [{ getAsFile: () => file, kind: "file", type: "image/png" }],
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
host!.dispatchEvent(paste);
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(container.textContent).toContain("Image upload failed"),
|
||||
);
|
||||
|
||||
// The socket dies while the tab is hidden: the reconnect is deferred.
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
get: () => "hidden",
|
||||
});
|
||||
await act(async () => {
|
||||
socket.onclose?.({ code: 1001, reason: "", wasClean: true });
|
||||
});
|
||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||
|
||||
// Coming back must start the deferred reconnect despite the old banner.
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
get: () => "visible",
|
||||
});
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2));
|
||||
});
|
||||
|
||||
it("treats loopback 4401 closes as stale-token reload candidates", async () => {
|
||||
const { default: ChatPage } = await import("./ChatPage");
|
||||
|
||||
|
||||
@@ -1207,6 +1207,10 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
!isActiveRef.current ||
|
||||
(typeof document !== "undefined" && document.visibilityState === "hidden")
|
||||
) {
|
||||
// Clear any stale banner (e.g. a failed image upload): the resume
|
||||
// listener refuses to reconnect while a banner sits on a closed PTY.
|
||||
setBanner(null);
|
||||
setBannerAction(null);
|
||||
setPtyState("closed");
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user