refactor: encapsulate search state, bound cache

This commit is contained in:
2026-06-16 01:00:32 +02:00
committed by Milas Holsting
parent 3c30688058
commit 31a8da10b4
5 changed files with 197 additions and 77 deletions

View File

@@ -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 { setSearchState, setClearButtonState, clearResults } from "./render";
import { cancelScheduledFetch, fetchSearchItems } from "./fetch"; import { cancelScheduledFetch, fetchSearchItems } from "./fetch";
@@ -8,12 +16,11 @@ export const openSearch = (): void => {
return; return;
} }
state.lastFocusedSearchOpener = rememberSearchOpener();
document.activeElement instanceof HTMLElement ? document.activeElement : null;
if (searchDialog) { if (searchDialog) {
setSearchState(true); setSearchState(true);
searchInput.value = ""; searchInput.value = "";
state.lastQuery = ""; setLastQuery("");
cancelScheduledFetch(); cancelScheduledFetch();
setClearButtonState(false); setClearButtonState(false);
clearResults(); clearResults();
@@ -28,15 +35,16 @@ export const closeSearch = (): void => {
setSearchState(false); setSearchState(false);
cancelScheduledFetch(); cancelScheduledFetch();
if (state.activeRequestController) { const activeRequestController = getActiveRequestController();
state.activeRequestController.abort(); if (activeRequestController) {
state.activeRequestController = undefined; activeRequestController.abort();
setActiveRequestController(undefined);
} }
searchInput.value = ""; searchInput.value = "";
state.lastQuery = ""; setLastQuery("");
setClearButtonState(false); setClearButtonState(false);
clearResults(); clearResults();
state.lastFocusedSearchOpener?.focus(); focusLastSearchOpener();
}; };
export const clearSearchInput = (): void => { export const clearSearchInput = (): void => {

View File

@@ -1,5 +1,20 @@
import type { CommandPaletteItem, CommandPaletteResponse } from "./state"; 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 { import {
setClearButtonState, setClearButtonState,
clearResults, clearResults,
@@ -46,21 +61,23 @@ const renderPendingQuery = (query: string): void => {
}; };
export const cancelScheduledFetch = (): void => { export const cancelScheduledFetch = (): void => {
if (!state.fetchTimeout) { const fetchTimeout = getFetchTimeout();
if (!fetchTimeout) {
return; return;
} }
window.clearTimeout(state.fetchTimeout); window.clearTimeout(fetchTimeout);
state.fetchTimeout = undefined; setFetchTimeout(undefined);
}; };
export const fetchSearchItems = (query: string): void => { export const fetchSearchItems = (query: string): void => {
state.lastQuery = query; setLastQuery(query);
setClearButtonState(query !== ""); setClearButtonState(query !== "");
if (state.activeRequestController) { const activeRequestController = getActiveRequestController();
state.activeRequestController.abort(); if (activeRequestController) {
state.activeRequestController = undefined; activeRequestController.abort();
setActiveRequestController(undefined);
} }
if (query === "") { if (query === "") {
@@ -71,8 +88,7 @@ export const fetchSearchItems = (query: string): void => {
const cached = responseCache.get(query); const cached = responseCache.get(query);
if (cached) { if (cached) {
state.nextSearchPage = cached.nextPage; setSearchPagination(cached.nextPage, cached.hasNextPage);
state.hasNextSearchPage = cached.hasNextPage;
renderItems(visibleSearchItems(cached.items, query)); renderItems(visibleSearchItems(cached.items, query));
return; return;
} }
@@ -80,7 +96,7 @@ export const fetchSearchItems = (query: string): void => {
renderPendingQuery(query); renderPendingQuery(query);
const controller = new AbortController(); const controller = new AbortController();
state.activeRequestController = controller; setActiveRequestController(controller);
fetch("/api/command-palette?q=" + encodeURIComponent(query), { signal: controller.signal }) fetch("/api/command-palette?q=" + encodeURIComponent(query), { signal: controller.signal })
.then((res: Response) => { .then((res: Response) => {
@@ -90,15 +106,14 @@ export const fetchSearchItems = (query: string): void => {
return res.json(); return res.json();
}) })
.then((payload: unknown) => { .then((payload: unknown) => {
if (controller.signal.aborted || query !== state.lastQuery) { if (controller.signal.aborted || query !== getLastQuery()) {
return; return;
} }
const response = parseCommandPaletteResponse(payload); const response = parseCommandPaletteResponse(payload);
const visibleItems = visibleSearchItems(response.items, query); const visibleItems = visibleSearchItems(response.items, query);
state.activeRequestController = undefined; setActiveRequestController(undefined);
state.nextSearchPage = response.nextPage; setSearchPagination(response.nextPage, response.hasNextPage);
state.hasNextSearchPage = response.hasNextPage;
responseCache.set(query, response); responseCache.set(query, response);
renderItems(visibleItems); renderItems(visibleItems);
}) })
@@ -107,25 +122,21 @@ export const fetchSearchItems = (query: string): void => {
return; return;
} }
state.activeRequestController = undefined; setActiveRequestController(undefined);
console.error("Search overlay error:", err); console.error("Search overlay error:", err);
renderItems([]); renderItems([]);
}); });
}; };
export const fetchNextSearchPage = (): void => { export const fetchNextSearchPage = (): void => {
if ( const query = getLastQuery();
!state.lastQuery || const page = getNextSearchPage();
!state.hasNextSearchPage ||
!state.nextSearchPage || if (!query || !hasNextSearchPage() || !page || isFetchingNextPage()) {
state.isFetchingNextPage
) {
return; return;
} }
state.isFetchingNextPage = true; setFetchingNextPage(true);
const query = state.lastQuery;
const page = state.nextSearchPage;
fetch( fetch(
"/api/command-palette?q=" + "/api/command-palette?q=" +
@@ -140,7 +151,7 @@ export const fetchNextSearchPage = (): void => {
return res.json(); return res.json();
}) })
.then((payload: unknown) => { .then((payload: unknown) => {
if (query !== state.lastQuery) { if (query !== getLastQuery()) {
return; return;
} }
@@ -154,15 +165,14 @@ export const fetchNextSearchPage = (): void => {
nextPage: response.nextPage, nextPage: response.nextPage,
}); });
} }
state.nextSearchPage = response.nextPage; setSearchPagination(response.nextPage, response.hasNextPage);
state.hasNextSearchPage = response.hasNextPage;
appendItems(visibleItems); appendItems(visibleItems);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
console.error("Search overlay pagination error:", err); console.error("Search overlay pagination error:", err);
}) })
.finally(() => { .finally(() => {
state.isFetchingNextPage = false; setFetchingNextPage(false);
}); });
}; };
@@ -179,14 +189,12 @@ export const onResultsScroll = (): void => {
}; };
export const scheduleFetch = (): void => { export const scheduleFetch = (): void => {
if (state.fetchTimeout) { const fetchTimeout = getFetchTimeout();
window.clearTimeout(state.fetchTimeout); if (fetchTimeout) {
window.clearTimeout(fetchTimeout);
} }
const query = searchInput?.value.trim() || ""; const query = searchInput?.value.trim() || "";
setClearButtonState(query !== ""); setClearButtonState(query !== "");
state.fetchTimeout = window.setTimeout( setFetchTimeout(window.setTimeout(() => fetchSearchItems(query), query.length >= 2 ? 240 : 80));
() => fetchSearchItems(query),
query.length >= 2 ? 240 : 80,
);
}; };

