fix(offline-sync): harden collab auth-failure handler, drop dead sync state
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 <noreply@anthropic.com>
This commit is contained in:
@@ -31,8 +31,6 @@ export interface PageCollabProviders {
|
|||||||
remote: HocuspocusProvider | null;
|
remote: HocuspocusProvider | null;
|
||||||
socket: HocuspocusProviderWebsocket | null;
|
socket: HocuspocusProviderWebsocket | null;
|
||||||
providersReady: boolean;
|
providersReady: boolean;
|
||||||
isLocalSynced: boolean;
|
|
||||||
isRemoteSynced: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,14 +43,19 @@ export interface PageCollabProviders {
|
|||||||
*/
|
*/
|
||||||
export function usePageCollabProviders(pageId: string): PageCollabProviders {
|
export function usePageCollabProviders(pageId: string): PageCollabProviders {
|
||||||
const collaborationURL = useCollaborationUrl();
|
const collaborationURL = useCollaborationUrl();
|
||||||
const [isLocalSynced, setIsLocalSynced] = useState(false);
|
|
||||||
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
|
|
||||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
);
|
);
|
||||||
const setIsLocalSyncedAtom = useSetAtom(isLocalSyncedAtom);
|
const setIsLocalSyncedAtom = useSetAtom(isLocalSyncedAtom);
|
||||||
const setIsRemoteSyncedAtom = useSetAtom(isRemoteSyncedAtom);
|
const setIsRemoteSyncedAtom = useSetAtom(isRemoteSyncedAtom);
|
||||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
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<string | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
collabTokenRef.current = collabQuery?.token;
|
||||||
|
}, [collabQuery?.token]);
|
||||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||||
const documentState = useDocumentVisibility();
|
const documentState = useDocumentVisibility();
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
@@ -68,13 +71,12 @@ export function usePageCollabProviders(pageId: string): PageCollabProviders {
|
|||||||
const [providersReady, setProvidersReady] = useState(false);
|
const [providersReady, setProvidersReady] = useState(false);
|
||||||
|
|
||||||
// Mirror the local/remote sync flags into shared atoms so the header
|
// 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) => {
|
const setLocalSynced = (value: boolean) => {
|
||||||
setIsLocalSynced(value);
|
|
||||||
setIsLocalSyncedAtom(value);
|
setIsLocalSyncedAtom(value);
|
||||||
};
|
};
|
||||||
const setRemoteSynced = (value: boolean) => {
|
const setRemoteSynced = (value: boolean) => {
|
||||||
setIsRemoteSynced(value);
|
|
||||||
setIsRemoteSyncedAtom(value);
|
setIsRemoteSyncedAtom(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -114,20 +116,31 @@ export function usePageCollabProviders(pageId: string): PageCollabProviders {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onAuthenticationFailedHandler = () => {
|
const onAuthenticationFailedHandler = () => {
|
||||||
const payload = jwtDecode(collabQuery?.token);
|
// Read the token from the ref, not the closed-over `collabQuery`: this
|
||||||
const now = Date.now().valueOf() / 1000;
|
// handler is created once and would otherwise decode a stale token after
|
||||||
const isTokenExpired = now >= payload.exp;
|
// a refetch. A missing/malformed token must NOT crash the handler —
|
||||||
if (isTokenExpired) {
|
// jwtDecode(undefined) throws — so treat any decode failure as "needs
|
||||||
refetchCollabToken().then((result) => {
|
// refresh" and proceed to refetch + reconnect instead of getting stuck.
|
||||||
if (result.data?.token) {
|
const token = collabTokenRef.current;
|
||||||
socket.disconnect();
|
let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect
|
||||||
setTimeout(() => {
|
if (token) {
|
||||||
remote.configuration.token = result.data.token;
|
try {
|
||||||
socket.connect();
|
const payload = jwtDecode<{ exp: number }>(token);
|
||||||
}, 100);
|
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({
|
const remote = new HocuspocusProvider({
|
||||||
websocketProvider: socket,
|
websocketProvider: socket,
|
||||||
@@ -188,7 +201,5 @@ export function usePageCollabProviders(pageId: string): PageCollabProviders {
|
|||||||
remote: providersRef.current?.remote ?? null,
|
remote: providersRef.current?.remote ?? null,
|
||||||
socket: providersRef.current?.socket ?? null,
|
socket: providersRef.current?.socket ?? null,
|
||||||
providersReady,
|
providersReady,
|
||||||
isLocalSynced,
|
|
||||||
isRemoteSynced,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user