Skip to content
Open
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
27 changes: 26 additions & 1 deletion src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { usePocketBase } from './usePocketBase';
* };
* ```
*/
export function useAuth<User extends AuthRecord>({ collectionName = 'users', realtime = true }: UseAuthOptions = {}): UseAuthResult<User> {
export function useAuth<User extends AuthRecord>({ collectionName = 'users', realtime = true, refreshOnMount = false }: UseAuthOptions = {}): UseAuthResult<User> {
const pb = usePocketBase();
const [user, setUser] = useState<User | null>(pb.authStore.record as User);
const [isLoading, setIsLoading] = useState(false);
Expand All @@ -43,6 +43,31 @@ export function useAuth<User extends AuthRecord>({ collectionName = 'users', rea
});
}, [pb]);

useEffect(() => {
if (!refreshOnMount || !pb.authStore.isValid) {
return;
}

let cancelled = false;

const refresh = async () => {
try {
const authData = await recordService.authRefresh<User>();
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;
Expand Down
9 changes: 9 additions & 0 deletions src/types/useAuth.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
99 changes: 99 additions & 0 deletions tests/hooks/useAuth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down