implement Web Worker-based search to eliminate input lag
All checks were successful
CI / update (push) Successful in 1m13s

Replace synchronous DOM manipulation with Web Worker + Svelte reactive state for recipe search. This moves text normalization and filtering off the main thread, ensuring zero input lag while typing. Search now runs in parallel with UI rendering, improving performance significantly for 240+ recipes.

- Add search.worker.js for background search processing
- Update Search.svelte to use Web Worker with $state runes
- Update +page.svelte with reactive filtering based on worker results
- Add language-aware recipe data synchronization for proper English/German search
- Migrate to Svelte 5 event handlers (onsubmit, onclick)
This commit is contained in:
2025-12-31 14:09:16 +01:00
parent 5a55eb7cdd
commit 314d6225cc
3 changed files with 182 additions and 73 deletions

View File

@@ -1,10 +1,19 @@
<script>
import {onMount} from "svelte";
import {onMount, onDestroy} from "svelte";
import { browser } from '$app/environment';
import "$lib/css/nordtheme.css";
// Filter props for different contexts
let { category = null, tag = null, icon = null, season = null, favoritesOnly = false, lang = 'de' } = $props();
let {
category = null,
tag = null,
icon = null,
season = null,
favoritesOnly = false,
lang = 'de',
recipes = [],
onSearchResults = (matchedIds, matchedCategories) => {}
} = $props();
const isEnglish = $derived(lang === 'en');
const searchResultsUrl = $derived(isEnglish ? '/recipes/search' : '/rezepte/search');
@@ -15,6 +24,8 @@
});
let searchQuery = $state('');
let worker = $state(null);
let isWorkerReady = $state(false);
// Build search URL with current filters
function buildSearchUrl(query) {
@@ -45,19 +56,35 @@
function clearSearch() {
searchQuery = '';
if (browser) {
// Reset any client-side filtering if present
const recipes = document.querySelectorAll(".search_me");
recipes.forEach(recipe => {
recipe.style.display = 'flex';
recipe.classList.remove("matched-recipe");
});
document.querySelectorAll(".media_scroller_wrapper").forEach( scroller => {
scroller.style.display= 'block'
// Trigger search with empty query to show all results
if (worker && isWorkerReady) {
worker.postMessage({
type: 'search',
data: { query: '' }
});
}
}
// Effect to update worker data when recipes change (e.g., language switch)
$effect(() => {
if (worker && isWorkerReady && browser && recipes.length > 0) {
worker.postMessage({
type: 'init',
data: { recipes }
});
}
});
// Effect to trigger search when query changes
$effect(() => {
if (worker && isWorkerReady && browser) {
worker.postMessage({
type: 'search',
data: { query: searchQuery }
});
}
});
onMount(() => {
// Swap buttons for JS-enabled experience
const submitButton = document.getElementById('submit-search');
@@ -75,56 +102,53 @@
searchQuery = urlQuery;
}
// Enhanced client-side filtering (existing functionality)
const recipes = document.querySelectorAll(".search_me");
const search = document.getElementById("search");
// Initialize Web Worker for search
if (recipes.length > 0) {
worker = new Worker(
new URL('./search.worker.js', import.meta.url),
{ type: 'module' }
);
if (recipes.length > 0 && search) {
function do_search(click_only_result=false){
const searchText = search.value.toLowerCase().trim().normalize('NFD').replace(/\p{Diacritic}/gu, "");
const searchTerms = searchText.split(" ");
const hasFilter = searchText.length > 0;
// Handle messages from worker
worker.onmessage = (e) => {
const { type, matchedIds, matchedCategories } = e.data;
let scrollers_with_results = [];
let scrollers = [];
recipes.forEach(recipe => {
const searchString = `${recipe.textContent} ${recipe.dataset.tags}`.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, "").replace(/&shy;|­/g, '');
const isMatch = searchTerms.every(term => searchString.includes(term));
recipe.style.display = (isMatch ? 'flex' : 'none');
recipe.classList.toggle("matched-recipe", hasFilter && isMatch);
if(!scrollers.includes(recipe.parentNode)){
scrollers.push(recipe.parentNode)
}
if(!scrollers_with_results.includes(recipe.parentNode) && isMatch){
scrollers_with_results.push(recipe.parentNode)
}
})
scrollers_with_results.forEach( scroller => {
scroller.parentNode.style.display= 'block'
})
scrollers.filter(item => !scrollers_with_results.includes(item)).forEach( scroller => {
scroller.parentNode.style.display= 'none'
})
let items = document.querySelectorAll(".matched-recipe");
items = [...new Set(items)]
if(click_only_result && scrollers_with_results.length == 1 && items.length == 1){
items[0].click();
}
}
search.addEventListener("input", () => {
searchQuery = search.value;
do_search();
})
// Initial search if URL had query
if (type === 'ready') {
isWorkerReady = true;
// Perform initial search if URL had query
if (urlQuery) {
do_search(true);
worker.postMessage({
type: 'search',
data: { query: urlQuery }
});
} else {
// Show all recipes initially
worker.postMessage({
type: 'search',
data: { query: '' }
});
}
}
if (type === 'results') {
// Pass results to parent component
onSearchResults(new Set(matchedIds), matchedCategories);
}
};
// Initialize worker with recipe data
worker.postMessage({
type: 'init',
data: { recipes }
});
}
});
onDestroy(() => {
// Clean up worker
if (worker) {
worker.terminate();
}
});
</script>
@@ -186,7 +210,7 @@ scale: 0.8 0.8;
height: 100%;
}
</style>
<form class="search" method="get" action={buildSearchUrl('')} on:submit|preventDefault={handleSubmit}>
<form class="search" method="get" action={buildSearchUrl('')} onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
{#if category}<input type="hidden" name="category" value={category} />{/if}
{#if tag}<input type="hidden" name="tag" value={tag} />{/if}
{#if icon}<input type="hidden" name="icon" value={icon} />{/if}
@@ -201,7 +225,7 @@ scale: 0.8 0.8;
</button>
<!-- Clear button (hidden by default, shown when JS loads) -->
<button type="button" id="clear-search" class="search-button js-only" style="display: none;" on:click={clearSearch}>
<button type="button" id="clear-search" class="search-button js-only" style="display: none;" onclick={clearSearch}>
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><title>{labels.clearTitle}</title><path d="M135.19 390.14a28.79 28.79 0 0021.68 9.86h246.26A29 29 0 00432 371.13V140.87A29 29 0 00403.13 112H156.87a28.84 28.84 0 00-21.67 9.84v0L46.33 256l88.86 134.11z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32"></path><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M336.67 192.33L206.66 322.34M336.67 322.34L206.66 192.33M336.67 192.33L206.66 322.34M336.67 322.34L206.66 192.33"></path></svg>
</button>
</form>

View File

@@ -0,0 +1,60 @@
/**
* Web Worker for recipe search
* Handles text normalization and filtering off the main thread
*/
let recipes = [];
self.onmessage = (e) => {
const { type, data } = e.data;
if (type === 'init') {
// Initialize worker with recipe data
recipes = data.recipes || [];
self.postMessage({ type: 'ready' });
}
if (type === 'search') {
const query = data.query;
// Empty query = show all recipes
if (!query || query.trim().length === 0) {
self.postMessage({
type: 'results',
matchedIds: recipes.map(r => r._id),
matchedCategories: new Set(recipes.map(r => r.category))
});
return;
}
// Normalize and split search query
const searchText = query.toLowerCase().trim()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, "");
const searchTerms = searchText.split(" ").filter(term => term.length > 0);
// Filter recipes
const matched = recipes.filter(recipe => {
// Build searchable string from recipe data
const searchString = [
recipe.name || '',
recipe.description || '',
...(recipe.tags || [])
].join(' ')
.toLowerCase()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, "")
.replace(/&shy;|­/g, ''); // Remove soft hyphens
// All search terms must match
return searchTerms.every(term => searchString.includes(term));
});
// Return matched recipe IDs and categories with results
self.postMessage({
type: 'results',
matchedIds: matched.map(r => r._id),
matchedCategories: new Set(matched.map(r => r.category))
});
}
};

View File

@@ -5,7 +5,19 @@
import Card from '$lib/components/Card.svelte';
import Search from '$lib/components/Search.svelte';
let { data }: { data: PageData } = $props();
let current_month = new Date().getMonth() + 1
let current_month = new Date().getMonth() + 1;
// Search state
let matchedRecipeIds = $state(new Set());
let matchedCategories = $state(new Set());
let hasActiveSearch = $state(false);
// Handle search results from Search component
function handleSearchResults(ids, categories) {
matchedRecipeIds = ids;
matchedCategories = categories || new Set();
hasActiveSearch = ids.size < data.all_brief.length;
}
const isEnglish = $derived(data.lang === 'en');
const categories = $derived(isEnglish
@@ -49,20 +61,33 @@ h1{
<h1>{labels.title}</h1>
<p class=subheading>{labels.subheading}</p>
<Search lang={data.lang}></Search>
<Search lang={data.lang} recipes={data.all_brief} onSearchResults={handleSearchResults}></Search>
<MediaScroller title={labels.inSeason}>
{#each data.season as recipe}
{#if true}
{@const seasonRecipes = data.season.filter(recipe =>
!hasActiveSearch || matchedRecipeIds.has(recipe._id)
)}
{#if seasonRecipes.length > 0}
<MediaScroller title={labels.inSeason}>
{#each seasonRecipes as recipe}
<Card {recipe} {current_month} loading_strat={"eager"} do_margin_right={true} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></Card>
{/each}
</MediaScroller>
{/each}
</MediaScroller>
{/if}
{/if}
{#each categories as category}
{@const categoryRecipes = data.all_brief.filter(recipe =>
recipe.category === category &&
(!hasActiveSearch || matchedRecipeIds.has(recipe._id))
)}
{#if categoryRecipes.length > 0}
<MediaScroller title={category}>
{#each data.all_brief.filter(recipe => recipe.category == category) as recipe}
{#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>
{/if}
{/each}
{#if !isEnglish}
<AddButton href="/rezepte/add"></AddButton>