From 5129567c2e118596f81766f7a339fb5bf27a9a62 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Mon, 22 Jun 2026 02:20:36 +0300 Subject: [PATCH] fix(offline-sync): harden collab auth-failure handler, drop dead sync state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The onAuthenticationFailed handler is created once per page (effect keyed on pageId), so it closed over the initial collab token and decoded a STALE value after a refetch. Worse, jwtDecode(undefined) throws, so when the token had not loaded (or the request failed) the handler crashed before it could refetch and reconnect — leaving the editor stuck disconnected. Mirror the latest token into a ref the handler reads live, and guard the decode: a missing or malformed token is treated as 'needs refresh' so it refetches and reconnects instead of throwing. A valid, unexpired token still early-returns. Also remove two local useState sync flags (isLocalSynced/isRemoteSynced) that were set but never read — the header indicator consumes the Jotai atoms, and the hook's return values were never destructured by any caller. The setter wrappers now drive only the atoms. Co-Authored-By: Claude Opus 4.8 --- .../editor/hooks/use-page-collab-providers.ts | 55 +++++++++++-------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/apps/client/src/features/editor/hooks/use-page-collab-providers.ts b/apps/client/src/features/editor/hooks/use-page-collab-providers.ts index c59252a4..a3f150eb 100644 --- a/apps/client/src/features/editor/hooks/use-page-collab-providers.ts +++ b/apps/client/src/features/editor/hooks/use-page-collab-providers.ts @@ -31,8 +31,6 @@ export interface PageCollabProviders { remote: HocuspocusProvider | null; socket: HocuspocusProviderWebsocket | null; providersReady: boolean; - isLocalSynced: boolean; - isRemoteSynced: boolean; } /** @@ -45,14 +43,19 @@ export interface PageCollabProviders { */ export function usePageCollabProviders(pageId: string): PageCollabProviders { const collaborationURL = useCollaborationUrl(); - const [isLocalSynced, setIsLocalSynced] = useState(false); - const [isRemoteSynced, setIsRemoteSynced] = useState(false); const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( yjsConnectionStatusAtom, ); const setIsLocalSyncedAtom = useSetAtom(isLocalSyncedAtom); const setIsRemoteSyncedAtom = useSetAtom(isRemoteSyncedAtom); const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken(); + // The provider-creating effect runs only once per pageId, so any token read + // inside its handlers would be captured STALE (the old token at first render). + // Mirror the latest token into a ref the auth-failure handler can read live. + const collabTokenRef = useRef(undefined); + useEffect(() => { + collabTokenRef.current = collabQuery?.token; + }, [collabQuery?.token]); const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false }); const documentState = useDocumentVisibility(); const { pageSlug } = useParams(); @@ -68,13 +71,12 @@ export function usePageCollabProviders(pageId: string): PageCollabProviders { const [providersReady, setProvidersReady] = useState(false); // Mirror the local/remote sync flags into shared atoms so the header - // indicator can read them, and keep the local component state in sync too. + // indicator can read them. These atoms are the single source of truth; the + // wrappers keep the existing call sites valid while driving only the atoms. const setLocalSynced = (value: boolean) => { - setIsLocalSynced(value); setIsLocalSyncedAtom(value); }; const setRemoteSynced = (value: boolean) => { - setIsRemoteSynced(value); setIsRemoteSyncedAtom(value); }; @@ -114,20 +116,31 @@ export function usePageCollabProviders(pageId: string): PageCollabProviders { } }; const onAuthenticationFailedHandler = () => { - const payload = jwtDecode(collabQuery?.token); - const now = Date.now().valueOf() / 1000; - const isTokenExpired = now >= payload.exp; - if (isTokenExpired) { - refetchCollabToken().then((result) => { - if (result.data?.token) { - socket.disconnect(); - setTimeout(() => { - remote.configuration.token = result.data.token; - socket.connect(); - }, 100); - } - }); + // Read the token from the ref, not the closed-over `collabQuery`: this + // handler is created once and would otherwise decode a stale token after + // a refetch. A missing/malformed token must NOT crash the handler — + // jwtDecode(undefined) throws — so treat any decode failure as "needs + // refresh" and proceed to refetch + reconnect instead of getting stuck. + const token = collabTokenRef.current; + let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect + if (token) { + try { + const payload = jwtDecode<{ exp: number }>(token); + needsRefresh = Date.now() / 1000 >= payload.exp; + } catch { + needsRefresh = true; // malformed token -> refresh + } } + if (!needsRefresh) return; + refetchCollabToken().then((result) => { + if (result.data?.token) { + socket.disconnect(); + setTimeout(() => { + remote.configuration.token = result.data.token; + socket.connect(); + }, 100); + } + }); }; const remote = new HocuspocusProvider({ websocketProvider: socket, @@ -188,7 +201,5 @@ export function usePageCollabProviders(pageId: string): PageCollabProviders { remote: providersRef.current?.remote ?? null, socket: providersRef.current?.socket ?? null, providersReady, - isLocalSynced, - isRemoteSynced, }; }