supabase/supabase-js

supabase.auth.setSession and supabase.auth.updateUser do not resolve unless there is an error

Opened this issue · 13 comments

Summary
When using the Supabase client in an async function, both supabase.auth.setSession and supabase.auth.updateUser fail to resolve or return unless an error occurs. This blocks further execution in the code despite successful authentication or updates.

// Scenario 1: setSession
const { data, error } = await supabase.auth.setSession({
  access_token,
  refresh_token,
});
console.log('Setting session with token:', data, error); // Only logs if error occurs
console.log(data?.user); // Never reached if no error
// Scenario 2: updateUser
const { error: updateError } = await supabase.auth.updateUser({
  password: newPassword,
});
console.log('Update password error:', updateError); // Not called unless there's an error

Expected Behavior
Both methods should resolve regardless of whether there is an error, returning the appropriate data or null. Logging should be possible after the calls, and the behavior should be consistent with other auth methods like getSession.

Actual Behavior
setSession and updateUser appear to hang or do not return unless there's an error.

Causes the rest of the async function to not proceed, giving the illusion of being stuck.

Environment
Supabase JS SDK version: @supabase/supabase-js@2.9.9

Runtime: React Native / Expo (also affects Web SDK)

Additional Context
This issue is critical in flows like password recovery and token-based login where setSession or updateUser are required. It breaks user flows and leads to confusion.

One of the issues is related to supabase/supabase#35911

Are you calling these methods inside of onAuthStateChange?

No @j4w8n , here is relevant code:

const loginWithToken = async (
    access_token: string,
    refresh_token: string
  ): Promise<{ success: true; message: string } | { success: false; message: string }> => {
    try {
      const { error } = await supabase.auth.setSession({
        access_token,
        refresh_token,
      });

     // NEVER GETS HERE

      if (error) {
        throw error;
      }

      await supabase.auth.refreshSession();

      return {
        success: true,
        message: "Recovery session established.",
      };
    } catch (error: unknown) {
      let message = "Failed to establish recovery session.";
      if (error instanceof Error) {
        message = error.message;
      }
      return { success: false, message };
    }
  };

this is getting called on a click handler. It logs me in because onAuthStateChange triggers but when i console log after the setSession line, it doesn't even console log

useEffect(() => {

    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      async (event, session) => {
        console.log('Auth state changed:', event, session?.user?.email);

        setSession(session);

        if (session?.user) {
          await loadUserProfile(session.user);
        } else {
          setUser(null);
        }

        setIsLoading(false);
      }
    );

    return () => {
      subscription.unsubscribe();
    };
  }, []);

This is onAuthStateChange code

Could be because of your loadUserProfile() call then. Calling an async function inside of onAuthStateChange is a bit tricky in general, but especially if it's awaiting another supabase call. So, is loadUserProfile calling any supabase methods?

Yes, it does

const { data: profile, error } = await supabase
        .from('profiles')
        .select('email, name, avatar_url')
        .eq('id', authUser.id)
        .single();

which never returns after a setSession which actually might be the culprit here. Works fine with signInWithPassword() though. so definitely a bug. Whats a better way to handle this?

There is a workaround on that page

Thank you @j4w8n you wouldn't consider this a bug then?

@elitenas idk how the team views it, but it's been a stumbling block for many people.

+1

I just ran into this, with a bunch of stuff just mysteriously not working until I worked through many different bits of functionality piece by piece back to onAuthStateChange. This is a bizarre design decision, why is the function I give an event listener blocking any other piece of code?

I am running into this issue and it seems to be related to a bug on local storage locks management.

After adding some debug logs, I could find out that the indefinite hanging happens at this line in GoTrueClient.js:

return this._acquireLock(-1, async () => {
....
})

This never resolves and as a result getSession and all other async methods will never resolve.

Via console, I've logged await navigator.locks.query(), and I could see one lock held for ever.

I've fixed the issue temporarily by making a patch setting the acquiredTimeout to 10000 instead of -1 everywhere it was -1 (infinite), so that it will hang 10 seconds at most.

I've found out that closing all the other tabs in the client was releasing the locks, so it sounds like one of the tab had the app still running with a lock never released. So, I guess the real question is why a lock from another tab could have remained forever?

My temporary fix is not very satisfying as it will have to wait 10 seconds every time until the problematic tab is closed.

Just wondering if the issue is not that if the function fn in _acquireLock never terminates, it will hang forever.

Maybe adding a timeout so that it will be robust to fn never terminating, making sure such a case won't cause lock to remain indefinitely?

Something like

const result = Promise.race([
    fn(),
    new Promise<never>((_, reject) => 
      setTimeout(() => reject(new Error('Lock timeout')), 60000)
    )
  ])

instead of

const result = fn()

and

const result = Promise.race([
    (async () => {
      await last
      return await fn()
    })(),
    new Promise<never>((_, reject) => 
      setTimeout(() => reject(new Error('Lock timeout')), 60000)
    )
  ])

instead of

const result = (async () => {
  await last
  return await fn()
})()

?

I am not very familiar with the lock api, but it sounds like the acquireTimeout allows to say to execute the function even if there exists a lock on hold after acquireTimeout waiting, but it does not mean that the lock it creates will be automatically released if the function does not terminated within acquireTimeout, am I right that it's two different things?

But maybe the real issue is that all fn provided should terminate and never hang forever and the real question is how can it happen that in some cases, the provided fn never terminates.

FYI, it seems my client experienced the issue after logging out, with my logout code clearing local storage before calling supabase logout function, so I guess the library is not handling well that possibility.