From 3079a92492eb0f651a3fa1378d3e36b9f0bdec53 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sat, 7 Feb 2026 12:45:02 +0530 Subject: [PATCH] feat(board): implement usePageActive hook to manage session refresh based on tab visibility --- frontend/src/app/activity/page.tsx | 5 ++- frontend/src/app/boards/[boardId]/page.tsx | 15 +++++-- .../src/components/BoardOnboardingChat.tsx | 11 ++++- frontend/src/hooks/usePageActive.ts | 42 +++++++++++++++++++ 4 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 frontend/src/hooks/usePageActive.ts diff --git a/frontend/src/app/activity/page.tsx b/frontend/src/app/activity/page.tsx index 2b2c30a4..73257eb0 100644 --- a/frontend/src/app/activity/page.tsx +++ b/frontend/src/app/activity/page.tsx @@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button"; import { createExponentialBackoff } from "@/lib/backoff"; import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime"; import { cn } from "@/lib/utils"; +import { usePageActive } from "@/hooks/usePageActive"; const SSE_RECONNECT_BACKOFF = { baseMs: 1_000, @@ -132,6 +133,7 @@ FeedCard.displayName = "FeedCard"; export default function ActivityPage() { const { isSignedIn } = useAuth(); + const isPageActive = usePageActive(); const feedQuery = useListTaskCommentFeedApiV1ActivityTaskCommentsGet< listTaskCommentFeedApiV1ActivityTaskCommentsGetResponse, @@ -189,6 +191,7 @@ export default function ActivityPage() { }, []); useEffect(() => { + if (!isPageActive) return; if (!isSignedIn) return; let isCancelled = false; const abortController = new AbortController(); @@ -278,7 +281,7 @@ export default function ActivityPage() { window.clearTimeout(reconnectTimeout); } }; - }, [isSignedIn, pushFeedItem]); + }, [isPageActive, isSignedIn, pushFeedItem]); const orderedFeed = useMemo(() => { return [...feedItems].sort((a, b) => { diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index 17255606..b0e5f1f5 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -72,6 +72,7 @@ import type { import { createExponentialBackoff } from "@/lib/backoff"; import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime"; import { cn } from "@/lib/utils"; +import { usePageActive } from "@/hooks/usePageActive"; type Board = BoardRead; @@ -298,6 +299,7 @@ export default function BoardDetailPage() { const boardIdParam = params?.boardId; const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam; const { isSignedIn } = useAuth(); + const isPageActive = usePageActive(); const taskIdFromUrl = searchParams.get("taskId"); const [board, setBoard] = useState(null); @@ -580,7 +582,9 @@ export default function BoardDetailPage() { }; useEffect(() => { + if (!isPageActive) return; if (!isSignedIn || !boardId || !board) return; + if (!isChatOpen) return; let isCancelled = false; const abortController = new AbortController(); const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF); @@ -685,9 +689,10 @@ export default function BoardDetailPage() { window.clearTimeout(reconnectTimeout); } }; - }, [board, boardId, isSignedIn]); + }, [board, boardId, isChatOpen, isPageActive, isSignedIn]); useEffect(() => { + if (!isPageActive) return; if (!isSignedIn || !boardId || !board) return; let isCancelled = false; const abortController = new AbortController(); @@ -817,7 +822,7 @@ export default function BoardDetailPage() { window.clearTimeout(reconnectTimeout); } }; - }, [board, boardId, isSignedIn]); + }, [board, boardId, isPageActive, isSignedIn]); useEffect(() => { if (!selectedTask) { @@ -840,6 +845,7 @@ export default function BoardDetailPage() { }, [selectedTask]); useEffect(() => { + if (!isPageActive) return; if (!isSignedIn || !boardId || !board) return; let isCancelled = false; const abortController = new AbortController(); @@ -1003,9 +1009,10 @@ export default function BoardDetailPage() { window.clearTimeout(reconnectTimeout); } }; - }, [board, boardId, isSignedIn, pushLiveFeed]); + }, [board, boardId, isPageActive, isSignedIn, pushLiveFeed]); useEffect(() => { + if (!isPageActive) return; if (!isSignedIn || !boardId) return; let isCancelled = false; const abortController = new AbortController(); @@ -1109,7 +1116,7 @@ export default function BoardDetailPage() { window.clearTimeout(reconnectTimeout); } }; - }, [board, boardId, isSignedIn]); + }, [board, boardId, isPageActive, isSignedIn]); const resetForm = () => { setTitle(""); diff --git a/frontend/src/components/BoardOnboardingChat.tsx b/frontend/src/components/BoardOnboardingChat.tsx index 5d4e006d..512cbc22 100644 --- a/frontend/src/components/BoardOnboardingChat.tsx +++ b/frontend/src/components/BoardOnboardingChat.tsx @@ -9,6 +9,7 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; +import { usePageActive } from "@/hooks/usePageActive"; import { answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost, @@ -116,6 +117,7 @@ export function BoardOnboardingChat({ boardId: string; onConfirmed: (board: BoardRead) => void; }) { + const isPageActive = usePageActive(); const [session, setSession] = useState(null); const [loading, setLoading] = useState(false); const [otherText, setOtherText] = useState(""); @@ -180,10 +182,15 @@ export function BoardOnboardingChat({ }, [boardId]); useEffect(() => { - startSession(); + void startSession(); + }, [startSession]); + + useEffect(() => { + if (!isPageActive) return; + void refreshSession(); const interval = setInterval(refreshSession, 2000); return () => clearInterval(interval); - }, [startSession, refreshSession]); + }, [isPageActive, refreshSession]); const handleAnswer = useCallback( async (value: string, freeText?: string) => { diff --git a/frontend/src/hooks/usePageActive.ts b/frontend/src/hooks/usePageActive.ts new file mode 100644 index 00000000..1c7da124 --- /dev/null +++ b/frontend/src/hooks/usePageActive.ts @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const computeIsActive = () => { + if (typeof document === "undefined") return true; + const visible = + document.visibilityState === "visible" && + // `hidden` is a more widely-supported signal; keep both for safety. + !document.hidden; + const focused = typeof document.hasFocus === "function" ? document.hasFocus() : true; + return visible && focused; +}; + +/** + * Returns true when this tab/window is both visible and focused. + * + * Rationale: background tabs/windows should not keep long-lived connections + * (SSE/polling), otherwise opening multiple tabs can exhaust per-origin + * connection limits and make the app feel "hung". + */ +export function usePageActive(): boolean { + const [active, setActive] = useState(() => computeIsActive()); + + useEffect(() => { + const update = () => setActive(computeIsActive()); + + update(); + document.addEventListener("visibilitychange", update); + window.addEventListener("focus", update); + window.addEventListener("blur", update); + + return () => { + document.removeEventListener("visibilitychange", update); + window.removeEventListener("focus", update); + window.removeEventListener("blur", update); + }; + }, []); + + return active; +} +