From 92a327d408eeadb452d487e1b1fcc7ac0d4885f3 Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:52:05 +0200 Subject: [PATCH] fix(auth): the hydration ceiling did not reach isAuthenticated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timeline was not slow. It was waiting on a gate that could no longer open. useRequireAuth exposes isLoading and hydrated as EFFECTIVE values: a 4s ceiling resolves them so a page stops waiting on an auth store that is never going to answer. isAuthenticated was computed from the RAW store, so it opted itself out of the ceiling that every other field honours. /timeline renders `isLoading ? spinner : !isAuthenticated ? spinner : content`. The ceiling cleared the first gate after 4s; the second stayed shut forever. It never redirected either, because the redirect only fires when there is no user — and there was one, which is why the header rendered notifications the whole time. Measured live before the fix, on a fully signed-in session: domInteractive 903ms all support API calls done by 2.1s feed request NEVER MADE long tasks none articles after 122s 0 Zero network and zero long tasks is what distinguishes this from a performance problem. Nothing was slow; nothing was running at all. The same page had rendered in ~10s on other loads, which is what made it look like variable slowness rather than a stuck gate. `!!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, and the "no user" case is asserted so the ceiling can never invent a session. The hook had no tests at all despite gating every authenticated page. Proven by mutation: restoring the raw flags fails the "lets a signed-in user through" case and only that one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5 --- __tests__/unit/hooks/useRequireAuth.test.tsx | 101 +++++++++++++++++++ src/hooks/useAuthRedirects.ts | 17 +++- 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 __tests__/unit/hooks/useRequireAuth.test.tsx 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, }; }