diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index a594b740d..bc28ba6a0 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -8,7 +8,7 @@ import { } from "@assistant-ui/react"; import { useQueryClient } from "@tanstack/react-query"; import { useAtomValue, useSetAtom } from "jotai"; -import { useParams, useSearchParams } from "next/navigation"; +import { useParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { z } from "zod"; @@ -388,22 +388,32 @@ export default function NewChatPage() { }, [searchSpaceId, queryClient]); // Handle scroll to comment from URL query params (e.g., from inbox item click) - const searchParams = useSearchParams(); - const targetCommentIdParam = searchParams.get("commentId"); - - // Set target comment ID from URL param - the AssistantMessage and CommentItem - // components will handle scrolling and highlighting once comments are loaded + // Read from window.location.search inside the effect instead of subscribing via + // useSearchParams() — avoids re-rendering this heavy component tree on every + // unrelated query-string change. (Vercel Best Practice: rerender-defer-reads 5.2) useEffect(() => { - if (targetCommentIdParam && !isInitializing) { - const commentId = Number.parseInt(targetCommentIdParam, 10); - if (!Number.isNaN(commentId)) { - setTargetCommentId(commentId); + const readAndApplyCommentId = () => { + const params = new URLSearchParams(window.location.search); + const raw = params.get("commentId"); + if (raw && !isInitializing) { + const commentId = Number.parseInt(raw, 10); + if (!Number.isNaN(commentId)) { + setTargetCommentId(commentId); + } } - } + }; + + readAndApplyCommentId(); + + // Also respond to SPA navigations (back/forward) that change the query string + window.addEventListener("popstate", readAndApplyCommentId); // Cleanup on unmount or when navigating away - return () => clearTargetCommentId(); - }, [targetCommentIdParam, isInitializing, setTargetCommentId, clearTargetCommentId]); + return () => { + window.removeEventListener("popstate", readAndApplyCommentId); + clearTargetCommentId(); + }; + }, [isInitializing, setTargetCommentId, clearTargetCommentId]); // Sync current thread state to atom useEffect(() => { diff --git a/surfsense_web/app/dashboard/page.tsx b/surfsense_web/app/dashboard/page.tsx index 525060bed..09ace6542 100644 --- a/surfsense_web/app/dashboard/page.tsx +++ b/surfsense_web/app/dashboard/page.tsx @@ -3,7 +3,7 @@ import { useAtomValue } from "jotai"; import { AlertCircle, Plus, Search } from "lucide-react"; import { motion } from "motion/react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; @@ -89,7 +89,6 @@ function EmptyState({ onCreateClick }: { onCreateClick: () => void }) { export default function DashboardPage() { const router = useRouter(); - const searchParams = useSearchParams(); const [showCreateDialog, setShowCreateDialog] = useState(false); const t = useTranslations("dashboard"); @@ -99,11 +98,12 @@ export default function DashboardPage() { if (isLoading) return; if (searchSpaces.length > 0) { - const params = searchParams.toString(); - const query = params ? `?${params}` : ""; + // Read the query string at the time of redirect — no subscription needed. + // (Vercel Best Practice: rerender-defer-reads 5.2) + const query = window.location.search; router.replace(`/dashboard/${searchSpaces[0].id}/new-chat${query}`); } - }, [isLoading, searchSpaces, router, searchParams]); + }, [isLoading, searchSpaces, router]); // Show loading while fetching or while we have spaces and are about to redirect const shouldShowLoading = isLoading || searchSpaces.length > 0; diff --git a/surfsense_web/components/TokenHandler.tsx b/surfsense_web/components/TokenHandler.tsx index 230cda81a..02a7089d9 100644 --- a/surfsense_web/components/TokenHandler.tsx +++ b/surfsense_web/components/TokenHandler.tsx @@ -1,6 +1,5 @@ "use client"; -import { useSearchParams } from "next/navigation"; import { useEffect } from "react"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; import { getAndClearRedirectPath, setBearerToken, setRefreshToken } from "@/lib/auth-utils"; @@ -26,8 +25,6 @@ const TokenHandler = ({ tokenParamName = "token", storageKey = "surfsense_bearer_token", }: TokenHandlerProps) => { - const searchParams = useSearchParams(); - // Always show loading for this component - spinner animation won't reset useGlobalLoadingEffect(true); @@ -35,9 +32,13 @@ const TokenHandler = ({ // Only run on client-side if (typeof window === "undefined") return; - // Get tokens from URL parameters - const token = searchParams.get(tokenParamName); - const refreshToken = searchParams.get("refresh_token"); + // Read tokens from URL at mount time — no subscription needed. + // TokenHandler only runs once after an auth redirect, so a stale read + // is impossible and useSearchParams() would add a pointless subscription. + // (Vercel Best Practice: rerender-defer-reads 5.2) + const params = new URLSearchParams(window.location.search); + const token = params.get(tokenParamName); + const refreshToken = params.get("refresh_token"); if (token) { try { @@ -74,7 +75,7 @@ const TokenHandler = ({ window.location.href = redirectPath; } } - }, [searchParams, tokenParamName, storageKey, redirectPath]); + }, [tokenParamName, storageKey, redirectPath]); // Return null - the global provider handles the loading UI return null; diff --git a/surfsense_web/components/public-chat/public-chat-footer.tsx b/surfsense_web/components/public-chat/public-chat-footer.tsx index 79b317ddf..e341a9a0c 100644 --- a/surfsense_web/components/public-chat/public-chat-footer.tsx +++ b/surfsense_web/components/public-chat/public-chat-footer.tsx @@ -1,7 +1,7 @@ "use client"; import { Copy } from "lucide-react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -15,7 +15,6 @@ interface PublicChatFooterProps { export function PublicChatFooter({ shareToken }: PublicChatFooterProps) { const router = useRouter(); - const searchParams = useSearchParams(); const [isCloning, setIsCloning] = useState(false); const hasAutoCloned = useRef(false); @@ -36,9 +35,11 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) { } }, [shareToken, router]); - // Auto-trigger clone if user just logged in with action=clone + // Auto-trigger clone if user just logged in with action=clone. + // Read from window.location.search on mount — no subscription needed since + // this is a one-time post-login check. (Vercel Best Practice: rerender-defer-reads 5.2) useEffect(() => { - const action = searchParams.get("action"); + const action = new URLSearchParams(window.location.search).get("action"); const token = getBearerToken(); // Only auto-clone once, if authenticated and action=clone is present @@ -46,7 +47,7 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) { hasAutoCloned.current = true; triggerClone(); } - }, [searchParams, isCloning, triggerClone]); + }, [isCloning, triggerClone]); const handleCopyAndContinue = async () => { const token = getBearerToken();