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 { 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 => {

View File

@@ -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));
};

View File

@@ -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;
}

View File

@@ -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<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.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]);
};

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 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<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> = {
bookmark: "M19 21l-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z",