The hook held the previous query's entries while the next loaded — by design, to avoid flashing — but nothing recorded which query they belonged to, so after the loading dim was removed a calendar search showed Gmail cards at full opacity. Entries now carry their request key and the hook exposes stale; the page keeps held rows only while every token of the live text matches their name or domain, so refinements keep their rows and new words drop the old ones instantly. Also: extra pages reset when the request changes, which unwedges the sentinel after a mid-flight query switch (the in-flight guard held it until a scroll), and stops minutes-old extras reviving verbatim under a fresh first page.
Unified · Split
| @@ -351,6 +351,9 @@ const BROWSE_LIMIT = 60; · 350 unmodified lines | |||
| 351 | export interface CatalogSearchState { | 351 | export interface CatalogSearchState { |
| 352 | readonly entries: readonly CatalogSearchEntry[]; | 352 | readonly entries: readonly CatalogSearchEntry[]; |
| 353 | readonly loading: boolean; | 353 | readonly loading: boolean; |
| 354 | /** The entries belong to a previous query, held over while the current one | ||
| 355 | * loads. Callers must not render them as if they answered the live query. */ | ||
| 356 | readonly stale: boolean; | ||
| 354 | /** The registry could not be reached. The rest of the page still works, so | 357 | /** The registry could not be reached. The rest of the page still works, so |
| 355 | * this is a section-level notice rather than a page error. */ | 358 | * this is a section-level notice rather than a page error. */ |
| 356 | readonly failed: boolean; | 359 | readonly failed: boolean; |
| @@ -388,14 +391,20 @@ export function useCatalogBrowse(input: CatalogQuery): CatalogSearchState { · 31 unmodified lines | |||
| 388 | const query = input.query.trim().toLowerCase(); | 391 | const query = input.query.trim().toLowerCase(); |
| 389 | const kind = input.kind; | 392 | const kind = input.kind; |
| 390 | const limit = input.limit ?? BROWSE_LIMIT; | 393 | const limit = input.limit ?? BROWSE_LIMIT; |
| 394 | const requestKeyOf = (forQuery: string) => | ||
| 395 | cacheKey({ query: forQuery, limit, ...(kind ? { kind } : {}) }); | ||
| 396 | const requestKey = requestKeyOf(query); | ||
| 391 | const [state, setState] = useState<{ | 397 | const [state, setState] = useState<{ |
| 392 | readonly entries: readonly CatalogSearchEntry[]; | 398 | readonly entries: readonly CatalogSearchEntry[]; |
| 399 | /** The request the entries belong to. Held-over results from a previous | ||
| 400 | * query are DELIBERATE (no empty flash between keystrokes), but they | ||
| 401 | * must be knowable as stale — rendering them unmarked is how a | ||
| 402 | * "calendar" search showed Gmail cards. */ | ||
| 403 | readonly forKey: string; | ||
| 393 | readonly loading: boolean; | 404 | readonly loading: boolean; |
| 394 | readonly failed: boolean; | 405 | readonly failed: boolean; |
| 395 | }>({ entries: [], loading: true, failed: false }); | 406 | }>({ entries: [], forKey: requestKey, loading: true, failed: false }); |
| 396 | const generation = useRef(0); | 407 | const generation = useRef(0); |
| 397 | |||
| 398 | const requestKey = cacheKey({ query, limit, ...(kind ? { kind } : {}) }); | ||
| 399 | // Pages beyond the first, keyed to the request they extend so a keystroke | 408 | // Pages beyond the first, keyed to the request they extend so a keystroke |
| 400 | // discards them with the first page rather than leaking into new results. | 409 | // discards them with the first page rather than leaking into new results. |
| 401 | const [more, setMore] = useState<{ | 410 | const [more, setMore] = useState<{ |
| @@ -408,18 +417,30 @@ export function useCatalogBrowse(input: CatalogQuery): CatalogSearchState { · 6 unmodified lines | |||
| 408 | readonly exhausted: boolean; | 417 | readonly exhausted: boolean; |
| 409 | }>({ key: requestKey, entries: [], rawConsumed: 0, loadingMore: false, exhausted: false }); | 418 | }>({ key: requestKey, entries: [], rawConsumed: 0, loadingMore: false, exhausted: false }); |
| 410 | 419 | ||
| 420 | useEffect(() => { | ||
| 421 | setMore({ key: requestKey, entries: [], rawConsumed: 0, loadingMore: false, exhausted: false }); | ||
| 422 | // requestKey is derived from (query, kind, limit) — same trigger set. | ||
| 423 | // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| 424 | }, [query, kind, limit]); | ||
| 425 | |||
| 411 | useEffect(() => { | 426 | useEffect(() => { |
| 412 | const requestId = ++generation.current; | 427 | const requestId = ++generation.current; |
| 413 | // A partial word is not yet a query; leave the previous results in place | 428 | // A partial word is not yet a query; leave the previous results in place |
| 414 | // rather than flashing an empty list between keystrokes. | 429 | // rather than flashing an empty list between keystrokes. |
| 415 | if (query.length > 0 && query.length < MIN_QUERY_LENGTH) { | 430 | if (query.length > 0 && query.length < MIN_QUERY_LENGTH) { |
| 431 | // Held entries keep their own forKey, so the hook reports them stale. | ||
| 416 | setState((previous) => ({ ...previous, loading: false })); | 432 | setState((previous) => ({ ...previous, loading: false })); |
| 417 | return; | 433 | return; |
| 418 | } | 434 | } |
| 419 | const request: CatalogQuery = { query, limit, ...(kind ? { kind } : {}) }; | 435 | const request: CatalogQuery = { query, limit, ...(kind ? { kind } : {}) }; |
| 420 | const cached = searchCache.get(cacheKey(request)); | 436 | const cached = searchCache.get(cacheKey(request)); |
| 421 | if (cached && Date.now() - cached.at < SEARCH_TTL_MS) { | 437 | if (cached && Date.now() - cached.at < SEARCH_TTL_MS) { |
| 422 | setState({ entries: cached.entries, loading: false, failed: false }); | 438 | setState({ |
| 439 | entries: cached.entries, | ||
| 440 | forKey: cacheKey(request), | ||
| 441 | loading: false, | ||
| 442 | failed: false, | ||
| 443 | }); | ||
| 423 | return; | 444 | return; |
| 424 | } | 445 | } |
| 425 | setState((previous) => ({ ...previous, loading: true })); | 446 | setState((previous) => ({ ...previous, loading: true })); |
| @@ -432,6 +453,7 @@ export function useCatalogBrowse(input: CatalogQuery): CatalogSearchState { · 6 unmodified lines | |||
| 432 | if (generation.current !== requestId) return; | 453 | if (generation.current !== requestId) return; |
| 433 | setState({ | 454 | setState({ |
| 434 | entries: Exit.isSuccess(exit) ? exit.value.entries : [], | 455 | entries: Exit.isSuccess(exit) ? exit.value.entries : [], |
| 456 | forKey: cacheKey(request), | ||
| 435 | loading: false, | 457 | loading: false, |
| 436 | failed: Exit.isFailure(exit), | 458 | failed: Exit.isFailure(exit), |
| 437 | }); | 459 | }); |
| @@ -514,7 +536,11 @@ export function useCatalogBrowse(input: CatalogQuery): CatalogSearchState { · 76 unmodified lines | |||
| 514 | entries, | 536 | entries, |
| 515 | loading: state.loading, | 537 | loading: state.loading, |
| 516 | failed: state.failed, | 538 | failed: state.failed, |
| 517 | hasMore, | 539 | /** The rendered entries belong to a PREVIOUS query (held over while the |
| 540 | * current one loads, or while the input is one character). Callers | ||
| 541 | * decide the treatment; unmarked stale results read as wrong results. */ | ||
| 542 | stale: state.forKey !== requestKey, | ||
| 543 | hasMore: hasMore && state.forKey === requestKey, | ||
| 518 | loadingMore: extras?.loadingMore ?? false, | 544 | loadingMore: extras?.loadingMore ?? false, |
| 519 | loadMore, | 545 | loadMore, |
| 520 | }; | 546 | }; |
| @@ -394,10 +394,24 @@ export function IntegrationBrowsePage() { · 393 unmodified lines | |||
| 394 | const excludeDomains = useMemo(() => new Set<string>(), []); | 394 | const excludeDomains = useMemo(() => new Set<string>(), []); |
| 395 | 395 | ||
| 396 | const catalog = useCatalogBrowse({ query: listQuery }); | 396 | const catalog = useCatalogBrowse({ query: listQuery }); |
| 397 | const catalogEntries = useMemo( | 397 | const catalogEntries = useMemo(() => { |
| 398 | () => filterCatalogEntries(catalog.entries, { excludeDomains, availableKinds }), | 398 | const usable = filterCatalogEntries(catalog.entries, { excludeDomains, availableKinds }); |
| 399 | [catalog.entries, excludeDomains, availableKinds], | 399 | if (!catalog.stale) return usable; |
| 400 | ); | 400 | // Held-over results from the previous query: keep only what still reads |
| 401 | // as an answer to the live text. Every whitespace token must appear in | ||
| 402 | // the entry's name or domain — refining "google"→"google cal" keeps | ||
| 403 | // cloud.google.com rows (the match lives in the domain), while a new | ||
| 404 | // word drops the old rows instead of parading them under the wrong | ||
| 405 | // query, which is how a calendar search showed Gmail. An empty text | ||
| 406 | // (clearing back to browse) holds nothing. | ||
| 407 | const tokens = listQuery.trim().toLowerCase().split(/\s+/).filter(Boolean); | ||
| 408 | if (tokens.length === 0) return []; | ||
| 409 | return usable.filter((entry) => { | ||
| 410 | const haystack = | ||
| 411 | `${entry.name ?? domainDisplayName(entry.domain)} ${entry.domain}`.toLowerCase(); | ||
| 412 | return tokens.every((token) => haystack.includes(token)); | ||
| 413 | }); | ||
| 414 | }, [catalog.entries, catalog.stale, listQuery, excludeDomains, availableKinds]); | ||
| 401 | 415 | ||
| 402 | /** What is already connected, as `<slug>:<kind>`. | 416 | /** What is already connected, as `<slug>:<kind>`. |
| 403 | * | 417 | * |