This commit is contained in:
2026-07-12 19:46:17 +02:00
parent f7a6104896
commit 2492ca8c25
2 changed files with 76 additions and 54 deletions

View File

@@ -184,61 +184,69 @@ window.cancelAnimFrame = (function () {
return; return;
} }
if (!lazyObserver) { // Chromium fix: always disconnect the old observer before creating a new one.
lazyObserver = new IntersectionObserver((entries) => { // Chromium holds detached DOM nodes in an IntersectionObserver alive much longer
entries.forEach(entry => { // than Firefox does. If we reuse a stale observer after an AJAX content swap,
if (entry.isIntersecting) { // Chromium can fire callbacks for old detached nodes, causing duplicate image
const thumb = entry.target; // fetches and flooding the network. Disconnecting releases those references.
let bg = thumb.dataset.bg; if (lazyObserver) {
if (bg && !thumb.classList.contains('loaded')) { lazyObserver.disconnect();
const mode = thumb.getAttribute('data-mode'); lazyObserver = null;
const blurNsfw = localStorage.getItem('blurNsfw') === 'true';
const blurNsfl = localStorage.getItem('blurNsfl') === 'true';
const blurSfw = localStorage.getItem('blurSfw') === 'true';
const blurUntagged = localStorage.getItem('blurUntagged') === 'true';
let shouldBlurThis = false;
if (mode === 'nsfw') shouldBlurThis = blurNsfw;
else if (mode === 'nsfl') shouldBlurThis = blurNsfl;
else if (mode === 'sfw') shouldBlurThis = blurSfw;
else if (mode === 'null' || !mode) shouldBlurThis = blurUntagged;
if (shouldBlurThis && !thumb.classList.contains('revealed')) {
bg = bg.replace('.webp', '_blur.webp');
}
bg = window.applyThumbCacheBust(bg);
const img = new Image();
img.onload = () => {
thumb.style.backgroundImage = `url('${bg}')`;
thumb.classList.add('loaded');
thumb.classList.remove('lazy-thumb');
};
img.onerror = () => {
const retries = parseInt(thumb.dataset.retries || '0');
if (retries < 3) {
thumb.dataset.retries = retries + 1;
setTimeout(() => {
img.src = bg + '?r=' + Date.now();
}, 1000);
} else {
// All retries exhausted — show fallback for audio items
const mime = thumb.dataset.mime || '';
if (mime.startsWith('audio/')) {
thumb.style.backgroundImage = `url('/s/img/audio.webp')`;
thumb.classList.add('thumb-fallback');
}
thumb.classList.remove('lazy-thumb');
}
};
img.src = bg;
}
lazyObserver.unobserve(thumb);
}
});
}, { rootMargin: '300px 0px', threshold: 0.01 });
} }
lazyObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const thumb = entry.target;
let bg = thumb.dataset.bg;
if (bg && !thumb.classList.contains('loaded')) {
const mode = thumb.getAttribute('data-mode');
const blurNsfw = localStorage.getItem('blurNsfw') === 'true';
const blurNsfl = localStorage.getItem('blurNsfl') === 'true';
const blurSfw = localStorage.getItem('blurSfw') === 'true';
const blurUntagged = localStorage.getItem('blurUntagged') === 'true';
let shouldBlurThis = false;
if (mode === 'nsfw') shouldBlurThis = blurNsfw;
else if (mode === 'nsfl') shouldBlurThis = blurNsfl;
else if (mode === 'sfw') shouldBlurThis = blurSfw;
else if (mode === 'null' || !mode) shouldBlurThis = blurUntagged;
if (shouldBlurThis && !thumb.classList.contains('revealed')) {
bg = bg.replace('.webp', '_blur.webp');
}
bg = window.applyThumbCacheBust(bg);
const img = new Image();
img.onload = () => {
thumb.style.backgroundImage = `url('${bg}')`;
thumb.classList.add('loaded');
thumb.classList.remove('lazy-thumb');
};
img.onerror = () => {
const retries = parseInt(thumb.dataset.retries || '0');
if (retries < 3) {
thumb.dataset.retries = retries + 1;
setTimeout(() => {
img.src = bg + '?r=' + Date.now();
}, 1000);
} else {
// All retries exhausted — show fallback for audio items
const mime = thumb.dataset.mime || '';
if (mime.startsWith('audio/')) {
thumb.style.backgroundImage = `url('/s/img/audio.webp')`;
thumb.classList.add('thumb-fallback');
}
thumb.classList.remove('lazy-thumb');
}
};
img.src = bg;
}
lazyObserver.unobserve(thumb);
}
});
}, { rootMargin: '300px 0px', threshold: 0.01 });
// Nudge lazy loading on tab switch to prevent stuck skeletons in inactive tabs // Nudge lazy loading on tab switch to prevent stuck skeletons in inactive tabs
if (!window._lazyVisibilityBound) { if (!window._lazyVisibilityBound) {
window._lazyVisibilityBound = true; window._lazyVisibilityBound = true;
@@ -254,7 +262,10 @@ window.cancelAnimFrame = (function () {
} }
document.querySelectorAll('.lazy-thumb').forEach(thumb => { document.querySelectorAll('.lazy-thumb').forEach(thumb => {
if (!thumb.dataset.lazyObserved) { // Reset lazyObserved on every initLazyLoading call so the fresh observer
// picks up all unloaded thumbs regardless of previous observation state.
delete thumb.dataset.lazyObserved;
if (!thumb.classList.contains('loaded')) {
thumb.dataset.lazyObserved = 'true'; thumb.dataset.lazyObserved = 'true';
lazyObserver.observe(thumb); lazyObserver.observe(thumb);
} }

View File

@@ -535,6 +535,17 @@ process.on('uncaughtException', err => {
} }
return; return;
} }
// Thumbnails (/t/) and covers (/ca/) are static WebP files that never change at a
// given URL — the client appends a ?t= cache-bust param when a thumb is regenerated.
// Without an explicit Cache-Control header, Chromium treats these as uncacheable and
// re-fetches every visible thumbnail on every AJAX navigation, causing severe lag.
// Firefox uses a more lenient heuristic and caches them anyway — hence the difference.
if (req.url.pathname.match(/^\/(t|ca)\//)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
} else if (req.url.pathname.match(/^\/(b|a|memes)\//)) {
// Full-size images and avatars: cache for 1 hour, re-validate after
res.setHeader('Cache-Control', 'public, max-age=3600');
}
return; return;
} }
const availableThemes = cfg.websrv.themes || ['amoled', 'atmos', 'f0ck', 'f0ck95d', 'iced', 'orange', 'p1nk', '4d']; const availableThemes = cfg.websrv.themes || ['amoled', 'atmos', 'f0ck', 'f0ck95d', 'iced', 'orange', 'p1nk', '4d'];