mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-26 02:16:23 -05:00
feat(client): add hydrateSession for SSR session hydration (#8733)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"better-auth": minor
|
||||
---
|
||||
|
||||
Add `hydrateSession` to seed the client with a server-fetched session so `useSession` returns data on the first render.
|
||||
@@ -459,6 +459,47 @@ if(error?.code){
|
||||
}
|
||||
```
|
||||
|
||||
### SSR Hydration
|
||||
|
||||
With SSR frameworks like Next.js, Nuxt, or SvelteKit, `useSession` fetches the session on the client, causing a loading flash. Pass the server-fetched session to `hydrateSession` and use it as a fallback so the first render has data on both the server and the client:
|
||||
|
||||
```tsx title="page.tsx (server component)"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { headers } from "next/headers"
|
||||
import { SessionCard } from "./session-card"
|
||||
|
||||
export default async function Page() {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
})
|
||||
return <SessionCard initialSession={session} />
|
||||
}
|
||||
```
|
||||
|
||||
```tsx title="session-card.tsx (client component)"
|
||||
"use client"
|
||||
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
type Props = {
|
||||
initialSession: typeof authClient.$Infer.Session | null
|
||||
}
|
||||
|
||||
export function SessionCard({ initialSession }: Props) {
|
||||
authClient.hydrateSession(initialSession)
|
||||
const { data, isPending, isRefetching } = authClient.useSession()
|
||||
const session = isPending && !isRefetching ? initialSession : data
|
||||
|
||||
if (!session) return <p>Not signed in</p>
|
||||
return <pre>{JSON.stringify(session, null, 2)}</pre>
|
||||
}
|
||||
```
|
||||
|
||||
<Callout>
|
||||
* Only the first non-null call to `hydrateSession` takes effect.
|
||||
* Passing `null` is safe. It will be ignored and `useSession` will fetch as usual.
|
||||
</Callout>
|
||||
|
||||
### Plugins
|
||||
|
||||
You can extend the client with plugins to add more functionality. Plugins can add new functions to the client or modify existing ones.
|
||||
|
||||
@@ -118,6 +118,240 @@ describe("run time proxy", async () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should hydrate session on an existing client", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = createSolidClient({
|
||||
fetchOptions: {
|
||||
customFetchImpl: async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
user: {
|
||||
id: "2",
|
||||
email: "fresh@email.com",
|
||||
},
|
||||
session: {
|
||||
id: "session-2",
|
||||
},
|
||||
}),
|
||||
),
|
||||
baseURL: "http://localhost:3000",
|
||||
},
|
||||
});
|
||||
|
||||
client.hydrateSession({
|
||||
user: {
|
||||
id: "1",
|
||||
name: "Hydrated User",
|
||||
email: "hydrated@email.com",
|
||||
emailVerified: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
session: {
|
||||
id: "session-1",
|
||||
userId: "1",
|
||||
expiresAt: new Date(),
|
||||
token: "session-token-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const session = client.useSession();
|
||||
|
||||
expect(session()).toMatchObject({
|
||||
data: {
|
||||
user: {
|
||||
id: "1",
|
||||
email: "hydrated@email.com",
|
||||
},
|
||||
session: {
|
||||
id: "session-1",
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
isPending: false,
|
||||
isRefetching: false,
|
||||
});
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(session()).toMatchObject({
|
||||
data: {
|
||||
user: {
|
||||
id: "2",
|
||||
email: "fresh@email.com",
|
||||
},
|
||||
session: {
|
||||
id: "session-2",
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
isPending: false,
|
||||
isRefetching: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not overwrite session when already hydrated", async () => {
|
||||
const client = createSolidClient({
|
||||
fetchOptions: {
|
||||
customFetchImpl: async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
user: { id: "1", email: "fresh@email.com" },
|
||||
session: { id: "session-1" },
|
||||
}),
|
||||
),
|
||||
baseURL: "http://localhost:3000",
|
||||
},
|
||||
});
|
||||
|
||||
client.hydrateSession({
|
||||
user: {
|
||||
id: "1",
|
||||
name: "First",
|
||||
email: "first@email.com",
|
||||
emailVerified: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
session: {
|
||||
id: "session-1",
|
||||
userId: "1",
|
||||
expiresAt: new Date(),
|
||||
token: "token-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Second call should be a no-op
|
||||
client.hydrateSession({
|
||||
user: {
|
||||
id: "2",
|
||||
name: "Second",
|
||||
email: "second@email.com",
|
||||
emailVerified: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
session: {
|
||||
id: "session-2",
|
||||
userId: "2",
|
||||
expiresAt: new Date(),
|
||||
token: "token-2",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const session = client.useSession();
|
||||
expect(session().data?.user?.email).toBe("first@email.com");
|
||||
});
|
||||
|
||||
it("should not hydrate when session is null", () => {
|
||||
const client = createSolidClient({
|
||||
fetchOptions: {
|
||||
customFetchImpl: async () => new Response(JSON.stringify(null)),
|
||||
baseURL: "http://localhost:3000",
|
||||
},
|
||||
});
|
||||
|
||||
client.hydrateSession(null);
|
||||
|
||||
const session = client.useSession();
|
||||
expect(session().data).toBeNull();
|
||||
expect(session().isPending).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* Re-running hydrateSession on a later render (e.g. after sign-out cleared
|
||||
* the atom) must not restore the stale initial session. Hydration is
|
||||
* one-shot per client instance.
|
||||
*/
|
||||
it("should not re-hydrate after the session atom has been cleared", () => {
|
||||
const client = createVanillaClient({
|
||||
fetchOptions: {
|
||||
customFetchImpl: async () => new Response(JSON.stringify(null)),
|
||||
baseURL: "http://localhost:3000",
|
||||
},
|
||||
});
|
||||
|
||||
const initialSession = {
|
||||
user: {
|
||||
id: "1",
|
||||
name: "Hydrated",
|
||||
email: "hydrated@email.com",
|
||||
emailVerified: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
session: {
|
||||
id: "session-1",
|
||||
userId: "1",
|
||||
expiresAt: new Date(),
|
||||
token: "token-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
client.hydrateSession(initialSession);
|
||||
|
||||
// Simulate sign-out clearing the session atom.
|
||||
const sessionAtom = client.$store.atoms.session!;
|
||||
sessionAtom.set({
|
||||
...sessionAtom.get(),
|
||||
data: null,
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
// A later render calls hydrateSession again with the same initialSession.
|
||||
client.hydrateSession(initialSession);
|
||||
|
||||
expect(sessionAtom.get().data).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* The auth client is a module-level singleton, so the session atom is shared
|
||||
* across concurrent SSR requests. Writing during server render would leak one
|
||||
* request's session into another. Hydration must be a no-op on the server.
|
||||
*/
|
||||
it("should not write to the shared atom during server render", () => {
|
||||
const client = createVanillaClient({
|
||||
fetchOptions: {
|
||||
customFetchImpl: async () => new Response(JSON.stringify(null)),
|
||||
baseURL: "http://localhost:3000",
|
||||
},
|
||||
});
|
||||
|
||||
vi.stubGlobal("window", undefined);
|
||||
try {
|
||||
client.hydrateSession({
|
||||
user: {
|
||||
id: "user-a",
|
||||
name: "User A",
|
||||
email: "a@email.com",
|
||||
emailVerified: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
session: {
|
||||
id: "session-a",
|
||||
userId: "user-a",
|
||||
expiresAt: new Date(),
|
||||
token: "token-a",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
expect(client.$store.atoms.session!.get().data).toBeNull();
|
||||
});
|
||||
|
||||
it("should allow second argument fetch options", async () => {
|
||||
let called = false;
|
||||
const client = createSolidClient({
|
||||
@@ -201,6 +435,21 @@ describe("type", () => {
|
||||
isPending: boolean;
|
||||
}>();
|
||||
});
|
||||
it("should infer hydrateSession react", () => {
|
||||
const client = createReactClient({
|
||||
plugins: [testClientPlugin()],
|
||||
baseURL: "http://localhost:3000",
|
||||
fetchOptions: {
|
||||
customFetchImpl: async () => {
|
||||
return new Response();
|
||||
},
|
||||
},
|
||||
});
|
||||
type HydrateSession = typeof client.hydrateSession;
|
||||
expectTypeOf<HydrateSession>().toMatchTypeOf<
|
||||
(session: ReturnType<typeof client.useSession>["data"]) => void
|
||||
>();
|
||||
});
|
||||
it("should infer resolved hooks react", () => {
|
||||
const client = createReactClient({
|
||||
plugins: [testClientPlugin()],
|
||||
|
||||
@@ -8,7 +8,8 @@ import type { WritableAtom } from "nanostores";
|
||||
import { getBaseURL } from "../utils/url";
|
||||
import { redirectPlugin } from "./fetch-plugins";
|
||||
import { parseJSON } from "./parser";
|
||||
import { getSessionAtom } from "./session-atom";
|
||||
import type { SessionData } from "./session-atom";
|
||||
import { getSessionAtom, hydrateSessionAtom } from "./session-atom";
|
||||
|
||||
const resolvePublicAuthUrl = (basePath?: string) => {
|
||||
if (typeof process === "undefined") return undefined;
|
||||
@@ -94,6 +95,14 @@ export const getClientConfig = (
|
||||
$fetch,
|
||||
options,
|
||||
);
|
||||
|
||||
let hasHydrated = false;
|
||||
const hydrateSession = (sessionData: SessionData | null) => {
|
||||
if (hasHydrated || sessionData === null) return;
|
||||
hasHydrated = true;
|
||||
hydrateSessionAtom(session, sessionData);
|
||||
};
|
||||
|
||||
const plugins = options?.plugins || [];
|
||||
let pluginsActions = {} as Record<string, any>;
|
||||
const pluginsAtoms = {
|
||||
@@ -181,6 +190,7 @@ export const getClientConfig = (
|
||||
pluginsAtoms,
|
||||
pluginPathMethods,
|
||||
atomListeners,
|
||||
hydrateSession,
|
||||
$fetch,
|
||||
$store,
|
||||
};
|
||||
|
||||
@@ -51,6 +51,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
|
||||
pluginPathMethods,
|
||||
pluginsActions,
|
||||
pluginsAtoms,
|
||||
hydrateSession,
|
||||
$fetch,
|
||||
$store,
|
||||
atomListeners,
|
||||
@@ -63,6 +64,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
|
||||
const routes = {
|
||||
...pluginsActions,
|
||||
...resolvedHooks,
|
||||
hydrateSession,
|
||||
$fetch,
|
||||
$store,
|
||||
};
|
||||
@@ -85,6 +87,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
|
||||
return proxy as UnionToIntersection<InferResolvedHooks<Option>> &
|
||||
ClientAPI &
|
||||
InferActions<Option> & {
|
||||
hydrateSession: (session: NonNullable<Session> | null) => void;
|
||||
useSession: () => {
|
||||
data: Session;
|
||||
isPending: boolean;
|
||||
|
||||
@@ -51,6 +51,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
|
||||
pluginPathMethods,
|
||||
pluginsActions,
|
||||
pluginsAtoms,
|
||||
hydrateSession,
|
||||
$fetch,
|
||||
$store,
|
||||
atomListeners,
|
||||
@@ -63,6 +64,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
|
||||
const routes = {
|
||||
...pluginsActions,
|
||||
...resolvedHooks,
|
||||
hydrateSession,
|
||||
$fetch,
|
||||
$store,
|
||||
};
|
||||
@@ -85,6 +87,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
|
||||
return proxy as UnionToIntersection<InferResolvedHooks<Option>> &
|
||||
ClientAPI &
|
||||
InferActions<Option> & {
|
||||
hydrateSession: (session: NonNullable<Session> | null) => void;
|
||||
useSession: () => {
|
||||
data: Session;
|
||||
isPending: boolean;
|
||||
|
||||
@@ -6,20 +6,40 @@ import type { AuthQueryAtom } from "./query";
|
||||
import { useAuthQuery } from "./query";
|
||||
import { createSessionRefreshManager } from "./session-refresh";
|
||||
|
||||
export type SessionAtom = AuthQueryAtom<{
|
||||
export type SessionData = {
|
||||
user: User;
|
||||
session: Session;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SessionAtom = AuthQueryAtom<SessionData>;
|
||||
|
||||
export function hydrateSessionAtom(
|
||||
sessionAtom: SessionAtom,
|
||||
session: SessionData | null,
|
||||
) {
|
||||
// The client is a module-level singleton, so writing during SSR would leak
|
||||
// one request's session into concurrent requests sharing the same process.
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const currentSession = sessionAtom.get();
|
||||
if (currentSession.data !== null || session === null) {
|
||||
return;
|
||||
}
|
||||
sessionAtom.set({
|
||||
...currentSession,
|
||||
data: session,
|
||||
error: null,
|
||||
isPending: false,
|
||||
});
|
||||
}
|
||||
|
||||
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, {
|
||||
const session = useAuthQuery<SessionData>($signal, "/get-session", $fetch, {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
|
||||
pluginPathMethods,
|
||||
pluginsActions,
|
||||
pluginsAtoms,
|
||||
hydrateSession,
|
||||
$fetch,
|
||||
atomListeners,
|
||||
} = getClientConfig(options);
|
||||
@@ -62,6 +63,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
|
||||
const routes = {
|
||||
...pluginsActions,
|
||||
...resolvedHooks,
|
||||
hydrateSession,
|
||||
};
|
||||
const proxy = createDynamicPathProxy(
|
||||
routes,
|
||||
@@ -83,6 +85,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
|
||||
return proxy as UnionToIntersection<InferResolvedHooks<Option>> &
|
||||
InferClientAPI<Option> &
|
||||
InferActions<Option> & {
|
||||
hydrateSession: (session: NonNullable<Session> | null) => void;
|
||||
useSession: () => Accessor<{
|
||||
data: Session;
|
||||
isPending: boolean;
|
||||
|
||||
@@ -47,6 +47,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
|
||||
pluginPathMethods,
|
||||
pluginsActions,
|
||||
pluginsAtoms,
|
||||
hydrateSession,
|
||||
$fetch,
|
||||
atomListeners,
|
||||
$store,
|
||||
@@ -58,6 +59,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
|
||||
const routes = {
|
||||
...pluginsActions,
|
||||
...resolvedHooks,
|
||||
hydrateSession,
|
||||
$fetch,
|
||||
$store,
|
||||
};
|
||||
@@ -81,6 +83,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
|
||||
return proxy as UnionToIntersection<InferResolvedHooks<Option>> &
|
||||
InferClientAPI<Option> &
|
||||
InferActions<Option> & {
|
||||
hydrateSession: (session: NonNullable<Session> | null) => void;
|
||||
useSession: () => Atom<{
|
||||
data: Session;
|
||||
error: BetterFetchError | null;
|
||||
|
||||
@@ -47,6 +47,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
|
||||
pluginPathMethods,
|
||||
pluginsActions,
|
||||
pluginsAtoms,
|
||||
hydrateSession,
|
||||
$fetch,
|
||||
atomListeners,
|
||||
$store,
|
||||
@@ -58,6 +59,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
|
||||
const routes = {
|
||||
...pluginsActions,
|
||||
...resolvedHooks,
|
||||
hydrateSession,
|
||||
$fetch,
|
||||
$store,
|
||||
};
|
||||
@@ -81,6 +83,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
|
||||
return proxy as UnionToIntersection<InferResolvedHooks<Option>> &
|
||||
ClientAPI &
|
||||
InferActions<Option> & {
|
||||
hydrateSession: (session: NonNullable<Session> | null) => void;
|
||||
useSession: Atom<{
|
||||
data: Session;
|
||||
error: BetterFetchError | null;
|
||||
|
||||
@@ -55,6 +55,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
|
||||
pluginPathMethods,
|
||||
pluginsActions,
|
||||
pluginsAtoms,
|
||||
hydrateSession,
|
||||
$fetch,
|
||||
$store,
|
||||
atomListeners,
|
||||
@@ -118,6 +119,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
|
||||
const routes = {
|
||||
...pluginsActions,
|
||||
...resolvedHooks,
|
||||
hydrateSession,
|
||||
useSession,
|
||||
$fetch,
|
||||
$store,
|
||||
@@ -134,6 +136,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
|
||||
return proxy as UnionToIntersection<InferResolvedHooks<Option>> &
|
||||
InferClientAPI<Option> &
|
||||
InferActions<Option> & {
|
||||
hydrateSession: (session: NonNullable<Session> | null) => void;
|
||||
useSession: typeof useSession;
|
||||
$Infer: {
|
||||
Session: NonNullable<Session>;
|
||||
|
||||
Reference in New Issue
Block a user