From 02bd356a8946c19dc0ddd26cd2f2cac1318474d4 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sun, 25 Oct 2026 19:11:56 -0400 Subject: [PATCH] fix(media): retry an image once when it raced the service worker On a first visit (or right after an update) the service worker only takes control when it activates. An image requested before then goes out without the access token, fails, and showed "Retry". Now a failed image waits for the worker to take control (or a moment) and retries once by itself; "Retry" appears only if that fails too. Encrypted media is unchanged. Also the cause of the flaky "#219 gallery lightbox" e2e test (1 in 4 locally, 10/10 after). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- .../message/content/ImageContent.tsx | 14 +++++++++++- src/sw-session.ts | 22 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 47ece05e1..c085e8479 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -33,6 +33,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { ModalWide } from '../../../styles/Modal.css'; import { validBlurHash } from '../../../utils/blurHash'; import { useNearViewport } from '../../../hooks/useNearViewport'; +import { whenMediaAuthReady } from '../../../../sw-session'; type RenderViewerProps = { src: string; @@ -115,13 +116,24 @@ export const ImageContent = as<'div', ImageContentProps>( const handleLoad = () => { setLoad(true); }; + // An image requested before the service worker controls the page goes out + // without the access token and fails (first visit, just after an update). + // Retry once by itself when that's ready; only then offer "Retry". + const [attempt, setAttempt] = useState(0); + const autoRetried = useRef(false); const handleError = () => { setLoad(false); + if (!autoRetried.current && !encInfo) { + autoRetried.current = true; + whenMediaAuthReady().then(() => setAttempt((n) => n + 1)); + return; + } setError(true); }; const handleRetry = () => { setError(false); + setAttempt((n) => n + 1); loadSrc(); }; @@ -182,7 +194,7 @@ export const ImageContent = as<'div', ImageContentProps>( )} {srcState.status === AsyncStatus.Success && ( - + {renderImage({ alt: body, title: body, diff --git a/src/sw-session.ts b/src/sw-session.ts index e4d2672da..a094f45db 100644 --- a/src/sw-session.ts +++ b/src/sw-session.ts @@ -9,3 +9,25 @@ export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?: userId, }); } + +/** + * Resolves once media requests can carry the session: immediately-ish when a + * service worker already controls the page, otherwise when one takes control + * (first visit / just updated) or after `timeoutMs`. Used to retry a media + * load that raced the worker's activation and went out without a token. + */ +export function whenMediaAuthReady(timeoutMs = 3000): Promise { + return new Promise((resolve) => { + const sw = 'serviceWorker' in navigator ? navigator.serviceWorker : undefined; + if (!sw || sw.controller) { + window.setTimeout(resolve, 500); + return; + } + const done = () => { + sw.removeEventListener('controllerchange', done); + resolve(); + }; + sw.addEventListener('controllerchange', done); + window.setTimeout(done, timeoutMs); + }); +}