From 5854ba8de7bb461bdbc1fc64e172e850fcd150f8 Mon Sep 17 00:00:00 2001 From: emilianocalzada Date: Wed, 26 Nov 2025 15:07:53 -0600 Subject: [PATCH] feat(useAuth): add refreshOnMount option to sync user data Fixes issue where useAuth returns stale user data after screen navigation. When refreshOnMount is true, the hook calls authRefresh() on mount to fetch the latest user data from the server. --- src/hooks/useAuth.ts | 27 +++++++++- src/types/useAuth.type.ts | 9 ++++ tests/hooks/useAuth.test.tsx | 99 ++++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index fdb0ff9..9babda6 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -30,7 +30,7 @@ import { usePocketBase } from './usePocketBase'; * }; * ``` */ -export function useAuth({ collectionName = 'users', realtime = true }: UseAuthOptions = {}): UseAuthResult { +export function useAuth({ collectionName = 'users', realtime = true, refreshOnMount = false }: UseAuthOptions = {}): UseAuthResult { const pb = usePocketBase(); const [user, setUser] = useState(pb.authStore.record as User); const [isLoading, setIsLoading] = useState(false); @@ -43,6 +43,31 @@ export function useAuth({ collectionName = 'users', rea }); }, [pb]); + useEffect(() => { + if (!refreshOnMount || !pb.authStore.isValid) { + return; + } + + let cancelled = false; + + const refresh = async () => { + try { + const authData = await recordService.authRefresh(); + if (!cancelled) { + setUser(authData.record); + } + } catch { + // silently ignore refresh errors on mount (e.g., network issues, invalid token) + } + }; + + refresh(); + + return () => { + cancelled = true; + }; + }, [refreshOnMount, pb.authStore.isValid, recordService]); + useEffect(() => { if (!user || !realtime) { return; diff --git a/src/types/useAuth.type.ts b/src/types/useAuth.type.ts index d59e3fb..6ff7359 100644 --- a/src/types/useAuth.type.ts +++ b/src/types/useAuth.type.ts @@ -14,6 +14,15 @@ export interface UseAuthOptions { * Enable or disable real-time updates (default: true) */ realtime?: boolean; + + /** + * Refresh user data from the server on mount (default: false) + * + * When true, the hook will call authRefresh() on mount if the user is + * already authenticated. This ensures the user data is up-to-date + * after screen navigation or component remounting. + */ + refreshOnMount?: boolean; } /** diff --git a/tests/hooks/useAuth.test.tsx b/tests/hooks/useAuth.test.tsx index 5188eb5..4d1bd43 100644 --- a/tests/hooks/useAuth.test.tsx +++ b/tests/hooks/useAuth.test.tsx @@ -208,6 +208,105 @@ describe('useAuth', () => { }); }); + describe('refreshOnMount', () => { + it('should refresh user data on mount when refreshOnMount is true', async () => { + const staleUser = { id: '1', email: 'test@example.com', custom_field: 'old_value' }; + const freshUser = { id: '1', email: 'test@example.com', custom_field: 'new_value' }; + + const mockPocketBaseWithUser = createMockAuthPocketBase({ + isValid: true, + record: staleUser, + }); + + const mockAuthRefresh = vi.fn().mockResolvedValue({ record: freshUser, token: 'new-token' }); + mockPocketBaseWithUser.collection = vi.fn().mockReturnValue({ + authRefresh: mockAuthRefresh, + subscribe: vi.fn(() => Promise.resolve(() => {})), + }); + + const wrapper = createWrapper(mockPocketBaseWithUser); + + const { result } = renderHook(() => useAuth({ refreshOnMount: true }), { wrapper }); + + expect(result.current.user).toEqual(staleUser); + + await waitFor(() => { + expect(mockAuthRefresh).toHaveBeenCalled(); + expect(result.current.user).toEqual(freshUser); + }); + }); + + it('should not refresh when refreshOnMount is false (default)', async () => { + const staleUser = { id: '1', email: 'test@example.com', custom_field: 'old_value' }; + + const mockPocketBaseWithUser = createMockAuthPocketBase({ + isValid: true, + record: staleUser, + }); + + const mockAuthRefresh = vi.fn().mockResolvedValue({ record: staleUser, token: 'token' }); + mockPocketBaseWithUser.collection = vi.fn().mockReturnValue({ + authRefresh: mockAuthRefresh, + subscribe: vi.fn(() => Promise.resolve(() => {})), + }); + + const wrapper = createWrapper(mockPocketBaseWithUser); + + renderHook(() => useAuth(), { wrapper }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockAuthRefresh).not.toHaveBeenCalled(); + }); + + it('should not refresh when not authenticated', async () => { + const mockPocketBase = createMockAuthPocketBase({ + isValid: false, + record: null, + }); + + const mockAuthRefresh = vi.fn().mockResolvedValue({ record: {}, token: 'token' }); + mockPocketBase.collection = vi.fn().mockReturnValue({ + authRefresh: mockAuthRefresh, + subscribe: vi.fn(() => Promise.resolve(() => {})), + }); + + const wrapper = createWrapper(mockPocketBase); + + renderHook(() => useAuth({ refreshOnMount: true }), { wrapper }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockAuthRefresh).not.toHaveBeenCalled(); + }); + + it('should silently ignore refresh errors', async () => { + const staleUser = { id: '1', email: 'test@example.com' }; + + const mockPocketBaseWithUser = createMockAuthPocketBase({ + isValid: true, + record: staleUser, + }); + + const mockAuthRefresh = vi.fn().mockRejectedValue(new Error('Network error')); + mockPocketBaseWithUser.collection = vi.fn().mockReturnValue({ + authRefresh: mockAuthRefresh, + subscribe: vi.fn(() => Promise.resolve(() => {})), + }); + + const wrapper = createWrapper(mockPocketBaseWithUser); + + const { result } = renderHook(() => useAuth({ refreshOnMount: true }), { wrapper }); + + await waitFor(() => { + expect(mockAuthRefresh).toHaveBeenCalled(); + }); + + expect(result.current.user).toEqual(staleUser); + expect(result.current.error).toBe(null); + }); + }); + describe('signIn', () => { it.each` scenario | options | expected