History

Stale search results stop impersonating live ones

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.

Rhys Sullivan committed 2 months ago

Commit 8ebd2a61 parent cc033852 files · +49 −9Browse files

Unified · Split

2 files
  1. packages/react/src/lib/integrations-sh-catalog.ts +31 −5
  2. packages/react/src/pages/integration-browse.tsx +18 −4
packages/react/src/lib/integrations-sh-catalog.ts+31−5
@@ -351,6 +351,9 @@ const BROWSE_LIMIT = 60; · 350 unmodified lines
351351 export interface CatalogSearchState {
352352 readonly entries: readonly CatalogSearchEntry[];
353353 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;
354357 /** The registry could not be reached. The rest of the page still works, so
355358 * this is a section-level notice rather than a page error. */
356359 readonly failed: boolean;
@@ -388,14 +391,20 @@ export function useCatalogBrowse(input: CatalogQuery): CatalogSearchState { · 31 unmodified lines
388391 const query = input.query.trim().toLowerCase();
389392 const kind = input.kind;
390393 const limit = input.limit ?? BROWSE_LIMIT;
394+ const requestKeyOf = (forQuery: string) =>
395+ cacheKey({ query: forQuery, limit, ...(kind ? { kind } : {}) });
396+ const requestKey = requestKeyOf(query);
391397 const [state, setState] = useState<{
392398 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;
393404 readonly loading: boolean;
394405 readonly failed: boolean;
395− }>({ entries: [], loading: true, failed: false });
406+ }>({ entries: [], forKey: requestKey, loading: true, failed: false });
396407 const generation = useRef(0);
397−
398− const requestKey = cacheKey({ query, limit, ...(kind ? { kind } : {}) });
399408 // Pages beyond the first, keyed to the request they extend so a keystroke
400409 // discards them with the first page rather than leaking into new results.
401410 const [more, setMore] = useState<{
@@ -408,18 +417,30 @@ export function useCatalogBrowse(input: CatalogQuery): CatalogSearchState { · 6 unmodified lines
408417 readonly exhausted: boolean;
409418 }>({ key: requestKey, entries: [], rawConsumed: 0, loadingMore: false, exhausted: false });
410419
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+
411426 useEffect(() => {
412427 const requestId = ++generation.current;
413428 // A partial word is not yet a query; leave the previous results in place
414429 // rather than flashing an empty list between keystrokes.
415430 if (query.length > 0 && query.length < MIN_QUERY_LENGTH) {
431+ // Held entries keep their own forKey, so the hook reports them stale.
416432 setState((previous) => ({ ...previous, loading: false }));
417433 return;
418434 }
419435 const request: CatalogQuery = { query, limit, ...(kind ? { kind } : {}) };
420436 const cached = searchCache.get(cacheKey(request));
421437 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+ });
423444 return;
424445 }
425446 setState((previous) => ({ ...previous, loading: true }));
@@ -432,6 +453,7 @@ export function useCatalogBrowse(input: CatalogQuery): CatalogSearchState { · 6 unmodified lines
432453 if (generation.current !== requestId) return;
433454 setState({
434455 entries: Exit.isSuccess(exit) ? exit.value.entries : [],
456+ forKey: cacheKey(request),
435457 loading: false,
436458 failed: Exit.isFailure(exit),
437459 });
@@ -514,7 +536,11 @@ export function useCatalogBrowse(input: CatalogQuery): CatalogSearchState { · 76 unmodified lines
514536 entries,
515537 loading: state.loading,
516538 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,
518544 loadingMore: extras?.loadingMore ?? false,
519545 loadMore,
520546 };
packages/react/src/pages/integration-browse.tsx+18−4
@@ -394,10 +394,24 @@ export function IntegrationBrowsePage() { · 393 unmodified lines
394394 const excludeDomains = useMemo(() => new Set<string>(), []);
395395
396396 const catalog = useCatalogBrowse({ query: listQuery });
397− const catalogEntries = useMemo(
398− () => filterCatalogEntries(catalog.entries, { excludeDomains, availableKinds }),
399− [catalog.entries, excludeDomains, availableKinds],
400− );
397+ const catalogEntries = useMemo(() => {
398+ const usable = filterCatalogEntries(catalog.entries, { excludeDomains, availableKinds });
399+ if (!catalog.stale) return usable;
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]);
401415
402416 /** What is already connected, as `<slug>:<kind>`.
403417 *