From 236fcda67173467c2d07db30f839e2f5f707f7ff Mon Sep 17 00:00:00 2001 From: yohlo Date: Mon, 9 Feb 2026 23:53:54 -0600 Subject: [PATCH] session fixes --- src/app/routes/__root.tsx | 2 + src/app/routes/refresh-session.tsx | 25 +++++--- src/components/session-monitor.tsx | 60 +++++++++++++++++++ src/features/players/queries.ts | 42 ++++++++++--- src/lib/supertokens/client.ts | 41 +++++++------ .../hooks/use-server-mutation.ts | 39 ++++++++---- 6 files changed, 162 insertions(+), 47 deletions(-) create mode 100644 src/components/session-monitor.tsx diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx index e630790..03b49f4 100644 --- a/src/app/routes/__root.tsx +++ b/src/app/routes/__root.tsx @@ -11,6 +11,7 @@ import { type QueryClient } from "@tanstack/react-query"; import { ensureSuperTokensFrontend } from "@/lib/supertokens/client"; import { AuthContextType } from "@/contexts/auth-context"; import Providers from "@/features/core/components/providers"; +import { SessionMonitor } from "@/components/session-monitor"; import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core"; import { HeaderConfig } from "@/features/core/types/header-config"; import { playerQueries } from "@/features/players/queries"; @@ -126,6 +127,7 @@ function RootComponent() { return ( + diff --git a/src/app/routes/refresh-session.tsx b/src/app/routes/refresh-session.tsx index dad66b0..9d627ed 100644 --- a/src/app/routes/refresh-session.tsx +++ b/src/app/routes/refresh-session.tsx @@ -2,7 +2,8 @@ import { createFileRoute } from '@tanstack/react-router' import { useEffect, useRef } from 'react' import FullScreenLoader from '@/components/full-screen-loader' import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session' -import { resetRefreshFlag } from '@/lib/supertokens/client' +import { resetRefreshFlag, getOrCreateRefreshPromise } from '@/lib/supertokens/client' +import { logger } from '@/lib/supertokens' export const Route = createFileRoute('/refresh-session')({ component: RouteComponent, @@ -17,23 +18,31 @@ function RouteComponent() { const handleRefresh = async () => { try { - resetRefreshFlag(); - const refreshed = await attemptRefreshingSession() + logger.info("Refresh session route: starting refresh"); + + const refreshed = await getOrCreateRefreshPromise(async () => { + return await attemptRefreshingSession(); + }); if (refreshed) { - const urlParams = new URLSearchParams(window.location.search) - const redirect = urlParams.get('redirect') + logger.info("Refresh session route: refresh successful"); + const urlParams = new URLSearchParams(window.location.search); + const redirect = urlParams.get('redirect'); if (redirect && !redirect.includes('_serverFn') && !redirect.includes('/api/')) { - window.location.href = decodeURIComponent(redirect) + logger.info("Refresh session route: redirecting to", redirect); + window.location.href = decodeURIComponent(redirect); } else { + logger.info("Refresh session route: redirecting to home"); window.location.href = '/'; } } else { - window.location.href = '/login' + logger.warn("Refresh session route: refresh failed, redirecting to login"); + window.location.href = '/login'; } } catch (error) { - window.location.href = '/login' + logger.error("Refresh session route: error during refresh", error); + window.location.href = '/login'; } } diff --git a/src/components/session-monitor.tsx b/src/components/session-monitor.tsx new file mode 100644 index 0000000..ee4e3ec --- /dev/null +++ b/src/components/session-monitor.tsx @@ -0,0 +1,60 @@ +import { useEffect, useRef } from 'react'; +import { doesSessionExist } from 'supertokens-web-js/recipe/session'; +import { getOrCreateRefreshPromise } from '@/lib/supertokens/client'; +import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session'; +import { logger } from '@/lib/supertokens'; + +export function SessionMonitor() { + const lastRefreshTimeRef = useRef(0); + const REFRESH_COOLDOWN = 30 * 1000; + + useEffect(() => { + if (typeof window === 'undefined') return; + + const handleVisibilityChange = async () => { + if (document.visibilityState !== 'visible') return; + + const publicRoutes = ['/login', '/logout', '/refresh-session']; + if (publicRoutes.some(route => window.location.pathname === route)) { + return; + } + + const now = Date.now(); + if (now - lastRefreshTimeRef.current < REFRESH_COOLDOWN) { + logger.info('Session monitor: skipping refresh (cooldown)'); + return; + } + + try { + const sessionExists = await doesSessionExist(); + if (!sessionExists) { + logger.info('Session monitor: no session exists, skipping refresh'); + return; + } + + logger.info('Session monitor: tab became visible, refreshing session'); + + const refreshed = await getOrCreateRefreshPromise(async () => { + return await attemptRefreshingSession(); + }); + + if (refreshed) { + lastRefreshTimeRef.current = Date.now(); + logger.info('Session monitor: session refreshed successfully'); + } else { + logger.warn('Session monitor: refresh returned false'); + } + } catch (error) { + logger.error('Session monitor: error refreshing session', error); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, []); + + return null; +} diff --git a/src/features/players/queries.ts b/src/features/players/queries.ts index eb61288..53f2b49 100644 --- a/src/features/players/queries.ts +++ b/src/features/players/queries.ts @@ -1,5 +1,8 @@ import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks"; import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches, getUnenrolledPlayers, getPlayersActivity } from "./server"; +import { logger } from '@/lib/supertokens'; + +let queryRefreshRedirect: Promise | null = null; export const playerKeys = { auth: ['auth'], @@ -54,24 +57,45 @@ export const playerQueries = { export const useMe = () => { const { queryKey, queryFn } = playerQueries.auth(); - return useServerSuspenseQuery({ - queryKey, + return useServerSuspenseQuery({ + queryKey, queryFn, options: { - staleTime: 0, - refetchOnMount: true, + staleTime: 30 * 1000, + refetchOnMount: false, + refetchOnWindowFocus: true, retry: (failureCount, error: any) => { if (error?.response?.status === 401) { const errorData = error?.response?.data; - if (errorData?.error === "SESSION_REFRESH_REQUIRED") { - const currentUrl = window.location.pathname + window.location.search; - window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`; + if (errorData?.error === 'SESSION_REFRESH_REQUIRED') { + logger.warn("Query detected SESSION_REFRESH_REQUIRED"); + + if (!queryRefreshRedirect) { + const currentUrl = window.location.pathname + window.location.search; + logger.info("Query initiating refresh redirect to:", currentUrl); + + queryRefreshRedirect = new Promise((resolve) => { + setTimeout(() => { + window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`; + resolve(); + }, 100); + }); + + queryRefreshRedirect.finally(() => { + setTimeout(() => { + queryRefreshRedirect = null; + }, 1000); + }); + } else { + logger.info("Query: refresh redirect already in progress"); + } + return false; } } return failureCount < 3; - } - } + }, + }, }); }; diff --git a/src/lib/supertokens/client.ts b/src/lib/supertokens/client.ts index fbed58a..d994557 100644 --- a/src/lib/supertokens/client.ts +++ b/src/lib/supertokens/client.ts @@ -4,30 +4,34 @@ import Passwordless from "supertokens-web-js/recipe/passwordless"; import { appInfo } from "./config"; import { logger } from "./"; -let refreshAttemptCount = 0; +let refreshPromise: Promise | null = null; export const resetRefreshFlag = () => { - refreshAttemptCount = 0; + refreshPromise = null; }; -const setupFetchInterceptor = () => { - if (typeof window === 'undefined') return; +export const getOrCreateRefreshPromise = (refreshFn: () => Promise): Promise => { + if (refreshPromise) { + logger.info("Reusing existing refresh promise"); + return refreshPromise; + } - const originalFetch = window.fetch; - //@ts-ignore - window.fetch = async (resource: RequestInfo | URL, options?: RequestInit) => { - const url = typeof resource === 'string' ? resource : - resource instanceof URL ? resource.toString() : resource.url; + logger.info("Creating new refresh promise"); + refreshPromise = refreshFn() + .then((result) => { + logger.info("Refresh completed successfully:", result); + setTimeout(() => { + refreshPromise = null; + }, 500); + return result; + }) + .catch((error) => { + logger.error("Refresh failed:", error); + refreshPromise = null; + throw error; + }); - if (url.includes('/api/auth/session/refresh')) { - refreshAttemptCount++; - if (refreshAttemptCount > 1) { - throw new Error('Duplicate refresh attempt blocked'); - } - } - - return originalFetch.call(window, resource, options); - }; + return refreshPromise; }; export const frontendConfig = () => { @@ -53,7 +57,6 @@ export function ensureSuperTokensFrontend() { if (typeof window === "undefined") return; if (!initialized) { - setupFetchInterceptor(); SuperTokens.init(frontendConfig()); initialized = true; logger.info("SuperTokens initialized"); diff --git a/src/lib/tanstack-query/hooks/use-server-mutation.ts b/src/lib/tanstack-query/hooks/use-server-mutation.ts index 673768c..9484785 100644 --- a/src/lib/tanstack-query/hooks/use-server-mutation.ts +++ b/src/lib/tanstack-query/hooks/use-server-mutation.ts @@ -1,8 +1,10 @@ import { useMutation, UseMutationOptions } from "@tanstack/react-query"; import { ServerResult } from "../types"; import toast from '@/lib/sonner' +import { logger } from '@/lib/supertokens' -let isMutationRefreshingSession = false; + +let sessionRefreshRedirect: Promise | null = null; export function useServerMutation( options: Omit, 'mutationFn'> & { @@ -39,24 +41,39 @@ export function useServerMutation( } catch (error: any) { if (error?.response?.status === 401) { try { - const errorData = typeof error.response.data === 'string' - ? JSON.parse(error.response.data) + const errorData = typeof error.response.data === 'string' + ? JSON.parse(error.response.data) : error.response.data; - + if (errorData?.error === "SESSION_REFRESH_REQUIRED") { - if (!isMutationRefreshingSession) { - isMutationRefreshingSession = true; + logger.warn("Mutation detected SESSION_REFRESH_REQUIRED"); + + if (!sessionRefreshRedirect) { const currentUrl = window.location.pathname + window.location.search; - setTimeout(() => { - isMutationRefreshingSession = false; - }, 1000); - window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`; + logger.info("Mutation initiating refresh redirect to:", currentUrl); + + sessionRefreshRedirect = new Promise((resolve) => { + setTimeout(() => { + window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`; + resolve(); + }, 100); + }); + + sessionRefreshRedirect.finally(() => { + setTimeout(() => { + sessionRefreshRedirect = null; + }, 1000); + }); + } else { + logger.info("Mutation: refresh redirect already in progress, waiting..."); + await sessionRefreshRedirect; } + throw new Error("SESSION_REFRESH_REQUIRED"); } } catch (parseError) {} } - + throw error; } },