Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/nine-pans-shake.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
15 changes: 11 additions & 4 deletions src/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,19 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
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(
Expand Down
60 changes: 60 additions & 0 deletions tests/authProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -23,6 +24,7 @@ const Consumer = () => {
<div>
<span data-testid="user">{auth.user ? auth.user.email : 'none'}</span>
<span data-testid="isAuthenticated">{String(auth.isAuthenticated)}</span>
<span data-testid="loading">{String(auth.loading)}</span>
<span data-testid="hasRoleAdmin">{String(auth.hasRole('admin'))}</span>
<span data-testid="hasScopedRoleAdminRead">
{String(auth.hasScopedRole('admin:read'))}
Expand Down Expand Up @@ -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(
<StrictMode>
<AuthProvider apiHost={apiHost}>
<Consumer />
</AuthProvider>
</StrictMode>
);
});

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(
<StrictMode>
<AuthProvider apiHost={apiHost}>
<Consumer />
</AuthProvider>
</StrictMode>
);
});

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