implement category-based lazy loading to improve initial page load
All checks were successful
CI / update (push) Successful in 1m10s
All checks were successful
CI / update (push) Successful in 1m10s
Add Intersection Observer-based lazy loading for recipe categories to dramatically reduce initial render time. Categories render progressively as users scroll, reducing initial DOM from 240 cards to ~30-50 cards. Performance improvements: - First 2 categories render eagerly (~30-50 cards) for fast perceived load - Remaining categories lazy-load 600px before entering viewport - Categories render immediately during active search for instant results - "In Season" section always renders first as hero content Implementation: - Add LazyCategory component with IntersectionObserver for vertical lazy loading - Wrap MediaScroller categories with progressive loading logic - Maintain scroll position with placeholder heights (300px per category) - Keep search functionality fully intact with all 240 recipes searchable - Horizontal lazy loading not implemented (IntersectionObserver doesn't work well with overflow-x scroll containers)
This commit is contained in:
65
src/lib/components/LazyCategory.svelte
Normal file
65
src/lib/components/LazyCategory.svelte
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
let {
|
||||||
|
title = '',
|
||||||
|
eager = false,
|
||||||
|
estimatedHeight = 400,
|
||||||
|
rootMargin = '400px',
|
||||||
|
children
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let isVisible = $state(eager); // If eager=true, render immediately
|
||||||
|
let containerRef = $state(null);
|
||||||
|
let observer = $state(null);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!browser || eager) return;
|
||||||
|
|
||||||
|
// Create Intersection Observer to detect when category approaches viewport
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting && !isVisible) {
|
||||||
|
isVisible = true;
|
||||||
|
// Once visible, stop observing (keep it rendered)
|
||||||
|
if (observer && containerRef) {
|
||||||
|
observer.unobserve(containerRef);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rootMargin, // Start loading 400px before entering viewport
|
||||||
|
threshold: 0
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (containerRef) {
|
||||||
|
observer.observe(containerRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isVisible}
|
||||||
|
<!-- Render actual content when visible -->
|
||||||
|
<div bind:this={containerRef}>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Placeholder with estimated height to maintain scroll position -->
|
||||||
|
<div
|
||||||
|
bind:this={containerRef}
|
||||||
|
style="height: {estimatedHeight}px; min-height: {estimatedHeight}px;"
|
||||||
|
aria-label="Loading {title}"
|
||||||
|
>
|
||||||
|
<!-- Empty placeholder - IntersectionObserver will trigger when this enters viewport -->
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
import AddButton from '$lib/components/AddButton.svelte';
|
import AddButton from '$lib/components/AddButton.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Search from '$lib/components/Search.svelte';
|
import Search from '$lib/components/Search.svelte';
|
||||||
|
import LazyCategory from '$lib/components/LazyCategory.svelte';
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
let current_month = new Date().getMonth() + 1;
|
let current_month = new Date().getMonth() + 1;
|
||||||
|
|
||||||
@@ -68,25 +69,38 @@ h1{
|
|||||||
!hasActiveSearch || matchedRecipeIds.has(recipe._id)
|
!hasActiveSearch || matchedRecipeIds.has(recipe._id)
|
||||||
)}
|
)}
|
||||||
{#if seasonRecipes.length > 0}
|
{#if seasonRecipes.length > 0}
|
||||||
<MediaScroller title={labels.inSeason}>
|
<LazyCategory title={labels.inSeason} eager={true}>
|
||||||
{#each seasonRecipes as recipe}
|
{#snippet children()}
|
||||||
<Card {recipe} {current_month} loading_strat={"eager"} do_margin_right={true} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></Card>
|
<MediaScroller title={labels.inSeason}>
|
||||||
{/each}
|
{#each seasonRecipes as recipe}
|
||||||
</MediaScroller>
|
<Card {recipe} {current_month} loading_strat={"eager"} do_margin_right={true} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></Card>
|
||||||
|
{/each}
|
||||||
|
</MediaScroller>
|
||||||
|
{/snippet}
|
||||||
|
</LazyCategory>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each categories as category}
|
{#each categories as category, index}
|
||||||
{@const categoryRecipes = data.all_brief.filter(recipe =>
|
{@const categoryRecipes = data.all_brief.filter(recipe =>
|
||||||
recipe.category === category &&
|
recipe.category === category &&
|
||||||
(!hasActiveSearch || matchedRecipeIds.has(recipe._id))
|
(!hasActiveSearch || matchedRecipeIds.has(recipe._id))
|
||||||
)}
|
)}
|
||||||
{#if categoryRecipes.length > 0}
|
{#if categoryRecipes.length > 0}
|
||||||
<MediaScroller title={category}>
|
<LazyCategory
|
||||||
{#each categoryRecipes as recipe}
|
title={category}
|
||||||
<Card {recipe} {current_month} do_margin_right={true} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></Card>
|
eager={index < 2 || hasActiveSearch}
|
||||||
{/each}
|
estimatedHeight={300}
|
||||||
</MediaScroller>
|
rootMargin="600px"
|
||||||
|
>
|
||||||
|
{#snippet children()}
|
||||||
|
<MediaScroller title={category}>
|
||||||
|
{#each categoryRecipes as recipe}
|
||||||
|
<Card {recipe} {current_month} do_margin_right={true} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></Card>
|
||||||
|
{/each}
|
||||||
|
</MediaScroller>
|
||||||
|
{/snippet}
|
||||||
|
</LazyCategory>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{#if !isEnglish}
|
{#if !isEnglish}
|
||||||
|
|||||||
Reference in New Issue
Block a user