Skip to content

Commit 331d72e

Browse files
authored
fix(auth): persist updateUser to server (#112)
1 parent d41dcce commit 331d72e

File tree

5 files changed

+84
-9
lines changed

5 files changed

+84
-9
lines changed

docs/content/5.api/1.composables.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,16 @@ await fetchSession({
117117

118118
#### `updateUser`
119119

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

122122
```ts
123-
updateUser({ name: 'New Name' })
123+
await updateUser({ name: 'New Name' })
124124
```
125125

126+
::note
127+
During SSR, `updateUser` only patches local state since no client is available.
128+
::
129+
126130
::tip
127131
**Reactivity**: `user` and `session` are global states using `useState`. Changes in one component are instantly reflected everywhere.
128132
::

src/runtime/app/composables/useUserSession.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ export interface UseUserSessionReturn {
1919
signOut: (options?: SignOutOptions) => Promise<void>
2020
waitForSession: () => Promise<void>
2121
fetchSession: (options?: { headers?: HeadersInit, force?: boolean }) => Promise<void>
22-
updateUser: (updates: Partial<AuthUser>) => void
22+
updateUser: (updates: Partial<AuthUser>) => Promise<void>
2323
}
2424

2525
// Singleton client instance to ensure consistent state across all useUserSession calls
2626
let _client: AppAuthClient | null = null
27+
interface UpdateUserResponse { error?: unknown }
28+
2729
function getClient(baseURL: string): AppAuthClient {
2830
if (!_client)
2931
_client = createAppAuthClient(baseURL)
@@ -98,9 +100,33 @@ export function useUserSession(): UseUserSessionReturn {
98100
user.value = null
99101
}
100102

101-
function updateUser(updates: Partial<AuthUser>) {
102-
if (user.value)
103-
user.value = { ...user.value, ...updates }
103+
async function updateUser(updates: Partial<AuthUser>) {
104+
if (!user.value)
105+
return
106+
107+
const previousUser = user.value
108+
user.value = { ...user.value, ...updates }
109+
110+
if (!client)
111+
return
112+
113+
try {
114+
const clientWithUpdateUser = client as AppAuthClient & { updateUser: (updates: Partial<AuthUser>) => Promise<UpdateUserResponse> }
115+
const result = await clientWithUpdateUser.updateUser(updates)
116+
if (result?.error) {
117+
if (typeof result.error === 'string')
118+
throw new Error(result.error)
119+
if (result.error instanceof Error)
120+
throw result.error
121+
if (typeof result.error === 'object' && result.error && 'message' in result.error && typeof result.error.message === 'string')
122+
throw new Error(result.error.message)
123+
throw new Error('Failed to update user')
124+
}
125+
}
126+
catch (error) {
127+
user.value = previousUser
128+
throw error
129+
}
104130
}
105131

106132
// On client, subscribe to better-auth's reactive session store

src/runtime/types/augment.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,5 @@ export interface UserSessionComposable {
4444
fetchSession: (options?: { headers?: HeadersInit, force?: boolean }) => Promise<void>
4545
waitForSession: () => Promise<void>
4646
signOut: (options?: { onSuccess?: () => void | Promise<void> }) => Promise<void>
47-
updateUser: (updates: Partial<AuthUser>) => void
47+
updateUser: (updates: Partial<AuthUser>) => Promise<void>
4848
}

test/cases/plugins-type-inference/server/auth.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ function customAdminLikePlugin() {
2525

2626
export default defineServerAuth({
2727
emailAndPassword: { enabled: true },
28-
plugins: [customAdminLikePlugin()],
28+
plugins: [customAdminLikePlugin()] as const,
2929
user: {
3030
additionalFields: {
3131
internalCode: {

test/use-user-session.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const sessionAtom = ref<SessionState>({
3636
error: null,
3737
})
3838

39-
const mockClient = {
39+
const mockClient: Record<string, any> = {
4040
useSession: vi.fn(() => sessionAtom),
4141
getSession: vi.fn(async () => ({ data: null })),
4242
$store: {
@@ -108,6 +108,7 @@ describe('useUserSession hydration bootstrap', () => {
108108
mockClient.getSession.mockClear()
109109
mockClient.$store.listen.mockClear()
110110
mockClient.signOut.mockClear()
111+
mockClient.updateUser = undefined
111112
mockClient.signIn.social.mockClear()
112113
mockClient.signIn.email.mockClear()
113114
mockClient.signUp.email.mockClear()
@@ -203,6 +204,50 @@ describe('useUserSession hydration bootstrap', () => {
203204
expect(auth.user.value).toEqual({ id: 'user-2', email: 'user@example.com' })
204205
})
205206

207+
it('updateUser persists on client and updates local state optimistically', async () => {
208+
mockClient.updateUser = vi.fn(async () => ({ data: { status: true } }))
209+
const useUserSession = await loadUseUserSession()
210+
const auth = useUserSession()
211+
auth.user.value = { id: 'user-1', name: 'Old', email: 'a@b.com' }
212+
213+
await auth.updateUser({ name: 'New' })
214+
215+
expect(mockClient.updateUser).toHaveBeenCalledWith({ name: 'New' })
216+
expect(auth.user.value!.name).toBe('New')
217+
})
218+
219+
it('updateUser reverts local state when the server call throws', async () => {
220+
mockClient.updateUser = vi.fn(async () => {
221+
throw new Error('fail')
222+
})
223+
const useUserSession = await loadUseUserSession()
224+
const auth = useUserSession()
225+
auth.user.value = { id: 'user-1', name: 'Old', email: 'a@b.com' }
226+
227+
await expect(auth.updateUser({ name: 'New' })).rejects.toThrow('fail')
228+
expect(auth.user.value!.name).toBe('Old')
229+
})
230+
231+
it('updateUser reverts local state when server returns an error payload', async () => {
232+
mockClient.updateUser = vi.fn(async () => ({ error: { message: 'invalid user update' } }))
233+
const useUserSession = await loadUseUserSession()
234+
const auth = useUserSession()
235+
auth.user.value = { id: 'user-1', name: 'Old', email: 'a@b.com' }
236+
237+
await expect(auth.updateUser({ name: 'New' })).rejects.toThrow('invalid user update')
238+
expect(auth.user.value!.name).toBe('Old')
239+
})
240+
241+
it('updateUser only updates local state on server (no client)', async () => {
242+
setRuntimeFlags({ client: false, server: true })
243+
const useUserSession = await loadUseUserSession()
244+
const auth = useUserSession()
245+
auth.user.value = { id: 'user-1', name: 'Old', email: 'a@b.com' }
246+
247+
await auth.updateUser({ name: 'New' })
248+
expect(auth.user.value!.name).toBe('New')
249+
})
250+
206251
it('syncs session on $sessionSignal when option is enabled and SSR payload is hydrated', async () => {
207252
payload.serverRendered = true
208253
runtimeConfig.public.auth.session.skipHydratedSsrGetSession = true

0 commit comments

Comments
 (0)