From 31a8da10b47d1fc9a2125ffeaf300d2db9786eac Mon Sep 17 00:00:00 2001 From: mkelvers Date: Tue, 16 Jun 2026 01:00:32 +0200 Subject: [PATCH] refactor: encapsulate search state, bound cache --- static/search/actions.ts | 26 ++++++--- static/search/fetch.ts | 78 ++++++++++++++----------- static/search/overlay.ts | 6 +- static/search/render.ts | 42 ++++++++------ static/search/state.ts | 122 +++++++++++++++++++++++++++++++++++---- 5 files changed, 197 insertions(+), 77 deletions(-) diff --git a/static/search/actions.ts b/static/search/actions.ts index 220b3b6..57496a2 100644 --- a/static/search/actions.ts +++ b/static/search/actions.ts @@ -1,4 +1,12 @@ -import { state, searchInput, searchDialog } from "./state"; +import { + searchInput, + searchDialog, + rememberSearchOpener, + focusLastSearchOpener, + setLastQuery, + getActiveRequestController, + setActiveRequestController, +} from "./state"; import { setSearchState, setClearButtonState, clearResults } from "./render"; import { cancelScheduledFetch, fetchSearchItems } from "./fetch"; @@ -8,12 +16,11 @@ export const openSearch = (): void => { return; } - state.lastFocusedSearchOpener = - document.activeElement instanceof HTMLElement ? document.activeElement : null; + rememberSearchOpener(); if (searchDialog) { setSearchState(true); searchInput.value = ""; - state.lastQuery = ""; + setLastQuery(""); cancelScheduledFetch(); setClearButtonState(false); clearResults(); @@ -28,15 +35,16 @@ export const closeSearch = (): void => { setSearchState(false); cancelScheduledFetch(); - if (state.activeRequestController) { - state.activeRequestController.abort(); - state.activeRequestController = undefined; + const activeRequestController = getActiveRequestController(); + if (activeRequestController) { + activeRequestController.abort(); + setActiveRequestController(undefined); } searchInput.value = ""; - state.lastQuery = ""; + setLastQuery(""); setClearButtonState(false); clearResults(); - state.lastFocusedSearchOpener?.focus(); + focusLastSearchOpener(); }; export const clearSearchInput = (): void => { diff --git a/static/search/fetch.ts b/static/search/fetch.ts index 2a87a40..c44e6d2 100644 --- a/static/search/fetch.ts +++ b/static/search/fetch.ts @@ -1,5 +1,20 @@ import type { CommandPaletteItem, CommandPaletteResponse } from "./state"; -import { state, searchInput, searchResults, responseCache } from "./state"; +import { + searchInput, + searchResults, + responseCache, + getFetchTimeout, + setFetchTimeout, + setLastQuery, + getLastQuery, + getActiveRequestController, + setActiveRequestController, + setSearchPagination, + getNextSearchPage, + hasNextSearchPage, + isFetchingNextPage, + setFetchingNextPage, +} from "./state"; import { setClearButtonState, clearResults, @@ -46,21 +61,23 @@ const renderPendingQuery = (query: string): void => { }; export const cancelScheduledFetch = (): void => { - if (!state.fetchTimeout) { + const fetchTimeout = getFetchTimeout(); + if (!fetchTimeout) { return; } - window.clearTimeout(state.fetchTimeout); - state.fetchTimeout = undefined; + window.clearTimeout(fetchTimeout); + setFetchTimeout(undefined); }; export const fetchSearchItems = (query: string): void => { - state.lastQuery = query; + setLastQuery(query); setClearButtonState(query !== ""); - if (state.activeRequestController) { - state.activeRequestController.abort(); - state.activeRequestController = undefined; + const activeRequestController = getActiveRequestController(); + if (activeRequestController) { + activeRequestController.abort(); + setActiveRequestController(undefined); } if (query === "") { @@ -71,8 +88,7 @@ export const fetchSearchItems = (query: string): void => { const cached = responseCache.get(query); if (cached) { - state.nextSearchPage = cached.nextPage; - state.hasNextSearchPage = cached.hasNextPage; + setSearchPagination(cached.nextPage, cached.hasNextPage); renderItems(visibleSearchItems(cached.items, query)); return; } @@ -80,7 +96,7 @@ export const fetchSearchItems = (query: string): void => { renderPendingQuery(query); const controller = new AbortController(); - state.activeRequestController = controller; + setActiveRequestController(controller); fetch("/api/command-palette?q=" + encodeURIComponent(query), { signal: controller.signal }) .then((res: Response) => { @@ -90,15 +106,14 @@ export const fetchSearchItems = (query: string): void => { return res.json(); }) .then((payload: unknown) => { - if (controller.signal.aborted || query !== state.lastQuery) { + if (controller.signal.aborted || query !== getLastQuery()) { return; } const response = parseCommandPaletteResponse(payload); const visibleItems = visibleSearchItems(response.items, query); - state.activeRequestController = undefined; - state.nextSearchPage = response.nextPage; - state.hasNextSearchPage = response.hasNextPage; + setActiveRequestController(undefined); + setSearchPagination(response.nextPage, response.hasNextPage); responseCache.set(query, response); renderItems(visibleItems); }) @@ -107,25 +122,21 @@ export const fetchSearchItems = (query: string): void => { return; } - state.activeRequestController = undefined; + setActiveRequestController(undefined); console.error("Search overlay error:", err); renderItems([]); }); }; export const fetchNextSearchPage = (): void => { - if ( - !state.lastQuery || - !state.hasNextSearchPage || - !state.nextSearchPage || - state.isFetchingNextPage - ) { + const query = getLastQuery(); + const page = getNextSearchPage(); + + if (!query || !hasNextSearchPage() || !page || isFetchingNextPage()) { return; } - state.isFetchingNextPage = true; - const query = state.lastQuery; - const page = state.nextSearchPage; + setFetchingNextPage(true); fetch( "/api/command-palette?q=" + @@ -140,7 +151,7 @@ export const fetchNextSearchPage = (): void => { return res.json(); }) .then((payload: unknown) => { - if (query !== state.lastQuery) { + if (query !== getLastQuery()) { return; } @@ -154,15 +165,14 @@ export const fetchNextSearchPage = (): void => { nextPage: response.nextPage, }); } - state.nextSearchPage = response.nextPage; - state.hasNextSearchPage = response.hasNextPage; + setSearchPagination(response.nextPage, response.hasNextPage); appendItems(visibleItems); }) .catch((err: unknown) => { console.error("Search overlay pagination error:", err); }) .finally(() => { - state.isFetchingNextPage = false; + setFetchingNextPage(false); }); }; @@ -179,14 +189,12 @@ export const onResultsScroll = (): void => { }; export const scheduleFetch = (): void => { - if (state.fetchTimeout) { - window.clearTimeout(state.fetchTimeout); + const fetchTimeout = getFetchTimeout(); + if (fetchTimeout) { + window.clearTimeout(fetchTimeout); } const query = searchInput?.value.trim() || ""; setClearButtonState(query !== ""); - state.fetchTimeout = window.setTimeout( - () => fetchSearchItems(query), - query.length >= 2 ? 240 : 80, - ); + setFetchTimeout(window.setTimeout(() => fetchSearchItems(query), query.length >= 2 ? 240 : 80)); }; diff --git a/static/search/overlay.ts b/static/search/overlay.ts index 3b3208a..96d386a 100644 --- a/static/search/overlay.ts +++ b/static/search/overlay.ts @@ -1,5 +1,4 @@ import { - state, commandPaletteInitializedKey, globalWindow, searchInput, @@ -10,6 +9,7 @@ import { searchCloseButtons, searchClearButtons, searchDialog, + getSelectedIndex, isSearchOpen, isTypingTarget, } from "./state"; @@ -26,13 +26,13 @@ const onDocumentClick = (event: MouseEvent): void => { const onInputKeydown = (event: KeyboardEvent): void => { if (event.key === "ArrowDown") { event.preventDefault(); - selectItem(state.selectedIndex + 1, true); + selectItem(getSelectedIndex() + 1, true); return; } if (event.key === "ArrowUp") { event.preventDefault(); - selectItem(state.selectedIndex - 1, true); + selectItem(getSelectedIndex() - 1, true); return; } diff --git a/static/search/render.ts b/static/search/render.ts index d63a137..265b9c1 100644 --- a/static/search/render.ts +++ b/static/search/render.ts @@ -1,12 +1,17 @@ import { dedupeByID, dedupeWithin } from "../dedupe"; import type { CommandPaletteItem } from "./state"; import { - state, searchResults, searchClearButtons, shortcutHints, searchDialog, responseCache, + getResultItems, + setResultItems, + getSelectedIndex, + setSelectedIndex, + getLastQuery, + resetSearchResultsState, iconPaths, typeLabels, groupOrder, @@ -94,23 +99,21 @@ const buildPosterImage = (item: CommandPaletteItem): HTMLElement => { }; export const clearResults = (): void => { - state.resultItems = []; - state.selectedIndex = 0; - state.nextSearchPage = undefined; - state.hasNextSearchPage = false; - state.isFetchingNextPage = false; + resetSearchResultsState(); searchResults?.replaceChildren(); }; export const selectItem = (index: number, scrollIntoView: boolean): void => { - if (!searchResults || state.resultItems.length === 0) { - state.selectedIndex = 0; + const resultItems = getResultItems(); + if (!searchResults || resultItems.length === 0) { + setSelectedIndex(0); return; } - state.selectedIndex = Math.max(0, Math.min(index, state.resultItems.length - 1)); + const selectedIndex = Math.max(0, Math.min(index, resultItems.length - 1)); + setSelectedIndex(selectedIndex); searchResults.querySelectorAll("[data-command-palette-item]").forEach((item) => { - const selected = Number(item.dataset.resultIndex) === state.selectedIndex; + const selected = Number(item.dataset.resultIndex) === selectedIndex; item.classList.toggle("opacity-75", selected); item.setAttribute("aria-selected", String(selected)); if (selected && scrollIntoView) { @@ -120,7 +123,7 @@ export const selectItem = (index: number, scrollIntoView: boolean): void => { }; export const runSelectedItem = (): void => { - const item = state.resultItems[state.selectedIndex]; + const item = getResultItems()[getSelectedIndex()]; if (!item) { return; } @@ -155,7 +158,7 @@ export const removeContinueWatchingItem = (item: CommandPaletteItem): void => { responseCache.clear(); removeContinueWatchingCard(animeID); - renderItems(state.resultItems.filter((candidate) => candidate.id !== item.id)); + renderItems(getResultItems().filter((candidate) => candidate.id !== item.id)); }) .catch((err: unknown) => { console.error("Continue watching remove error:", err); @@ -183,7 +186,7 @@ const buildCard = (item: CommandPaletteItem, index: number): HTMLAnchorElement = card.dataset.id = item.id; card.dataset.resultIndex = String(index); card.setAttribute("role", "option"); - card.setAttribute("aria-selected", String(index === state.selectedIndex)); + card.setAttribute("aria-selected", String(index === getSelectedIndex())); card.addEventListener("mouseenter", () => selectItem(index, false)); const media = document.createElement("div"); @@ -231,7 +234,7 @@ const buildCompactItem = (item: CommandPaletteItem, index: number): HTMLAnchorEl row.dataset.id = item.id; row.dataset.resultIndex = String(index); row.setAttribute("role", "option"); - row.setAttribute("aria-selected", String(index === state.selectedIndex)); + row.setAttribute("aria-selected", String(index === getSelectedIndex())); row.addEventListener("mouseenter", () => selectItem(index, false)); const thumb = document.createElement("div"); @@ -333,10 +336,10 @@ export const renderItems = (items: CommandPaletteItem[]): void => { return; } - state.selectedIndex = 0; + setSelectedIndex(0); if (items.length === 0) { - renderEmptyState(state.lastQuery); + renderEmptyState(getLastQuery()); return; } @@ -345,7 +348,7 @@ export const renderItems = (items: CommandPaletteItem[]): void => { const groups = groupedItems(dedupeByID(items, (item) => item.id)); const keys = orderedGroupKeys(groups); - state.resultItems = keys.flatMap((key) => groups.get(key) || []); + setResultItems(keys.flatMap((key) => groups.get(key) || [])); let cursor = 0; keys.forEach((key) => { @@ -371,7 +374,8 @@ export const appendItems = (items: CommandPaletteItem[]): void => { return; } - const existingIDs = new Set(state.resultItems.map((item) => item.id)); + const resultItems = getResultItems(); + const existingIDs = new Set(resultItems.map((item) => item.id)); const nextItems = dedupeByID(items, (item) => item.id).filter( (item) => !existingIDs.has(item.id), ); @@ -379,5 +383,5 @@ export const appendItems = (items: CommandPaletteItem[]): void => { return; } - renderItems([...state.resultItems, ...nextItems]); + renderItems([...resultItems, ...nextItems]); }; diff --git a/static/search/state.ts b/static/search/state.ts index 33c3c9a..6cce06c 100644 --- a/static/search/state.ts +++ b/static/search/state.ts @@ -37,19 +37,119 @@ export const searchCloseButtons = document.querySelectorAll("[data-command-palet export const searchClearButtons = document.querySelectorAll("[data-command-palette-clear]"); export const shortcutHints = document.querySelectorAll("[data-command-palette-shortcut]"); -export const state = { - resultItems: [] as CommandPaletteItem[], - selectedIndex: 0, - fetchTimeout: undefined as number | undefined, - lastQuery: "", - activeRequestController: undefined as AbortController | undefined, - nextSearchPage: undefined as number | undefined, - hasNextSearchPage: false, - isFetchingNextPage: false, - lastFocusedSearchOpener: null as HTMLElement | null, +let resultItems: CommandPaletteItem[] = []; +let selectedIndex = 0; +let fetchTimeout: number | undefined; +let lastQuery = ""; +let activeRequestController: AbortController | undefined; +let nextSearchPage: number | undefined; +let searchHasNextPage = false; +let fetchingNextPage = false; +let lastFocusedSearchOpener: HTMLElement | null = null; + +export const getResultItems = (): CommandPaletteItem[] => resultItems; + +export const setResultItems = (items: CommandPaletteItem[]): void => { + resultItems = items; }; -export const responseCache = new Map(); +export const getSelectedIndex = (): number => selectedIndex; + +export const setSelectedIndex = (index: number): void => { + selectedIndex = index; +}; + +export const getLastQuery = (): string => lastQuery; + +export const setLastQuery = (query: string): void => { + lastQuery = query; +}; + +export const getFetchTimeout = (): number | undefined => fetchTimeout; + +export const setFetchTimeout = (timeout: number | undefined): void => { + fetchTimeout = timeout; +}; + +export const getActiveRequestController = (): AbortController | undefined => + activeRequestController; + +export const setActiveRequestController = (controller: AbortController | undefined): void => { + activeRequestController = controller; +}; + +export const getNextSearchPage = (): number | undefined => nextSearchPage; + +export const hasNextSearchPage = (): boolean => searchHasNextPage; + +export const setSearchPagination = (nextPage: number | undefined, hasNextPage: boolean): void => { + nextSearchPage = nextPage; + searchHasNextPage = hasNextPage; +}; + +export const isFetchingNextPage = (): boolean => fetchingNextPage; + +export const setFetchingNextPage = (isFetching: boolean): void => { + fetchingNextPage = isFetching; +}; + +export const resetSearchResultsState = (): void => { + resultItems = []; + selectedIndex = 0; + nextSearchPage = undefined; + searchHasNextPage = false; + fetchingNextPage = false; +}; + +export const rememberSearchOpener = (): void => { + lastFocusedSearchOpener = + document.activeElement instanceof HTMLElement ? document.activeElement : null; +}; + +export const focusLastSearchOpener = (): void => { + lastFocusedSearchOpener?.focus(); +}; + +const maxCachedResponses = 20; + +const createResponseCache = () => { + const responses = new Map(); + + return { + get(query: string): CommandPaletteResponse | undefined { + const response = responses.get(query); + if (!response) { + return undefined; + } + + responses.delete(query); + responses.set(query, response); + return response; + }, + + set(query: string, response: CommandPaletteResponse): void { + if (responses.has(query)) { + responses.delete(query); + } + + responses.set(query, response); + if (responses.size <= maxCachedResponses) { + return; + } + + const oldestResponse = responses.keys().next(); + if (!oldestResponse.done) { + responses.delete(oldestResponse.value); + } + }, + + clear(): void { + responses.clear(); + }, + }; +}; + +export const responseCache = createResponseCache(); export const iconPaths: Record = { bookmark: "M19 21l-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z",