From 1dc6a6bfab787370af72702778c6ef05ab8a4556 Mon Sep 17 00:00:00 2001 From: onmax Date: Wed, 26 Aug 2026 10:36:21 +0000 Subject: [PATCH 1/2] fix(auth): defer initial session bridge --- src/runtime/app/composables/useUserSession.ts | 31 ++++++++++++++----- test/use-user-session.test.ts | 16 ++++++---- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/runtime/app/composables/useUserSession.ts b/src/runtime/app/composables/useUserSession.ts index 66fe91d0..48d9a9b0 100644 --- a/src/runtime/app/composables/useUserSession.ts +++ b/src/runtime/app/composables/useUserSession.ts @@ -120,9 +120,31 @@ export function useUserSession(): UseUserSessionReturn { } } + function queueHydrationReconciliation() { + if (hydrationReconcileQueued.value) + return + + hydrationReconcileQueued.value = true + nuxtApp.hook('app:mounted', async () => { + await fetchSession({ force: true }) + hydrationReconcileQueued.value = false + }) + } + // On client, subscribe to better-auth's reactive session store if (runtimeFlags.client && rawClient) { const clientSession = rawClient.useSession() + const initialClientSession = clientSession.value + + const shouldReconcileInitialHydration + = nuxtApp.isHydrating + && nuxtApp.payload.serverRendered + && Boolean(session.value && user.value) + && !initialClientSession?.data?.session + && !initialClientSession?.data?.user + + if (shouldReconcileInitialHydration) + queueHydrationReconciliation() watch( () => clientSession.value, @@ -148,13 +170,7 @@ export function useUserSession(): UseUserSessionReturn { && !newSession?.data?.user if (isHydrationEmptySnapshot) { - if (!hydrationReconcileQueued.value) { - hydrationReconcileQueued.value = true - nuxtApp.hook('app:mounted', async () => { - await fetchSession({ force: true }) - hydrationReconcileQueued.value = false - }) - } + queueHydrationReconciliation() return } @@ -163,7 +179,6 @@ export function useUserSession(): UseUserSessionReturn { if (!authReady.value && !newSession?.isPending && !newSession?.isRefetching) authReady.value = true }, - { immediate: true }, ) } diff --git a/test/use-user-session.test.ts b/test/use-user-session.test.ts index b0ca85b7..a5d1a3bb 100644 --- a/test/use-user-session.test.ts +++ b/test/use-user-session.test.ts @@ -182,14 +182,16 @@ describe('useUserSession hydration bootstrap', () => { delete (globalThis as { __NUXT_BETTER_AUTH_TEST_FLAGS__?: { client: boolean, server: boolean } }).__NUXT_BETTER_AUTH_TEST_FLAGS__ }) - it('bootstraps client session by default even when SSR payload is hydrated', async () => { + it('subscribes without synchronously bridging the initial client snapshot', async () => { payload.serverRendered = true seedHydratedState() const useUserSession = await loadUseUserSession() const auth = useUserSession() - expect(auth.ready.value).toBe(true) + expect(auth.ready.value).toBe(false) + expect(auth.session.value).toEqual({ id: 'session-1' }) + expect(auth.user.value).toEqual({ id: 'user-1' }) expect(mockClient.useSession).toHaveBeenCalledOnce() }) @@ -850,16 +852,20 @@ describe('useUserSession hydration bootstrap', () => { const useUserSession = await loadUseUserSession() const auth = useUserSession() - expect(auth.ready.value).toBe(true) + expect(auth.ready.value).toBe(false) sessionAtom.value = refreshedSession await flushPromises() + expect(auth.ready.value).toBe(true) expect(auth.session.value).toEqual({ id: 'session-3', ipAddress: '127.0.0.1' }) expect(auth.user.value).toEqual({ id: 'user-3', email: 'user3@example.com' }) }) it('does not re-sync session for nested mutations within the current Better Auth snapshot', async () => { + const useUserSession = await loadUseUserSession() + const auth = useUserSession() + sessionAtom.value = { data: { session: { id: 'session-1', metadata: { role: 'member' } }, @@ -869,9 +875,7 @@ describe('useUserSession hydration bootstrap', () => { isRefetching: false, error: null, } - - const useUserSession = await loadUseUserSession() - const auth = useUserSession() + await flushPromises() const bridgedSession = auth.session.value const metadata = sessionAtom.value.data!.session.metadata as { role: string } From db673698d872e010a2168f6bae76e2bfd9c678ba Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 26 Aug 2026 11:24:07 +0000 Subject: [PATCH 2/2] fix(auth): wait for settled hydration snapshot --- src/runtime/app/composables/useUserSession.ts | 2 ++ test/use-user-session.test.ts | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/src/runtime/app/composables/useUserSession.ts b/src/runtime/app/composables/useUserSession.ts index 48d9a9b0..d1df3969 100644 --- a/src/runtime/app/composables/useUserSession.ts +++ b/src/runtime/app/composables/useUserSession.ts @@ -142,6 +142,8 @@ export function useUserSession(): UseUserSessionReturn { && Boolean(session.value && user.value) && !initialClientSession?.data?.session && !initialClientSession?.data?.user + && !initialClientSession?.isPending + && !initialClientSession?.isRefetching if (shouldReconcileInitialHydration) queueHydrationReconciliation() diff --git a/test/use-user-session.test.ts b/test/use-user-session.test.ts index a5d1a3bb..1eae55af 100644 --- a/test/use-user-session.test.ts +++ b/test/use-user-session.test.ts @@ -285,6 +285,7 @@ describe('useUserSession hydration bootstrap', () => { payload.serverRendered = true nuxtApp.isHydrating = true seedHydratedState() + sessionAtom.value.isPending = true mockClient.getSession.mockResolvedValueOnce({ data: { @@ -298,9 +299,15 @@ describe('useUserSession hydration bootstrap', () => { await flushPromises() expect(mockClient.getSession).not.toHaveBeenCalled() + expect(nuxtHooks.get('app:mounted')).toBeUndefined() expect(auth.session.value).toEqual({ id: 'session-1' }) expect(auth.user.value).toEqual({ id: 'user-1' }) + sessionAtom.value = { ...sessionAtom.value, isPending: false } + await flushPromises() + + expect((nuxtHooks.get('app:mounted') || [])).toHaveLength(1) + nuxtApp.isHydrating = false await triggerNuxtHook('app:mounted') await flushPromises()