diff --git a/packages/better-auth/src/client/session-refresh.test.ts b/packages/better-auth/src/client/session-refresh.test.ts index ca8ec75b77..95b1a07d33 100644 --- a/packages/better-auth/src/client/session-refresh.test.ts +++ b/packages/better-auth/src/client/session-refresh.test.ts @@ -1,10 +1,17 @@ // @vitest-environment happy-dom import { atom } from "nanostores"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getGlobalOnlineManager } from "./online-manager"; import { createSessionRefreshManager } from "./session-refresh"; describe("session-refresh", () => { + beforeEach(() => { + // Reset online manager state before each test + const onlineManager = getGlobalOnlineManager(); + onlineManager.setOnline(true); + }); + it("should trigger network fetch and update session when refetchInterval fires", async () => { vi.useFakeTimers(); @@ -59,4 +66,336 @@ describe("session-refresh", () => { manager.cleanup(); vi.useRealTimers(); }); + + it("should rate limit refetch on focus if a session request was made recently", async () => { + vi.useFakeTimers(); + + const sessionAtom = atom({ + data: { + user: { id: "1", email: "test@test.com" }, + session: { id: "session-1" }, + }, + error: null, + isPending: false, + isRefetching: false, + }); + const sessionSignal = atom(false); + + let signalChangeCount = 0; + const unsubscribeSignal = sessionSignal.subscribe(() => { + signalChangeCount++; + }); + + const mockFetch = vi.fn(async () => ({ + data: { user: { id: "1" }, session: { id: "session-1" } }, + error: null, + })); + + const manager = createSessionRefreshManager({ + sessionAtom, + sessionSignal, + $fetch: mockFetch as any, + options: { + sessionOptions: { + refetchOnWindowFocus: true, + }, + }, + }); + + manager.init(); + + // Trigger a poll event to set lastSessionRequest + manager.triggerRefetch({ event: "poll" }); + await vi.runAllTimersAsync(); + + const initialSignalCount = signalChangeCount; + + // Immediately trigger a focus event (within rate limit window) + manager.triggerRefetch({ event: "visibilitychange" }); + + // Signal should not change because rate limit prevents refetch + expect(signalChangeCount).toBe(initialSignalCount); + + unsubscribeSignal(); + manager.cleanup(); + vi.useRealTimers(); + }); + + it("should allow refetch on focus after rate limit window expires", async () => { + vi.useFakeTimers(); + + const sessionAtom = atom({ + data: { + user: { id: "1", email: "test@test.com" }, + session: { id: "session-1" }, + }, + error: null, + isPending: false, + }); + const sessionSignal = atom(false); + + let signalChangeCount = 0; + const unsubscribeSignal = sessionSignal.subscribe(() => { + signalChangeCount++; + }); + + const mockFetch = vi.fn(async () => ({ + data: { user: { id: "1" }, session: { id: "session-1" } }, + error: null, + })); + + const manager = createSessionRefreshManager({ + sessionAtom, + sessionSignal, + $fetch: mockFetch as any, + options: { + sessionOptions: { + refetchOnWindowFocus: true, + }, + }, + }); + + manager.init(); + + // Trigger a poll event to set lastSessionRequest + manager.triggerRefetch({ event: "poll" }); + await vi.runAllTimersAsync(); + + const initialSignalCount = signalChangeCount; + + // Advance time by 6 seconds (more than the 5 second rate limit) + await vi.advanceTimersByTimeAsync(6000); + + // Now trigger a focus event (after rate limit window) + manager.triggerRefetch({ event: "visibilitychange" }); + + // Signal should change because rate limit has expired + expect(signalChangeCount).toBeGreaterThan(initialSignalCount); + + unsubscribeSignal(); + manager.cleanup(); + vi.useRealTimers(); + }); + + it("should allow refetch on focus when session is null even within rate limit", async () => { + vi.useFakeTimers(); + + const sessionAtom = atom({ + data: { + user: { id: "1", email: "test@test.com" }, + session: { id: "session-1" }, + }, + error: null, + isPending: false, + }); + const sessionSignal = atom(false); + + let signalChangeCount = 0; + const unsubscribeSignal = sessionSignal.subscribe(() => { + signalChangeCount++; + }); + + const mockFetch = vi.fn(async () => ({ + data: { user: { id: "1" }, session: { id: "session-1" } }, + error: null, + })); + + const manager = createSessionRefreshManager({ + sessionAtom, + sessionSignal, + $fetch: mockFetch as any, + options: { + sessionOptions: { + refetchOnWindowFocus: true, + }, + }, + }); + + manager.init(); + + // Trigger a visibilitychange event with session data to set lastSessionRequest + manager.triggerRefetch({ event: "visibilitychange" }); + const signalCountAfterFirstFocus = signalChangeCount; + expect(signalCountAfterFirstFocus).toBeGreaterThan(0); + + // Now set session to null and trigger another focus event + sessionAtom.set({ + data: null as any, + error: null, + isPending: false, + }); + + // Immediately trigger another focus event (within rate limit window) + manager.triggerRefetch({ event: "visibilitychange" }); + + // Signal should change because session is null (rate limit bypassed) + expect(signalChangeCount).toBeGreaterThan(signalCountAfterFirstFocus); + + unsubscribeSignal(); + manager.cleanup(); + vi.useRealTimers(); + }); + + it("should allow refetch on focus when session is undefined even within rate limit", async () => { + vi.useFakeTimers(); + + const sessionAtom = atom({ + data: { + user: { id: "1", email: "test@test.com" }, + session: { id: "session-1" }, + }, + error: null, + isPending: false, + }); + const sessionSignal = atom(false); + + let signalChangeCount = 0; + const unsubscribeSignal = sessionSignal.subscribe(() => { + signalChangeCount++; + }); + + const mockFetch = vi.fn(async () => ({ + data: { user: { id: "1" }, session: { id: "session-1" } }, + error: null, + })); + + const manager = createSessionRefreshManager({ + sessionAtom, + sessionSignal, + $fetch: mockFetch as any, + options: { + sessionOptions: { + refetchOnWindowFocus: true, + }, + }, + }); + + manager.init(); + + // Trigger a visibilitychange event with session data to set lastSessionRequest + manager.triggerRefetch({ event: "visibilitychange" }); + const signalCountAfterFirstFocus = signalChangeCount; + expect(signalCountAfterFirstFocus).toBeGreaterThan(0); + + // Now set session to undefined and trigger another focus event + sessionAtom.set({ + data: undefined as any, + error: null, + isPending: false, + }); + + // Immediately trigger another focus event (within rate limit window) + manager.triggerRefetch({ event: "visibilitychange" }); + + // Signal should change because session is undefined (rate limit bypassed) + expect(signalChangeCount).toBeGreaterThan(signalCountAfterFirstFocus); + + unsubscribeSignal(); + manager.cleanup(); + vi.useRealTimers(); + }); + + it("should update lastSessionRequest when poll event triggers fetch", async () => { + vi.useFakeTimers(); + + const sessionAtom = atom({ + data: { + user: { id: "1", email: "test@test.com" }, + session: { id: "session-1" }, + }, + error: null, + isPending: false, + }); + const sessionSignal = atom(false); + + let signalChangeCount = 0; + const unsubscribeSignal = sessionSignal.subscribe(() => { + signalChangeCount++; + }); + + const mockFetch = vi.fn(async () => ({ + data: { user: { id: "1" }, session: { id: "session-1" } }, + error: null, + })); + + const manager = createSessionRefreshManager({ + sessionAtom, + sessionSignal, + $fetch: mockFetch as any, + }); + + // Trigger a poll event - this will trigger a signal change + manager.triggerRefetch({ event: "poll" }); + await vi.runAllTimersAsync(); + + const signalCountAfterPoll = signalChangeCount; + expect(signalCountAfterPoll).toBeGreaterThan(0); + + // Immediately trigger a focus event - should be rate limited + manager.triggerRefetch({ event: "visibilitychange" }); + + // Should be rate limited (no additional signal change) + expect(signalChangeCount).toBe(signalCountAfterPoll); + + unsubscribeSignal(); + manager.cleanup(); + vi.useRealTimers(); + }); + + it("should not refetch when offline unless refetchWhenOffline is true", () => { + const onlineManager = getGlobalOnlineManager(); + // Ensure we start online, then set offline + onlineManager.setOnline(true); + onlineManager.setOnline(false); + + const sessionAtom = atom({ + data: { + user: { id: "1", email: "test@test.com" }, + session: { id: "session-1" }, + }, + error: null, + isPending: false, + }); + const sessionSignal = atom(false); + + // Track signal changes from the start + let signalChangeCount = 0; + const unsubscribeSignal = sessionSignal.subscribe(() => { + signalChangeCount++; + }); + + const mockFetch = vi.fn(async () => ({ + data: { user: { id: "1" }, session: { id: "session-1" } }, + error: null, + })); + + const manager = createSessionRefreshManager({ + sessionAtom, + sessionSignal, + $fetch: mockFetch as any, + options: { + sessionOptions: { + refetchWhenOffline: false, + }, + }, + }); + + // Verify we're offline + expect(onlineManager.isOnline).toBe(false); + + // Get initial signal count (should be 0, but capture it) + const initialSignalCount = signalChangeCount; + + // Trigger refetch - should be blocked by shouldRefetch() returning false + manager.triggerRefetch({ event: "visibilitychange" }); + + // Should not refetch when offline (shouldRefetch returns false) + // Signal count should remain the same + expect(signalChangeCount).toBe(initialSignalCount); + expect(mockFetch).not.toHaveBeenCalled(); + + unsubscribeSignal(); + manager.cleanup(); + onlineManager.setOnline(true); + }); }); diff --git a/packages/better-auth/src/client/session-refresh.ts b/packages/better-auth/src/client/session-refresh.ts index 7f4eeda728..d66e9dbf9f 100644 --- a/packages/better-auth/src/client/session-refresh.ts +++ b/packages/better-auth/src/client/session-refresh.ts @@ -7,6 +7,11 @@ import { getGlobalOnlineManager } from "./online-manager"; const now = () => Math.floor(Date.now() / 1000); +/** + * Rate limit: don't refetch on focus if a session request was made within this many seconds + */ +const FOCUS_REFETCH_RATE_LIMIT_SECONDS = 5; + export interface SessionRefreshOptions { sessionAtom: WritableAtom; sessionSignal: WritableAtom; @@ -16,6 +21,7 @@ export interface SessionRefreshOptions { interface SessionRefreshState { lastSync: number; + lastSessionRequest: number; cachedSession: any; pollInterval?: ReturnType | undefined; unsubscribeBroadcast?: (() => void) | undefined; @@ -34,6 +40,7 @@ export function createSessionRefreshManager(opts: SessionRefreshOptions) { const state: SessionRefreshState = { lastSync: 0, + lastSessionRequest: 0, cachedSession: undefined, }; @@ -59,6 +66,7 @@ export function createSessionRefreshManager(opts: SessionRefreshOptions) { const currentSession = sessionAtom.get(); if (event?.event === "poll") { + state.lastSessionRequest = now(); $fetch("/get-session") .then((res) => { sessionAtom.set({ @@ -73,11 +81,26 @@ export function createSessionRefreshManager(opts: SessionRefreshOptions) { return; } + // Rate limit: don't refetch on focus if a session request was made recently + if (event?.event === "visibilitychange") { + const timeSinceLastRequest = now() - state.lastSessionRequest; + if ( + timeSinceLastRequest < FOCUS_REFETCH_RATE_LIMIT_SECONDS && + currentSession?.data !== null && + currentSession?.data !== undefined + ) { + return; + } + } + if ( currentSession?.data === null || currentSession?.data === undefined || event?.event === "visibilitychange" ) { + if (event?.event === "visibilitychange") { + state.lastSessionRequest = now(); + } state.lastSync = now(); sessionSignal.set(!sessionSignal.get()); } @@ -155,6 +178,7 @@ export function createSessionRefreshManager(opts: SessionRefreshOptions) { state.unsubscribeOnline = undefined; } state.lastSync = 0; + state.lastSessionRequest = 0; state.cachedSession = undefined; };