From d1d179bb93ca91934daa0487ef81eee50448ca76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 22 Apr 2026 12:02:44 +0400 Subject: [PATCH] Lazy-load place photos Only preload photos in view as well as the next one(s), not all of them --- app/components/place-photos-carousel.gjs | 6 +- app/modifiers/fade-in-image.js | 67 ++++++++++++++++--- .../components/place-photos-carousel-test.gjs | 2 +- 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/app/components/place-photos-carousel.gjs b/app/components/place-photos-carousel.gjs index d4ac75e..46e5b6d 100644 --- a/app/components/place-photos-carousel.gjs +++ b/app/components/place-photos-carousel.gjs @@ -123,11 +123,11 @@ export default class PlacePhotosCarousel extends Component { {{#if photo.thumbUrl}} {{/if}} {{@name}} { element.classList.remove('loaded'); element.classList.remove('loaded-instant'); - // Create an off-DOM image to reliably check cache status - // without waiting for the actual DOM element to load it - const img = new Image(); - img.src = url; - - if (img.complete) { - // Already in browser cache, skip the animation - element.classList.add('loaded-instant'); - return; - } + let observer; const handleLoad = () => { - element.classList.add('loaded'); + // Only apply the fade-in animation if it wasn't already loaded instantly + if (!element.classList.contains('loaded-instant')) { + element.classList.add('loaded'); + } }; element.addEventListener('load', handleLoad); + const loadWhenVisible = (entries, obs) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + // Stop observing once we start loading + obs.unobserve(element); + + // Check if the image is already in the browser cache + // Create an off-DOM image to reliably check cache status + // without waiting for the actual DOM element to load it + const img = new Image(); + img.src = url; + + if (img.complete) { + // Already in browser cache, skip the animation + element.classList.add('loaded-instant'); + } + + // If this image is inside a tag, we also need to swap tags + const parent = element.parentElement; + if (parent && parent.tagName === 'PICTURE') { + const sources = parent.querySelectorAll('source'); + sources.forEach((source) => { + if (source.dataset.srcset) { + source.srcset = source.dataset.srcset; + } + }); + } + + // Swap data-src to src to trigger the actual network fetch (or render from cache) + if (element.dataset.src) { + element.src = element.dataset.src; + } else { + // Fallback if data-src wasn't used but the modifier was called + element.src = url; + } + } + }); + }; + + // Setup Intersection Observer to only load when the image enters the viewport + observer = new IntersectionObserver(loadWhenVisible, { + root: null, // Use the viewport as the root + rootMargin: '100px 100%', // Load one full viewport width ahead/behind + threshold: 0, // Trigger immediately when any part enters the expanded margin + }); + + observer.observe(element); + return () => { element.removeEventListener('load', handleLoad); + if (observer) { + observer.disconnect(); + } }; }); diff --git a/tests/integration/components/place-photos-carousel-test.gjs b/tests/integration/components/place-photos-carousel-test.gjs index 2c8f5c5..6c3488d 100644 --- a/tests/integration/components/place-photos-carousel-test.gjs +++ b/tests/integration/components/place-photos-carousel-test.gjs @@ -43,7 +43,7 @@ module('Integration | Component | place-photos-carousel', function (hooks) { assert.dom('.carousel-slide').exists({ count: 1 }, 'it renders one slide'); assert .dom('img.place-header-photo') - .hasAttribute('src', 'photo1.jpg', 'it renders the photo'); + .hasAttribute('data-src', 'photo1.jpg', 'it sets the data-src correctly'); // There should be no chevrons when there's only 1 photo assert