From 127f641c37cc2945bcb119805de115eae60d1c94 Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:39:02 +0200 Subject: [PATCH] fix(timeline): a warm-up request wedged the server render of every feed page warmCurrentUserId is called at the top of every timeline read, and timeline reads happen during server rendering too. It started an unconditional /auth/v1/user request from the SERVER using the BROWSER Supabase client, which carries no request cookies - so it could only ever fail, and it failed while the route was rendering. That is not a wasted request, it is an outage. /timeline, /community and /dashboard sat on their route-level loading.tsx skeleton forever on a fresh page load. Navigating to the very same route from inside the app worked, because that path never server-renders - which is what made it look intermittent. The skeleton is near-white on a white page, so the symptom users see is "the timeline is blank". Nothing in the console, no failed request, every check green. I introduced this in #826 and did not catch it, because I verified the fix on the post page and by measuring the network waterfall, neither of which exercises a cold server render of a feed route. The warm is now a no-op on the server, silently: it is an optimisation, and an optimisation must never be the reason a page fails. The test runs in the `node` jest environment on purpose. Under jsdom `window` exists, the guard is never exercised, and the test would pass while the server path stayed broken - which is exactly how this shipped. Mutation-proved: removing the guard turns it red. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5 --- .../warm-reader-is-browser-only.test.ts | 52 +++++++++++++++++++ src/services/supabase/auth/session.ts | 20 +++++++ 2 files changed, 72 insertions(+) create mode 100644 __tests__/unit/services/warm-reader-is-browser-only.test.ts 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(); }