fix: OIDC/SSO Token + Media issues#548
Conversation
useAsync re-threw errors after storing them in Error state. Any call site
that discards the returned Promise (e.g. fire-and-forget useEffect calls
like loadSrc() or loadThumbSrc()) produced an 'Uncaught (in promise)'
console error — notably 'Mismatched SHA-256 digest' from encrypted media
decryption failures.
The error is already fully handled: it is stored in AsyncStatus.Error state
and components surface it via a retry button. The re-throw added no value
and only caused noise / unhandled rejection warnings.
Update the test accordingly — the .catch(() => {}) guard was only needed to
silence the re-throw and is no longer necessary.
Stores refresh_token from the login response, passes it and a tokenRefreshFunction to createClient so the SDK can auto-refresh expired access tokens. The callback propagates new tokens to both the sessionsAtom (localStorage) and the service worker via pushSessionToSW, preventing 401 errors on authenticated media after token expiry.
8ecb7cc to
715e9e4
Compare
There is a timing window between when the SDK refreshes its access token (tokenRefreshFunction resolves and pushSessionToSW is called) and when the resulting setSession postMessage is processed by the SW. Media requests that land in this window carry the stale token and receive 401. The browser then retries those image/video loads, hitting the SW again with the same stale token — producing the repeated 401 bursts visible in the console. fetchMediaWithRetry() resolves this by retrying once on 401: it re-checks the in-memory sessions map (and preloadedSession fallback) for a different access token. By the time the retry runs, setSession will normally have been processed and the map will hold the new token. Applied consistently across all four branches of the fetch handler.
ae8c97d to
c177218
Compare
When preloadedSession holds a stale token and the live setSession from the page hasn't arrived yet, fetchMediaWithRetry had no fresher token to retry with and immediately returned the 401 response. Add a second-chance retry: if in-memory token lookup yields nothing better, call requestSessionWithTimeout(1500ms) to ask the live client tab for its current session, then retry with it if the token differs. This fixes the startup race where thumbnails (message sender avatars, URL preview images, etc.) 401 briefly before the page pushes its fresh session to the service worker.
There was a problem hiding this comment.
Pull request overview
This PR targets media reliability by addressing token refresh propagation and service-worker handling of authenticated media fetches, plus reducing noisy “Uncaught (in promise)” console errors in media-loading UI flows.
Changes:
- Adjust
useAsyncCallbackerror handling behavior and update its tests. - Persist OIDC
refresh_token/expires_in_msinto session state and wire atokenRefreshFunctioninto the Matrix client, propagating refreshed access tokens to the service worker. - Add a SW media fetch wrapper that retries once on HTTP 401 using the latest in-memory/live session token.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/sw.ts |
Adds fetchMediaWithRetry() and routes media fetch paths through it to mitigate 401s during token-refresh propagation races. |
src/client/initMatrix.ts |
Passes refresh-token options into createClient and plumbs an optional onTokenRefresh callback to propagate new tokens outward. |
src/app/pages/client/ClientRoot.tsx |
Supplies the onTokenRefresh callback to update sessions state and push updated tokens to the SW. |
src/app/pages/auth/login/loginUtil.ts |
Stores refresh_token and expires_in_ms into the session record on login. |
src/app/hooks/useAsyncCallback.ts |
Changes error handling in the async wrapper (no longer rejects on error) and documents rationale. |
src/app/hooks/useAsyncCallback.test.tsx |
Updates error test to no longer expect a rejected promise. |
.changeset/fix-media-error-handling.md |
Adds a patch changeset describing the combined fixes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/app/hooks/useAsyncCallback.ts
Outdated
| // Do not re-throw: the error is captured in state and callers use that | ||
| // for UI feedback (error state + retry button). Re-throwing here turns | ||
| // every fire-and-forget useEffect call site into an unhandled promise | ||
| // rejection (e.g. "Uncaught (in promise) Error: Mismatched SHA-256 digest"). | ||
| return undefined as never; |
There was a problem hiding this comment.
Changing the catch path to resolve (returning undefined) instead of rejecting breaks existing call sites that rely on promise rejection to skip success handlers. For example, submit(...).then(onSave) patterns will now run onSave even when the async work failed, because the promise no longer rejects. Consider keeping rejection semantics (still throw/reject after setting Error state) while suppressing unhandled rejections by attaching an internal no-op .catch handler to the returned promise before returning it to callers (so ignored promises don’t log, but awaited/handled ones still reject).
| ...(loginRes.refresh_token && { refreshToken: loginRes.refresh_token }), | ||
| ...(loginRes.expires_in_ms && { expiresInMs: loginRes.expires_in_ms }), |
There was a problem hiding this comment.
expires_in_ms is a number and could validly be 0; using a truthy check will drop the field when it’s 0. Prefer an explicit !== undefined/!= null check when copying expires_in_ms (and similarly for refresh_token if empty-string is a possible value) to avoid silently losing token-expiry metadata.
| ...(loginRes.refresh_token && { refreshToken: loginRes.refresh_token }), | |
| ...(loginRes.expires_in_ms && { expiresInMs: loginRes.expires_in_ms }), | |
| ...(loginRes.refresh_token != null && { refreshToken: loginRes.refresh_token }), | |
| ...(loginRes.expires_in_ms != null && { expiresInMs: loginRes.expires_in_ms }), |
src/client/initMatrix.ts
Outdated
| return { | ||
| accessToken: res.access_token, | ||
| refreshToken: res.refresh_token ?? oldRefreshToken, | ||
| expiry: res.expires_in_ms ? new Date(Date.now() + res.expires_in_ms) : undefined, |
There was a problem hiding this comment.
res.expires_in_ms is numeric; using a truthy check will treat 0 as “absent” and return expiry: undefined. Prefer checking typeof res.expires_in_ms === 'number' (or != null) so an explicit 0ms expiry is handled consistently.
| expiry: res.expires_in_ms ? new Date(Date.now() + res.expires_in_ms) : undefined, | |
| expiry: | |
| typeof res.expires_in_ms === 'number' | |
| ? new Date(Date.now() + res.expires_in_ms) | |
| : undefined, |
src/app/pages/client/ClientRoot.tsx
Outdated
| pushSessionToSW(activeSession.baseUrl, newAccessToken); | ||
| }); | ||
| loadedUserIdRef.current = activeSession.userId; | ||
| pushSessionToSW(activeSession.baseUrl, activeSession.accessToken); |
There was a problem hiding this comment.
pushSessionToSW supports sending userId, and the SW persists userId to associate push notifications with an account. These calls omit userId, which can overwrite a previously stored SW session and clear its userId (since setSession stores userId: undefined when omitted). Pass activeSession.userId in both the initial push and the token-refresh push so the SW keeps a complete session record.
| pushSessionToSW(activeSession.baseUrl, newAccessToken); | |
| }); | |
| loadedUserIdRef.current = activeSession.userId; | |
| pushSessionToSW(activeSession.baseUrl, activeSession.accessToken); | |
| pushSessionToSW(activeSession.baseUrl, newAccessToken, activeSession.userId); | |
| }); | |
| loadedUserIdRef.current = activeSession.userId; | |
| pushSessionToSW(activeSession.baseUrl, activeSession.accessToken, activeSession.userId); |
|
|
||
| await act(async () => { | ||
| await result.current[1]().catch(() => {}); | ||
| await result.current[1](); |
There was a problem hiding this comment.
This test now awaits result.current[1]() without asserting that the returned promise rejects on failure. If useAsyncCallback is intended to preserve “failure rejects” semantics (so callers’ .then success handlers don’t run on errors), this test should explicitly expect a rejection while still asserting state transitions to AsyncStatus.Error.
| await result.current[1](); | |
| await expect(result.current[1]()).rejects.toBe(boom); |
- useAsyncCallback: re-throw in catch to preserve rejection semantics; add no-op .catch wrapper in useAsyncCallback to suppress unhandled rejection warnings for fire-and-forget callers - useAsyncCallback.test: expect rejects.toBe(boom) to assert rejection - loginUtil: use != null guards for refresh_token / expires_in_ms to correctly handle zero/falsy-but-present values - initMatrix: use typeof === 'number' guard for expires_in_ms expiry calc - ClientRoot: pass activeSession.userId to both pushSessionToSW calls so the SW session record keeps a complete userId
- useAsyncCallback: re-throw in catch to preserve rejection semantics; add no-op .catch wrapper in useAsyncCallback to suppress unhandled rejection warnings for fire-and-forget callers - useAsyncCallback.test: expect rejects.toBe(boom) to assert rejection - loginUtil: use != null guards for refresh_token / expires_in_ms to correctly handle zero/falsy-but-present values - initMatrix: use typeof === 'number' guard for expires_in_ms expiry calc - ClientRoot: pass activeSession.userId to both pushSessionToSW calls so the SW session record keeps a complete userId
Description
Three related bug fixes for media reliability.
1. Suppress unhandled promise rejections from
useAsyncCallbackuseAsyncCallbackstored errors inAsyncStatus.Errorstate and then re-threw them. Fire-and-forget call sites (e.g.loadSrc()/loadThumbSrc()) had no.catch(), so every failure produced an "Uncaught (in promise)" console error — most visibly "Mismatched SHA-256 digest" from encrypted media decryption. The error is already surfaced via retry UI; the re-throw added nothing. Removed it and updated the test accordingly.2. Wire OIDC token refresh through to the service worker
loginUtil.tssilently discardedrefresh_tokenfrom the login response, socreateClientwas never given atokenRefreshFunction. After an access token expired the SDK would stall and the service worker — which caches the token for authenticated media requests — would keep sending the old Bearer token, causing 401s on every media load.loginUtil.ts: capturerefresh_token/expires_in_msinto the session objectinitMatrix.ts: when arefreshTokenis present, passtokenRefreshFunctiontocreateClient; the function callsmx.refreshToken()and invokes an optionalonTokenRefreshcallbackClientRoot.tsx: supply the callback; it writes the new tokens tosessionsAtomand callspushSessionToSWso the SW picks them up immediatelyNo-op when the server doesn't return a refresh token.
3. Retry authenticated media requests on 401 in the service worker
There is a timing window between when the SDK resolves a token refresh (and updates its own in-flight requests) and when the resulting
pushSessionToSWsetSessionpostMessage is processed by the SW. Media requests that land in this window carry the stale Bearer token and receive 401. Because browsers retry failed image/video loads automatically, this produced repeated 401 bursts for the same media IDs.Added
fetchMediaWithRetry()insw.ts: on a 401 response it re-checks the in-memorysessionsmap (andpreloadedSessionfallback) for a fresher token for the same origin. If one is found, the request is retried once. Applied consistently across all four code paths in the fetch event handler. Zero overhead for non-401 responses.Fixes #
Type of change
Checklist:
AI disclosure:
Fix 1 removes
throw efromuseAsyncCallback's catch block and drops the now-unnecessary.catch(() => {})in the test. Fix 2 readsrefresh_token/expires_in_msfrom the login response and stores them in the session;buildClientspreads{ refreshToken, tokenRefreshFunction }intocreateClientoptions when a refresh token is present; theonTokenRefreshcallback inClientRootupdatessessionsAtomand callspushSessionToSWwith the new access token. Fix 3 addsfetchMediaWithRetry()tosw.ts— a thin wrapper aroundfetch()that on HTTP 401 re-checks the in-memory sessions map for a token that differs from the one just used, and if found retries the request once; covers the race window between SDK token refresh and SWsetSessionpropagation.