fix: rewrite AuthContext to use canonical Supabase onAuthStateChange pattern

The previous implementation had two concurrent session recovery paths:
1. loadInitialSession() calling supabase.auth.getSession()
2. onAuthStateChange handling INITIAL_SESSION/SIGNED_IN

These raced unpredictably causing setLoading(false) to never fire in
certain interleavings, leaving the app permanently stuck on the spinner.

Fix: Remove loadInitialSession() entirely. Start loading=true. Rely
solely on onAuthStateChange — INITIAL_SESSION fires immediately with
the current session state, eliminating the race. One path, one
setLoading(false) call, no interleaving possible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kcar 2026-05-21 18:05:51 +00:00
parent d789586fca
commit b1681d86fb

View File

@ -31,7 +31,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const navigate = useNavigate();
const [user, setUser] = useState<CCUser | null>(null);
const [user_role, setUserRole] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true); // true until INITIAL_SESSION fires
const [error, setError] = useState<Error | null>(null);
const persistSession = useCallback((session: Session | null) => {
@ -42,40 +42,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
}, []);
const restoreSessionFromStorage = useCallback(async (): Promise<Session | null> => {
const persistedSession = storageService.get(StorageKeys.SUPABASE_SESSION);
if (!persistedSession) {
return null;
}
if (!persistedSession.access_token || !persistedSession.refresh_token) {
storageService.remove(StorageKeys.SUPABASE_SESSION);
return null;
}
const { data: restored, error: restoreError } = await supabase.auth.setSession({
access_token: persistedSession.access_token,
refresh_token: persistedSession.refresh_token,
});
if (restoreError) {
logger.warn('auth-context', '⚠️ Failed to restore persisted Supabase session', {
error: restoreError.message ?? restoreError,
});
storageService.remove(StorageKeys.SUPABASE_SESSION);
return null;
}
if (restored.session) {
persistSession(restored.session);
return restored.session;
}
storageService.remove(StorageKeys.SUPABASE_SESSION);
return null;
}, [persistSession]);
const buildUserFromSupabase = useCallback(async (supabaseUser: User | null): Promise<{ user: CCUser | null; role: string | null }> => {
if (!supabaseUser) {
return { user: null, role: null };
@ -109,114 +75,42 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, []);
useEffect(() => {
let isMounted = true;
const loadInitialSession = async () => {
setLoading(true);
try {
const { data: { session }, error } = await supabase.auth.getSession();
if (error) {
throw error;
}
let activeSession: Session | null = session ?? null;
if (!activeSession) {
activeSession = await restoreSessionFromStorage();
}
if (!isMounted) {
return;
}
if (activeSession?.user) {
persistSession(activeSession);
try {
const { user: resolvedUser, role } = await buildUserFromSupabase(activeSession.user);
if (!isMounted) {
return;
}
setUser(resolvedUser);
setUserRole(role);
} catch (buildError) {
logger.error('auth-context', '❌ Failed to build user from initial session', {
error: buildError,
});
if (!isMounted) {
return;
}
setUser(null);
setUserRole(null);
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
}
} else {
persistSession(null);
setUser(null);
setUserRole(null);
}
} catch (error) {
logger.error('auth-context', '❌ Failed to load initial session', { error });
if (isMounted) {
setError(error instanceof Error ? error : new Error('Failed to load user'));
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
loadInitialSession();
// Canonical Supabase auth pattern: rely solely on onAuthStateChange.
// INITIAL_SESSION fires immediately with the current session state,
// eliminating the race condition between loadInitialSession + onAuthStateChange.
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
if (!isMounted) {
return;
}
logger.debug('auth-context', '🔄 Auth state change', { event, hasSession: !!session });
switch (event) {
case 'INITIAL_SESSION':
case 'SIGNED_IN':
case 'TOKEN_REFRESHED':
case 'INITIAL_SESSION': {
case 'TOKEN_REFRESHED': {
persistSession(session ?? null);
if (session?.user) {
setLoading(true);
try {
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
if (!isMounted) {
return;
}
setUser(resolvedUser);
setUserRole(role);
} catch (buildError) {
logger.error('auth-context', '❌ Failed to build user from session', {
event,
error: buildError,
});
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
setUser(null);
setUserRole(null);
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
} finally {
if (isMounted) {
setLoading(false);
}
}
} else {
setUser(null);
setUserRole(null);
if (isMounted) {
}
// Always clear loading after the first auth event resolves
setLoading(false);
}
}
break;
}
case 'SIGNED_OUT': {
persistSession(null);
setUser(null);
setUserRole(null);
if (isMounted) {
setLoading(false);
}
break;
}
default:
@ -225,11 +119,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
);
return () => {
isMounted = false;
subscription.unsubscribe();
};
}, [buildUserFromSupabase, persistSession, restoreSessionFromStorage]);
return () => subscription.unsubscribe();
}, [buildUserFromSupabase, persistSession]);
const signIn = async (email: string, password: string) => {
try {