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:
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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(/­|/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));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user