diff --git a/__tests__/unit/hooks/useRequireAuth.test.tsx b/__tests__/unit/hooks/useRequireAuth.test.tsx new file mode 100644 index 000000000..cee3fa368 --- /dev/null +++ b/__tests__/unit/hooks/useRequireAuth.test.tsx @@ -0,0 +1,101 @@ +/** + * The hydration ceiling has to reach every gate, including `isAuthenticated`. + * + * `useRequireAuth` exposes `isLoading` and `hydrated` as EFFECTIVE values: once + * a 4s ceiling fires, they resolve so a page stops waiting on an auth store + * that is never going to answer. `isAuthenticated` was computed from the RAW + * store instead, so it opted itself out of the ceiling. + * + * On /timeline that produced an infinite spinner. The page renders + * `isLoading ? spinner : !isAuthenticated ? spinner : content`; the ceiling + * cleared the first gate after 4s and the second stayed shut forever. It never + * redirected either — the redirect only fires when there is no user, and there + * was one. Measured live before the fix: 122 seconds of skeleton, zero network + * requests, zero long tasks. The app was not slow; it was waiting on a gate + * that could no longer open. + * + * The two cases below are the whole contract: a wedged store with a user must + * eventually let the page render, and a wedged store WITHOUT one must not. + */ + +import { renderHook, act } from '@testing-library/react'; +import { useRequireAuth } from '@/hooks/useAuthRedirects'; + +const replace = jest.fn(); +const push = jest.fn(); +jest.mock('next/navigation', () => ({ + useRouter: () => ({ replace, push }), + usePathname: () => '/timeline', +})); + +let storeState: Record = {}; +jest.mock('@/stores/auth', () => ({ + useAuthStore: () => storeState, +})); + +function wedged(overrides: Record = {}) { + // What a stuck Supabase session looks like: never hydrated, never resolves. + storeState = { + user: { id: 'u1' }, + session: { access_token: 'x' }, + profile: null, + isLoading: true, + hydrated: false, + ...overrides, + }; +} + +describe('useRequireAuth hydration ceiling', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it('lets a signed-in user through once the ceiling fires', () => { + wedged(); + const { result } = renderHook(() => useRequireAuth()); + + // Before the ceiling: still waiting, which is correct. + expect(result.current.isLoading).toBe(true); + expect(result.current.isAuthenticated).toBe(false); + + act(() => { + jest.advanceTimersByTime(4100); + }); + + // After it: the page must be able to render rather than sit on a spinner. + expect(result.current.isLoading).toBe(false); + expect(result.current.hydrated).toBe(true); + expect(result.current.isAuthenticated).toBe(true); + expect(result.current.hydrationTimedOut).toBe(true); + }); + + it('does not claim authentication for someone with no user', () => { + wedged({ user: null, session: null }); + const { result } = renderHook(() => useRequireAuth()); + + act(() => { + jest.advanceTimersByTime(4100); + }); + + // The ceiling widens the exit; it must never invent a session. + expect(result.current.isAuthenticated).toBe(false); + }); + + it('reports authentication normally when auth resolves in time', () => { + storeState = { + user: { id: 'u1' }, + session: { access_token: 'x' }, + profile: { id: 'u1' }, + isLoading: false, + hydrated: true, + }; + const { result } = renderHook(() => useRequireAuth()); + + expect(result.current.isAuthenticated).toBe(true); + expect(result.current.hydrationTimedOut).toBe(false); + }); +}); diff --git a/src/hooks/useAuthRedirects.ts b/src/hooks/useAuthRedirects.ts index b62dbcab4..538b51ffe 100644 --- a/src/hooks/useAuthRedirects.ts +++ b/src/hooks/useAuthRedirects.ts @@ -119,7 +119,22 @@ export function useRequireAuth() { /** True when the 4s hydration ceiling fired before auth resolved. * Pages can branch on this to show "auth seems stuck — sign in" UX. */ hydrationTimedOut, - isAuthenticated: !!user && hydrated && !isLoading, + // The EFFECTIVE flags, for the same reason the two above are computed: + // this line used to read raw `hydrated` / `isLoading`, so it opted itself + // out of the ceiling that every other field honours. + // + // What that cost, measured on /timeline in production: the page renders + // `isLoading ? spinner : !isAuthenticated ? spinner : content`. After 4s + // the ceiling cleared the FIRST gate and the second one — reading raw + // state — stayed false forever, so the page sat on "Redirecting to + // login..." indefinitely. It never redirected either, because the redirect + // only fires when there is no `user`, and there was one. Observed live: + // 122 seconds of skeleton, zero network requests, zero long tasks — the + // app was not slow, it was waiting for a gate that could no longer open. + // + // `!!user` is unchanged and still does the real work: a timed-out ceiling + // can only report authenticated for someone who already has a user object. + isAuthenticated: !!user && effectiveHydrated && !effectiveIsLoading, }; }