mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-16 23:20:14 -05:00
fix(client): restructure session fetch architecture (#8760)
This commit is contained in:
5
.changeset/restructure-session-fetch.md
Normal file
5
.changeset/restructure-session-fetch.md
Normal 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.
|
||||
141
packages/better-auth/src/client/equality.test.ts
Normal file
141
packages/better-auth/src/client/equality.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
57
packages/better-auth/src/client/equality.ts
Normal file
57
packages/better-auth/src/client/equality.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
import { PACKAGE_VERSION } from "../version";
|
||||
|
||||
export * from "./broadcast-channel";
|
||||
export * from "./equality";
|
||||
export {
|
||||
type FocusListener,
|
||||
type FocusManager,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user