diff --git a/__tests__/unit/services/warm-reader-is-browser-only.test.ts b/__tests__/unit/services/warm-reader-is-browser-only.test.ts new file mode 100644 index 000000000..5125b4fc4 --- /dev/null +++ b/__tests__/unit/services/warm-reader-is-browser-only.test.ts @@ -0,0 +1,52 @@ +/** + * @jest-environment node + * + * Warming the reader's id must do NOTHING on the server. + * + * `warmCurrentUserId` is called at the top of every timeline read, and timeline + * reads happen during server rendering too. Unguarded, it started an + * unconditional `/auth/v1/user` request from the server using the BROWSER + * Supabase client — which carries no request cookies, so it can only fail, and + * it fails while the route is rendering. + * + * That wedged the server render of every feed page. /timeline, /community and + * /dashboard sat on their route-level loading.tsx skeleton forever on a fresh + * page load, while navigating to the same route from inside the app worked, + * because that path never server-renders. Nothing appeared in the console and + * every check was green; the skeleton is near-white on white, so it read as a + * blank page. + * + * This test runs in the `node` environment on purpose. Under jsdom `window` + * exists, the guard is never exercised, and this file would pass while the + * server path stayed broken — which is exactly how the bug shipped. + */ + +const getUser = jest.fn(); + +jest.mock('@/lib/supabase/browser', () => ({ + __esModule: true, + default: { + auth: { + getUser: (...a: unknown[]) => getUser(...a), + onAuthStateChange: () => ({ data: { subscription: { unsubscribe: () => {} } } }), + }, + }, +})); + +import { warmCurrentUserId } from '@/services/supabase/auth/session'; + +describe('warmCurrentUserId on the server', () => { + it('has no window to speak of', () => { + // Guard the premise: if this environment ever gains a window, the test + // below stops testing anything and must not silently pass. + expect(typeof window).toBe('undefined'); + }); + + it('does not touch the network', async () => { + warmCurrentUserId(); + // Let any promise it might have started settle. + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(getUser).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/supabase/auth/session.ts b/src/services/supabase/auth/session.ts index 6d67888e9..1a3f51fb1 100644 --- a/src/services/supabase/auth/session.ts +++ b/src/services/supabase/auth/session.ts @@ -149,8 +149,28 @@ export async function getCurrentUserId(): Promise { * For callers that know they will need it later and can overlap the round-trip * with work that does not depend on it — fetching a feed, say. Returns nothing, * so it cannot be mistaken for the id itself. + * + * BROWSER ONLY, and the guard is load-bearing. This is called at the top of + * every timeline read, and timeline reads happen during server rendering too. + * Without the guard it started an unconditional `/auth/v1/user` request from + * the server using the BROWSER client — which has no request cookies, so it + * can only ever fail, and it does so while the route is rendering. + * + * That is not a wasted request, it is an outage: it wedged the server render + * of every feed page. /timeline, /community and /dashboard stayed on their + * route-level loading.tsx skeleton forever on a fresh page load, while + * navigating to the very same route from inside the app worked, because that + * path never server-renders. A near-white skeleton on a white page reads as a + * blank screen, so the symptom was "the timeline is empty", with nothing in + * the console and every check green. + * + * Deliberately silent on the server rather than throwing: this is an + * optimisation, and an optimisation must never be the reason a page fails. */ export function warmCurrentUserId(): void { + if (typeof window === 'undefined') { + return; + } void getCurrentUserId(); }