Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions packages/frontend/src/api/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => new Promise((resolve) => setTimeout(resolve, ms));

/** Reject if `promise` doesn't settle within `ms` milliseconds. */
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((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;
Expand All @@ -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);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can sometimes hang forever if there's a stale session in the browser and your current domain isn't Firebase-authorized, i.e. if you're futzing around a lot in Netlify previews.

@kasbah kasbah Jul 8, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean by "futzing around"? You tried to login via Google/Github on the preview?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I don't think you have to have tried to log in to make this break, it's something about the mere request for a firebase token that then gets turned down. I wouldn't be able to fully diagnose it myself but I could send you some robot's description if you like.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is what the robot says:

Symptom: On the sirius Netlify branch preview (branch-sirius--catcolab.netlify.app, built in staging → backend-next + Firebase project catcolab-next), any backend call hung forever — "creating…" spinner never resolved, import silently reset, zero RPC requests in the Network tab, nothing logged. Incognito worked fine.

Root cause: The frontend attaches auth per-request in createFetchWithAuth (packages/frontend/src/api/rpc.ts):

if (currentUser) {
    const token = await currentUser.getIdToken();  // <-- stalls, never settles
    headers.set("Authorization", `Bearer ${token}`);
}
return await fetch(...);   // never reached

currentUser.getIdToken() never resolved, so the fetch was never dispatched. It stalls because the preview origin isn't in catcolab-next's Authorized domains, and Firebase's token-refresh path coordinates through the cross-origin gapi iframe hosted at the authDomain — on an unauthorized origin that handshake never posts back (it doesn't error, it just hangs).

@KevinDCarlson KevinDCarlson Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, we're confused about how currentUser could ever have gotten to be non-null on a non-Firebase-whitelisted domain, and the forensics trail is cold. But the incognito check seems to prove that's what somehow happened. Might be some deeper mystery in here, but cutting off these infinite hangs does at least fix the symptom. Or, if this is ever observed again, whitelisting that preview on Firebase also fixes it. Sorry I can't actually explain how it happened.

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);
};
Expand Down
38 changes: 23 additions & 15 deletions packages/frontend/src/user/user_state_provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserState> = await api.repo.find(userStateDocId as DocumentId);
if (currentUserId !== userId) {
return;
}
const docHandle: DocHandle<UserState> = 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(() => {
Expand Down
Loading