View File

@@ -1,5 +1,4 @@
import { import {
state,
commandPaletteInitializedKey, commandPaletteInitializedKey,
globalWindow, globalWindow,
searchInput, searchInput,
@@ -10,6 +9,7 @@ import {
searchCloseButtons, searchCloseButtons,
searchClearButtons, searchClearButtons,
searchDialog, searchDialog,
getSelectedIndex,
isSearchOpen, isSearchOpen,
isTypingTarget, isTypingTarget,
} from "./state"; } from "./state";
@@ -26,13 +26,13 @@ const onDocumentClick = (event: MouseEvent): void => {
const onInputKeydown = (event: KeyboardEvent): void => { const onInputKeydown = (event: KeyboardEvent): void => {
if (event.key === "ArrowDown") { if (event.key === "ArrowDown") {
event.preventDefault(); event.preventDefault();
selectItem(state.selectedIndex + 1, true); selectItem(getSelectedIndex() + 1, true);
return; return;
} }
if (event.key === "ArrowUp") { if (event.key === "ArrowUp") {
event.preventDefault(); event.preventDefault();
selectItem(state.selectedIndex - 1, true); selectItem(getSelectedIndex() - 1, true);
return; return;
} }

View File

@@ -1,12 +1,17 @@
import { dedupeByID, dedupeWithin } from "../dedupe"; import { dedupeByID, dedupeWithin } from "../dedupe";
import type { CommandPaletteItem } from "./state"; import type { CommandPaletteItem } from "./state";
import { import {
state,
searchResults, searchResults,
searchClearButtons, searchClearButtons,
shortcutHints, shortcutHints,
searchDialog, searchDialog,
responseCache, responseCache,
getResultItems,
setResultItems,
getSelectedIndex,
setSelectedIndex,
getLastQuery,
resetSearchResultsState,
iconPaths, iconPaths,
typeLabels, typeLabels,
groupOrder, groupOrder,
@@ -94,23 +99,21 @@ const buildPosterImage = (item: CommandPaletteItem): HTMLElement => {
}; };
export const clearResults = (): void => { export const clearResults = (): void => {
state.resultItems = []; resetSearchResultsState();
state.selectedIndex = 0;
state.nextSearchPage = undefined;
state.hasNextSearchPage = false;
state.isFetchingNextPage = false;
searchResults?.replaceChildren(); searchResults?.replaceChildren();
}; };
export const selectItem = (index: number, scrollIntoView: boolean): void => { export const selectItem = (index: number, scrollIntoView: boolean): void => {
if (!searchResults || state.resultItems.length === 0) { const resultItems = getResultItems();
state.selectedIndex = 0; if (!searchResults || resultItems.length === 0) {
setSelectedIndex(0);
return; 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<HTMLElement>("[data-command-palette-item]").forEach((item) => { searchResults.querySelectorAll<HTMLElement>("[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.classList.toggle("opacity-75", selected);
item.setAttribute("aria-selected", String(selected)); item.setAttribute("aria-selected", String(selected));
if (selected && scrollIntoView) { if (selected && scrollIntoView) {
@@ -120,7 +123,7 @@ export const selectItem = (index: number, scrollIntoView: boolean): void => {
}; };
export const runSelectedItem = (): void => { export const runSelectedItem = (): void => {
const item = state.resultItems[state.selectedIndex]; const item = getResultItems()[getSelectedIndex()];
if (!item) { if (!item) {
return; return;
} }
@@ -155,7 +158,7 @@ export const removeContinueWatchingItem = (item: CommandPaletteItem): void => {
responseCache.clear(); responseCache.clear();
removeContinueWatchingCard(animeID); removeContinueWatchingCard(animeID);
renderItems(state.resultItems.filter((candidate) => candidate.id !== item.id)); renderItems(getResultItems().filter((candidate) => candidate.id !== item.id));
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
console.error("Continue watching remove error:", err); 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.id = item.id;
card.dataset.resultIndex = String(index); card.dataset.resultIndex = String(index);
card.setAttribute("role", "option"); 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)); card.addEventListener("mouseenter", () => selectItem(index, false));
const media = document.createElement("div"); const media = document.createElement("div");
@@ -231,7 +234,7 @@ const buildCompactItem = (item: CommandPaletteItem, index: number): HTMLAnchorEl
row.dataset.id = item.id; row.dataset.id = item.id;
row.dataset.resultIndex = String(index); row.dataset.resultIndex = String(index);
row.setAttribute("role", "option"); 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)); row.addEventListener("mouseenter", () => selectItem(index, false));
const thumb = document.createElement("div"); const thumb = document.createElement("div");
@@ -333,10 +336,10 @@ export const renderItems = (items: CommandPaletteItem[]): void => {
return; return;
} }
state.selectedIndex = 0; setSelectedIndex(0);
if (items.length === 0) { if (items.length === 0) {
renderEmptyState(state.lastQuery); renderEmptyState(getLastQuery());
return; return;
} }
@@ -345,7 +348,7 @@ export const renderItems = (items: CommandPaletteItem[]): void => {
const groups = groupedItems(dedupeByID(items, (item) => item.id)); const groups = groupedItems(dedupeByID(items, (item) => item.id));
const keys = orderedGroupKeys(groups); const keys = orderedGroupKeys(groups);
state.resultItems = keys.flatMap((key) => groups.get(key) || []); setResultItems(keys.flatMap((key) => groups.get(key) || []));
let cursor = 0; let cursor = 0;
keys.forEach((key) => { keys.forEach((key) => {
@@ -371,7 +374,8 @@ export const appendItems = (items: CommandPaletteItem[]): void => {
return; 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( const nextItems = dedupeByID(items, (item) => item.id).filter(
(item) => !existingIDs.has(item.id), (item) => !existingIDs.has(item.id),
); );
@@ -379,5 +383,5 @@ export const appendItems = (items: CommandPaletteItem[]): void => {
return; return;
} }
renderItems([...state.resultItems, ...nextItems]); renderItems([...resultItems, ...nextItems]);
}; };

View File

@@ -37,19 +37,119 @@ export const searchCloseButtons = document.querySelectorAll("[data-command-palet
export const searchClearButtons = document.querySelectorAll("[data-command-palette-clear]"); export const searchClearButtons = document.querySelectorAll("[data-command-palette-clear]");
export const shortcutHints = document.querySelectorAll("[data-command-palette-shortcut]"); export const shortcutHints = document.querySelectorAll("[data-command-palette-shortcut]");
export const state = { let resultItems: CommandPaletteItem[] = [];
resultItems: [] as CommandPaletteItem[], let selectedIndex = 0;
selectedIndex: 0, let fetchTimeout: number | undefined;
fetchTimeout: undefined as number | undefined, let lastQuery = "";
lastQuery: "", let activeRequestController: AbortController | undefined;
activeRequestController: undefined as AbortController | undefined, let nextSearchPage: number | undefined;
nextSearchPage: undefined as number | undefined, let searchHasNextPage = false;
hasNextSearchPage: false, let fetchingNextPage = false;
isFetchingNextPage: false, let lastFocusedSearchOpener: HTMLElement | null = null;
lastFocusedSearchOpener: null as HTMLElement | null,
export const getResultItems = (): CommandPaletteItem[] => resultItems;
export const setResultItems = (items: CommandPaletteItem[]): void => {
resultItems = items;
}; };
export const responseCache = new Map<string, CommandPaletteResponse>(); 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<string, CommandPaletteResponse>();
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<string, string> = { export const iconPaths: Record<string, string> = {
bookmark: "M19 21l-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z", bookmark: "M19 21l-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z",