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.
This commit is contained in:
2026-04-23 15:50:08 +02:00
parent 38330d7020
commit c01dff197f
3 changed files with 31 additions and 19 deletions
+1 -1
View File
@@ -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] 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] 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). - [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 ## 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. [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.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.46.24", "version": "1.46.25",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+29 -17
View File
@@ -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<object, string>} */
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) // Perform search directly (no worker)
/** @param {string} query */ /** @param {string} query */
function performSearch(query) { function performSearch(query) {
@@ -138,25 +164,11 @@
} }
// Normalize and split search query // Normalize and split search query
const searchText = query.toLowerCase().trim() const searchText = normalizeSearchText(query.trim());
.normalize('NFD') const searchTerms = searchText.split(' ').filter((/** @type {string} */ term) => term.length > 0);
.replace(/\p{Diacritic}/gu, "");
const searchTerms = searchText.split(" ").filter((/** @type {string} */ term) => term.length > 0);
// Filter recipes by text
const matched = filteredByNonText.filter((/** @type {any} */ recipe) => { const matched = filteredByNonText.filter((/** @type {any} */ recipe) => {
// Build searchable string from recipe data const searchString = searchStringFor(recipe);
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((/** @type {string} */ term) => searchString.includes(term)); return searchTerms.every((/** @type {string} */ term) => searchString.includes(term));
}); });