From c01dff197faf16372f6b8c82edb72020678a1a16 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Thu, 23 Apr 2026 15:50:08 +0200 Subject: [PATCH] perf(recipes/search): memoise per-recipe normalized search string Every keystroke the filter rebuilt the lowercased + diacritic-stripped + soft-hyphen-stripped concat of name/description/tags per recipe. For a 200+ recipe catalogue that's a lot of regex work on the hot path. Cache the normalised string in a WeakMap keyed by the recipe object; first keystroke still pays the full cost, every subsequent one is a single indexOf per recipe. Picked client-side memoisation over the audit's suggested server-side `_searchKey` to avoid duplicating every recipe's text over the wire. --- TODO.md | 2 +- package.json | 2 +- src/lib/components/recipes/Search.svelte | 46 +++++++++++++++--------- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/TODO.md b/TODO.md index 9df72f2b..01338a1d 100644 --- a/TODO.md +++ b/TODO.md @@ -14,7 +14,7 @@ Order = impact. Font items + app.html preload intentionally skipped. - [x] 8. Calendar payload trim — `yearDays` narrowed to `{iso, color}` (needle lookup only), new pre-filtered `feastDots` array carries feast-specific metadata. Also fixed a stray double `locals.session ?? (locals.session ?? …)` in both calendar page loaders. - [x] 9. History sessions endpoint — projection narrowed to exactly what SessionCard reads (drops notes, templates, mode, endTime, session-level gpsPreview); added `.lean()`. - [x] 10. `Cache-Control` headers: 8 h public on the shuffled recipe list endpoints (`all_brief`, `category/[c]`, `tag/[t]`, `icon/[i]`, `in_season/[m]`) — rand_array is seeded per UTC day, safe to share. 1 h public on distinct-value lists (`category`, `tag`, `icon`). 5 min public on recipe detail. `private 1h` on fitness `/exercises/filters`. Calendar page skipped (session serialised into layout HTML). -- [ ] 11. Search — debounce 100 ms + server-side pre-normalized `_searchKey` +- [x] 11. Search — debounce was already 100 ms. Instead of a server-side `_searchKey` (would duplicate text over the wire), memoise per-recipe normalized string in a `WeakMap` on the client — built lazily, reused across every subsequent keystroke. ## Features [x] on /fitness/measure, fill "Past measurements" in SSR only for the last 10 measurements. anything further should be fetched client side on mount to decreae initial page load time. use a "show more" button and paginate measurments. diff --git a/package.json b/package.json index 0811669e..de78de9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.46.24", + "version": "1.46.25", "private": true, "type": "module", "scripts": { diff --git a/src/lib/components/recipes/Search.svelte b/src/lib/components/recipes/Search.svelte index 68fd3c81..b17bf190 100644 --- a/src/lib/components/recipes/Search.svelte +++ b/src/lib/components/recipes/Search.svelte @@ -122,6 +122,32 @@ }); } + /** @param {string} s */ + function normalizeSearchText(s) { + return s.toLowerCase() + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .replace(/­|­/g, ''); + } + + // Memoised normalized search string per recipe. Building it is the hot + // path (NFD + regex replace for every recipe × every keystroke), so we + // compute it once per recipe array and reuse across keystrokes. Shipping + // a pre-normalized `_searchKey` from the server would duplicate the text + // fields over the wire — this keeps the payload small and amortises the + // cost on the client instead. + /** @type {WeakMap} */ + const searchIndex = new WeakMap(); + /** @param {any} recipe */ + function searchStringFor(recipe) { + const cached = searchIndex.get(recipe); + if (cached !== undefined) return cached; + const raw = [recipe.name || '', recipe.description || '', ...(recipe.tags || [])].join(' '); + const norm = normalizeSearchText(raw); + searchIndex.set(recipe, norm); + return norm; + } + // Perform search directly (no worker) /** @param {string} query */ function performSearch(query) { @@ -138,25 +164,11 @@ } // Normalize and split search query - const searchText = query.toLowerCase().trim() - .normalize('NFD') - .replace(/\p{Diacritic}/gu, ""); - const searchTerms = searchText.split(" ").filter((/** @type {string} */ term) => term.length > 0); + const searchText = normalizeSearchText(query.trim()); + const searchTerms = searchText.split(' ').filter((/** @type {string} */ term) => term.length > 0); - // Filter recipes by text const matched = filteredByNonText.filter((/** @type {any} */ recipe) => { - // Build searchable string from recipe data - const searchString = [ - recipe.name || '', - recipe.description || '', - ...(recipe.tags || []) - ].join(' ') - .toLowerCase() - .normalize('NFD') - .replace(/\p{Diacritic}/gu, "") - .replace(/­|­/g, ''); // Remove soft hyphens - - // All search terms must match + const searchString = searchStringFor(recipe); return searchTerms.every((/** @type {string} */ term) => searchString.includes(term)); });