From e497fed705cff721507f755905b20c6c54940bff Mon Sep 17 00:00:00 2001 From: Kevin Carlson Date: Tue, 7 Jul 2026 13:46:18 -0700 Subject: [PATCH] Protect against some previewing errors I noticed this morning --- packages/frontend/src/api/rpc.ts | 42 +++++++++++++++---- .../frontend/src/user/user_state_provider.tsx | 38 ++++++++++------- 2 files changed, 57 insertions(+), 23 deletions(-) diff --git a/packages/frontend/src/api/rpc.ts b/packages/frontend/src/api/rpc.ts index d7925c5fa..379a2649e 100644 --- a/packages/frontend/src/api/rpc.ts +++ b/packages/frontend/src/api/rpc.ts @@ -8,6 +8,26 @@ import type { QubitServer, RpcResult } from "catcolab-api"; /** RPC client for communicating with the CatColab backend. */ export type RpcClient = QubitServer; +/** Resolve after `ms` milliseconds. Used to bound an await that could otherwise hang. */ +const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Reject if `promise` doesn't settle within `ms` milliseconds. */ +function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err) => { + clearTimeout(timer); + reject(err); + }, + ); + }); +} + /** Create a fetch function that automatically attaches Firebase auth tokens. */ export function createFetchWithAuth(firebaseApp?: FirebaseApp): typeof fetch { let currentUser: User | null = null; @@ -23,15 +43,21 @@ export function createFetchWithAuth(firebaseApp?: FirebaseApp): typeof fetch { }); return async (input, init?) => { - await authInitialized; + // Don't block forever if auth never initializes (e.g. unauthorized preview domain). + await Promise.race([authInitialized, delay(3000)]); if (currentUser) { - const token = await currentUser.getIdToken(); - const headers = new Headers(init?.headers); - headers.set("Authorization", `Bearer ${token}`); - init = { - ...init, - headers, - }; + try { + const token = await withTimeout(currentUser.getIdToken(), 5000); + const headers = new Headers(init?.headers); + headers.set("Authorization", `Bearer ${token}`); + init = { + ...init, + headers, + }; + } catch (e) { + // Fall through to an unauthenticated request rather than hanging. + console.warn("Auth token unavailable; proceeding unauthenticated", e); + } } return await fetch(input, init); }; diff --git a/packages/frontend/src/user/user_state_provider.tsx b/packages/frontend/src/user/user_state_provider.tsx index 46dfe84b1..153dfe657 100644 --- a/packages/frontend/src/user/user_state_provider.tsx +++ b/packages/frontend/src/user/user_state_provider.tsx @@ -35,24 +35,32 @@ export function UserStateProvider(props: { children: JSX.Element }) { teardownDocHandle(); setUserState(INITIAL_USER_STATE); - const userStateDocId = unwrap(await api.rpc.get_user_state_doc_id.query()); - if (currentUserId !== userId) { - return; - } + try { + const userStateDocId = unwrap(await api.rpc.get_user_state_doc_id.query()); + if (currentUserId !== userId) { + return; + } - const docHandle: DocHandle = await api.repo.find(userStateDocId as DocumentId); - if (currentUserId !== userId) { - return; - } + const docHandle: DocHandle = await api.repo.find( + userStateDocId as DocumentId, + ); + if (currentUserId !== userId) { + return; + } - currentDocHandle = docHandle; - const onChange = ({ doc }: { doc: UserState }) => { - setUserState(reconcile(normalizeImmutableStrings(doc))); - }; - currentChangeHandler = onChange; + currentDocHandle = docHandle; + const onChange = ({ doc }: { doc: UserState }) => { + setUserState(reconcile(normalizeImmutableStrings(doc))); + }; + currentChangeHandler = onChange; - setUserState(reconcile(normalizeImmutableStrings(docHandle.doc()))); - docHandle.on("change", onChange); + setUserState(reconcile(normalizeImmutableStrings(docHandle.doc()))); + docHandle.on("change", onChange); + } catch (e) { + // Failed to load user state (e.g. unauthenticated or backend error). Leave the + // initial state in place rather than escaping as an unhandled rejection. + console.error("Failed to load user state", e); + } }); onCleanup(() => {