export function useSearch() {
const appContext = useAppContext();
const fetchControllerRef = useRef<AbortController>(new AbortController());
// Guard to prevent the mount useEffect from triggering duplicate searches
// when dependencies change (e.g. suppliers array getting a new reference
// after LOAD_FROM_STORAGE dispatches in App.tsx).
const isSearchInitiatedRef = useRef<boolean>(false);
const initialState: SearchState = {
isLoading: false,
status: false,
error: undefined,
resultCount: 0,
};
const [state, setState] = useState<SearchState>(initialState);
const [tableText, setTableText] = useState<string>("");
const [searchResults, setSearchResults] = useState<Product[]>([]);
// The query string of the most recently executed search. Displayed in the
// results panel header so the user can see what they searched for.
const [executedQuery, setExecutedQuery] = useState<string>("");
// Guard to ensure session storage is only loaded once, even under StrictMode's
// double-invoke of effects in development.
const hasLoadedFromStorageRef = useRef<boolean>(false);
// Load search results from Chrome storage on mount - this restores session persistence!
useEffect(() => {
if (hasLoadedFromStorageRef.current) return;
hasLoadedFromStorageRef.current = true;
const loadSearchData = async () => {
try {
// Check for a pending new-search submission *before* rehydrating from
// IndexedDB. If one is queued, loading stale results first would flash
// the previous search's rows into the table for a frame before the new
// search's setSearchResults([]) clears them. Read both in parallel so
// we don't add a serial round-trip to the hot path.
const [cachedResults, sessionData] = await Promise.all([
getSearchResults(),
cstorage.session.get([CACHE.QUERY, CACHE.SEARCH_IS_NEW_SEARCH]),
]);
const hasPendingSearch = Boolean(
sessionData[CACHE.SEARCH_IS_NEW_SEARCH] &&
sessionData[CACHE.QUERY] &&
sessionData[CACHE.QUERY].trim(),
);
if (!hasPendingSearch && cachedResults.length > 0) {
console.debug("Loading previous search results from IndexedDB", {
length: cachedResults.length,
results: cachedResults,
});
setSearchResults(cachedResults);
setState((prev) => ({
...prev,
resultCount: cachedResults.length,
status: false, // Don't show status when loading from storage
}));
// Restore the query label shown in the results header so it
// survives a popup reopen.
if (typeof sessionData[CACHE.QUERY] === "string") {
setExecutedQuery(sessionData[CACHE.QUERY]);
}
}
// Only execute search if this is a new search submission
if (hasPendingSearch) {
isSearchInitiatedRef.current = true;
console.debug("Found new search submission, executing search", {
query: sessionData[CACHE.QUERY],
});
// Await the flag removal to prevent race conditions with re-runs
try {
await cstorage.session.remove([String(CACHE.SEARCH_IS_NEW_SEARCH)]);
} catch (error) {
console.warn(`Failed to clear ${CACHE.SEARCH_IS_NEW_SEARCH} flag`, { error });
}
console.debug("executing search FROM USEFFECT", {
query: sessionData[CACHE.QUERY],
});
// Execute the search - performSearch reads supplierResultLimit/suppliers
// from appContext via its default parameters, so we don't need to pass them.
performSearch({ query: sessionData[CACHE.QUERY] });
}
} catch (error) {
console.warn("Failed to load search data from session storage:", { error });
}
};
loadSearchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Listen for external clears of search results (e.g. SpeedDial "Clear Results")
useEffect(() => {
const handler = () => {
setSearchResults([]);
setState((prev) => ({ ...prev, resultCount: 0, status: false }));
};
window.addEventListener(IDB_SEARCH_RESULTS_CLEARED, handler);
return () => window.removeEventListener(IDB_SEARCH_RESULTS_CLEARED, handler);
}, []);
const performSearch = useCallback(
async ({
query,
supplierResultLimit = appContext.userSettings.supplierResultLimit ?? 15,
suppliers = appContext.selectedSuppliers ?? [],
}: {
query: string;
supplierResultLimit?: number;
suppliers?: string[];
}) => {
const { searchFilters } = appContext;
const filtersActive = hasActiveFilters(searchFilters, appContext.userSettings);
console.debug("performSearch", {
query,
supplierResultLimit,
suppliers,
userSettings: appContext.userSettings,
filtersActive,
searchFilters,
});
// Reset state for new search. Clearing `tableText` here prevents a stale
// empty-state message (e.g. "No results found for X" from the previous
// search, or "Search aborted") from bleeding into the new search's
// empty-state cell. While `isLoading === true` the cell renders
// "Searching..." regardless, so clearing now doesn't cause a flash.
setState({
isLoading: true,
status: "Searching...",
error: undefined,
resultCount: 0,
});
setSearchResults([]);
setTableText("");
setExecutedQuery(query);
// Drop stale persisted results so a 0-result/aborted search doesn't
// rehydrate them on next open. Silent so it doesn't bounce off the panel.
await clearSearchResults({ notify: false });
// Create a history entry immediately so it's recorded even if the search is cancelled or hangs.
// The resultCount will be updated live as results stream in.
const historyTimestamp = Date.now();
console.log("Searching for", { query, suppliers, appContext });
void createInitialHistoryEntry(
query,
historyTimestamp,
searchFilters,
appContext.selectedSuppliers ?? [],
);
// Signal search start — the badge controller owns the loading animation.
emitSearchEvent(SearchEvent.STARTED, { query });
const columnFilterConfig = getColumnFilterConfig();
const userLimit = appContext.userSettings.supplierResultLimit ?? 15;
// When filters are active, fetch more results so there's enough after filtering.
// The per-supplier limit is applied post-filter, so we ask each supplier for more.
const fetchLimit = filtersActive ? userLimit * 5 : userLimit;
console.debug("fetchLimit", {
fetchLimit,
userLimit,
filtersActive,
supplierResultLimit: appContext.userSettings.supplierResultLimit,
});
// Create new abort controller for this search
fetchControllerRef.current = new AbortController();
try {
// Create the search factory object, which sets the query, supplier search limits,
// and the abort controller for the search.
const productQueryFactory = new SupplierFactory(
query,
fetchLimit,
fetchControllerRef.current,
appContext.selectedSuppliers,
appContext.userSettings.caching,
appContext.userSettings.fuzzScorerOverride,
appContext.userSettings.doNotCacheEmptyResults,
appContext.userSettings.cacheTtlMinutes,
);
const startSearchTime = performance.now();
const resultsTable = window.resultsTable as Table<Product>;
// Execute the search for all suppliers.
const productQueryResults = await productQueryFactory.executeAllStream(3);
// When filters are active, collect all results first, then filter and limit.
// When no filters are active, stream results directly for immediate UI feedback.
if (filtersActive) {
const allResults: Product[] = [];
// Collect all streamed results
for await (const result of productQueryResults) {
allResults.push(result);
// Show progress while collecting
startTransition(() => {
setState((prev) => ({
...prev,
status: `Fetching results... (${allResults.length} found)`,
}));
});
}
// Apply pre-search filters
const filtered = allResults.filter((product) =>
passesSearchFilters(product, searchFilters, appContext.userSettings),
);
// Apply per-supplier limit on the filtered set
const limited = applyPerSupplierLimit(filtered, userLimit);
// Build column filter config for final results
for (const result of limited) {
updateColumnFilterFromResult(columnFilterConfig, result);
}
// Set all filtered+limited results at once. The badge count is driven
// by ResultsTable emitting SearchEvent.RESULTS_COUNT off its filtered row count
// count — committing this state update re-renders the table, which
// re-emits the count, so no direct badge update is needed here.
const finalResults = limited.map((r, idx) => ({ ...r, id: idx }));
setSearchResults(finalResults);
// Save to Chrome storage and update history with final count
await saveResultsToSession(finalResults);
await updateHistoryResultCount(historyTimestamp, finalResults.length);
console.debug("Fetched results", {
allResults,
filtered,
finalResults,
});
} else {
// No filters active — stream results directly; ResultsTable re-emits
// the count per appended row, so no direct badge update is needed here.
for await (const result of productQueryResults) {
// Update state with current count using startTransition for better performance
startTransition(() => {
setState((prev) => ({
...prev,
resultCount: resultsTable.getRowCount(),
status: `Found ${resultsTable.getRowCount()} result${resultsTable.getRowCount() !== 1 ? "s" : ""}...`,
}));
});
// Build column filter config for this result
updateColumnFilterFromResult(columnFilterConfig, result);
// Add result immediately to the table - streaming behavior restored!
const productWithId = {
...result,
id: resultsTable.getRowCount() - 1,
};
// Update results immediately using startTransition for better performance
startTransition(() => {
setSearchResults((prevSearchResults) => {
const newResults = [...prevSearchResults, productWithId];
const indexedResults = newResults.map((r, idx) => ({ ...r, id: idx }));
// Persist and update history live (fire and forget)
void (async () => {
await saveResultsToSession(indexedResults);
await updateHistoryResultCount(historyTimestamp, newResults.length);
})();
return newResults;
});
});
}
}
const endSearchTime = performance.now();
const searchTime = endSearchTime - startSearchTime;
console.debug(
`Found ${resultsTable.getRowCount()} products in ${searchTime} milliseconds`,
{ query, fetchLimit, productQueryResults, startSearchTime, endSearchTime, searchTime },
);
// If no results were found, then try to suggest alternative search terms using cactus.nci.nih.gov API.
if (resultsTable.getRowCount() === 0) {
const message = await buildNoResultsMessage(query, filtersActive);
setTableText(message);
console.debug("setting table text", { tableText: message });
} else {
// Clear any status text from a previous search.
setTableText("");
}
// Signal completion; the badge controller reconciles the final count.
emitSearchEvent(SearchEvent.COMPLETED, { count: resultsTable.getRowCount() });
// Final state - search complete
// NOTE: Do NOT wrap in startTransition — the isLoading:false update must be
// high priority so the LoadingBackdrop closes immediately. startTransition
// would let React defer it behind the queued streaming updates indefinitely.
setState({
isLoading: false,
status: false, // Hide status when complete
error: undefined,
resultCount: resultsTable.getRowCount(),
});
} catch (error) {
// Signal the terminal outcome; the badge controller clears the badge.
if (error instanceof Error && error.name === "AbortError") {
emitSearchEvent(SearchEvent.ABORTED);
setState((prev) => ({
...prev,
isLoading: false,
status: "Search aborted",
error: undefined,
}));
setTableText("Search aborted");
} else {
emitSearchEvent(SearchEvent.FAILED, {
error: error instanceof Error ? error.message : "Search failed",
});
setState((prev) => ({
...prev,
isLoading: false,
status: false,
error: error instanceof Error ? error.message : "Search failed",
}));
}
}
},
[appContext.userSettings, appContext.selectedSuppliers, appContext.searchFilters],
);
const executeSearch = useCallback(
(query: string) => {
if (!query.trim()) {
return;
}
// Keep the drawer's search term in sync with whatever query is being executed
if (appContext.searchFilters.titleQuery !== query.trim()) {
appContext.setSearchFilters({ ...appContext.searchFilters, titleQuery: query.trim() });
}
console.debug(`executing search FROM EXECUTESEARCH`, { query });
// Use startTransition for better performance during search
startTransition(() => {
performSearch({ query });
});
},
[appContext, performSearch],
);
/**
* Remove a product from the current results and persist it to the
* excluded-products list so future searches skip it as well. Drives the
* "Ignore Product" context-menu action: it immediately drops the row from
* the visible table, updates the session-storage snapshot so a reload
* doesn't resurrect it, and writes the exclusion entry to local storage.
*
* Matching is done by `(url, supplier)` pair — identical to the shape of
* the exclusion key used in `SupplierBase.getProductData`.
*
* @param product - The product to exclude and drop from results.
* @example
* ```ts
* const { excludeProduct } = useSearch();
* await excludeProduct(row.original);
* ```
* @source
*/
const excludeProduct = useCallback(async (product: Product) => {
if (!product?.url || !product?.supplier) return;
let nextResults: Product[] = [];
setSearchResults((prev) => {
nextResults = prev.filter((p) => !(p.url === product.url && p.supplier === product.supplier));
return nextResults;
});
// setSearchResults re-renders the table, which re-emits the count; no
// direct badge update needed here.
try {
await addExcludedProduct(product.url, product.supplier, { title: product.title });
} catch (error) {
console.warn("Failed to persist excluded product:", { error });
}
// Persist the updated results so a reload doesn't resurrect the row.
await saveResultsToSession(nextResults);
}, []);
const handleStopSearch = useCallback(() => {
console.debug("triggering abort..");
fetchControllerRef.current.abort();
startTransition(() => {
setState((prev) => ({
...prev,
isLoading: false,
status: "Search aborted",
}));
setTableText("Search aborted");
});
}, []);
// Listen for the global abort-search hotkey (mod+.) dispatched from App.tsx.
// Kept here rather than in App.tsx so we have direct access to the local
// AbortController ref without threading it through context.
useEffect(() => {
const handler = () => handleStopSearch();
window.addEventListener(ABORT_SEARCH_EVENT, handler);
return () => window.removeEventListener(ABORT_SEARCH_EVENT, handler);
}, [handleStopSearch]);
return {
searchResults,
isLoading: state.isLoading,
statusLabel: state.status,
error: state.error,
resultCount: state.resultCount,
executeSearch,
handleStopSearch,
excludeProduct,
tableText,
executedQuery,
};
}
React v19 enhanced search hook that maintains streaming behavior.
This version preserves the original streaming approach where results appear in the table as they're found, with live counter updates, AND restores session persistence so results are maintained across page reloads.
When pre-search filters are active (set via the drawer), the hook: