From 4d08cbcbb0270fe1ce8630a400b0e565fc99395c Mon Sep 17 00:00:00 2001 From: Guy MANDINA Date: Sun, 9 Aug 2026 19:08:10 +0200 Subject: [PATCH] perf(feed): memoize search haystack index to avoid per-query rebuild The feed store rebuilt each mission's search haystack (concatenating and lowercasing title/client/description/location/source/stack) on every search keystroke. The haystack only depends on the missions, not the query, so typing in the search box was doing O(n) string allocation work per keystroke over the full feed. Split into two $derived layers: - missionHaystacks: query-independent index, recomputes only when the missions array changes (new scan / reload). - filteredMissions: query-dependent filter that does a cheap .includes() per mission against the prebuilt haystacks. Behavior is identical: same field set and ordering (title -> client -> description -> location -> source -> stack), same "string && length > 0" guards, same .join(' ').toLowerCase() normalization, same substring match, and same empty-query short-circuit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/extension/src/lib/state/feed.svelte.ts | 75 +++++++++++++++++---- 1 file changed, 62 insertions(+), 13 deletions(-) diff --git a/apps/extension/src/lib/state/feed.svelte.ts b/apps/extension/src/lib/state/feed.svelte.ts index 8120acee..a749d9b6 100644 --- a/apps/extension/src/lib/state/feed.svelte.ts +++ b/apps/extension/src/lib/state/feed.svelte.ts @@ -2,20 +2,58 @@ import type { Mission } from '$lib/core/types/mission'; export type FeedState = 'empty' | 'loading' | 'loaded' | 'error'; -const recomputeFilteredMissions = (missions: Mission[], searchQuery: string): Mission[] => { - if (!searchQuery.trim()) { - return missions; +/** + * Build the lowercased, space-joined haystack of a mission's searchable + * fields. Pure and query-independent, so it can be memoised in a `$derived` + * that only recomputes when the *missions* change — not on every search + * keystroke. + * + * Field set, ordering, and the `string && length > 0` guard are identical to + * the previous inline implementation, so matching results are unchanged: + * title → client → description → location → source → stack items. + */ +const buildSearchHaystack = (mission: Mission): string => { + const parts: string[] = []; + if (typeof mission.title === 'string' && mission.title.length > 0) { + parts.push(mission.title); } + if (typeof mission.client === 'string' && mission.client.length > 0) { + parts.push(mission.client); + } + if (typeof mission.description === 'string' && mission.description.length > 0) { + parts.push(mission.description); + } + if (typeof mission.location === 'string' && mission.location.length > 0) { + parts.push(mission.location); + } + // mission.source is always a non-empty string enum value. + parts.push(mission.source); + for (const item of mission.stack) { + if (typeof item === 'string' && item.length > 0) { + parts.push(item); + } + } + return parts.join(' ').toLowerCase(); +}; - const query = searchQuery.toLowerCase().trim(); - return missions.filter((m) => { - const searchableText = [m.title, m.client, m.description, m.location, m.source, ...m.stack] - .filter((value): value is string => typeof value === 'string' && value.length > 0) - .join(' ') - .toLowerCase(); - - return searchableText.includes(query); - }); +/** + * Filter missions by a precomputed lowercase query against a parallel array + * of lowercased haystacks. Only the `.includes` check runs per query here; + * the expensive haystack construction lives in `missionHaystacks` above and + * is recomputed solely when missions change. + */ +const filterMissionsByQuery = ( + missions: Mission[], + haystacks: string[], + normalizedQuery: string +): Mission[] => { + const matches: Mission[] = []; + for (let i = 0; i < missions.length; i++) { + if (haystacks[i].includes(normalizedQuery)) { + matches.push(missions[i]); + } + } + return matches; }; export function createFeedStore() { @@ -24,7 +62,18 @@ export function createFeedStore() { let searchQuery = $state(''); let error = $state(null); - const filteredMissions = $derived(recomputeFilteredMissions(missions, searchQuery)); + // Query-independent index: rebuilds only when missions change (new scan / + // reload), not when the user types. Previously this work was redone for + // every mission on every search. + const missionHaystacks = $derived(missions.map((m) => buildSearchHaystack(m))); + + const filteredMissions = $derived.by(() => { + const trimmed = searchQuery.trim(); + if (trimmed.length === 0) { + return missions; + } + return filterMissionsByQuery(missions, missionHaystacks, trimmed.toLowerCase()); + }); return { get state() {