Problem
On a fresh page load, compact preview thumbnails take a couple of seconds to appear in task lists, goals and the Inbox. Each thumbnail re-downloads the full-resolution capture and re-runs the multi-pass canvas downsampling (downsampleToCanvas in previewDownsampling.ts) on the main thread.
Two causes on main:
- Every load refetches the full image. Task and goal previews are rewritten to ProPR's authenticated media route (
/api/preview-media/{pulls|comments}/:owner/:repo/:number/:assetId, see projectApplicationMedia in packages/api/services/previewMediaProjection.ts). The UI loads them as same-origin blobs (usePreviewMediaSource). The route responds with Cache-Control: private, no-store, so the browser cache never helps, and the API itself fetches the asset from GitHub on each request.
- Every load re-downsamples. Nothing persists the downsampled result.
Hard constraint (learned in #2502)
Direct https://github.com/user-attachments/assets/<id> URLs redirect to S3 without CORS headers. A canvas that draws them is tainted and can't be exported with toDataURL/toBlob, and createImageBitmap doesn't get around it. Client-side thumbnail caching is only possible for same-origin sources, meaning previews served through /api/preview-media/... as blobs. Don't attempt CORS probes or no-cors fallbacks.
Proposal
- Persist downsampled thumbnails for same-origin sources. After
PreviewImage downsamples a blob-backed preview, store the result (WebP or PNG blob plus dimensions) in IndexedDB. On the next mount, paint from the cache before fetching or decoding anything.
- Key: account/desktop scope + media URL + CSS width × height + device pixel ratio. Use the same scope key
usePreviewMediaSource already uses, so a cached thumbnail never crosses accounts or desktop profiles.
- TTL: 7 days. Prune expired entries at idle, cap total entries or bytes, and clear the account's entries on logout and account switch.
- If IndexedDB is unavailable (private mode or blocked), fall back to an in-memory map without failing.
- On a cache hit, skip
usePreviewMediaSource's full-size fetch entirely for compact thumbnails. Fetch the original only when the user opens the full view or lightbox.
- Let the browser cache the media route privately. GitHub user-attachment asset IDs are immutable, so
/api/preview-media/... can return Cache-Control: private, max-age=86400, or something similar, instead of no-store. That speeds up the full view too. Keep Vary: Authorization, Cookie, and confirm that revoking access is acceptable within the max-age window. If it isn't, keep no-store and rely on step 1.
- Check coverage. Confirm that every surface rendering compact thumbnails (task list, task detail, goals, Inbox, dashboard) receives projected
/api/preview-media/... URLs. Where a surface still gets raw GitHub URLs, extend the projection rather than adding client workarounds. Previews that stay on raw GitHub URLs keep today's behavior.
Acceptance criteria
- On a second load of a task list, goal or Inbox view, compact thumbnails paint from the cache, with no media fetch and no downsampling passes. Verify with a component or browser test that counts fetches and halving passes.
- A cached thumbnail is never shown under a different account or desktop profile, and logout clears that account's entries.
- Expired entries are ignored and pruned. Storage stays within the cap.
- Raw GitHub-URL previews keep working; nothing tries to export a tainted canvas.
- The full view and lightbox still load the original image.
Out of scope
Service-worker caching of opaque GitHub responses (#2502's final design). It skips the network fetch but still decodes and downsamples on every load, and it can't cache the thumbnail itself.
Problem
On a fresh page load, compact preview thumbnails take a couple of seconds to appear in task lists, goals and the Inbox. Each thumbnail re-downloads the full-resolution capture and re-runs the multi-pass canvas downsampling (
downsampleToCanvasinpreviewDownsampling.ts) on the main thread.Two causes on
main:/api/preview-media/{pulls|comments}/:owner/:repo/:number/:assetId, seeprojectApplicationMediainpackages/api/services/previewMediaProjection.ts). The UI loads them as same-origin blobs (usePreviewMediaSource). The route responds withCache-Control: private, no-store, so the browser cache never helps, and the API itself fetches the asset from GitHub on each request.Hard constraint (learned in #2502)
Direct
https://github.com/user-attachments/assets/<id>URLs redirect to S3 without CORS headers. A canvas that draws them is tainted and can't be exported withtoDataURL/toBlob, andcreateImageBitmapdoesn't get around it. Client-side thumbnail caching is only possible for same-origin sources, meaning previews served through/api/preview-media/...as blobs. Don't attempt CORS probes or no-cors fallbacks.Proposal
PreviewImagedownsamples a blob-backed preview, store the result (WebP or PNG blob plus dimensions) in IndexedDB. On the next mount, paint from the cache before fetching or decoding anything.usePreviewMediaSourcealready uses, so a cached thumbnail never crosses accounts or desktop profiles.usePreviewMediaSource's full-size fetch entirely for compact thumbnails. Fetch the original only when the user opens the full view or lightbox./api/preview-media/...can returnCache-Control: private, max-age=86400, or something similar, instead ofno-store. That speeds up the full view too. KeepVary: Authorization, Cookie, and confirm that revoking access is acceptable within the max-age window. If it isn't, keepno-storeand rely on step 1./api/preview-media/...URLs. Where a surface still gets raw GitHub URLs, extend the projection rather than adding client workarounds. Previews that stay on raw GitHub URLs keep today's behavior.Acceptance criteria
Out of scope
Service-worker caching of opaque GitHub responses (#2502's final design). It skips the network fetch but still decodes and downsamples on every load, and it can't cache the thumbnail itself.