From 2ee1545f244d87e5edd6a003b48b90a5539f8afa Mon Sep 17 00:00:00 2001 From: Harry Smaje Date: Tue, 22 Sep 2026 18:19:25 +0000 Subject: [PATCH] fix(client): preserve auth query snapshots during hydration (#11316) Co-authored-by: Taesu --- .changeset/quiet-auth-query-hydration.md | 5 + docs/content/docs/concepts/client.mdx | 2 + packages/better-auth/src/client/query-atom.ts | 26 ++ packages/better-auth/src/client/query.ts | 5 +- .../src/client/react/hydration.test.ts | 224 ++++++++++++++++++ .../src/client/react/react-store.ts | 10 +- .../better-auth/src/client/session-atom.ts | 17 +- 7 files changed, 279 insertions(+), 10 deletions(-) create mode 100644 .changeset/quiet-auth-query-hydration.md create mode 100644 packages/better-auth/src/client/query-atom.ts create mode 100644 packages/better-auth/src/client/react/hydration.test.ts diff --git a/.changeset/quiet-auth-query-hydration.md b/.changeset/quiet-auth-query-hydration.md new file mode 100644 index 0000000000..c63f2bdebf --- /dev/null +++ b/.changeset/quiet-auth-query-hydration.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +Fix React hydration mismatches when a session or plugin auth query resolves before a streamed component hydrates. Preserve the server-rendered pending state during hydration, then update to the current client state without changing ordinary or computed plugin stores. diff --git a/docs/content/docs/concepts/client.mdx b/docs/content/docs/concepts/client.mdx index 57d94dd4d3..b1c34930a0 100644 --- a/docs/content/docs/concepts/client.mdx +++ b/docs/content/docs/concepts/client.mdx @@ -501,6 +501,8 @@ export function SessionCard({ initialSession }: Props) { * Passing `null` is safe. It will be ignored and `useSession` will fetch as usual. +In React, auth query hooks such as `useSession` and `useListOrganizations` return their initial pending state during server rendering and hydration, then update to the current client state. This keeps streamed components consistent with their server HTML even if another component has already fetched or hydrated the query. Keep using the server-fetched session as a fallback, as shown above, to display it during hydration. Custom plugin stores that do not use `useAuthQuery` retain their existing snapshot behavior. + ### Plugins You can extend the client with plugins to add more functionality. Plugins can add new functions to the client or modify existing ones. diff --git a/packages/better-auth/src/client/query-atom.ts b/packages/better-auth/src/client/query-atom.ts new file mode 100644 index 0000000000..f3a0076e71 --- /dev/null +++ b/packages/better-auth/src/client/query-atom.ts @@ -0,0 +1,26 @@ +import type { Store, StoreValue } from "nanostores"; +import { atom } from "nanostores"; + +// Auth queries preserve their creation-time state as a stable hydration +// snapshot. The brand lets framework adapters identify them without changing +// SSR behavior for ordinary or computed plugin stores. +const kAuthQuery = Symbol.for("better-auth:auth-query"); + +type AuthQueryStore = SomeStore & { + readonly [kAuthQuery]: true; + readonly init: StoreValue; +}; + +export function isAuthQueryStore( + store: SomeStore, +): store is AuthQueryStore { + return kAuthQuery in store && store[kAuthQuery] === true; +} + +export function createAuthQueryAtom(initialValue: T) { + const queryAtom = atom(initialValue); + Object.defineProperty(queryAtom, kAuthQuery, { + value: true, + }); + return queryAtom; +} diff --git a/packages/better-auth/src/client/query.ts b/packages/better-auth/src/client/query.ts index 1a7a7270c1..c6a38c3dae 100644 --- a/packages/better-auth/src/client/query.ts +++ b/packages/better-auth/src/client/query.ts @@ -1,8 +1,9 @@ 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 { onMount } from "nanostores"; import { isJsonEqual, withEquality } from "./equality"; +import { createAuthQueryAtom } from "./query-atom"; import type { SessionQueryParams } from "./types"; // SSR detection @@ -50,7 +51,7 @@ export const useAuthQuery = ( ) | undefined, ) => { - const value: AuthQueryAtom = atom({ + const value: AuthQueryAtom = createAuthQueryAtom>({ data: null, error: null, isPending: true, diff --git a/packages/better-auth/src/client/react/hydration.test.ts b/packages/better-auth/src/client/react/hydration.test.ts new file mode 100644 index 0000000000..ead33b412d --- /dev/null +++ b/packages/better-auth/src/client/react/hydration.test.ts @@ -0,0 +1,224 @@ +// @vitest-environment node + +import { organizationClient } from "better-auth/client/plugins"; +import { organization } from "better-auth/plugins"; +import { + createAuthClient as createReactClient, + useStore, +} from "better-auth/react"; +import { getTestInstance } from "better-auth/test"; +import { Window } from "happy-dom"; +import { atom, cleanStores, computed } from "nanostores"; +import { act, createElement } from "react"; +import type { Root } from "react-dom/client"; +import { createRoot, hydrateRoot } from "react-dom/client"; +import { renderToString } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const roots: Root[] = []; +const stores: Parameters = []; + +beforeEach(() => { + vi.useFakeTimers(); + const window = new Window(); + // Keep Node's HTTP globals: browser Headers hide Set-Cookie from the test client. + vi.stubGlobal("window", window); + vi.stubGlobal("document", window.document); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +}); + +afterEach(async () => { + await act(async () => { + for (const root of roots.splice(0)) root.unmount(); + }); + cleanStores(...stores.splice(0)); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +/** + * @see https://github.com/better-auth/better-auth/issues/10972 + */ +describe("React auth query hydration", () => { + it("hydrates the generated useSession hook with its server snapshot", async () => { + const client = createReactClient({ + baseURL: "http://localhost:3000", + fetchOptions: { + customFetchImpl: async () => + new Response(JSON.stringify(null), { + headers: { "content-type": "application/json" }, + }), + }, + }); + stores.push(client.$store.atoms.session); + const renderedPendingStates: boolean[] = []; + + function SaveButton() { + const { isPending } = client.useSession(); + renderedPendingStates.push(isPending); + return createElement( + "button", + { "aria-disabled": isPending }, + isPending ? "Loading" : "Save", + ); + } + + const container = document.createElement("div"); + container.innerHTML = renderToString(createElement(SaveButton)); + expect(container.innerHTML).toBe( + '', + ); + + const earlyContainer = document.createElement("div"); + const earlyRoot = createRoot(earlyContainer); + roots.push(earlyRoot); + await act(async () => { + earlyRoot.render(createElement(SaveButton)); + await vi.runAllTimersAsync(); + }); + expect(earlyContainer.textContent).toBe("Save"); + + renderedPendingStates.length = 0; + const onRecoverableError = vi.fn(); + await act(async () => { + roots.push( + hydrateRoot(container, createElement(SaveButton), { + onRecoverableError, + }), + ); + }); + + expect(onRecoverableError).not.toHaveBeenCalled(); + expect(renderedPendingStates[0]).toBe(true); + expect(container.textContent).toBe("Save"); + }); + + it.each([ + "signed in", + "signed out", + ])("hydrates a late session subscriber after the session resolves %s", async (status) => { + const { client, signInWithTestUser } = await getTestInstance(); + stores.push(client.useSession); + + function Session() { + const { data, isPending } = useStore(client.useSession); + return createElement( + isPending ? "span" : "p", + null, + isPending ? "Loading" : data ? data.user.email : "Signed out", + ); + } + + const container = document.createElement("div"); + container.innerHTML = renderToString(createElement(Session)); + expect(container.innerHTML).toBe("Loading"); + const early = document.createElement("div"); + const earlyRoot = createRoot(early); + roots.push(earlyRoot); + await act(async () => earlyRoot.render(createElement(Session))); + + await act(async () => { + if (status === "signed in") { + const { runWithUser } = await signInWithTestUser(); + await runWithUser(async () => client.useSession.get().refetch()); + } else { + await client.useSession.get().refetch(); + } + }); + expect(client.useSession.get().isPending).toBe(false); + expect(early.textContent).toBe( + status === "signed in" ? "test@test.com" : "Signed out", + ); + + const onRecoverableError = vi.fn(); + await act(async () => { + roots.push( + hydrateRoot(container, createElement(Session), { onRecoverableError }), + ); + }); + expect(onRecoverableError).not.toHaveBeenCalled(); + expect(container.innerHTML).toBe(early.innerHTML); + }); + + it("hydrates a session seeded with hydrateSession before the first subscriber", async () => { + const { client, auth, signInWithTestUser } = await getTestInstance(); + stores.push(client.useSession); + function Session() { + const { data, isPending } = useStore(client.useSession); + return createElement("p", null, isPending ? "Loading" : data?.user.email); + } + const container = document.createElement("div"); + container.innerHTML = renderToString(createElement(Session)); + const { headers } = await signInWithTestUser(); + const session = await auth.api.getSession({ headers }); + expect(session).not.toBeNull(); + client.hydrateSession(session); + const onRecoverableError = vi.fn(); + await act(async () => { + roots.push( + hydrateRoot(container, createElement(Session), { onRecoverableError }), + ); + }); + expect(onRecoverableError).not.toHaveBeenCalled(); + expect(container.textContent).toBe("test@test.com"); + }); + + it("hydrates a resolved organization query and keeps subsequent updates", async () => { + const { client } = await getTestInstance( + { plugins: [organization()] }, + { clientOptions: { plugins: [organizationClient()] } }, + ); + const query = client.useListOrganizations; + stores.push(query); + function Organizations() { + const { data, isPending } = useStore(query); + return createElement( + "p", + null, + isPending ? "Loading" : `${data?.length ?? 0} organizations`, + ); + } + const container = document.createElement("div"); + container.innerHTML = renderToString(createElement(Organizations)); + query.set({ ...query.get(), data: [], isPending: false }); + const onRecoverableError = vi.fn(); + await act(async () => { + roots.push( + hydrateRoot(container, createElement(Organizations), { + onRecoverableError, + }), + ); + }); + expect(onRecoverableError).not.toHaveBeenCalled(); + expect(container.textContent).toBe("0 organizations"); + await act(async () => query.set({ ...query.get(), isPending: true })); + expect(container.textContent).toBe("Loading"); + }); + + it("preserves live SSR values for ordinary and computed plugin stores", async () => { + const label = atom(undefined); + label.set("Ready"); + const length = computed(label, (value) => value?.length ?? 0); + const count = atom(1); + count.set(2); + const { client } = await getTestInstance( + {}, + { + clientOptions: { + plugins: [ + { id: "ssr-stores", getAtoms: () => ({ label, length, count }) }, + ], + }, + }, + ); + stores.push(label, length, count); + function Values() { + return createElement( + "p", + null, + `${useStore(client.useLabel)}: ${useStore(client.useLength)}, ${useStore(client.useCount)}`, + ); + } + expect(renderToString(createElement(Values))).toBe("

Ready: 5, 2

"); + }); +}); diff --git a/packages/better-auth/src/client/react/react-store.ts b/packages/better-auth/src/client/react/react-store.ts index c618c92e83..f1f091d95c 100644 --- a/packages/better-auth/src/client/react/react-store.ts +++ b/packages/better-auth/src/client/react/react-store.ts @@ -3,6 +3,8 @@ import { listenKeys } from "nanostores"; import type { DependencyList } from "react"; import { useCallback, useRef, useSyncExternalStore } from "react"; +import { isAuthQueryStore } from "../query-atom"; + type StoreKeys = T extends { setKey: (k: infer K, v: any) => unknown } ? K : never; @@ -69,5 +71,11 @@ export function useStore( const get = () => snapshotRef.current as StoreValue; - return useSyncExternalStore(subscribe, get, get); + // A query may resolve before a streamed subtree hydrates. React must + // replay its initial pending state before subscribing to the live value. + return useSyncExternalStore( + subscribe, + get, + isAuthQueryStore(store) ? () => store.init : get, + ); } diff --git a/packages/better-auth/src/client/session-atom.ts b/packages/better-auth/src/client/session-atom.ts index ddef2a5048..a6813ccb8f 100644 --- a/packages/better-auth/src/client/session-atom.ts +++ b/packages/better-auth/src/client/session-atom.ts @@ -4,6 +4,7 @@ import { atom, onMount, STORE_UNMOUNT_DELAY } from "nanostores"; import type { Session, User } from "../types"; import { isJsonEqual, withEquality } from "./equality"; import type { AuthQueryAtom, AuthQueryState } from "./query"; +import { createAuthQueryAtom } from "./query-atom"; import { createSessionRefreshManager } from "./session-refresh"; import type { SessionQueryParams } from "./types"; @@ -113,13 +114,15 @@ export function getSessionAtom( queryParams?: { query?: SessionQueryParams } | undefined, ): Promise => fetchSession(queryParams); - const session: SessionAtom = atom>({ - data: null, - error: null, - isPending: true, - isRefetching: false, - refetch, - }); + const session: SessionAtom = createAuthQueryAtom>( + { + data: null, + error: null, + isPending: true, + isRefetching: false, + refetch, + }, + ); withEquality(session, isSessionAtomEqual); const executeSessionFetch = async (