Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/content/5.api/1.composables.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,16 @@ await fetchSession({

#### `updateUser`

Optimistically updates the local user object.
Updates the user on the server and optimistically patches local state. Local state reverts if the server call fails.

```ts
updateUser({ name: 'New Name' })
await updateUser({ name: 'New Name' })
```

::note
During SSR, `updateUser` only patches local state since no client is available.
::

::tip
**Reactivity**: `user` and `session` are global states using `useState`. Changes in one component are instantly reflected everywhere.
::
Expand Down
34 changes: 30 additions & 4 deletions src/runtime/app/composables/useUserSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ export interface UseUserSessionReturn {
signOut: (options?: SignOutOptions) => Promise<void>
waitForSession: () => Promise<void>
fetchSession: (options?: { headers?: HeadersInit, force?: boolean }) => Promise<void>
updateUser: (updates: Partial<AuthUser>) => void
updateUser: (updates: Partial<AuthUser>) => Promise<void>
}

// Singleton client instance to ensure consistent state across all useUserSession calls
let _client: AppAuthClient | null = null
interface UpdateUserResponse { error?: unknown }

function getClient(baseURL: string): AppAuthClient {
if (!_client)
_client = createAppAuthClient(baseURL)
Expand Down Expand Up @@ -98,9 +100,33 @@ export function useUserSession(): UseUserSessionReturn {
user.value = null
}

function updateUser(updates: Partial<AuthUser>) {
if (user.value)
user.value = { ...user.value, ...updates }
async function updateUser(updates: Partial<AuthUser>) {
if (!user.value)
return

const previousUser = user.value
user.value = { ...user.value, ...updates }

if (!client)
return

try {
const clientWithUpdateUser = client as AppAuthClient & { updateUser: (updates: Partial<AuthUser>) => Promise<UpdateUserResponse> }
const result = await clientWithUpdateUser.updateUser(updates)
if (result?.error) {
if (typeof result.error === 'string')
throw new Error(result.error)
if (result.error instanceof Error)
throw result.error
if (typeof result.error === 'object' && result.error && 'message' in result.error && typeof result.error.message === 'string')
throw new Error(result.error.message)
throw new Error('Failed to update user')
}
}
catch (error) {
user.value = previousUser
throw error
}
}

// On client, subscribe to better-auth's reactive session store
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/types/augment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,5 @@ export interface UserSessionComposable {
fetchSession: (options?: { headers?: HeadersInit, force?: boolean }) => Promise<void>
waitForSession: () => Promise<void>
signOut: (options?: { onSuccess?: () => void | Promise<void> }) => Promise<void>
updateUser: (updates: Partial<AuthUser>) => void
updateUser: (updates: Partial<AuthUser>) => Promise<void>
}
2 changes: 1 addition & 1 deletion test/cases/plugins-type-inference/server/auth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function customAdminLikePlugin() {

export default defineServerAuth({
emailAndPassword: { enabled: true },
plugins: [customAdminLikePlugin()],
plugins: [customAdminLikePlugin()] as const,
user: {
additionalFields: {
internalCode: {
Expand Down
47 changes: 46 additions & 1 deletion test/use-user-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const sessionAtom = ref<SessionState>({
error: null,
})

const mockClient = {
const mockClient: Record<string, any> = {
useSession: vi.fn(() => sessionAtom),
getSession: vi.fn(async () => ({ data: null })),
$store: {
Expand Down Expand Up @@ -108,6 +108,7 @@ describe('useUserSession hydration bootstrap', () => {
mockClient.getSession.mockClear()
mockClient.$store.listen.mockClear()
mockClient.signOut.mockClear()
mockClient.updateUser = undefined
mockClient.signIn.social.mockClear()
mockClient.signIn.email.mockClear()
mockClient.signUp.email.mockClear()
Expand Down Expand Up @@ -203,6 +204,50 @@ describe('useUserSession hydration bootstrap', () => {
expect(auth.user.value).toEqual({ id: 'user-2', email: '[email protected]' })
})

it('updateUser persists on client and updates local state optimistically', async () => {
mockClient.updateUser = vi.fn(async () => ({ data: { status: true } }))
const useUserSession = await loadUseUserSession()
const auth = useUserSession()
auth.user.value = { id: 'user-1', name: 'Old', email: '[email protected]' }

await auth.updateUser({ name: 'New' })

expect(mockClient.updateUser).toHaveBeenCalledWith({ name: 'New' })
expect(auth.user.value!.name).toBe('New')
})

it('updateUser reverts local state when the server call throws', async () => {
mockClient.updateUser = vi.fn(async () => {
throw new Error('fail')
})
const useUserSession = await loadUseUserSession()
const auth = useUserSession()
auth.user.value = { id: 'user-1', name: 'Old', email: '[email protected]' }

await expect(auth.updateUser({ name: 'New' })).rejects.toThrow('fail')
expect(auth.user.value!.name).toBe('Old')
})

it('updateUser reverts local state when server returns an error payload', async () => {
mockClient.updateUser = vi.fn(async () => ({ error: { message: 'invalid user update' } }))
const useUserSession = await loadUseUserSession()
const auth = useUserSession()
auth.user.value = { id: 'user-1', name: 'Old', email: '[email protected]' }

await expect(auth.updateUser({ name: 'New' })).rejects.toThrow('invalid user update')
expect(auth.user.value!.name).toBe('Old')
})

it('updateUser only updates local state on server (no client)', async () => {
setRuntimeFlags({ client: false, server: true })
const useUserSession = await loadUseUserSession()
const auth = useUserSession()
auth.user.value = { id: 'user-1', name: 'Old', email: '[email protected]' }

await auth.updateUser({ name: 'New' })
expect(auth.user.value!.name).toBe('New')
})

it('syncs session on $sessionSignal when option is enabled and SSR payload is hydrated', async () => {
payload.serverRendered = true
runtimeConfig.public.auth.session.skipHydratedSsrGetSession = true
Expand Down
Loading