fix: eliminate queue tab flash on mobile by using useSyncExternalStore (#46)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-02-28 09:54:14 -08:00 committed by GitHub
parent 2df47658fd
commit 0ac9ef4ff8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 25 additions and 12 deletions

View file

@ -1,15 +1,13 @@
import { useState, useEffect } from "react"; import { useSyncExternalStore } from "react";
export function useMediaQuery(query: string): boolean { export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false); return useSyncExternalStore(
(callback) => {
useEffect(() => { const mq = window.matchMedia(query);
const mq = window.matchMedia(query); mq.addEventListener("change", callback);
setMatches(mq.matches); return () => mq.removeEventListener("change", callback);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches); },
mq.addEventListener("change", handler); () => window.matchMedia(query).matches,
return () => mq.removeEventListener("change", handler); () => false
}, [query]); );
return matches;
} }

View file

@ -1,4 +1,5 @@
import { useLoaderData, Link, useRevalidator } from "react-router"; import { useLoaderData, Link, useRevalidator } from "react-router";
import { useAuth } from "@clerk/react-router";
import { eq, and, asc, desc, inArray } from "drizzle-orm"; import { eq, and, asc, desc, inArray } from "drizzle-orm";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
@ -229,6 +230,7 @@ export default function DraftRoom() {
ownerMap, ownerMap,
} = useLoaderData<typeof loader>(); } = useLoaderData<typeof loader>();
const { revalidate, state: revalidatorState } = useRevalidator(); const { revalidate, state: revalidatorState } = useRevalidator();
const { userId: clerkUserId } = useAuth();
const { isConnected, connectionError, isReconnecting, reconnectCount, on, off } = useDraftSocket(season.id, userTeam?.id); const { isConnected, connectionError, isReconnecting, reconnectCount, on, off } = useDraftSocket(season.id, userTeam?.id);
const { const {
permissionState: notificationsPermission, permissionState: notificationsPermission,
@ -268,6 +270,19 @@ export default function DraftRoom() {
} }
}, [reconnectCount, revalidate]); }, [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. // Track revalidation lifecycle to sync local state from fresh loader data.
// //
// The problem: `picks` and `currentPick` are local state initialized once // The problem: `picks` and `currentPick` are local state initialized once