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 = () => {