From 0ac9ef4ff8c97fba13d15a66c93b6656b2ec3c5f Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Sat, 28 Feb 2026 09:54:14 -0800 Subject: [PATCH] fix: eliminate queue tab flash on mobile by using useSyncExternalStore (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: eliminate queue tab flash on mobile by using useSyncExternalStore useMediaQuery initialized to false and updated in useEffect, causing a paint where isMobile was false on mobile devices. This rendered the desktop layout first, hiding the queue tab until the effect fired. useSyncExternalStore reads matchMedia synchronously on the client so the correct layout is used on the first render with no extra paint. Co-Authored-By: Claude Sonnet 4.6 * fix: revalidate draft room when Clerk JWT expires at load time Clerk's short-lived JWTs (~1 min) can expire between navigations. Server-side clerkMiddleware can't refresh them for client-side loader fetches, so getAuth() returns userId=null — causing userTeam=undefined, the queue button to disappear, and the tab to default to "board". Added a useEffect that detects the mismatch: if the Clerk client SDK has a valid userId but the loader ran without one, trigger revalidate() so the loader re-runs with a fresh token and returns correct userTeam/userQueue data. Co-Authored-By: Claude Sonnet 4.6 * fix: prevent infinite revalidation loop in auth guard If the server consistently fails to populate currentUserId after JWT refresh (e.g. misconfigured CLERK_SECRET_KEY), the previous effect would loop indefinitely. A one-shot ref ensures we attempt auth recovery at most once per mount. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- app/hooks/useMediaQuery.ts | 22 +++++++++---------- .../leagues/$leagueId.draft.$seasonId.tsx | 15 +++++++++++++ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/app/hooks/useMediaQuery.ts b/app/hooks/useMediaQuery.ts index adf0f65..e184f1e 100644 --- a/app/hooks/useMediaQuery.ts +++ b/app/hooks/useMediaQuery.ts @@ -1,15 +1,13 @@ -import { useState, useEffect } from "react"; +import { useSyncExternalStore } from "react"; export function useMediaQuery(query: string): boolean { - const [matches, setMatches] = useState(false); - - useEffect(() => { - const mq = window.matchMedia(query); - setMatches(mq.matches); - const handler = (e: MediaQueryListEvent) => setMatches(e.matches); - mq.addEventListener("change", handler); - return () => mq.removeEventListener("change", handler); - }, [query]); - - return matches; + return useSyncExternalStore( + (callback) => { + const mq = window.matchMedia(query); + mq.addEventListener("change", callback); + return () => mq.removeEventListener("change", callback); + }, + () => window.matchMedia(query).matches, + () => false + ); } diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 4b89b0f..ac58bd3 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -1,4 +1,5 @@ import { useLoaderData, Link, useRevalidator } from "react-router"; +import { useAuth } from "@clerk/react-router"; import { eq, and, asc, desc, inArray } from "drizzle-orm"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; @@ -229,6 +230,7 @@ export default function DraftRoom() { ownerMap, } = useLoaderData(); const { revalidate, state: revalidatorState } = useRevalidator(); + const { userId: clerkUserId } = useAuth(); const { isConnected, connectionError, isReconnecting, reconnectCount, on, off } = useDraftSocket(season.id, userTeam?.id); const { permissionState: notificationsPermission, @@ -268,6 +270,19 @@ export default function DraftRoom() { } }, [reconnectCount, revalidate]); + // Guard against Clerk JWT expiry race: if the loader ran without a userId (expired + // short-lived JWT) but the Clerk client SDK has a valid session, revalidate once so + // the loader re-runs with a fresh token and returns correct userTeam/userQueue data. + // The ref prevents an infinite loop if the server consistently fails to return a + // userId (e.g. misconfigured secret key) — we only attempt this recovery once. + const authRevalidatedRef = useRef(false); + useEffect(() => { + if (clerkUserId && !currentUserId && !authRevalidatedRef.current) { + authRevalidatedRef.current = true; + revalidate(); + } + }, [clerkUserId, currentUserId, revalidate]); + // Track revalidation lifecycle to sync local state from fresh loader data. // // The problem: `picks` and `currentPick` are local state initialized once