Add _hasHydrated flag to authStore and update AuthGuard to wait for store hydration, ensuring stability during loading phases in tests and app.

This commit is contained in:
2025-11-02 14:16:56 +01:00
parent 29f98f059b
commit 6d1b730ae7
3 changed files with 36 additions and 5 deletions

View File

@@ -63,11 +63,16 @@ function LoadingSpinner() {
export function AuthGuard({ children, requireAdmin = false, fallback }: AuthGuardProps) { export function AuthGuard({ children, requireAdmin = false, fallback }: AuthGuardProps) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { isAuthenticated, isLoading: authLoading, user } = useAuthStore(); const { isAuthenticated, isLoading: authLoading, user, _hasHydrated } = useAuthStore();
// Fetch user data if authenticated but user not loaded // Fetch user data if authenticated but user not loaded
const { isLoading: userLoading } = useMe(); const { isLoading: userLoading } = useMe();
// Wait for store to hydrate from localStorage to prevent hook order issues
if (!_hasHydrated) {
return fallback ? <>{fallback}</> : <LoadingSpinner />;
}
// Determine overall loading state // Determine overall loading state
const isLoading = authLoading || (isAuthenticated && !user && userLoading); const isLoading = authLoading || (isAuthenticated && !user && userLoading);

View File

@@ -31,6 +31,7 @@ interface AuthState {
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
tokenExpiresAt: number | null; // Unix timestamp tokenExpiresAt: number | null; // Unix timestamp
_hasHydrated: boolean; // Internal flag for persist middleware
// Actions // Actions
setAuth: (user: User, accessToken: string, refreshToken: string, expiresIn?: number) => Promise<void>; setAuth: (user: User, accessToken: string, refreshToken: string, expiresIn?: number) => Promise<void>;
@@ -39,6 +40,7 @@ interface AuthState {
clearAuth: () => Promise<void>; clearAuth: () => Promise<void>;
loadAuthFromStorage: () => Promise<void>; loadAuthFromStorage: () => Promise<void>;
isTokenExpired: () => boolean; isTokenExpired: () => boolean;
setHasHydrated: (hasHydrated: boolean) => void; // Internal method for persist
} }
/** /**
@@ -125,6 +127,7 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: false, isAuthenticated: false,
isLoading: false, // No longer needed - persist handles hydration isLoading: false, // No longer needed - persist handles hydration
tokenExpiresAt: null, tokenExpiresAt: null,
_hasHydrated: false,
// Set complete auth state (user + tokens) // Set complete auth state (user + tokens)
setAuth: async (user, accessToken, refreshToken, expiresIn) => { setAuth: async (user, accessToken, refreshToken, expiresIn) => {
@@ -223,6 +226,11 @@ export const useAuthStore = create<AuthState>()(
if (!tokenExpiresAt) return true; if (!tokenExpiresAt) return true;
return Date.now() >= tokenExpiresAt; return Date.now() >= tokenExpiresAt;
}, },
// Internal method for persist middleware
setHasHydrated: (hasHydrated) => {
set({ _hasHydrated: hasHydrated });
},
}), }),
{ {
name: 'auth_store', // Storage key name: 'auth_store', // Storage key
@@ -238,6 +246,10 @@ export const useAuthStore = create<AuthState>()(
if (error) { if (error) {
console.error('Failed to rehydrate auth store:', error); console.error('Failed to rehydrate auth store:', error);
} }
// Mark store as hydrated to prevent rendering issues
if (state) {
state.setHasHydrated(true);
}
}; };
}, },
} }

View File

@@ -23,10 +23,12 @@ let mockAuthState: {
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
user: any; user: any;
_hasHydrated: boolean;
} = { } = {
isAuthenticated: false, isAuthenticated: false,
isLoading: false, isLoading: false,
user: null, user: null,
_hasHydrated: true, // In tests, assume store is always hydrated
}; };
jest.mock('@/lib/stores/authStore', () => ({ jest.mock('@/lib/stores/authStore', () => ({
@@ -69,6 +71,7 @@ describe('AuthGuard', () => {
isAuthenticated: false, isAuthenticated: false,
isLoading: false, isLoading: false,
user: null, user: null,
_hasHydrated: true, // In tests, assume store is always hydrated
}; };
mockMeState = { mockMeState = {
isLoading: false, isLoading: false,
@@ -82,6 +85,7 @@ describe('AuthGuard', () => {
isAuthenticated: false, isAuthenticated: false,
isLoading: true, isLoading: true,
user: null, user: null,
_hasHydrated: true,
}; };
render( render(
@@ -100,6 +104,7 @@ describe('AuthGuard', () => {
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
user: null, user: null,
_hasHydrated: true,
}; };
mockMeState = { mockMeState = {
isLoading: true, isLoading: true,
@@ -121,6 +126,7 @@ describe('AuthGuard', () => {
isAuthenticated: false, isAuthenticated: false,
isLoading: true, isLoading: true,
user: null, user: null,
_hasHydrated: true,
}; };
render( render(
@@ -142,6 +148,7 @@ describe('AuthGuard', () => {
isAuthenticated: false, isAuthenticated: false,
isLoading: false, isLoading: false,
user: null, user: null,
_hasHydrated: true,
}; };
render( render(
@@ -172,6 +179,7 @@ describe('AuthGuard', () => {
created_at: '2024-01-01', created_at: '2024-01-01',
updated_at: '2024-01-01', updated_at: '2024-01-01',
}, },
_hasHydrated: true,
}; };
render( render(
@@ -197,10 +205,11 @@ describe('AuthGuard', () => {
first_name: 'Admin', first_name: 'Admin',
last_name: 'User', last_name: 'User',
is_active: true, is_active: true,
is_superuser: true, is_superuser: true, // Admin user must have is_superuser: true
created_at: '2024-01-01', created_at: '2024-01-01',
updated_at: '2024-01-01', updated_at: '2024-01-01',
}, },
_hasHydrated: true,
}; };
render( render(
@@ -219,7 +228,7 @@ describe('AuthGuard', () => {
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
user: { user: {
id: '1', id: '1',
email: 'user@example.com', email: 'user@example.com',
first_name: 'Regular', first_name: 'Regular',
last_name: 'User', last_name: 'User',
@@ -228,6 +237,7 @@ describe('AuthGuard', () => {
created_at: '2024-01-01', created_at: '2024-01-01',
updated_at: '2024-01-01', updated_at: '2024-01-01',
}, },
_hasHydrated: true,
}; };
render( render(
@@ -249,7 +259,7 @@ describe('AuthGuard', () => {
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
user: { user: {
id: '1', id: '1',
email: 'user@example.com', email: 'user@example.com',
first_name: 'Regular', first_name: 'Regular',
last_name: 'User', last_name: 'User',
@@ -258,6 +268,7 @@ describe('AuthGuard', () => {
created_at: '2024-01-01', created_at: '2024-01-01',
updated_at: '2024-01-01', updated_at: '2024-01-01',
}, },
_hasHydrated: true,
}; };
render( render(
@@ -278,6 +289,7 @@ describe('AuthGuard', () => {
isAuthenticated: false, isAuthenticated: false,
isLoading: false, isLoading: false,
user: null, user: null,
_hasHydrated: true,
}; };
render( render(
@@ -301,6 +313,7 @@ describe('AuthGuard', () => {
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
user: null, user: null,
_hasHydrated: true,
}; };
mockMeState = { mockMeState = {
isLoading: true, isLoading: true,
@@ -322,7 +335,7 @@ describe('AuthGuard', () => {
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
user: { user: {
id: '1', id: '1',
email: 'user@example.com', email: 'user@example.com',
first_name: 'Test', first_name: 'Test',
last_name: 'User', last_name: 'User',
@@ -331,6 +344,7 @@ describe('AuthGuard', () => {
created_at: '2024-01-01', created_at: '2024-01-01',
updated_at: '2024-01-01', updated_at: '2024-01-01',
}, },
_hasHydrated: true,
}; };
mockMeState = { mockMeState = {
isLoading: false, isLoading: false,