fix(client): restructure session fetch architecture (#8760)

This commit is contained in:
Gustavo Valverde
2026-06-11 15:49:34 -07:00
committed by GitHub
parent e246ac0929
commit 8960f5f3bd
11 changed files with 782 additions and 804 deletions

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -0,0 +1,57 @@
import type { Store, StoreValue } from "nanostores";
import { onSet } from "nanostores";
function isPlainObject(value: unknown): value is Record<string, unknown> {
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<S extends Store>(
store: S,
isEqual: (a: StoreValue<S>, b: StoreValue<S>) => boolean,
): () => void {
return onSet(store, ({ newValue, abort }) => {
if (isEqual(store.value, newValue)) {
abort();
}
});
}

View File

@@ -6,6 +6,7 @@ import type {
import { PACKAGE_VERSION } from "../version";
export * from "./broadcast-channel";
export * from "./equality";
export {
type FocusListener,
type FocusManager,

View File

@@ -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<Response>(() => {});
},
});
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);
});
});

View File

@@ -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<T> = PreinitializedWritableAtom<{
export type AuthQueryState<T> = {
data: null | T;
error: null | BetterFetchError;
isPending: boolean;
@@ -15,7 +16,22 @@ export type AuthQueryAtom<T> = PreinitializedWritableAtom<{
refetch: (
queryParams?: { query?: SessionQueryParams } | undefined,
) => Promise<void>;
}>;
};
export type AuthQueryAtom<T> = PreinitializedWritableAtom<AuthQueryState<T>>;
function isAuthQueryStateEqual<T>(
a: AuthQueryState<T>,
b: AuthQueryState<T>,
): 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 = <T>(
initializedAtom:
@@ -41,6 +57,7 @@ export const useAuthQuery = <T>(
isRefetching: false,
refetch: (queryParams) => fn(queryParams),
});
withEquality(value, isAuthQueryStateEqual);
const fn = async (
queryParams?: { query?: SessionQueryParams } | undefined,
@@ -62,8 +79,15 @@ export const useAuthQuery = <T>(
...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 = <T>(
: [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 = <T>(
}
}, 0);
return () => {
value.off();
initAtom.off();
for (const u of cleanups) u();
clearTimeout(timeoutId);
};
});
}
});
cleanups.push(unbind);
}
return value;
};

View File

@@ -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<string, any>;
type SessionResponse = (
| { session: null; user: null; needsRefresh?: boolean }
| { session: Session; user: User; needsRefresh?: boolean }
) &
Record<string, any>;
/**
* 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<SessionData>,
b: AuthQueryState<SessionData>,
): 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<boolean>(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<void> => fetchSession(queryParams);
const session: SessionAtom = atom<AuthQueryState<SessionData>>({
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<void> => {
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<SessionResponse>("/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<SessionResponse>("/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<typeof setTimeout> | 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();
};
});

View File

@@ -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();
});
});

View File

@@ -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<string, any>
>;
fetchSession: () => Promise<void>;
shouldPollSession?: () => boolean;
sessionSignal: WritableAtom<boolean>;
$fetch: BetterFetch;
options?: BetterAuthClientOptions | undefined;
}
interface SessionRefreshState {
lastSync: number;
isInitialized: boolean;
lastSessionRequest: number;
cachedSession: any;
pollInterval?: ReturnType<typeof setInterval> | 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<string, any>;
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<SessionResponse>("/get-session")
.then(async (res) => {
let { data, error } = normalizeSessionResponse(res);
if (data?.needsRefresh) {
try {
const refreshRes = await $fetch<SessionResponse>("/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<typeof sessionAtom.set>[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 {

View File

@@ -79,6 +79,16 @@ export const electronClient = <O extends ElectronClientOptions>(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 = <O extends ElectronClientOptions>(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 = <O extends ElectronClientOptions>(options: O) => {
const data = context.data;
setEncrypted(localCacheName, JSON.stringify(data));
}
if (context.request.url.toString().includes("/sign-out")) {
clearSessionCache();
}
},
onError: async (context) => {
webContents

View File

@@ -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 {