Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 28x 104x 64x 104x 21x 21x 13x 21x 6x 6x 104x 43x 35x 35x 43x 104x 28x 1x 1x 1x 22x 22x 22x 22x 1x 1x 1x 134x 134x 134x 134x 134x 134x 134x 134x 134x 134x 134x 134x 134x 134x 134x 134x 134x 1x 1x 1x 18x 18x 18x 18x 1x 1x 15x 15x 15x 15x 15x 15x 15x 15x 1x 1x 1x 1x 1x 1x 18x 18x 18x 18x 18x 3x 3x 18x 18x 18x 3x 3x 1x 1x 1x 1x 1x 3x 3x 15x 18x 3x 3x 2x 2x 2x 2x 3x 13x 18x 1x 18x 1x 1x 18x 13x 13x 1x 1x 14x 14x 14x 14x 14x 14x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 1x 1x 3x 13x 14x 2x 1x 1x 2x 12x 12x 12x 12x 3x 3x 14x 2x 2x 2x 2x 1x 1x 2x 11x 14x 1x 1x 14x 1x 1x 9x 9x 1x 1x 6x 6x 6x 9x 9x 9x 9x 9x 6x 1x 1x 1x 13x 13x 13x 1x 1x 26x 26x 26x 25x 24x 22x 20x 26x 1x 1x 1x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 35x 35x 35x 35x 35x 35x 35x 35x 35x 35x 35x 35x 35x 34x 34x 34x 35x 1x 35x 35x 35x 35x 35x 35x 35x 35x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 1x 7x 7x 7x 7x 34x 35x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 35x 1x 1x 1x 1x 35x 35x 111x 111x 111x 35x 1x 1x 1x 35x 35x 111x 111x 111x 27x 27x 27x 27x 27x 27x 27x 27x 27x 1x 1x 1x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 3x 3x 1x 1x 1x 1x 3x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 52x 52x 52x 52x 52x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 27x 27x 27x 27x 27x 1x 25x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 26x 27x 27x 27x 23x 23x 23x 23x 23x 23x 27x 6x 6x 6x 15x 15x 15x 15x 15x 15x 15x 15x 15x 6x 6x 6x 6x 6x 6x 14x 6x 6x 6x 6x 8x 8x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 27x 17x 17x 17x 11x 11x 11x 11x 11x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 15x 21x 21x 21x 21x 21x 21x 21x 21x 21x 27x 10x 10x 10x 1x 9x 9x 9x 9x 9x 9x 27x 11x 11x 11x 21x 21x 21x 21x 21x 21x 21x 21x 3x 18x 27x 27x 3x 3x 3x 3x 27x 18x 18x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 27x 5x 5x 5x 5x 5x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 5x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 27x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 27x 111x 111x 111x 27x 1x 1x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 111x 111x 111x 111x 111x 4x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 4x 4x 4x 4x 4x 1x 1x 3x 3x 3x 3x 111x 111x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 111x 111x 111x 111x 111x 35x 35x 35x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x 111x | import { getColumnFilterConfig } from '@/components/SearchPanel/TableColumns';
import {
AVAILABILITY_LABEL_MAP,
CACHE,
SEARCH_ABORT_REASON,
isShippingRange,
} from '@/constants/common';
import { supplierShippingMeta } from '@/constants/supplierMeta';
import { SUPPLIER_CLASS_NAMES, isSupplierClassName } from '@/constants/suppliers';
import { useAppContext } from '@/context';
import { SearchEvent, emitSearchEvent, type SearchOutcomeDetail } from '@/events/searchEvents';
import { getErrorMessage } from '@/helpers/exceptions';
import { addExcludedProduct } from '@/helpers/excludedProducts';
import { i18n } from '@/helpers/i18n';
import { flushPendingPriceHistory, recordProductPrices } from '@/helpers/priceHistory';
import { dedupeProducts, getProductDedupeKey } from '@/helpers/productIdentity';
import { suggestAdvancedQuery, suggestAlternativeSearch } from '@/helpers/pubchem';
import {
resolveSupplierSelection,
shippingCovers,
suppliersExcludedBySearchFilters,
} from '@/helpers/supplierFilters';
import { HotkeyEvent } from '@/hotkeys';
import type { SupplierFactory } from '@/suppliers/SupplierFactory';
import {
IDB_SEARCH_RESULTS_CLEARED,
addSearchHistoryEntry,
clearSearchResults,
getSearchHistory,
getSearchResultsRecord,
setSearchResults as idbSetSearchResults,
updateSearchHistoryResultCount as idbUpdateHistoryResultCount,
} from '@/utils/idbCache';
import { Logger } from '@/utils/Logger';
import { generateTraceId } from '@/utils/traceId';
import { recordSearch } from '@/utils/reviewStats';
import { extractAllPositiveTerms } from '@/utils/search-query/extractPositiveTerms';
import { hasAdvancedSyntax, parseSearchQuery } from '@/utils/search-query/parseSearchQuery';
import { cstorage } from '@/utils/storage';
import { flushPendingStats } from '@/utils/SupplierStatsStore';
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
const logger = new Logger('useSearch');
interface SearchState {
isLoading: boolean;
// True between the user requesting an abort and the in-flight requests
// finishing draining. The overlay stays up (showing "Aborting...") until
// the search settles and isLoading flips to false.
isAborting: boolean;
status: string | boolean;
error?: string;
resultCount: number;
}
type ColumnFilterConfig = Record<string, { filterVariant: string; filterData: unknown[] }>;
/**
* Mutates the column filter config based on a single product result.
* For "range" variants, tracks min/max; for "select"/"text" variants, tracks
* unique values seen. Intentionally mutates the config in-place so callers can
* build it up incrementally as results stream in.
*
* NOTE: preserves existing (possibly buggy) range logic — see original code.
*/
export function updateColumnFilterFromResult(config: ColumnFilterConfig, result: Product): void {
for (const [columnName, columnValue] of Object.entries(result)) {
if (columnName in config === false) continue;
const column = config[columnName];
if (column.filterVariant === 'range') {
if (typeof columnValue !== 'number') continue;
if (typeof column.filterData[0] !== 'number' || columnValue < column.filterData[0]) {
column.filterData[0] = columnValue;
} else if (typeof column.filterData[1] !== 'number' || columnValue < column.filterData[1]) {
column.filterData[1] = columnValue;
}
} else if (column.filterVariant === 'select' || column.filterVariant === 'text') {
if (!column.filterData.includes(columnValue)) {
column.filterData.push(columnValue);
}
}
}
}
/**
* Persists search results (and the query that produced them) to IndexedDB,
* logging (but not throwing on) errors.
* @param results - The products to persist.
* @param query - The originating search query, stored alongside the results so the
* header label survives a session-storage loss. Omit when re-persisting without a
* query context.
* @returns A promise that resolves once the write settles (or is swallowed on error).
* @example
* ```ts
* await saveResultsToSession(finalResults, "acetone");
* ```
* @source
*/
export async function saveResultsToSession(results: Product[], query?: string): Promise<void> {
try {
await idbSetSearchResults(results, query);
} catch (error) {
logger.warn(`Failed to save search results to IndexedDB: ${getErrorMessage(error)}`, { error });
}
}
/**
* Creates an initial search history entry in cstorage.local with a placeholder
* result count of 0. The count will be updated later via `updateHistoryResultCount`
* as results stream in. Keeps the most recent 100 entries.
*/
export async function createInitialHistoryEntry(
query: string,
timestamp: number,
filters: SearchFilters,
selectedSuppliers: SupplierClassName[],
excludeSelectedSuppliers = false,
): Promise<void> {
try {
await addSearchHistoryEntry({
query,
timestamp,
resultCount: 0,
type: 'search',
filters: { ...filters },
selectedSuppliers: [...selectedSuppliers],
excludeSelectedSuppliers,
});
} catch (error) {
logger.warn(`Failed to save search history: ${getErrorMessage(error)}`, { error });
}
}
/**
* Updates the result count on an existing history entry identified by its timestamp.
* Called as results stream in (live count) and again when the search completes (final count).
*/
export async function updateHistoryResultCount(timestamp: number, count: number): Promise<void> {
try {
await idbUpdateHistoryResultCount(timestamp, count);
} catch (error) {
logger.warn(`Failed to update search history result count: ${getErrorMessage(error)}`, {
error,
});
}
}
/**
* Collects the set of past queries that returned zero results, lowercased. Used to avoid
* suggesting an alternative name the user has already tried unsuccessfully (which would
* otherwise create A→B→A suggestion loops between synonyms of the same compound).
*/
async function getZeroResultQueries(): Promise<Set<string>> {
const failed = new Set<string>();
try {
const history = await getSearchHistory();
for (const entry of history) {
if (entry.type === 'search' && entry.resultCount === 0 && entry.query) {
failed.add(entry.query.toLowerCase());
}
}
} catch (error) {
logger.warn(`Failed to load search history for suggestions: ${getErrorMessage(error)}`, {
error,
});
}
return failed;
}
/**
* Advanced queries this session recommended after a basic search found nothing (lowercased).
* A recommended query that also finds nothing gets no further suggestion, which prevents a
* basic → advanced → basic suggestion loop.
*/
const suggestedAdvancedQueries = new Set<string>();
/**
* Builds the "no results found" message for the results table, optionally suggesting
* broader filters or a simpler PubChem alternative name (falling back to a CAS number).
* Only simple common names are suggested, and never one that previously returned no results.
*
* With `suggestAdvanced` on, a failed basic search instead gets an advanced (OR) query of the
* compound's most common names and CAS. A failed advanced search gets a simpler-phrase hint,
* unless that advanced query was itself our recommendation, in which case nothing is suggested.
* @param query - The search query that returned no results
* @param filtersActive - Whether pre-search filters are active
* @param suggestAdvanced - Whether the "suggest advanced search" setting is enabled
* @returns The newline-joined message to show in the results table
* @example
* ```typescript
* await buildNoResultsMessage('acetone', false, true);
* // 'No results found for "acetone"\nTry this advanced search: 2-propanone OR propanone OR 67-64-1'
* ```
* @source
*/
export async function buildNoResultsMessage(
query: string,
filtersActive: boolean,
suggestAdvanced = false,
): Promise<string> {
const lines = [i18n('search_no_results_for', [query])];
if (filtersActive) {
lines.push(i18n('search_try_broaden'));
}
const isAdvancedQuery = hasAdvancedSyntax(query);
try {
if (isAdvancedQuery) {
// PubChem can't resolve an AST query, so only a simpler phrase can be offered.
if (suggestAdvanced && !suggestedAdvancedQueries.has(query.trim().toLowerCase())) {
const [simpler] = extractAllPositiveTerms(parseSearchQuery(query).ast);
if (simpler) {
lines.push(i18n('search_suggest_simpler', [simpler]));
}
}
return lines.join('\n');
}
const excluded = await getZeroResultQueries();
if (suggestAdvanced) {
const advanced = await suggestAdvancedQuery(query, excluded);
if (advanced) {
suggestedAdvancedQueries.add(advanced.toLowerCase());
lines.push(i18n('search_suggest_advanced', [advanced]));
return lines.join('\n');
}
}
const { name, cas } = await suggestAlternativeSearch(query, excluded);
if (name) {
lines.push(i18n('search_suggest_name', [name]));
} else if (cas) {
lines.push(i18n('search_suggest_cas', [cas]));
}
} catch (error) {
logger.warn(`Failed to build alternative search suggestion: ${getErrorMessage(error)}`, {
error,
});
}
return lines.join('\n');
}
/**
* Checks whether a product passes the pre-search filters set via the drawer.
* Returns true if the product should be included in the results.
*/
function passesSearchFilters(
product: Product,
filters: SearchFilters,
userSettings: UserSettings,
): boolean {
// Availability filter
if (filters.availability.length > 0) {
const allowedStatuses = filters.availability.flatMap(
(label) => AVAILABILITY_LABEL_MAP[label] ?? [],
);
// Read the product's own availability (normalized to the canonical enum by
// ProductBuilder.setAvailability), not a nested variant's — variants never
// carry availability, so the old `variants[0]` read was always empty/raw and
// dropped every product. Mirrors the availability column's `row.original`.
const productAvailability = (
product.availability ??
product.status ??
product.statusTxt ??
''
).toLowerCase();
if (productAvailability && !allowedStatuses.includes(productAvailability)) {
return false;
}
}
// Country filter
if (filters.country.length > 0 && product.supplierCountry) {
if (!filters.country.includes(product.supplierCountry)) {
return false;
}
}
// Shipping type filter — hierarchical: a broader supplier scope fulfills a
// narrower requested one (a worldwide supplier satisfies a "domestic" filter).
if (
filters.shippingType.length > 0 &&
product.supplierShipping !== undefined &&
isShippingRange(product.supplierShipping)
) {
const scope = product.supplierShipping;
const covered = filters.shippingType.some(
(requested) => isShippingRange(requested) && shippingCovers(scope, requested),
);
if (!covered) {
return false;
}
}
// Price range filter
if (userSettings.priceMin != null && product.price < userSettings.priceMin) {
return false;
}
if (userSettings.priceMax != null && product.price > userSettings.priceMax) {
return false;
}
return true;
}
/**
* Applies the per-supplier result limit after filtering.
* Groups products by supplier and takes the first N from each.
*/
function applyPerSupplierLimit(products: Product[], limit: number): Product[] {
const supplierCounts: Partial<Record<SupplierClassName, number>> = {};
return products.filter((product) => {
if (!isSupplierClassName(product.supplier)) return false;
const supplier = product.supplier as SupplierClassName;
supplierCounts[supplier] = (supplierCounts[supplier] ?? 0) + 1;
return supplierCounts[supplier] <= limit;
});
}
/**
* Names why a search's `AbortController` was aborted, from its `signal.reason`.
* Both deliberate abort sites pass a {@link SEARCH_ABORT_REASON} value, so
* anything else (a stray abort, a browser-supplied `DOMException`) reports as
* `"unknown"` rather than leaking a free-form message into analytics.
* @param reason - The aborted signal's `reason`.
* @returns One of the {@link SEARCH_ABORT_REASON} values, or `"unknown"`.
* @example
* ```ts
* classifyAbortReason("user_aborted"); // => "user_aborted"
* classifyAbortReason(new DOMException("stop", "AbortError")); // => "unknown"
* ```
* @source
*/
export function classifyAbortReason(reason: unknown): string {
const known: readonly string[] = Object.values(SEARCH_ABORT_REASON);
return typeof reason === 'string' && known.includes(reason) ? reason : 'unknown';
}
/**
* Determines whether any pre-search filters are active.
*/
function hasActiveFilters(filters: SearchFilters, userSettings: UserSettings): boolean {
return (
filters.availability.length > 0 ||
filters.country.length > 0 ||
filters.shippingType.length > 0 ||
userSettings.priceMin != null ||
userSettings.priceMax != null
);
}
/**
* 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:
* 1. Fetches results with a higher limit to account for filtering
* 2. Applies drawer filters (availability, country, shipping, price)
* 3. Applies the per-supplier limit on the filtered set
* @source
*/
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);
// Normalized query currently executing. Prevents a single submission from
// running twice when both trigger paths fire (the useSearch mount effect and
// App.tsx's session-storage listener → executeSearch) — and StrictMode double
// invokes. Cleared when the search settles so a later re-search of the same
// term still runs.
const inFlightQueryRef = useRef<string | null>(null);
const initialState: SearchState = {
isLoading: false,
isAborting: 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 [cachedRecord, sessionData] = await Promise.all([
getSearchResultsRecord(),
cstorage.session.get([CACHE.QUERY, CACHE.SEARCH_IS_NEW_SEARCH]),
]);
const cachedResults = cachedRecord.data;
const hasPendingSearch = Boolean(
sessionData[CACHE.SEARCH_IS_NEW_SEARCH] &&
sessionData[CACHE.QUERY] &&
sessionData[CACHE.QUERY].trim(),
);
// In the full-tab view, App.tsx's storage.onChanged bridge consumes (and
// removes) the SEARCH_IS_NEW_SEARCH flag and sets pendingSearchQuery *before*
// this freshly-mounted effect runs. So the inbox flag alone can already be gone
// while a search is still imminent — a live pendingSearchQuery also means "search
// pending". Gate the cached-results rehydrate on both, otherwise a home-box search
// that remounts this panel repaints the previous search's rows over the new one.
const searchPending = hasPendingSearch || Boolean(appContext.pendingSearchQuery?.trim());
if (!searchPending && cachedResults.length > 0) {
logger.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. Prefer the query persisted alongside the results (always
// in sync with them); fall back to the session copy for legacy rows
// written before the query was stored in IndexedDB.
const restoredQuery =
cachedRecord.query ??
(typeof sessionData[CACHE.QUERY] === 'string' ? sessionData[CACHE.QUERY] : '');
if (restoredQuery) {
setExecutedQuery(restoredQuery);
}
}
// Only execute search if this is a new search submission
if (hasPendingSearch) {
isSearchInitiatedRef.current = true;
logger.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) {
logger.warn(
`Failed to clear ${CACHE.SEARCH_IS_NEW_SEARCH} flag: ${getErrorMessage(error)}`,
{ error },
);
}
logger.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) {
logger.warn(`Failed to load search data from session storage: ${getErrorMessage(error)}`, {
error,
});
}
};
loadSearchData();
}, []);
// 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.suppliers?.resultLimit ?? 15,
suppliers = appContext.selectedSuppliers ?? [],
}: {
query: string;
supplierResultLimit?: number;
suppliers?: string[];
}) => {
// Drop a duplicate trigger for the same query already in flight. Set
// synchronously (before any await) so two near-simultaneous callers can't
// both pass the check. Cleared in the finally below once the search settles.
const normalizedQuery = query.trim();
if (inFlightQueryRef.current === normalizedQuery) {
logger.debug('performSearch: duplicate in-flight query ignored', { query });
return;
}
inFlightQueryRef.current = normalizedQuery;
// Tag every remote log made while this search runs with its query, and with a trace id unique to
// this run, so logs can be grouped by query and told apart between repeated runs of it.
const traceId = generateTraceId();
Logger.setContext({ search_query: normalizedQuery, trace_id: traceId });
const { searchFilters } = appContext;
const filtersActive = hasActiveFilters(searchFilters, appContext.userSettings);
logger.debug('Starting search', {
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,
isAborting: false,
status: i18n('results_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();
// `suppliers` is the user's selection, which is empty for "no filter" (every supplier) and an
// exclusion list when inverted, so resolve it to the number actually being searched.
const invertSelection = appContext.userSettings.search?.invertSuppliersSelection ?? false;
const selection = resolveSupplierSelection(suppliers, invertSelection, SUPPLIER_CLASS_NAMES);
const supplierCount = selection.none
? 0
: selection.suppliers.length || SUPPLIER_CLASS_NAMES.length;
logger.log(`Searching ${supplierCount} suppliers`, {
query,
selectedSuppliers: suppliers,
invertSelection,
});
void createInitialHistoryEntry(
query,
historyTimestamp,
searchFilters,
appContext.selectedSuppliers ?? [],
appContext.userSettings.search?.invertSuppliersSelection ?? false,
);
// Signal search start — the badge controller owns the loading animation.
emitSearchEvent(SearchEvent.STARTED, { query });
// Taken alongside STARTED (rather than at the stream), so the reported
// duration matches the window the loading overlay is actually up. Declared
// out here so the catch branches below can report it too.
const startSearchTime = performance.now();
const columnFilterConfig = getColumnFilterConfig();
const userLimit = appContext.userSettings.suppliers?.resultLimit ?? 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;
logger.debug('Calculated per-supplier fetch limit', {
fetchLimit,
userLimit,
filtersActive,
supplierResultLimit: appContext.userSettings.suppliers?.resultLimit,
});
// Create new abort controller for this search. Held in a local as well, so
// the terminal branches below read this search's signal rather than
// whatever the ref points at by the time they run.
fetchControllerRef.current = new AbortController();
const controller = fetchControllerRef.current;
// Don't even query suppliers the active shipping/country filters rule out —
// the post-filter would drop all their products anyway. Fall back to the
// full selection if the filters would exclude every candidate (the
// post-filter then yields zero results rather than querying everyone).
// The drawer selection may be an exclusion list ("everything but these"); resolve it
// to the suppliers to search first. `noSuppliersLeft` is an inverted selection that
// covers every supplier, which must search nothing rather than fall back to "all".
const { suppliers: selectedToQuery, none: noSuppliersLeft } = resolveSupplierSelection(
appContext.selectedSuppliers ?? [],
appContext.userSettings.search?.invertSuppliersSelection ?? false,
SUPPLIER_CLASS_NAMES,
);
let suppliersToQuery = selectedToQuery;
if (searchFilters.shippingType.length > 0 || searchFilters.country.length > 0) {
const excluded = suppliersExcludedBySearchFilters(supplierShippingMeta(), searchFilters);
if (excluded.size > 0) {
const base = selectedToQuery.length > 0 ? selectedToQuery : SUPPLIER_CLASS_NAMES;
const compatible = base.filter((name) => !excluded.has(name));
if (compatible.length > 0) suppliersToQuery = compatible;
}
}
// Authoritative result count, tracked from the data we actually produce.
// `resultsTable.getRowCount()` only reflects the last committed render,
// which lags the `setSearchResults` calls below (the streaming branch
// defers them with startTransition), so reading it right after the
// stream drains can spuriously report 0 — see the no-results check below.
// Hoisted out of the try so the catch branches can report a partial count.
let totalResults = 0;
// Hoisted for the same reason — the catch branches read its supplier counters.
let productQueryFactory: SupplierFactory<Product> | undefined;
// Suppliers that contributed at least one product, for the end-of-search summary log.
const suppliersWithResults = new Set<string>();
/**
* Snapshots what this search produced, for whichever terminal event fires.
* @returns The outcome detail shared by COMPLETED, ABORTED, and FAILED.
*/
const searchOutcome = (): SearchOutcomeDetail => ({
count: totalResults,
durationMs: Math.round(performance.now() - startSearchTime),
suppliersQueried: productQueryFactory?.suppliersQueried ?? 0,
suppliersCompleted: productQueryFactory?.suppliersCompleted ?? 0,
});
/**
* Per-supplier tally for the end-of-search log: how many suppliers had results, none, or
* failed (and which failed). `suppliersNoResults` is derived, so it can be off by a supplier
* whose products were all removed by filters or de-duplication.
* @returns The search outcome plus the supplier breakdown.
*/
const searchSummary = () => {
const outcome = searchOutcome();
const failedSuppliers = (productQueryFactory?.executionErrors ?? []).map(
({ supplier }) => supplier.supplierName,
);
return {
resultCount: outcome.count,
durationMs: outcome.durationMs,
suppliersQueried: outcome.suppliersQueried,
suppliersCompleted: outcome.suppliersCompleted,
suppliersWithResults: suppliersWithResults.size,
suppliersNoResults: Math.max(
0,
outcome.suppliersCompleted - suppliersWithResults.size - failedSuppliers.length,
),
suppliersFailed: failedSuppliers.length,
failedSuppliers,
};
};
try {
// Create the search factory object, which sets the query, supplier search limits,
// and the abort controller for the search.
// Loaded here rather than imported at the top: this is the only place the
// popup needs a supplier *implementation*, and a static import would put all
// ~36 supplier classes (plus SupplierBase and its graphql/zod/fuzzball deps)
// in the startup bundle for a hook that mostly renders cached results.
const { SupplierFactory: SupplierFactoryClass } = await import(
'@/suppliers/SupplierFactory'
);
productQueryFactory = new SupplierFactoryClass(query, {
limit: fetchLimit,
controller,
suppliers: suppliersToQuery,
caching: appContext.userSettings.caching?.enabled,
// The scorer selector lives behind advanced mode, so only honor the
// override there; otherwise a stale value can't strand a normal user
// on a non-default scorer — they fall through to the WRatio default.
fuzzScorerOverride: appContext.advancedMode
? appContext.userSettings.fuzzScorerOverride
: undefined,
doNotCacheEmptyResults: appContext.userSettings.caching?.doNotCacheEmptyResults,
cacheTtlMinutes: appContext.userSettings.caching?.ttlMinutes,
noCacheStatusCodes: appContext.userSettings.noCacheStatusCodes,
supplierSearchTimeBudgetSec: appContext.userSettings.supplierSearchTimeBudgetSec,
fuzzyFilteringDisabled: appContext.userSettings.fuzzyFilteringDisabled,
location: appContext.userSettings.location,
excludeNonShippingSuppliers:
appContext.userSettings.suppliers?.excludeNonShipping ?? true,
hideRestrictedProducts: appContext.userSettings.search?.hideRestrictedProducts ?? true,
disabledSuppliers: noSuppliersLeft
? [...SUPPLIER_CLASS_NAMES]
: appContext.userSettings.suppliers?.disabled,
});
// Execute the search for all suppliers.
const productQueryResults = await productQueryFactory.executeAllStream(3);
// Tracks supplier-scoped identities already emitted this search so the
// streaming branch can skip a product that arrives more than once,
// keeping duplicates out of both the live table and storage.
const seenKeys = new Set<string>();
// 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: i18n('search_status_fetching', [String(allResults.length)]),
}));
});
}
// Drop products that streamed in more than once (same supplier-scoped
// identity) before filtering, so duplicates never reach state/storage
// and the per-supplier limit counts unique products.
const uniqueResults = dedupeProducts(allResults);
// Apply pre-search filters
const filtered = uniqueResults.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.
// Stamp the positional row index onto `_id`, leaving the real product
// `id` intact so persisted results keep their true identity.
const finalResults = limited.map((r, idx) => ({ ...r, _id: idx }));
setSearchResults(finalResults);
totalResults = finalResults.length;
for (const result of finalResults) suppliersWithResults.add(result.supplier);
// Record USD price history for the final result set (fire-and-forget;
// dedup means unchanged prices add nothing).
void recordProductPrices(finalResults, appContext.userSettings);
// Save to Chrome storage and update history with final count
await saveResultsToSession(finalResults, query);
await updateHistoryResultCount(historyTimestamp, finalResults.length);
logger.debug('Fetched and filtered search 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) {
// Skip a product whose identity already streamed in this search, so
// the same product is never appended (and persisted) twice.
const dedupeKey = getProductDedupeKey(result);
if (dedupeKey !== undefined) {
if (seenKeys.has(dedupeKey)) continue;
seenKeys.add(dedupeKey);
}
totalResults += 1;
// Update state with current count using startTransition for better performance
startTransition(() => {
setState((prev) => ({
...prev,
resultCount: totalResults,
status: i18n(
totalResults === 1 ? 'search_status_found_one' : 'search_status_found_other',
[String(totalResults)],
),
}));
});
// Build column filter config for this result
updateColumnFilterFromResult(columnFilterConfig, result);
// Add result immediately to the table - streaming behavior restored!
// `_id` carries the positional index; the real product `id` is left untouched.
const productWithId = {
...result,
_id: totalResults - 1,
};
suppliersWithResults.add(result.supplier);
logger.trace('Received product from stream', {
supplier: result.supplier,
title: result.title,
});
// Record USD price history for this streamed product (fire-and-forget;
// done outside the state updater so it runs once per product, not per
// whole-array remap).
void recordProductPrices([productWithId], appContext.userSettings);
// Update results immediately using startTransition for better performance
startTransition(() => {
setSearchResults((prevSearchResults) => {
const newResults = [...prevSearchResults, productWithId];
// Re-index positions on `_id` only; preserve each product's real `id`.
const indexedResults = newResults.map((r, idx) => ({ ...r, _id: idx }));
// Persist and update history live (fire and forget)
void (async () => {
await saveResultsToSession(indexedResults, query);
await updateHistoryResultCount(historyTimestamp, newResults.length);
})();
return newResults;
});
});
}
}
const outcome = searchOutcome();
logger.debug(`Found ${totalResults} products in ${outcome.durationMs} milliseconds`, {
query,
fetchLimit,
productQueryResults,
startSearchTime,
outcome,
});
// If no results were found, then try to suggest alternative search terms using cactus.nci.nih.gov API.
if (totalResults === 0) {
// When the shipping filter alone emptied the supplier set, explain that
// directly instead of the generic "no products" suggestion flow.
const message = productQueryFactory.shippingExcludedAll
? i18n('search_no_shipping_suppliers')
: await buildNoResultsMessage(
query,
filtersActive,
appContext.userSettings.search?.suggestAdvancedQuery ?? false,
);
setTableText(message);
logger.debug('Setting results table message', { tableText: message });
} else {
// Clear any status text from a previous search.
setTableText('');
}
// Signal completion; the badge controller reconciles the final count.
// A stopped search still lands here — SupplierFactory swallows each
// supplier's AbortError, so the stream drains rather than throwing — so
// tag the reason when the signal fired, letting consumers tell a full
// search from one that was cut short.
emitSearchEvent(SearchEvent.COMPLETED, {
...outcome,
...(controller.signal.aborted
? { abortReason: classifyAbortReason(controller.signal.reason) }
: {}),
});
if (controller.signal.aborted) {
logger.log('Search cut short', {
...searchSummary(),
abortReason: classifyAbortReason(controller.signal.reason),
});
} else {
logger.log('Search completed', { ...searchSummary() });
}
// Tally this search toward the review prompt. Past the in-flight guard, so
// retriggers of the same query don't double-count. Fire-and-forget.
void recordSearch(totalResults);
// 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,
isAborting: false,
status: false, // Hide status when complete
error: undefined,
resultCount: totalResults,
});
} catch (error) {
// Signal the terminal outcome; the badge controller clears the badge.
// Either branch lands here once the stream has fully drained — including
// after an abort, when in-flight requests have finished settling — so
// this is where isLoading/isAborting reset and the overlay closes.
if (error instanceof Error && error.name === 'AbortError') {
emitSearchEvent(SearchEvent.ABORTED, {
...searchOutcome(),
reason: classifyAbortReason(controller.signal.reason),
});
logger.log('Search aborted', {
...searchSummary(),
reason: classifyAbortReason(controller.signal.reason),
});
setState((prev) => ({
...prev,
isLoading: false,
isAborting: false,
status: i18n('search_status_aborted'),
error: undefined,
}));
setTableText(i18n('search_status_aborted'));
} else {
emitSearchEvent(SearchEvent.FAILED, {
...searchOutcome(),
error: error instanceof Error ? error.message : i18n('search_error_failed'),
});
logger.error(`Search failed: ${getErrorMessage(error)}`, { error, ...searchSummary() });
setState((prev) => ({
...prev,
isLoading: false,
isAborting: false,
status: false,
error: error instanceof Error ? error.message : i18n('search_error_failed'),
}));
}
} finally {
// Release the guard so a later deliberate re-search of the same term runs.
inFlightQueryRef.current = null;
// Stop tagging logs with this search, unless a newer search has already replaced it.
if (Logger.getContext()?.trace_id === traceId) {
Logger.setContext(undefined);
}
// Recording runs in every mode, but the popup can be closed the instant
// results are shown — flush now instead of waiting on the debounce timer.
void flushPendingStats();
// Same exposure for price history: recordProductPrices calls above are
// fire-and-forget and may still be mid-write when the popup closes.
void flushPendingPriceHistory();
}
},
[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() });
}
logger.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 {
// Key the exclusion by the product's stable identity (same key the
// product-detail cache uses); fall back to the URL when unstamped.
await addExcludedProduct(product.cacheKey ?? product.url, product.supplier, {
title: product.title,
url: product.url,
});
} catch (error) {
logger.warn(`Failed to persist excluded product: ${getErrorMessage(error)}`, { error });
}
// Persist the updated results so a reload doesn't resurrect the row. Keep the
// current query so removing a row doesn't drop the header label.
await saveResultsToSession(nextResults, executedQuery);
},
[executedQuery],
);
const handleStopSearch = useCallback(() => {
logger.debug('Aborting the current search');
// Signal the abort but keep the overlay up: requests already in flight will
// keep streaming back until the supplier streams settle. Flip into the
// "Aborting..." state now; performSearch resets isLoading/isAborting once the
// stream finishes draining, which is what actually closes the overlay.
fetchControllerRef.current.abort(SEARCH_ABORT_REASON.USER);
startTransition(() => {
setState((prev) => ({
...prev,
isAborting: true,
status: i18n('loading_aborting'),
}));
});
}, []);
// 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(HotkeyEvent.ABORT_SEARCH, handler);
return () => window.removeEventListener(HotkeyEvent.ABORT_SEARCH, handler);
}, [handleStopSearch]);
return {
searchResults,
isLoading: state.isLoading,
isAborting: state.isAborting,
statusLabel: state.status,
error: state.error,
resultCount: state.resultCount,
executeSearch,
handleStopSearch,
excludeProduct,
tableText,
executedQuery,
};
}
|