From 8960f5f3bd2f0dccbfb768d69737d8a24d793a9e Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Thu, 11 Jun 2026 15:49:34 -0700 Subject: [PATCH] fix(client): restructure session fetch architecture (#8760) --- .changeset/restructure-session-fetch.md | 5 + .../better-auth/src/client/equality.test.ts | 141 +++ packages/better-auth/src/client/equality.ts | 57 ++ packages/better-auth/src/client/index.ts | 1 + packages/better-auth/src/client/query.test.ts | 134 +++ packages/better-auth/src/client/query.ts | 38 +- .../better-auth/src/client/session-atom.ts | 205 ++++- .../src/client/session-refresh.test.ts | 813 +++--------------- .../better-auth/src/client/session-refresh.ts | 148 ++-- packages/electron/src/client.ts | 22 +- packages/expo/src/client.ts | 22 +- 11 files changed, 782 insertions(+), 804 deletions(-) create mode 100644 .changeset/restructure-session-fetch.md create mode 100644 packages/better-auth/src/client/equality.test.ts create mode 100644 packages/better-auth/src/client/equality.ts diff --git a/.changeset/restructure-session-fetch.md b/.changeset/restructure-session-fetch.md new file mode 100644 index 0000000000..549f8e03da --- /dev/null +++ b/.changeset/restructure-session-fetch.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +Session refreshes now avoid duplicate `/get-session` requests from focus and other browser session events. Client hooks keep stable data references when refetches return unchanged data, reducing unnecessary renders. Unmounting during an in-flight session request no longer leaves session state stuck in a loading state. diff --git a/packages/better-auth/src/client/equality.test.ts b/packages/better-auth/src/client/equality.test.ts new file mode 100644 index 0000000000..10ae34b85a --- /dev/null +++ b/packages/better-auth/src/client/equality.test.ts @@ -0,0 +1,141 @@ +import { atom } from "nanostores"; +import { describe, expect, it, vi } from "vitest"; +import { isJsonEqual, withEquality } from "./equality"; + +describe("isJsonEqual", () => { + it("returns true for identical primitives", () => { + expect(isJsonEqual(1, 1)).toBe(true); + expect(isJsonEqual("a", "a")).toBe(true); + expect(isJsonEqual(true, true)).toBe(true); + expect(isJsonEqual(null, null)).toBe(true); + }); + + it("returns false for different primitives", () => { + expect(isJsonEqual(1, 2)).toBe(false); + expect(isJsonEqual("a", "b")).toBe(false); + expect(isJsonEqual(true, false)).toBe(false); + expect(isJsonEqual(null, 1)).toBe(false); + expect(isJsonEqual(null, "a")).toBe(false); + }); + + it("returns true for same-reference objects", () => { + const obj = { a: 1 }; + expect(isJsonEqual(obj, obj)).toBe(true); + }); + + it("returns true for structurally equal objects", () => { + expect(isJsonEqual({ a: 1, b: "x" }, { a: 1, b: "x" })).toBe(true); + }); + + it("returns true for nested objects", () => { + expect( + isJsonEqual( + { user: { id: "1", email: "a@b.com" }, session: { id: "s1" } }, + { user: { id: "1", email: "a@b.com" }, session: { id: "s1" } }, + ), + ).toBe(true); + }); + + it("returns false for objects with different values", () => { + expect(isJsonEqual({ a: 1 }, { a: 2 })).toBe(false); + }); + + it("returns false for objects with different keys", () => { + expect(isJsonEqual({ a: 1 }, { b: 1 })).toBe(false); + expect(isJsonEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false); + }); + + it("returns true for identical arrays", () => { + expect(isJsonEqual([1, 2, 3], [1, 2, 3])).toBe(true); + }); + + it("returns false for arrays of different length", () => { + expect(isJsonEqual([1, 2], [1, 2, 3])).toBe(false); + }); + + it("returns false for arrays with different elements", () => { + expect(isJsonEqual([1, 2, 3], [1, 2, 4])).toBe(false); + }); + + it("returns true for deeply nested structures", () => { + expect( + isJsonEqual( + { a: [{ b: [1, 2] }, { c: null }] }, + { a: [{ b: [1, 2] }, { c: null }] }, + ), + ).toBe(true); + }); + + it("returns false for null vs object", () => { + expect(isJsonEqual(null, {})).toBe(false); + expect(isJsonEqual({}, null)).toBe(false); + }); + + it("returns false for array vs object", () => { + expect(isJsonEqual([], {})).toBe(false); + }); +}); + +describe("withEquality", () => { + it("suppresses set when values are structurally equal", () => { + const store = atom({ a: 1, b: "x" }); + withEquality(store, isJsonEqual); + + const listener = vi.fn(); + store.listen(listener); + + store.set({ a: 1, b: "x" }); + expect(listener).not.toHaveBeenCalled(); + }); + + it("allows set when values differ", () => { + const store = atom({ a: 1 }); + withEquality(store, isJsonEqual); + + const listener = vi.fn(); + store.listen(listener); + + store.set({ a: 2 }); + expect(listener).toHaveBeenCalledOnce(); + expect(store.get()).toEqual({ a: 2 }); + }); + + it("returns an unsubscribe function that removes the gate", () => { + const store = atom({ a: 1 }); + const unbind = withEquality(store, isJsonEqual); + + const listener = vi.fn(); + store.listen(listener); + + store.set({ a: 1 }); + expect(listener).not.toHaveBeenCalled(); + + unbind(); + + store.set({ a: 1 }); + expect(listener).toHaveBeenCalledOnce(); + }); + + it("works with nested session-like data", () => { + const store = atom({ + data: { user: { id: "1" }, session: { id: "s1" } }, + error: null, + }); + withEquality(store, isJsonEqual); + + const listener = vi.fn(); + store.listen(listener); + + store.set({ + data: { user: { id: "1" }, session: { id: "s1" } }, + error: null, + }); + expect(listener).not.toHaveBeenCalled(); + + store.set({ + data: { user: { id: "2" }, session: { id: "s1" } }, + error: null, + }); + expect(listener).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/better-auth/src/client/equality.ts b/packages/better-auth/src/client/equality.ts new file mode 100644 index 0000000000..4bf57b1f9a --- /dev/null +++ b/packages/better-auth/src/client/equality.ts @@ -0,0 +1,57 @@ +import type { Store, StoreValue } from "nanostores"; +import { onSet } from "nanostores"; + +function isPlainObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** + * Deep structural equality for JSON-serializable values. + * Handles: primitives, null, arrays, and plain objects. + * Short-circuits on referential equality at every recursion level. + */ +export function isJsonEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!isJsonEqual(a[i], b[i])) return false; + } + return true; + } + + if (isPlainObject(a) && isPlainObject(b)) { + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + for (const key of keysA) { + if (!(key in b) || !isJsonEqual(a[key], b[key])) return false; + } + return true; + } + + return false; +} + +/** + * Attach an equality gate to a nanostores atom via `onSet`. + * When `isEqual(currentValue, newValue)` returns true, the `set()` call + * is aborted: no listeners fire, no framework re-renders occur. + * + * Returns the unsubscribe function from `onSet`. + */ +export function withEquality( + store: S, + isEqual: (a: StoreValue, b: StoreValue) => boolean, +): () => void { + return onSet(store, ({ newValue, abort }) => { + if (isEqual(store.value, newValue)) { + abort(); + } + }); +} diff --git a/packages/better-auth/src/client/index.ts b/packages/better-auth/src/client/index.ts index f01245623c..d9977dad1f 100644 --- a/packages/better-auth/src/client/index.ts +++ b/packages/better-auth/src/client/index.ts @@ -6,6 +6,7 @@ import type { import { PACKAGE_VERSION } from "../version"; export * from "./broadcast-channel"; +export * from "./equality"; export { type FocusListener, type FocusManager, diff --git a/packages/better-auth/src/client/query.test.ts b/packages/better-auth/src/client/query.test.ts index c6aaae0b4f..f64620bab6 100644 --- a/packages/better-auth/src/client/query.test.ts +++ b/packages/better-auth/src/client/query.test.ts @@ -3,7 +3,9 @@ import { createFetch } from "@better-fetch/fetch"; import { atom } from "nanostores"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getGlobalFocusManager } from "./focus-manager"; import { useAuthQuery } from "./query"; +import { getSessionAtom } from "./session-atom"; import { createAuthClient } from "./solid"; import { testClientPlugin } from "./test-plugin"; @@ -18,6 +20,9 @@ describe("useAuthQuery - error handling", () => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); + delete (globalThis as any)[Symbol.for("better-auth:broadcast-channel")]; + delete (globalThis as any)[Symbol.for("better-auth:focus-manager")]; + delete (globalThis as any)[Symbol.for("better-auth:online-manager")]; }); it("should preserve stale data on network error (fetch throws)", async () => { @@ -99,6 +104,44 @@ describe("useAuthQuery - error handling", () => { expect(session().data).toBeNull(); }); + it("should normalize null session responses to null data", async () => { + const client = createAuthClient({ + plugins: [testClientPlugin()], + fetchOptions: { + customFetchImpl: async () => + new Response(JSON.stringify({ session: null, user: null })), + baseURL: "http://localhost:3000", + }, + }); + + const session = client.useSession(); + await vi.runAllTimersAsync(); + + expect(session().data).toBeNull(); + }); + + it("should preserve non-null session responses without a session object", async () => { + const client = createAuthClient({ + plugins: [testClientPlugin()], + fetchOptions: { + customFetchImpl: async () => + new Response( + JSON.stringify({ + user: { id: "1", email: "test@test.com" }, + }), + ), + baseURL: "http://localhost:3000", + }, + }); + + const session = client.useSession(); + await vi.runAllTimersAsync(); + + expect(session().data).toMatchObject({ + user: { id: "1", email: "test@test.com" }, + }); + }); + /** * @see https://github.com/better-auth/better-auth/issues/9077 */ @@ -214,4 +257,95 @@ describe("useAuthQuery - error handling", () => { user: { id: "1", email: "test@test.com" }, }); }); + + it("should preserve the session data reference when refetch returns identical data", async () => { + const getSessionPayload = () => ({ + user: { id: "1", email: "test@test.com" }, + session: { id: "session-1" }, + }); + + const client = createAuthClient({ + plugins: [testClientPlugin()], + fetchOptions: { + customFetchImpl: async () => + new Response(JSON.stringify(getSessionPayload())), + baseURL: "http://localhost:3000", + }, + }); + + const session = client.useSession(); + await vi.runAllTimersAsync(); + + const initialData = session().data; + expect(initialData).not.toBeNull(); + + await session().refetch(); + await vi.runAllTimersAsync(); + + // Reference should be preserved because data is structurally identical + expect(session().data).toBe(initialData); + }); + + it("should clear loading flags when an unmounted session request is aborted", async () => { + let fetchSignal: AbortSignal | undefined; + const $fetch = createFetch({ + baseURL: "http://localhost:3000", + customFetchImpl: async (_url, init) => { + fetchSignal = init?.signal ?? undefined; + return new Promise(() => {}); + }, + }); + const { session } = getSessionAtom($fetch); + + const unsubscribe = session.listen(() => {}); + await vi.advanceTimersByTimeAsync(0); + + expect(session.get().isPending).toBe(true); + expect(session.get().isRefetching).toBe(true); + + unsubscribe(); + await vi.advanceTimersByTimeAsync(1000); + + expect(fetchSignal?.aborted).toBe(true); + expect(session.get().isPending).toBe(false); + expect(session.get().isRefetching).toBe(false); + }); + + /** + * @see https://github.com/better-auth/better-auth/issues/9613 + */ + it("should avoid an extra post-focus session fetch when the refreshed payload is unchanged", async () => { + let fetchCallCount = 0; + const getSessionPayload = () => ({ + user: { id: "1", email: "test@test.com" }, + session: { id: "session-1" }, + }); + + const client = createAuthClient({ + plugins: [testClientPlugin()], + sessionOptions: { + refetchOnWindowFocus: true, + }, + fetchOptions: { + customFetchImpl: async () => { + fetchCallCount++; + return new Response(JSON.stringify(getSessionPayload())); + }, + baseURL: "http://localhost:3000", + }, + }); + + const session = client.useSession(); + await vi.runAllTimersAsync(); + + const initialData = session().data; + expect(fetchCallCount).toBe(1); + + getGlobalFocusManager().setFocused(true); + await vi.runAllTimersAsync(); + + // Only 2 fetches: initial + focus refetch (no double-fetch) + expect(fetchCallCount).toBe(2); + expect(session().data).toBe(initialData); + }); }); diff --git a/packages/better-auth/src/client/query.ts b/packages/better-auth/src/client/query.ts index 7f772fc571..97895ce2ca 100644 --- a/packages/better-auth/src/client/query.ts +++ b/packages/better-auth/src/client/query.ts @@ -2,12 +2,13 @@ import type { ClientFetchOption } from "@better-auth/core"; import type { BetterFetch, BetterFetchError } from "@better-fetch/fetch"; import type { PreinitializedWritableAtom } from "nanostores"; import { atom, onMount } from "nanostores"; +import { isJsonEqual, withEquality } from "./equality"; import type { SessionQueryParams } from "./types"; // SSR detection const isServer = () => typeof window === "undefined"; -export type AuthQueryAtom = PreinitializedWritableAtom<{ +export type AuthQueryState = { data: null | T; error: null | BetterFetchError; isPending: boolean; @@ -15,7 +16,22 @@ export type AuthQueryAtom = PreinitializedWritableAtom<{ refetch: ( queryParams?: { query?: SessionQueryParams } | undefined, ) => Promise; -}>; +}; + +export type AuthQueryAtom = PreinitializedWritableAtom>; + +function isAuthQueryStateEqual( + a: AuthQueryState, + b: AuthQueryState, +): boolean { + return ( + isJsonEqual(a.data, b.data) && + a.error === b.error && + a.isPending === b.isPending && + a.isRefetching === b.isRefetching && + a.refetch === b.refetch + ); +} export const useAuthQuery = ( initializedAtom: @@ -41,6 +57,7 @@ export const useAuthQuery = ( isRefetching: false, refetch: (queryParams) => fn(queryParams), }); + withEquality(value, isAuthQueryStateEqual); const fn = async ( queryParams?: { query?: SessionQueryParams } | undefined, @@ -62,8 +79,15 @@ export const useAuthQuery = ( ...queryParams?.query, }, async onSuccess(context) { + const current = value.get(); + const stableData = + current.data != null && + context.data != null && + isJsonEqual(current.data, context.data) + ? current.data + : context.data; value.set({ - data: context.data, + data: stableData, error: null, isPending: false, isRefetching: false, @@ -122,8 +146,10 @@ export const useAuthQuery = ( : [initializedAtom]; let isInitialized = false; + const cleanups: (() => void)[] = []; + for (const initAtom of initializedAtom) { - initAtom.subscribe(async () => { + const unbind = initAtom.subscribe(async () => { if (isServer()) { // On server, don't trigger fetch return; @@ -140,13 +166,13 @@ export const useAuthQuery = ( } }, 0); return () => { - value.off(); - initAtom.off(); + for (const u of cleanups) u(); clearTimeout(timeoutId); }; }); } }); + cleanups.push(unbind); } return value; }; diff --git a/packages/better-auth/src/client/session-atom.ts b/packages/better-auth/src/client/session-atom.ts index 8a0c1e4c3a..e315e0540a 100644 --- a/packages/better-auth/src/client/session-atom.ts +++ b/packages/better-auth/src/client/session-atom.ts @@ -1,44 +1,227 @@ import type { BetterAuthClientOptions } from "@better-auth/core"; -import type { BetterFetch } from "@better-fetch/fetch"; +import type { BetterFetch, BetterFetchError } from "@better-fetch/fetch"; import { atom, onMount } from "nanostores"; import type { Session, User } from "../types"; -import type { AuthQueryAtom } from "./query"; -import { useAuthQuery } from "./query"; +import { isJsonEqual, withEquality } from "./equality"; +import type { AuthQueryAtom, AuthQueryState } from "./query"; import { createSessionRefreshManager } from "./session-refresh"; +import type { SessionQueryParams } from "./types"; + +// SSR detection +const isServer = () => typeof window === "undefined"; export type SessionAtom = AuthQueryAtom<{ user: User; session: Session; }>; +type SessionData = { + user: User; + session: Session; +} & Record; + +type SessionResponse = ( + | { session: null; user: null; needsRefresh?: boolean } + | { session: Session; user: User; needsRefresh?: boolean } +) & + Record; + +/** + * Normalize $fetch response: `throw: true` returns data directly, + * otherwise `{ data, error }`. + */ +function normalizeSessionResponse(res: unknown): { + data: SessionResponse | null; + error: unknown; +} { + if ( + typeof res === "object" && + res !== null && + "data" in res && + "error" in res + ) { + return res as { data: SessionResponse | null; error: unknown }; + } + return { data: res as SessionResponse, error: null }; +} + +function normalizeSessionData( + data: SessionResponse | null, +): SessionData | null { + if (!data) return null; + if (data.session === null && data.user === null) return null; + return data as SessionData; +} + +function isSessionAtomEqual( + a: AuthQueryState, + b: AuthQueryState, +): boolean { + return ( + isJsonEqual(a.data, b.data) && + a.error === b.error && + a.isPending === b.isPending && + a.isRefetching === b.isRefetching && + a.refetch === b.refetch + ); +} + export function getSessionAtom( $fetch: BetterFetch, options?: BetterAuthClientOptions | undefined, ) { const $signal = atom(false); - const session: SessionAtom = useAuthQuery<{ - user: User; - session: Session; - }>($signal, "/get-session", $fetch, { - method: "GET", + + let abortController: AbortController | undefined; + + const refetch = ( + queryParams?: { query?: SessionQueryParams } | undefined, + ): Promise => fetchSession(queryParams); + + const session: SessionAtom = atom>({ + data: null, + error: null, + isPending: true, + isRefetching: false, + refetch, }); + withEquality(session, isSessionAtomEqual); + + const settleAbortedFetch = (controller: AbortController) => { + if (abortController !== controller) return; + + const current = session.get(); + abortController = undefined; + + if (!current.isPending && !current.isRefetching) return; + + session.set({ + ...current, + isPending: false, + isRefetching: false, + refetch, + }); + }; + + const fetchSession = async ( + queryParams?: { query?: SessionQueryParams } | undefined, + ): Promise => { + abortController?.abort(); + const controller = new AbortController(); + abortController = controller; + + const current = session.get(); + session.set({ + ...current, + isPending: current.data === null, + isRefetching: true, + error: null, + refetch, + }); + + try { + const res = await $fetch("/get-session", { + method: "GET", + query: queryParams?.query, + signal: controller.signal, + }); + if (controller.signal.aborted) { + settleAbortedFetch(controller); + return; + } + + let { data, error } = normalizeSessionResponse(res); + + if (data?.needsRefresh) { + try { + const refreshRes = await $fetch("/get-session", { + method: "POST", + signal: controller.signal, + }); + if (controller.signal.aborted) { + settleAbortedFetch(controller); + return; + } + ({ data, error } = normalizeSessionResponse(refreshRes)); + } catch { + if (controller.signal.aborted) { + settleAbortedFetch(controller); + return; + } + } + } + + if (error) { + const latest = session.get(); + const isUnauthorized = (error as BetterFetchError)?.status === 401; + session.set({ + data: isUnauthorized ? null : latest.data, + error: error as BetterFetchError, + isPending: false, + isRefetching: false, + refetch, + }); + return; + } + + const sessionData = normalizeSessionData(data); + const current = session.get(); + const stableData = + current.data != null && + sessionData != null && + isJsonEqual(current.data, sessionData) + ? current.data + : sessionData; + session.set({ + data: stableData as SessionData | null, + error: null, + isPending: false, + isRefetching: false, + refetch, + }); + } catch (fetchError) { + if (controller.signal.aborted) { + settleAbortedFetch(controller); + return; + } + const latest = session.get(); + session.set({ + data: latest.data, + error: fetchError as BetterFetchError, + isPending: false, + isRefetching: false, + refetch, + }); + } + }; let broadcastSessionUpdate: ( trigger: "signout" | "getSession" | "updateUser", ) => void = () => {}; onMount(session, () => { + let timeoutId: ReturnType | undefined; + + if (!isServer()) { + timeoutId = setTimeout(() => { + void fetchSession(); + }, 0); + } + const refreshManager = createSessionRefreshManager({ - sessionAtom: session, + fetchSession, + shouldPollSession: () => session.get().data != null, sessionSignal: $signal, - $fetch, options, }); - refreshManager.init(); broadcastSessionUpdate = refreshManager.broadcastSessionUpdate; return () => { + if (timeoutId) clearTimeout(timeoutId); + const controller = abortController; + controller?.abort(); + if (controller) settleAbortedFetch(controller); refreshManager.cleanup(); }; }); diff --git a/packages/better-auth/src/client/session-refresh.test.ts b/packages/better-auth/src/client/session-refresh.test.ts index bd844e8d1c..e87f8a77b4 100644 --- a/packages/better-auth/src/client/session-refresh.test.ts +++ b/packages/better-auth/src/client/session-refresh.test.ts @@ -4,7 +4,6 @@ import { atom } from "nanostores"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getGlobalBroadcastChannel } from "./broadcast-channel"; import { getGlobalOnlineManager } from "./online-manager"; -import type { SessionAtom } from "./session-atom"; import { createSessionRefreshManager } from "./session-refresh"; describe("session-refresh", () => { @@ -18,87 +17,67 @@ describe("session-refresh", () => { vi.useRealTimers(); vi.restoreAllMocks(); delete (globalThis as any)[Symbol.for("better-auth:broadcast-channel")]; + delete (globalThis as any)[Symbol.for("better-auth:focus-manager")]; + delete (globalThis as any)[Symbol.for("better-auth:online-manager")]; }); - it("should trigger network fetch and update session when refetchInterval fires", async () => { - const sessionAtom: SessionAtom = atom({ - data: { - user: { id: "1", email: "old@test.com" }, - session: { id: "session-1" }, - }, - error: null, - isPending: false, - }); + it("should call fetchSession when refetchInterval fires", async () => { const sessionSignal = atom(false); - - const updatedSessionData = { - user: { id: "1", email: "new@test.com" }, - session: { id: "session-1" }, - }; - - let fetchCallCount = 0; - const mockFetch = vi.fn(async (url: string) => { - fetchCallCount++; - return { - data: updatedSessionData, - error: null, - }; - }); + const mockFetchSession = vi.fn(async () => {}); const manager = createSessionRefreshManager({ - sessionAtom, + fetchSession: mockFetchSession, sessionSignal, - $fetch: mockFetch as any, options: { sessionOptions: { - refetchInterval: 5, // 5 seconds + refetchInterval: 5, + }, + }, + }); + + manager.init(); + expect(mockFetchSession).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(5000); + expect(mockFetchSession).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(5000); + expect(mockFetchSession).toHaveBeenCalledTimes(2); + + manager.cleanup(); + }); + + it("should not poll when shouldPollSession returns false", async () => { + const sessionSignal = atom(false); + const mockFetchSession = vi.fn(async () => {}); + + const manager = createSessionRefreshManager({ + fetchSession: mockFetchSession, + shouldPollSession: () => false, + sessionSignal, + options: { + sessionOptions: { + refetchInterval: 5, }, }, }); manager.init(); - expect(fetchCallCount).toBe(0); - await vi.advanceTimersByTimeAsync(5000); - expect(fetchCallCount).toBe(1); - expect(mockFetch).toHaveBeenCalledWith("/get-session"); - - const updatedSession = sessionAtom.get(); - expect(updatedSession.data).toEqual(updatedSessionData); - expect(updatedSession.error).toBeNull(); + expect(mockFetchSession).not.toHaveBeenCalled(); manager.cleanup(); - vi.useRealTimers(); }); - it("should rate limit refetch on focus if a session request was made recently", async () => { - const sessionAtom: SessionAtom = atom({ - data: { - user: { id: "1", email: "test@test.com" }, - session: { id: "session-1" }, - }, - error: null, - isPending: false, - isRefetching: false, - }); + it("should rate limit refetch on focus if a session request was made recently", () => { 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 mockFetchSession = vi.fn(async () => {}); const manager = createSessionRefreshManager({ - sessionAtom, + fetchSession: mockFetchSession, sessionSignal, - $fetch: mockFetch as any, options: { sessionOptions: { refetchOnWindowFocus: true, @@ -110,46 +89,24 @@ describe("session-refresh", () => { // Trigger a poll event to set lastSessionRequest manager.triggerRefetch({ event: "poll" }); - await vi.runAllTimersAsync(); - - const initialSignalCount = signalChangeCount; + expect(mockFetchSession).toHaveBeenCalledTimes(1); // 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); + // Should be rate-limited + expect(mockFetchSession).toHaveBeenCalledTimes(1); - unsubscribeSignal(); manager.cleanup(); - vi.useRealTimers(); }); it("should allow refetch on focus after rate limit window expires", async () => { - const sessionAtom: 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 mockFetchSession = vi.fn(async () => {}); const manager = createSessionRefreshManager({ - sessionAtom, + fetchSession: mockFetchSession, sessionSignal, - $fetch: mockFetch as any, options: { sessionOptions: { refetchOnWindowFocus: true, @@ -161,50 +118,25 @@ describe("session-refresh", () => { // Trigger a poll event to set lastSessionRequest manager.triggerRefetch({ event: "poll" }); - await vi.runAllTimersAsync(); + expect(mockFetchSession).toHaveBeenCalledTimes(1); - const initialSignalCount = signalChangeCount; - - // Advance time by 6 seconds (more than the 5 second rate limit) + // Advance time past the rate limit window (5 seconds) await vi.advanceTimersByTimeAsync(6000); - // Now trigger a focus event (after rate limit window) + // Now trigger a focus event manager.triggerRefetch({ event: "visibilitychange" }); - await vi.runAllTimersAsync(); + expect(mockFetchSession).toHaveBeenCalledTimes(2); - // Signal should change because rate limit has expired - expect(signalChangeCount).toBeGreaterThan(initialSignalCount); - - unsubscribeSignal(); manager.cleanup(); - vi.useRealTimers(); }); - it("should rate limit refetch on focus when session is null", async () => { - const sessionAtom: SessionAtom = atom({ - data: { - user: { id: "1", email: "test@test.com" }, - session: { id: "session-1" }, - }, - error: null, - isPending: false, - }); + it("should rate limit refetch on focus even after a visibilitychange fetch", async () => { 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 mockFetchSession = vi.fn(async () => {}); const manager = createSessionRefreshManager({ - sessionAtom, + fetchSession: mockFetchSession, sessionSignal, - $fetch: mockFetch as any, options: { sessionOptions: { refetchOnWindowFocus: true, @@ -214,170 +146,28 @@ describe("session-refresh", () => { manager.init(); - // Trigger a visibilitychange event with session data to set lastSessionRequest + // Trigger a visibilitychange event manager.triggerRefetch({ event: "visibilitychange" }); - await vi.runAllTimersAsync(); - 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, - isRefetching: false, - refetch: sessionAtom.get().refetch, - }); + expect(mockFetchSession).toHaveBeenCalledTimes(1); // Immediately trigger another focus event (within rate limit window) manager.triggerRefetch({ event: "visibilitychange" }); - await vi.runAllTimersAsync(); + expect(mockFetchSession).toHaveBeenCalledTimes(1); - // Signal should NOT change because rate limit should apply even when session is null - expect(signalChangeCount).toBe(signalCountAfterFirstFocus); - - unsubscribeSignal(); manager.cleanup(); - vi.useRealTimers(); - }); - - it("should rate limit refetch on focus when session is undefined", async () => { - const sessionAtom: 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" }); - await vi.runAllTimersAsync(); - 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, - isRefetching: false, - refetch: sessionAtom.get().refetch, - }); - - // Immediately trigger another focus event (within rate limit window) - manager.triggerRefetch({ event: "visibilitychange" }); - await vi.runAllTimersAsync(); - - // Signal should NOT change because rate limit should apply even when session is undefined - expect(signalChangeCount).toBe(signalCountAfterFirstFocus); - - unsubscribeSignal(); - manager.cleanup(); - vi.useRealTimers(); - }); - - it("should update lastSessionRequest when poll event triggers fetch", async () => { - const sessionAtom: 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: 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 mockFetchSession = vi.fn(async () => {}); const manager = createSessionRefreshManager({ - sessionAtom, + fetchSession: mockFetchSession, sessionSignal, - $fetch: mockFetch as any, options: { sessionOptions: { refetchWhenOffline: false, @@ -385,463 +175,88 @@ describe("session-refresh", () => { }, }); - // 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" }); + expect(mockFetchSession).not.toHaveBeenCalled(); - // 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); }); - it("should call POST when server returns needsRefresh: true", async () => { - vi.useFakeTimers(); - - const sessionAtom: SessionAtom = atom({ - data: { - user: { id: "1", email: "test@test.com" }, - session: { id: "session-1" }, - }, - error: null, - isPending: false, - isRefetching: false, - }); + it("should call fetchSession for storage events", async () => { const sessionSignal = atom(false); - - const refreshedSessionData = { - user: { id: "1", email: "test@test.com" }, - session: { id: "session-1", expiresAt: new Date() }, - }; - - const mockFetch = vi.fn( - async (url: string, options?: { method?: string }) => { - if (options?.method === "POST") { - return { - data: refreshedSessionData, - error: null, - }; - } - return { - data: { - user: { id: "1", email: "test@test.com" }, - session: { id: "session-1" }, - needsRefresh: true, - }, - error: null, - }; - }, - ); + const mockFetchSession = vi.fn(async () => {}); const manager = createSessionRefreshManager({ - sessionAtom, + fetchSession: mockFetchSession, sessionSignal, - $fetch: mockFetch as any, - options: { - sessionOptions: { - refetchInterval: 5, - }, - }, }); manager.init(); - await vi.advanceTimersByTimeAsync(5000); - - expect(mockFetch).toHaveBeenCalledWith("/get-session"); - expect(mockFetch).toHaveBeenCalledWith("/get-session", { method: "POST" }); - expect(mockFetch).toHaveBeenCalledTimes(2); - - const updatedSession = sessionAtom.get(); - expect(updatedSession.data).toEqual(refreshedSessionData); + manager.triggerRefetch({ event: "storage" }); + expect(mockFetchSession).toHaveBeenCalledTimes(1); manager.cleanup(); - vi.useRealTimers(); }); - it("should not call POST when server returns needsRefresh: false", async () => { - vi.useFakeTimers(); - - const sessionAtom: SessionAtom = atom({ - data: { - user: { id: "1", email: "test@test.com" }, - session: { id: "session-1" }, - }, - error: null, - isPending: false, - isRefetching: false, - }); + it("should call fetchSession when $sessionSignal is flipped", async () => { const sessionSignal = atom(false); - - const mockFetch = vi.fn(async () => ({ - data: { - user: { id: "1", email: "test@test.com" }, - session: { id: "session-1" }, - needsRefresh: false, - }, - error: null, - })); + const mockFetchSession = vi.fn(async () => {}); const manager = createSessionRefreshManager({ - sessionAtom, + fetchSession: mockFetchSession, sessionSignal, - $fetch: mockFetch as any, - options: { - sessionOptions: { - refetchInterval: 5, - }, - }, }); manager.init(); - await vi.advanceTimersByTimeAsync(5000); - - expect(mockFetch).toHaveBeenCalledWith("/get-session"); - expect(mockFetch).toHaveBeenCalledTimes(1); - - manager.cleanup(); - vi.useRealTimers(); - }); - - it("should not call POST when needsRefresh is undefined (deferSessionRefresh not enabled)", async () => { - vi.useFakeTimers(); - - const sessionAtom: SessionAtom = atom({ - data: { - user: { id: "1", email: "test@test.com" }, - session: { id: "session-1" }, - }, - error: null, - isPending: false, - isRefetching: false, - }); - const sessionSignal = atom(false); - - const mockFetch = vi.fn(async () => ({ - data: { - user: { id: "1", email: "test@test.com" }, - session: { id: "session-1" }, - }, - error: null, - })); - - const manager = createSessionRefreshManager({ - sessionAtom, - sessionSignal, - $fetch: mockFetch as any, - options: { - sessionOptions: { - refetchInterval: 5, - }, - }, - }); - - manager.init(); - - await vi.advanceTimersByTimeAsync(5000); - - expect(mockFetch).toHaveBeenCalledWith("/get-session"); - expect(mockFetch).toHaveBeenCalledTimes(1); - - manager.cleanup(); - vi.useRealTimers(); - }); - - it("should call POST on visibilitychange when needsRefresh: true", async () => { - vi.useFakeTimers(); - - const sessionAtom: SessionAtom = atom({ - data: { - user: { id: "1", email: "test@test.com" }, - session: { id: "session-1" }, - }, - error: null, - isPending: false, - isRefetching: false, - }); - const sessionSignal = atom(false); - - const refreshedSessionData = { - user: { id: "1", email: "test@test.com" }, - session: { id: "session-1", expiresAt: new Date() }, - }; - - const mockFetch = vi.fn( - async (url: string, options?: { method?: string }) => { - if (options?.method === "POST") { - return { - data: refreshedSessionData, - error: null, - }; - } - return { - data: { - user: { id: "1", email: "test@test.com" }, - session: { id: "session-1" }, - needsRefresh: true, - }, - error: null, - }; - }, - ); - - const manager = createSessionRefreshManager({ - sessionAtom, - sessionSignal, - $fetch: mockFetch as any, - options: { - sessionOptions: { - refetchOnWindowFocus: true, - }, - }, - }); - - manager.init(); - - manager.triggerRefetch({ event: "visibilitychange" }); + // Flip the signal (simulates proxy atomListener after auth action) + sessionSignal.set(!sessionSignal.get()); await vi.runAllTimersAsync(); - expect(mockFetch).toHaveBeenCalledWith("/get-session"); - expect(mockFetch).toHaveBeenCalledWith("/get-session", { method: "POST" }); - expect(mockFetch).toHaveBeenCalledTimes(2); - - const updatedSession = sessionAtom.get(); - expect(updatedSession.data).toEqual(refreshedSessionData); + expect(mockFetchSession).toHaveBeenCalledTimes(1); manager.cleanup(); - vi.useRealTimers(); }); - /** - * https://github.com/better-auth/better-auth/issues/8325 - */ - it("should preserve custom session fields on poll refresh", async () => { - vi.useFakeTimers(); - - const sessionAtom: SessionAtom = atom({ - data: { - user: { id: "1", email: "test@test.com" }, - session: { id: "session-1" }, - }, - error: null, - isPending: false, - }); + it("should refetch on $sessionSignal even when offline", async () => { + const onlineManager = getGlobalOnlineManager(); + onlineManager.setOnline(false); const sessionSignal = atom(false); - - const customSessionData = { - user: { id: "1", email: "test@test.com" }, - session: { id: "session-1" }, - activationInfo: { isActivated: true, activatedAt: "2024-01-01" }, - role: "admin", - }; - - const mockFetch = vi.fn(async () => ({ - data: customSessionData, - error: null, - })); + const mockFetchSession = vi.fn(async () => {}); const manager = createSessionRefreshManager({ - sessionAtom, + fetchSession: mockFetchSession, sessionSignal, - $fetch: mockFetch as any, options: { sessionOptions: { - refetchInterval: 5, + refetchWhenOffline: false, }, }, }); manager.init(); - await vi.advanceTimersByTimeAsync(5000); - - const updatedSession = sessionAtom.get(); - expect(updatedSession.data).toEqual(customSessionData); - expect((updatedSession.data as any)?.activationInfo).toEqual({ - isActivated: true, - activatedAt: "2024-01-01", - }); - expect((updatedSession.data as any)?.role).toBe("admin"); - - manager.cleanup(); - vi.useRealTimers(); - }); - - it("should preserve custom session fields on visibilitychange refresh", async () => { - vi.useFakeTimers(); - - const sessionAtom: SessionAtom = atom({ - data: { - user: { id: "1", email: "test@test.com" }, - session: { id: "session-1" }, - }, - error: null, - isPending: false, - }); - const sessionSignal = atom(false); - - const customSessionData = { - user: { id: "1", email: "test@test.com" }, - session: { id: "session-1" }, - customField: "custom value", - }; - - const mockFetch = vi.fn(async () => ({ - data: customSessionData, - error: null, - })); - - const manager = createSessionRefreshManager({ - sessionAtom, - sessionSignal, - $fetch: mockFetch as any, - options: { - sessionOptions: { - refetchOnWindowFocus: true, - }, - }, - }); - - manager.init(); - - manager.triggerRefetch({ event: "visibilitychange" }); + sessionSignal.set(!sessionSignal.get()); await vi.runAllTimersAsync(); - const updatedSession = sessionAtom.get(); - expect(updatedSession.data).toEqual(customSessionData); - expect((updatedSession.data as any)?.customField).toBe("custom value"); + expect(mockFetchSession).toHaveBeenCalledTimes(1); manager.cleanup(); - vi.useRealTimers(); }); - it("should preserve session when $fetch returns unwrapped data (throw: true)", async () => { - vi.useFakeTimers(); - - const initialData = { - user: { id: "1", email: "old@test.com" }, - session: { id: "session-1" }, - }; - - const refreshedData = { - user: { id: "1", email: "new@test.com" }, - session: { id: "session-1" }, - }; - - const sessionAtom: SessionAtom = atom({ - data: initialData, - error: null, - isPending: false, - isRefetching: false, - }); - const sessionSignal = atom(false); - - // When throw: true, $fetch returns data directly instead of { data, error } - const mockFetch = vi.fn(async () => refreshedData); - - const manager = createSessionRefreshManager({ - sessionAtom, - sessionSignal, - $fetch: mockFetch as any, - options: { - sessionOptions: { - refetchInterval: 5, - }, - }, - }); - - manager.init(); - - await vi.advanceTimersByTimeAsync(5000); - - expect(mockFetch).toHaveBeenCalledTimes(1); - const updatedSession = sessionAtom.get(); - expect(updatedSession.data).toEqual(refreshedData); - expect(updatedSession.data?.user?.email).toBe("new@test.com"); - - manager.cleanup(); - vi.useRealTimers(); - }); - - it("should preserve session on visibilitychange when $fetch returns unwrapped data (throw: true)", async () => { - vi.useFakeTimers(); - - const initialData = { - user: { id: "1", email: "old@test.com" }, - session: { id: "session-1" }, - }; - - const refreshedData = { - user: { id: "1", email: "new@test.com" }, - session: { id: "session-1" }, - }; - - const sessionAtom: SessionAtom = atom({ - data: initialData, - error: null, - isPending: false, - isRefetching: false, - }); - const sessionSignal = atom(false); - - // When throw: true, $fetch returns data directly - const mockFetch = vi.fn(async () => refreshedData); - - const manager = createSessionRefreshManager({ - sessionAtom, - sessionSignal, - $fetch: mockFetch as any, - options: { - sessionOptions: { - refetchOnWindowFocus: true, - }, - }, - }); - - manager.init(); - - manager.triggerRefetch({ event: "visibilitychange" }); - await vi.runAllTimersAsync(); - - expect(mockFetch).toHaveBeenCalledTimes(1); - const updatedSession = sessionAtom.get(); - expect(updatedSession.data).toEqual(refreshedData); - expect(updatedSession.data?.user?.email).toBe("new@test.com"); - - manager.cleanup(); - vi.useRealTimers(); - }); - - it("should broadcast session update when broadcastSessionUpdate is called with signout", () => { + it("should broadcast session update with signout", () => { const channel = getGlobalBroadcastChannel(); const postSpy = vi.spyOn(channel, "post"); - const sessionAtom: SessionAtom = atom({ - data: { - user: { id: "1", email: "test@test.com" }, - session: { id: "session-1" }, - }, - error: null, - isPending: false, - }); const sessionSignal = atom(false); - const mockFetch = vi.fn(async () => ({ data: null, error: null })); + const mockFetchSession = vi.fn(async () => {}); const manager = createSessionRefreshManager({ - sessionAtom, + fetchSession: mockFetchSession, sessionSignal, - $fetch: mockFetch as any, - options: {}, }); manager.init(); @@ -854,26 +269,16 @@ describe("session-refresh", () => { manager.cleanup(); }); - it("should broadcast session update when broadcastSessionUpdate is called with updateUser", () => { + it("should broadcast session update with updateUser", () => { const channel = getGlobalBroadcastChannel(); const postSpy = vi.spyOn(channel, "post"); - const sessionAtom: SessionAtom = atom({ - data: { - user: { id: "1", email: "test@test.com" }, - session: { id: "session-1" }, - }, - error: null, - isPending: false, - }); const sessionSignal = atom(false); - const mockFetch = vi.fn(async () => ({ data: null, error: null })); + const mockFetchSession = vi.fn(async () => {}); const manager = createSessionRefreshManager({ - sessionAtom, + fetchSession: mockFetchSession, sessionSignal, - $fetch: mockFetch as any, - options: {}, }); manager.init(); @@ -885,4 +290,62 @@ describe("session-refresh", () => { manager.cleanup(); }); + + it("should clean up all subscriptions", () => { + const sessionSignal = atom(false); + const mockFetchSession = vi.fn(async () => {}); + + const manager = createSessionRefreshManager({ + fetchSession: mockFetchSession, + sessionSignal, + options: { + sessionOptions: { + refetchInterval: 5, + refetchOnWindowFocus: true, + }, + }, + }); + + manager.init(); + manager.cleanup(); + + // After cleanup, signal flips should not trigger fetchSession + sessionSignal.set(!sessionSignal.get()); + expect(mockFetchSession).not.toHaveBeenCalled(); + }); + + it("should remove global setup listeners on cleanup", () => { + const sessionSignal = atom(false); + const mockFetchSession = vi.fn(async () => {}); + const storageEvent = () => + new StorageEvent("storage", { + key: "better-auth.message", + newValue: JSON.stringify({ + event: "session", + data: { trigger: "getSession" }, + clientId: "test-client", + timestamp: 1, + }), + }); + + const firstManager = createSessionRefreshManager({ + fetchSession: mockFetchSession, + sessionSignal, + }); + + firstManager.init(); + firstManager.cleanup(); + + const secondManager = createSessionRefreshManager({ + fetchSession: mockFetchSession, + sessionSignal, + }); + + secondManager.init(); + window.dispatchEvent(storageEvent()); + + expect(mockFetchSession).toHaveBeenCalledTimes(1); + + secondManager.cleanup(); + }); }); diff --git a/packages/better-auth/src/client/session-refresh.ts b/packages/better-auth/src/client/session-refresh.ts index a7681806bd..25b3f29bd4 100644 --- a/packages/better-auth/src/client/session-refresh.ts +++ b/packages/better-auth/src/client/session-refresh.ts @@ -1,75 +1,43 @@ import type { BetterAuthClientOptions } from "@better-auth/core"; -import type { BetterFetch } from "@better-fetch/fetch"; import type { WritableAtom } from "nanostores"; -import type { Session, User } from "../types"; import { getGlobalBroadcastChannel } from "./broadcast-channel"; import { getGlobalFocusManager } from "./focus-manager"; import { getGlobalOnlineManager } from "./online-manager"; -import type { AuthQueryAtom } from "./query"; const now = () => Math.floor(Date.now() / 1000); -/** - * Normalize $fetch response: `throw: true` returns data directly, otherwise `{ data, error }`. - */ -function normalizeSessionResponse(res: unknown): { - data: SessionResponse | null; - error: unknown; -} { - if ( - typeof res === "object" && - res !== null && - "data" in res && - "error" in res - ) { - return res as { data: SessionResponse | null; error: unknown }; - } - return { data: res as SessionResponse, error: null }; -} - /** * 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: AuthQueryAtom< - { - user: User; - session: Session; - } & Record - >; + fetchSession: () => Promise; + shouldPollSession?: () => boolean; sessionSignal: WritableAtom; - $fetch: BetterFetch; options?: BetterAuthClientOptions | undefined; } interface SessionRefreshState { - lastSync: number; + isInitialized: boolean; lastSessionRequest: number; - cachedSession: any; pollInterval?: ReturnType | undefined; unsubscribeBroadcast?: (() => void) | undefined; unsubscribeFocus?: (() => void) | undefined; unsubscribeOnline?: (() => void) | undefined; + unsubscribeSignal?: (() => void) | undefined; + cleanupBroadcastSetup?: (() => void) | undefined; + cleanupFocusSetup?: (() => void) | undefined; + cleanupOnlineSetup?: (() => void) | undefined; } -export type SessionResponse = ( - | { - session: null; - user: null; - needsRefresh?: boolean; - } - | { - session: Session; - user: User; - needsRefresh?: boolean; - } -) & - Record; - export function createSessionRefreshManager(opts: SessionRefreshOptions) { - const { sessionAtom, sessionSignal, $fetch, options = {} } = opts; + const { + fetchSession, + shouldPollSession = () => true, + sessionSignal, + options = {}, + } = opts; const refetchInterval = options.sessionOptions?.refetchInterval ?? 0; const refetchOnWindowFocus = @@ -78,9 +46,8 @@ export function createSessionRefreshManager(opts: SessionRefreshOptions) { options.sessionOptions?.refetchWhenOffline ?? false; const state: SessionRefreshState = { - lastSync: 0, + isInitialized: false, lastSessionRequest: 0, - cachedSession: undefined, }; const shouldRefetch = (): boolean => { @@ -97,64 +64,27 @@ export function createSessionRefreshManager(opts: SessionRefreshOptions) { if (!shouldRefetch()) return; if (event?.event === "storage") { - state.lastSync = now(); - sessionSignal.set(!sessionSignal.get()); + void fetchSession(); return; } - const currentSession = sessionAtom.get(); - - const fetchSessionWithRefresh = () => { - state.lastSessionRequest = now(); - $fetch("/get-session") - .then(async (res) => { - let { data, error } = normalizeSessionResponse(res); - - if (data?.needsRefresh) { - try { - const refreshRes = await $fetch("/get-session", { - method: "POST", - }); - ({ data, error } = normalizeSessionResponse(refreshRes)); - } catch {} - } - - const sessionData = data?.session && data?.user ? data : null; - - sessionAtom.set({ - ...currentSession, - data: sessionData, - error: error as Parameters[0]["error"], - }); - state.lastSync = now(); - sessionSignal.set(!sessionSignal.get()); - }) - .catch(() => {}); - }; - if (event?.event === "poll") { - fetchSessionWithRefresh(); + state.lastSessionRequest = now(); + void fetchSession(); 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) { return; } state.lastSessionRequest = now(); - } - - if (event?.event === "visibilitychange") { - fetchSessionWithRefresh(); + void fetchSession(); return; } - if (currentSession?.data === null || currentSession?.data === undefined) { - state.lastSync = now(); - sessionSignal.set(!sessionSignal.get()); - } + void fetchSession(); }; const broadcastSessionUpdate = ( @@ -170,8 +100,7 @@ export function createSessionRefreshManager(opts: SessionRefreshOptions) { const setupPolling = () => { if (refetchInterval && refetchInterval > 0) { state.pollInterval = setInterval(() => { - const currentSession = sessionAtom.get(); - if (currentSession?.data) { + if (shouldPollSession()) { triggerRefetch({ event: "poll" }); } }, refetchInterval * 1000); @@ -200,18 +129,30 @@ export function createSessionRefreshManager(opts: SessionRefreshOptions) { }); }; + const setupSignalSubscription = () => { + state.unsubscribeSignal = sessionSignal.listen(() => { + void fetchSession(); + }); + }; + const init = () => { + if (state.isInitialized) return; + state.isInitialized = true; + setupPolling(); setupBroadcast(); setupFocusRefetch(); setupOnlineRefetch(); + setupSignalSubscription(); - getGlobalBroadcastChannel().setup(); - getGlobalFocusManager().setup(); - getGlobalOnlineManager().setup(); + state.cleanupBroadcastSetup = getGlobalBroadcastChannel().setup(); + state.cleanupFocusSetup = getGlobalFocusManager().setup(); + state.cleanupOnlineSetup = getGlobalOnlineManager().setup(); }; const cleanup = () => { + if (!state.isInitialized) return; + if (state.pollInterval) { clearInterval(state.pollInterval); state.pollInterval = undefined; @@ -228,9 +169,24 @@ export function createSessionRefreshManager(opts: SessionRefreshOptions) { state.unsubscribeOnline(); state.unsubscribeOnline = undefined; } - state.lastSync = 0; + if (state.unsubscribeSignal) { + state.unsubscribeSignal(); + state.unsubscribeSignal = undefined; + } + if (state.cleanupBroadcastSetup) { + state.cleanupBroadcastSetup(); + state.cleanupBroadcastSetup = undefined; + } + if (state.cleanupFocusSetup) { + state.cleanupFocusSetup(); + state.cleanupFocusSetup = undefined; + } + if (state.cleanupOnlineSetup) { + state.cleanupOnlineSetup(); + state.cleanupOnlineSetup = undefined; + } + state.isInitialized = false; state.lastSessionRequest = 0; - state.cachedSession = undefined; }; return { diff --git a/packages/electron/src/client.ts b/packages/electron/src/client.ts index 679ea4c657..2cca810638 100644 --- a/packages/electron/src/client.ts +++ b/packages/electron/src/client.ts @@ -79,6 +79,16 @@ export const electronClient = (options: O) => { opts.storage, new Set([cookieName, localCacheName]), ); + const clearSessionCache = () => { + setEncrypted(cookieName, "{}"); + store?.atoms.session?.set({ + ...store.atoms.session.get(), + data: null, + error: null, + isPending: false, + }); + setEncrypted(localCacheName, "{}"); + }; if ( (isDevelopment() || isTest()) && @@ -116,14 +126,7 @@ export const electronClient = (options: O) => { }; if (url.endsWith("/sign-out")) { - setEncrypted(cookieName, "{}"); - store?.atoms.session?.set({ - ...store.atoms.session.get(), - data: null, - error: null, - isPending: false, - }); - setEncrypted(localCacheName, "{}"); + clearSessionCache(); } return { @@ -159,6 +162,9 @@ export const electronClient = (options: O) => { const data = context.data; setEncrypted(localCacheName, JSON.stringify(data)); } + if (context.request.url.toString().includes("/sign-out")) { + clearSessionCache(); + } }, onError: async (context) => { webContents diff --git a/packages/expo/src/client.ts b/packages/expo/src/client.ts index 240d048070..f49f42c1a2 100644 --- a/packages/expo/src/client.ts +++ b/packages/expo/src/client.ts @@ -347,6 +347,16 @@ export const expoClient = (opts: ExpoClientOptions) => { const storage = storageAdapter(opts?.storage); const isWeb = Platform.OS === "web"; const cookiePrefix = opts?.cookiePrefix || "better-auth"; + const clearSessionCache = async () => { + await storage.setItem(cookieName, "{}"); + store?.atoms.session?.set({ + ...store.atoms.session.get(), + data: null, + error: null, + isPending: false, + }); + await storage.setItem(localCacheName, "{}"); + }; const rawScheme = opts?.scheme || Constants.expoConfig?.scheme || Constants.platform?.scheme; @@ -439,6 +449,9 @@ export const expoClient = (opts: ExpoClientOptions) => { const data = context.data; await storage.setItem(localCacheName, JSON.stringify(data)); } + if (context.request.url.toString().includes("/sign-out")) { + await clearSessionCache(); + } if ( context.data?.redirect && @@ -553,14 +566,7 @@ export const expoClient = (opts: ExpoClientOptions) => { } } if (url.includes("/sign-out")) { - await storage.setItem(cookieName, "{}"); - store?.atoms.session?.set({ - ...store.atoms.session.get(), - data: null, - error: null, - isPending: false, - }); - await storage.setItem(localCacheName, "{}"); + await clearSessionCache(); } } return {