From f04f70dc8d99f7427820f6ccd5f604a8e0ce5e73 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 29 Jul 2026 19:49:00 -0400 Subject: [PATCH] fix(provider): keep the session store alive across a remount AuthProvider created the session store in useMemo but destroyed it in an effect cleanup. Those have different lifetimes: React can run mount, cleanup, mount against the same memoized value, which StrictMode does on every mount and Activity does whenever a hidden tree is shown again. destroy() is terminal, so the remounted provider held a store that refused every update, and refreshSession returned early before it could clear loading. Any app rendering the provider inside StrictMode, which is what the Vite template ships, stayed on loading forever, signed out or not. The provider no longer destroys the store. useSyncExternalStore removes its own listener on unmount and the store owns no timers, so it is reclaimed with the component. destroy() stays on the store for bindings that genuinely own its lifetime. --- .changeset/nine-pans-shake.md | 2 ++ src/AuthProvider.tsx | 15 ++++++--- tests/authProvider.test.tsx | 60 +++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/.changeset/nine-pans-shake.md b/.changeset/nine-pans-shake.md index b094efd..3cb1cb0 100644 --- a/.changeset/nine-pans-shake.md +++ b/.changeset/nine-pans-shake.md @@ -5,3 +5,5 @@ Stop calling the logout endpoint when the session check fails. A failed `/users/me` means the server already considers the session unusable, so the SDK now clears it locally instead of sending a `DELETE /logout` for a session that does not exist. Previously every anonymous page load fired that second request. Session state now lives in a framework-agnostic store behind `AuthProvider`, which reads it through `useSyncExternalStore`. The provider's public API is unchanged. Reading a previous sign-in goes through a storage port that falls back to memory when there is no `localStorage`, so the store is safe to create during server-side rendering. + +The store survives a remount. React can run mount, cleanup, mount against the same provider, which StrictMode does on every mount and Activity does whenever a hidden tree is shown again, so the provider no longer destroys the store from its effect cleanup. `destroy()` is terminal, and tearing it down there left the remounted provider holding a store that refused every update and stayed on `loading: true`. diff --git a/src/AuthProvider.tsx b/src/AuthProvider.tsx index fb0fcb6..7100b89 100644 --- a/src/AuthProvider.tsx +++ b/src/AuthProvider.tsx @@ -115,12 +115,19 @@ export const AuthProvider: React.FC = ({ session.getState ); + // The store is deliberately not destroyed on cleanup. `destroy()` is terminal, + // and React may run mount, cleanup, mount against the same memoized store: + // StrictMode does it on every mount today, and Activity will do it whenever a + // tree is hidden and shown again. Tearing down here left the remounted provider + // holding a store that refuses updates, stuck on `loading: true` forever. + // + // Nothing leaks by skipping it. `useSyncExternalStore` removes its own listener + // when the provider unmounts, and the store owns no timers or subscriptions, so + // it is reclaimed with the component. A refresh still in flight then resolves + // into a store nobody observes. `destroy()` stays on the store for bindings that + // genuinely own its lifetime. useEffect(() => { void session.actions.refreshSession(); - - return () => { - session.destroy(); - }; }, [session]); const value = useMemo( diff --git a/tests/authProvider.test.tsx b/tests/authProvider.test.tsx index 6ac784a..ecde6f9 100644 --- a/tests/authProvider.test.tsx +++ b/tests/authProvider.test.tsx @@ -5,6 +5,7 @@ */ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { StrictMode } from 'react'; import { AuthProvider, useAuth } from '../src/AuthProvider'; import { createFetchWithAuth } from '../src/fetchWithAuth'; @@ -23,6 +24,7 @@ const Consumer = () => {
{auth.user ? auth.user.email : 'none'} {String(auth.isAuthenticated)} + {String(auth.loading)} {String(auth.hasRole('admin'))} {String(auth.hasScopedRole('admin:read'))} @@ -463,6 +465,64 @@ describe('AuthProvider', () => { expect(returned.data).not.toHaveProperty('message'); }); + // StrictMode runs mount, cleanup, mount while useMemo keeps the same session + // store, so a provider that tore the store down on cleanup came back holding a + // store that refused every update and never left `loading`. The templates ship + // StrictMode, so this is the default path for a new app, not an edge case. + describe('StrictMode remount', () => { + it('settles a signed-out session instead of loading forever', async () => { + // The adapter answers a missing access cookie with 400, which is the + // ordinary anonymous first load. + mockFetchWithAuthImpl.mockResolvedValue( + failure(400, { error: 'Missing required cookie "seamless-access"' }) + ); + + await act(async () => { + render( + + + + + + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('false'); + }); + + expect(screen.getByTestId('isAuthenticated')).toHaveTextContent('false'); + expect(screen.getByTestId('user')).toHaveTextContent('none'); + }); + + it('still loads an authenticated session', async () => { + mockFetchWithAuthImpl.mockResolvedValue({ + ok: true, + json: async () => ({ + user: { id: '1', email: 'test@example.com', phone: '', roles: ['admin'] }, + credentials: [], + }), + } as any); + + await act(async () => { + render( + + + + + + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('user')).toHaveTextContent('test@example.com'); + }); + + expect(screen.getByTestId('loading')).toHaveTextContent('false'); + expect(screen.getByTestId('isAuthenticated')).toHaveTextContent('true'); + }); + }); + describe('failure paths', () => { it('throws when useAuth is called outside a provider', () => { const Orphan = () => {