-
-
Notifications
You must be signed in to change notification settings - Fork 11.7k
Description
Bug report
- I confirm this is a bug with Supabase, not with my own application.
- I confirm I have searched the Docs, GitHub Discussions, and Discord.
Describe the bug
I am experiencing an issue in my Next.js (App Router) application where the client-side supabase.auth.getUser() call hangs indefinitely under specific conditions. This occurs when a user, who has an active session, closes the browser tab/window and then returns to a page requiring authentication after a period of inactivity (e.g., 30 minutes but can be more than a day too). It doesn't happen every time, happens randomly.
Next.js version: 15.2.3
@supabase/supabase-js version: 2.49.2
@supabase/ssr version: 0.6.1
Browser: Google Chrome
Deployment: Vercel
Application URL if you want to check - https://www.marketingquest.dev/, I left the console logs present if you need to check (need to be on the authorised page such as /dashboard)
To Reproduce
User logs into the Next.js application.
User navigates to a page that is protected and requires an active session (e.g., /dashboard).
User closes the browser tab or window.
User waits for a significant period (e.g., 30+ minutes).
User reopens the browser and navigates back to the protected page (e.g., /dashboard but also happens in the state of the header which checks for the user).
Expected behavior
The client-side supabase.auth.getUser() call (or getSession()) should resolve relatively quickly, either successfully retrieving the user session (potentially after a refresh) or returning an error/null if the session is invalid or has expired.
The page should either load correctly with user data or redirect to login if the session cannot be re-established.
Actual Behavior:
The client-side supabase.auth.getUser() call, initiated from a useEffect hook in a layout component (app/(user)/layout.tsx), hangs indefinitely.
This prevents the rest of the component's data fetching and rendering logic from executing, leading to the UI being stuck in a loading state (e.g., showing skeletons).
We've confirmed this hang by wrapping the getUser() call in a Promise.race with a 10-second timeout, which consistently logs a timeout error for this specific call.
Clearing browser cookies for the site resolves the issue temporarily: the user is logged out, and can log back in successfully. This suggests the issue is related to the handling of an existing, potentially stale, session cookie.
The server-side middleware (middleware.ts) appears to be functioning correctly and redirects to /dashboard if a session cookie is present, implying the middleware considers the session valid initially. The hang occurs in the subsequent client-side hydration/data fetching.
Additional context
// root middleware.ts file
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// Do not run code between createServerClient and
// supabase.auth.getUser(). A simple mistake could make it very hard to debug
// issues with users being randomly logged out.
// IMPORTANT: DO NOT REMOVE auth.getUser()
const {
data: { user },
} = await supabase.auth.getUser()
// Public routes that don't require authentication
const isPublicRoute =
request.nextUrl.pathname.startsWith('/login') ||
request.nextUrl.pathname.startsWith('/signup') ||
request.nextUrl.pathname.startsWith('/auth') ||
request.nextUrl.pathname.startsWith('/_next') ||
request.nextUrl.pathname.startsWith('/api') ||
request.nextUrl.pathname.startsWith('/forgot-password') ||
request.nextUrl.pathname.startsWith('/reset-password') ||
request.nextUrl.pathname === '/'
// Protected routes that require both auth and completed onboarding
const isProtectedRoute =
request.nextUrl.pathname.startsWith('/dashboard') ||
request.nextUrl.pathname.startsWith('/tasks') ||
request.nextUrl.pathname.startsWith('/achievements') ||
request.nextUrl.pathname.startsWith('/history') ||
request.nextUrl.pathname.startsWith('/leaderboard') ||
request.nextUrl.pathname.startsWith('/partners') ||
request.nextUrl.pathname.startsWith('/settings')
// If there's no user and the route requires auth, redirect to login
if (!user && !isPublicRoute) {
const redirectUrl = new URL('/login', request.url)
redirectUrl.searchParams.set('redirectedFrom', request.nextUrl.pathname)
// Ensure cookies are passed if supabaseResponse was modified
const response = NextResponse.redirect(redirectUrl);
supabaseResponse.cookies.getAll().forEach(cookie => {
response.cookies.set(cookie.name, cookie.value, cookie);
});
return response;
}
// If there's a user, check their onboarding status
if (user) {
// Check if user has a profile
const { data: profile } = await supabase
.from('profiles')
.select('username')
.eq('id', user.id)
.single()
const needsOnboarding = !profile || !profile.username
const isOnboardingRoute = request.nextUrl.pathname.startsWith('/onboarding')
const isAuthRoute = request.nextUrl.pathname.startsWith('/login') ||
request.nextUrl.pathname.startsWith('/signup')
// If user needs onboarding and tries to access a protected route, redirect to onboarding
if (needsOnboarding && isProtectedRoute) {
const redirectUrl = new URL('/onboarding', request.url);
const response = NextResponse.redirect(redirectUrl);
// Ensure cookies are passed if supabaseResponse was modified
supabaseResponse.cookies.getAll().forEach(cookie => {
response.cookies.set(cookie.name, cookie.value, cookie);
});
return response;
}
// If user is fully onboarded and tries to access auth pages or onboarding, redirect to dashboard
if (!needsOnboarding && (isAuthRoute || isOnboardingRoute)) {
const redirectUrl = new URL('/dashboard', request.url);
const response = NextResponse.redirect(redirectUrl);
// Ensure cookies are passed if supabaseResponse was modified
supabaseResponse.cookies.getAll().forEach(cookie => {
response.cookies.set(cookie.name, cookie.value, cookie);
});
return response;
}
}
return supabaseResponse
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public assets (images, audio, etc.)
* Feel free to modify this pattern to include more paths.
*/
'/((?!_next/static|_next/image|favicon.ico|sounds|.*\\.(?:svg|png|jpg|jpeg|gif|webp|mp3|wav)$).*)',
],
}
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
// app/(user)/layout.tsx
"use client"
import { createClient } from "@/lib/supabase/client";
import { useEffect, useState, useCallback, useRef } from "react";
// ... other imports ...
export default function UserLayout({ children }: UserLayoutProps) {
const [userData, setUserData] = useState<UserData | null>(null);
// ... other states ...
const supabase = createClient();
const isMountedRef = useRef(true);
const refreshUserDataAndStatus = useCallback(async () => {
console.log('[UserLayout] Initiating refreshUserDataAndStatus...');
try {
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser(); // This might also hang if called here
// ... rest of the function ...
} catch (e) {
console.error('[UserLayout] CRITICAL ERROR in refreshUserDataAndStatus catch block:', e);
}
}, [supabase]);
useEffect(() => {
isMountedRef.current = true;
console.log('[UserLayout] Main data-fetching useEffect triggered.');
let userId: string | null = null;
let isMounted = true; // Local isMounted for this effect
const setup = async () => {
console.log('[UserLayout] setup() function initiated.');
try {
console.log('[UserLayout] setup(): Attempting supabase.auth.getUser()...');
const getUserPromise = supabase.auth.getUser();
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('supabase.auth.getUser() timed out after 10 seconds')), 10000)
);
let user: any = null; // Use 'any' or proper User type
let setupGetUserError: Error | null = null;
try {
const result = await Promise.race([
getUserPromise,
timeoutPromise
]) as { data: { user: any }, error: any }; // Adjust type as needed
if (result.error) {
setupGetUserError = result.error;
} else if (result.data) { // Check if data exists
user = result.data.user;
}
} catch (e: any) {
setupGetUserError = e;
}
if (setupGetUserError) {
console.error('[UserLayout] setup(): Error or timeout from supabase.auth.getUser():', setupGetUserError);
}
console.log('[UserLayout] setup(): supabase.auth.getUser() completed/timedout. User:', user ? user.id : 'null');
userId = user?.id ?? null;
await refreshUserDataAndStatus(); // Called regardless of user state from setup's getUser
// ... (rest of setup) ...
} catch (error) {
console.error('[UserLayout] setup(): Error in setup function:', error);
}
};
setup(); // Initial call
const { data: { subscription: authSubscription } } = supabase.auth.onAuthStateChange((_event, session) => {
console.log('[UserLayout] onAuthStateChange triggered. Event:', _event);
if (!isMountedRef.current) return; // Use ref here
if (_event === 'SIGNED_OUT') {
refreshUserDataAndStatus();
userId = null;
} else if (_event === 'SIGNED_IN' && session) {
console.log('[UserLayout] onAuthStateChange: SIGNED_IN event, calling setup().');
setup();
}
});
return () => {
isMountedRef.current = false;
if (authSubscription) authSubscription.unsubscribe();
// ... any other cleanup ...
};
}, [supabase, refreshUserDataAndStatus]);
if (userData === null) {
return <LoadingLayout />;
}
// ... rest of layout ...
return ( /* ... */ );
}