You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Opening /messages without a working network signs the user out of their own encryption keys and lands them on a blank page. For a PWA that ships an offline message queue, the messaging route cannot survive being offline.
Found while diagnosing why offline-queue.spec.ts was red. The test was at fault for cutting the network too early (fixed separately), but the app behaviour it exposed is real and reproduces whenever the network is genuinely unavailable or slow.
The chain
src/app/messages/page.tsx:126-176, in order:
:129-132 — await supabase.auth.getUser(). This is a network round trip to GoTrue. Offline it resolves with user: null.
:138-140 — restoreKeysFromSession(user?.id) is therefore called with undefined. key-service.ts:352-359 builds storageKey = userId ? 'stw_keys_' + userId : null, so the lookup is skipped entirely and it logs [key-cache] No cached keys for user unknown.
The keys are sitting in localStorage under stw_keys_<uid> the whole time. They are never read, because the only thing needed to find them — the user id — was fetched over the network.
The comment at :133-137 states this cache check runs beforehasKeys() precisely so a failing DB query cannot cause "a false redirect to /messages/setup which triggers Argon2id and crashes Firefox on CI." That reasoning is right, and the implementation defeats it: the fast path cannot work in exactly the conditions it was written to protect against.
:146 — cache miss falls through to hasKeys(). key-service.ts:492-499 makes anothergetUser() call and returns false on any auth failure:
const{data: { user },error: authError}=awaitsupabase.auth.getUser();if(authError||!user){returnfalse;}
An offline/AuthRetryableFetchError is indistinguishable from "this user has no keys."
:148-151 — so the app redirects to /messages/setup. Offline, that RSC payload cannot be fetched: Failed to fetch RSC payload for /messages/setup/index.txt. Falling back to browser navigation. TypeError: Failed to fetch, then ERR_INTERNET_DISCONNECTED. The document becomes chrome-error://chromewebdata/ — a blank page.
:150 — the bare return also skips setCheckingKeys(false), and checkKeys() is invoked at :173 with no .catch(). So any throw in this path leaves checkingKeys === true and the route hangs on its spinner forever (:528-534 renders only a spinner while true).
Impact
A signed-in user on a flaky connection, in a tunnel, or on airplane mode who opens or reloads /messages gets a blank page rather than their cached conversation.
Online but with a slow GoTrue response, the same path can push them to /messages/setup and make them re-derive keys via Argon2id — the expensive operation the cache exists to avoid.
The offline message queue cannot be reached at all, since the thread never renders.
Verified from a Playwright trace (run 30749833806, job 91502213896): console shows [key-cache] No cached keys for user unknown immediately after going offline, despite the job log confirming Injected pre-baked keys for e2e-s3-primary@mailinator.com (4caf7f63...) moments earlier.
Proposed fix
Get the user id without the network.supabase.auth.getSession() reads the persisted session from local storage synchronously-ish and does not round-trip. Use it to attempt restoreKeysFromSession first, falling back to getUser() only if there is no session. This makes the documented fast path actually fast, and correct offline.
Stop conflating "offline" with "no keys."key-service.ts:497-499 should propagate the auth error (or return a tri-state) rather than false, so callers can distinguish "cannot determine" from "definitely none."
Do not redirect to setup when offline. Guard :148-151 on navigator.onLine, and show a cached/offline state instead of navigating to a route that cannot load.
Never hang the spinner. Add .catch(() => setCheckingKeys(false)) to the checkKeys() call at :173, and replace the bare return at :150 so the flag is always cleared.
Items 1 and 4 are small and independently valuable; 2 and 3 are the correctness fixes.
Related
The E2E test that surfaced this is fixed separately — it now waits for the thread to render before going offline, so it tests the queue rather than a cold start with no network.
Same family as the swallowed-error problems in CI reports green on tests that cannot fail #76: an error path that returns a plausible-looking value (false, []) instead of signalling failure.
Opening
/messageswithout a working network signs the user out of their own encryption keys and lands them on a blank page. For a PWA that ships an offline message queue, the messaging route cannot survive being offline.Found while diagnosing why
offline-queue.spec.tswas red. The test was at fault for cutting the network too early (fixed separately), but the app behaviour it exposed is real and reproduces whenever the network is genuinely unavailable or slow.The chain
src/app/messages/page.tsx:126-176, in order::129-132—await supabase.auth.getUser(). This is a network round trip to GoTrue. Offline it resolves withuser: null.:138-140—restoreKeysFromSession(user?.id)is therefore called withundefined.key-service.ts:352-359buildsstorageKey = userId ? 'stw_keys_' + userId : null, so the lookup is skipped entirely and it logs[key-cache] No cached keys for user unknown.The keys are sitting in
localStorageunderstw_keys_<uid>the whole time. They are never read, because the only thing needed to find them — the user id — was fetched over the network.The comment at
:133-137states this cache check runs beforehasKeys()precisely so a failing DB query cannot cause "a false redirect to/messages/setupwhich triggers Argon2id and crashes Firefox on CI." That reasoning is right, and the implementation defeats it: the fast path cannot work in exactly the conditions it was written to protect against.:146— cache miss falls through tohasKeys().key-service.ts:492-499makes anothergetUser()call and returnsfalseon any auth failure:An offline/
AuthRetryableFetchErroris indistinguishable from "this user has no keys.":148-151— so the app redirects to/messages/setup. Offline, that RSC payload cannot be fetched:Failed to fetch RSC payload for /messages/setup/index.txt. Falling back to browser navigation. TypeError: Failed to fetch, thenERR_INTERNET_DISCONNECTED. The document becomeschrome-error://chromewebdata/— a blank page.:150— the barereturnalso skipssetCheckingKeys(false), andcheckKeys()is invoked at:173with no.catch(). So any throw in this path leavescheckingKeys === trueand the route hangs on its spinner forever (:528-534renders only a spinner while true).Impact
/messagesgets a blank page rather than their cached conversation./messages/setupand make them re-derive keys via Argon2id — the expensive operation the cache exists to avoid.Verified from a Playwright trace (run 30749833806, job 91502213896): console shows
[key-cache] No cached keys for user unknownimmediately after going offline, despite the job log confirmingInjected pre-baked keys for e2e-s3-primary@mailinator.com (4caf7f63...)moments earlier.Proposed fix
Get the user id without the network.
supabase.auth.getSession()reads the persisted session from local storage synchronously-ish and does not round-trip. Use it to attemptrestoreKeysFromSessionfirst, falling back togetUser()only if there is no session. This makes the documented fast path actually fast, and correct offline.Stop conflating "offline" with "no keys."
key-service.ts:497-499should propagate the auth error (or return a tri-state) rather thanfalse, so callers can distinguish "cannot determine" from "definitely none."Do not redirect to setup when offline. Guard
:148-151onnavigator.onLine, and show a cached/offline state instead of navigating to a route that cannot load.Never hang the spinner. Add
.catch(() => setCheckingKeys(false))to thecheckKeys()call at:173, and replace the barereturnat:150so the flag is always cleared.Items 1 and 4 are small and independently valuable; 2 and 3 are the correctness fixes.
Related
false,[]) instead of signalling failure.