From 84ad89b06d769132857bcba7a1f09a5bd6458ebe Mon Sep 17 00:00:00 2001 From: screentinker Date: Tue, 14 Jul 2026 13:28:12 -0500 Subject: [PATCH] fix(dashboard): make auth-image hydration lazy by default (#182 follow-up) (#185) #182 shipped hydrateAuthImages() loading every thumbnail immediately, which regressed the content-library grid from lazy to eager (a fetch per thumbnail on render) and left the IntersectionObserver as dead code. Restore lazy-by-default (observe-only) so large grids only fetch thumbnails as they scroll into view, and add an { eager: true } opt-in for the transient pickers where every item is on screen and immediate load reads better: the device assign-content modal, the playlist add-item modal, and the widget content picker. Grids and inline lists (content library, playlist items, device playlist tab, directory logo/background) use the lazy default. Behavior for those pickers is unchanged; only the large grids revert to the lazy loading they had before #182. Co-authored-by: Claude Opus 4.8 --- frontend/js/utils.js | 21 +++++++++++---------- frontend/js/views/device-detail.js | 2 +- frontend/js/views/playlists.js | 2 +- frontend/js/views/widgets.js | 2 +- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/frontend/js/utils.js b/frontend/js/utils.js index da4d7cd..fcbc626 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -73,8 +73,8 @@ export function isPlatformAdmin(user) { // Lazy-load authenticated images. A plain can't send the Bearer token, // and thumbnail/file endpoints require auth — a just-uploaded item's thumbnail -// 403's without it. We fetch with the token and swap in an object URL. -// IntersectionObserver keeps it lazy; the object URL is revoked after load. +// 403's without it. We fetch with the token and swap in an object URL, revoked +// after load. let _authImgObserver = null; export function loadAuthImage(img) { const url = img.dataset.authSrc; @@ -89,13 +89,17 @@ export function loadAuthImage(img) { }) .catch(() => { img.style.opacity = '0.25'; }); } -export function hydrateAuthImages(root) { +// Hydrate under `root`. Lazy by default: an +// IntersectionObserver loads each thumbnail only as it scrolls into view, so a +// large grid (content library, etc.) doesn't fire a fetch per thumbnail on +// render. Pass { eager: true } for small, transient surfaces (pickers/modals) +// where every item is on screen and immediate load reads better. +export function hydrateAuthImages(root, { eager = false } = {}) { const imgs = root.querySelectorAll('img[data-auth-src]'); if (!imgs.length) return; - // Load all images immediately; IntersectionObserver is used below - // only for images that are off-screen (lazy loading). - if (typeof IntersectionObserver === 'undefined') { + // Eager path (opt-in) and the no-IntersectionObserver fallback both load now. + if (eager || typeof IntersectionObserver === 'undefined') { imgs.forEach(loadAuthImage); return; } @@ -105,8 +109,5 @@ export function hydrateAuthImages(root) { for (const e of entries) if (e.isIntersecting) { obs.unobserve(e.target); loadAuthImage(e.target); } }, { rootMargin: '300px' }); } - - // Load every image now — the observer will also fire for them but - // loadAuthImage is idempotent (deletes data-auth-src on first call). - imgs.forEach(img => { loadAuthImage(img); _authImgObserver.observe(img); }); + imgs.forEach(img => _authImgObserver.observe(img)); } diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index b617029..e4b57f5 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -1454,7 +1454,7 @@ async function setupPlaylistActions(device) { `; document.body.appendChild(modal); - hydrateAuthImages(modal); + hydrateAuthImages(modal, { eager: true }); // Tab switching modal.querySelectorAll('.assign-tab').forEach(tab => { diff --git a/frontend/js/views/playlists.js b/frontend/js/views/playlists.js index 3fbc36f..e3507a0 100644 --- a/frontend/js/views/playlists.js +++ b/frontend/js/views/playlists.js @@ -687,7 +687,7 @@ async function showAddItemModal(playlistId, opts = {}) { `; }).join(''); - hydrateAuthImages(list); + hydrateAuthImages(list, { eager: true }); list.querySelectorAll('.add-item-btn').forEach(btn => { btn.addEventListener('click', async (e) => { diff --git a/frontend/js/views/widgets.js b/frontend/js/views/widgets.js index 8bae96c..413b7ca 100644 --- a/frontend/js/views/widgets.js +++ b/frontend/js/views/widgets.js @@ -74,7 +74,7 @@ function openContentPicker({ multiple = false, title } = {}) { `; }).join('') }`; - hydrateAuthImages(list); + hydrateAuthImages(list, { eager: true }); list.querySelectorAll('[data-pick-id]').forEach(el => el.onclick = () => { const id = el.dataset.pickId; if (multiple) {