Lazy-load place photos

Only preload photos in view as well as the next one(s), not all of them
This commit is contained in:
2026-04-22 12:02:44 +04:00
parent b83a16bf13
commit d1d179bb93
3 changed files with 60 additions and 15 deletions

View File

@@ -123,11 +123,11 @@ export default class PlacePhotosCarousel extends Component {
{{#if photo.thumbUrl}}
<source
media="(max-width: 768px)"
srcset={{photo.thumbUrl}}
data-srcset={{photo.thumbUrl}}
/>
{{/if}}
<img
src={{photo.url}}
data-src={{photo.url}}
class="place-header-photo landscape"
alt={{@name}}
{{fadeInImage photo.url}}
@@ -136,7 +136,7 @@ export default class PlacePhotosCarousel extends Component {
{{else}}
{{! Portrait uses thumb everywhere if available }}
<img
src={{if photo.thumbUrl photo.thumbUrl photo.url}}
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
class="place-header-photo portrait"
alt={{@name}}
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}

View File

@@ -7,24 +7,69 @@ export default modifier((element, [url]) => {
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 <picture> tag, we also need to swap <source> 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();
}
};
});

View File

@@ -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