From c45edd6b38faef4e726bca217ccc39e19ca8880e Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Fri, 27 Feb 2026 09:16:27 +0530 Subject: [PATCH] fix(client): broadcast session updates to other tabs on sign-out and user update (#8177) Co-authored-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com> --- packages/better-auth/src/client/config.ts | 12 +++- packages/better-auth/src/client/proxy.ts | 2 + .../better-auth/src/client/session-atom.ts | 8 +++ .../src/client/session-refresh.test.ts | 67 +++++++++++++++++++ packages/core/src/types/plugin-client.ts | 1 + 5 files changed, 89 insertions(+), 1 deletion(-) diff --git a/packages/better-auth/src/client/config.ts b/packages/better-auth/src/client/config.ts index f12f043d54..b6a9dfdf3e 100644 --- a/packages/better-auth/src/client/config.ts +++ b/packages/better-auth/src/client/config.ts @@ -61,7 +61,10 @@ export const getClientConfig = ( ...pluginsFetchPlugins, ], }); - const { $sessionSignal, session } = getSessionAtom($fetch, options); + const { $sessionSignal, session, broadcastSessionUpdate } = getSessionAtom( + $fetch, + options, + ); const plugins = options?.plugins || []; let pluginsActions = {} as Record; const pluginsAtoms = { @@ -92,6 +95,13 @@ export const getClientConfig = ( return matchesCommonPaths; }, + callback(path) { + if (path === "/sign-out") { + broadcastSessionUpdate("signout"); + } else if (path === "/update-user" || path === "/update-session") { + broadcastSessionUpdate("updateUser"); + } + }, }, ]; diff --git a/packages/better-auth/src/client/proxy.ts b/packages/better-auth/src/client/proxy.ts index 46d8f8be6f..f5a678d0af 100644 --- a/packages/better-auth/src/client/proxy.ts +++ b/packages/better-auth/src/client/proxy.ts @@ -118,6 +118,8 @@ export function createDynamicPathProxy>( //@ts-expect-error signal.set(!val); }, 10); + // we also call the callback if it exists + match.callback?.(routePath); } }, }); diff --git a/packages/better-auth/src/client/session-atom.ts b/packages/better-auth/src/client/session-atom.ts index c8336dcb6f..8a0c1e4c3a 100644 --- a/packages/better-auth/src/client/session-atom.ts +++ b/packages/better-auth/src/client/session-atom.ts @@ -23,6 +23,10 @@ export function getSessionAtom( method: "GET", }); + let broadcastSessionUpdate: ( + trigger: "signout" | "getSession" | "updateUser", + ) => void = () => {}; + onMount(session, () => { const refreshManager = createSessionRefreshManager({ sessionAtom: session, @@ -32,6 +36,7 @@ export function getSessionAtom( }); refreshManager.init(); + broadcastSessionUpdate = refreshManager.broadcastSessionUpdate; return () => { refreshManager.cleanup(); @@ -41,5 +46,8 @@ export function getSessionAtom( return { session, $sessionSignal: $signal, + broadcastSessionUpdate: ( + trigger: "signout" | "getSession" | "updateUser", + ) => broadcastSessionUpdate(trigger), }; } diff --git a/packages/better-auth/src/client/session-refresh.test.ts b/packages/better-auth/src/client/session-refresh.test.ts index 45fb0c02c6..8a4a44f56e 100644 --- a/packages/better-auth/src/client/session-refresh.test.ts +++ b/packages/better-auth/src/client/session-refresh.test.ts @@ -2,6 +2,7 @@ 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"; @@ -15,6 +16,8 @@ describe("session-refresh", () => { afterEach(() => { vi.useRealTimers(); + vi.restoreAllMocks(); + delete (globalThis as any)[Symbol.for("better-auth:broadcast-channel")]; }); it("should trigger network fetch and update session when refetchInterval fires", async () => { @@ -618,4 +621,68 @@ describe("session-refresh", () => { manager.cleanup(); vi.useRealTimers(); }); + + it("should broadcast session update when broadcastSessionUpdate is called 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 manager = createSessionRefreshManager({ + sessionAtom, + sessionSignal, + $fetch: mockFetch as any, + options: {}, + }); + + manager.init(); + manager.broadcastSessionUpdate("signout"); + + expect(postSpy).toHaveBeenCalledWith( + expect.objectContaining({ data: { trigger: "signout" } }), + ); + + manager.cleanup(); + }); + + it("should broadcast session update when broadcastSessionUpdate is called 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 manager = createSessionRefreshManager({ + sessionAtom, + sessionSignal, + $fetch: mockFetch as any, + options: {}, + }); + + manager.init(); + manager.broadcastSessionUpdate("updateUser"); + + expect(postSpy).toHaveBeenCalledWith( + expect.objectContaining({ data: { trigger: "updateUser" } }), + ); + + manager.cleanup(); + }); }); diff --git a/packages/core/src/types/plugin-client.ts b/packages/core/src/types/plugin-client.ts index 18b0df9ea9..f288ee3ed1 100644 --- a/packages/core/src/types/plugin-client.ts +++ b/packages/core/src/types/plugin-client.ts @@ -17,6 +17,7 @@ export interface ClientStore { export type ClientAtomListener = { matcher: (path: string) => boolean; signal: "$sessionSignal" | Omit; + callback?: (path: string) => void; }; /**