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