fix(dashboard): make auth-image hydration lazy by default (#182 follow-up) (#185)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run

#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 <noreply@anthropic.com>
This commit is contained in:
screentinker 2026-07-14 13:28:12 -05:00 committed by GitHub
parent 00e8300af7
commit 84ad89b06d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 14 additions and 13 deletions

View file

@ -73,8 +73,8 @@ export function isPlatformAdmin(user) {
// Lazy-load authenticated images. A plain <img> 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 <img data-auth-src> 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));
}

View file

@ -1454,7 +1454,7 @@ async function setupPlaylistActions(device) {
</div>
`;
document.body.appendChild(modal);
hydrateAuthImages(modal);
hydrateAuthImages(modal, { eager: true });
// Tab switching
modal.querySelectorAll('.assign-tab').forEach(tab => {

View file

@ -687,7 +687,7 @@ async function showAddItemModal(playlistId, opts = {}) {
</div>
`;
}).join('');
hydrateAuthImages(list);
hydrateAuthImages(list, { eager: true });
list.querySelectorAll('.add-item-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {

View file

@ -74,7 +74,7 @@ function openContentPicker({ multiple = false, title } = {}) {
</div>`;
}).join('')
}</div>`;
hydrateAuthImages(list);
hydrateAuthImages(list, { eager: true });
list.querySelectorAll('[data-pick-id]').forEach(el => el.onclick = () => {
const id = el.dataset.pickId;
if (multiple) {