@findlane/browser 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -74,6 +74,9 @@ export type SearchResponse<T> = {
74
74
  totalRelation?: "eq" | "gte";
75
75
  hasMore?: boolean;
76
76
  facets: Record<string, Record<string, number>>;
77
+ /** True when the facet counts are estimates: on an index of 100,000 records or more, a search with more
78
+ * than 2,000 matches counts its facets on a sample of them, scaled to the total. */
79
+ facetsEstimated?: boolean;
77
80
  /** With `facetStats`: each field's smallest and largest value across the matches, without the field's
78
81
  * own filter. A field no match has is left out. */
79
82
  facetStats?: Record<string, {
@@ -170,6 +173,45 @@ export type RecommendationsResponse<T> = {
170
173
  /** What `together`'s counts are of. */
171
174
  togetherBasis?: "orders";
172
175
  };
176
+ /** One of an index's lists, which its owner sets up in the dashboard: a row of products, such as Popular or
177
+ * New arrivals, from a filter and a sort, with products pinned first and others hidden; or a facet field's
178
+ * values with how many products each has, for a menu. `updatedAt` is when what the lists hold last changed. */
179
+ export type ProductList<T = Record<string, unknown>> = {
180
+ id: string;
181
+ title: string;
182
+ kind: "products";
183
+ hits: {
184
+ id: string;
185
+ record: T;
186
+ }[];
187
+ /** The list's filters and sort, for a "See all" link to the same products as a search. */
188
+ browse: {
189
+ sort: string;
190
+ filters: Record<string, unknown>;
191
+ };
192
+ updatedAt: string;
193
+ };
194
+ export type ValueList = {
195
+ id: string;
196
+ title: string;
197
+ kind: "values";
198
+ /** The facet field the values are of. */
199
+ field: string;
200
+ values: {
201
+ value: string;
202
+ count: number;
203
+ }[];
204
+ browse: {
205
+ filters: Record<string, unknown>;
206
+ };
207
+ updatedAt: string;
208
+ };
209
+ export type List<T = Record<string, unknown>> = ProductList<T> | ValueList;
210
+ export type ListOptions = {
211
+ /** At most this many products or values from each list (each list's own limit otherwise). */
212
+ limit?: number;
213
+ signal?: AbortSignal;
214
+ };
173
215
  export declare class FindlaneError extends Error {
174
216
  status: number;
175
217
  constructor(status: number, message: string);
@@ -243,6 +285,7 @@ export type BrowserClientOptions = {
243
285
  };
244
286
  export declare class SearchBrowserClient {
245
287
  private readonly url;
288
+ private readonly origin;
246
289
  private readonly fetcher;
247
290
  /** Views, clicks, add-to-carts and orders, for the index's Events page and Bought together. */
248
291
  readonly events: BrowserEvents;
@@ -251,6 +294,10 @@ export declare class SearchBrowserClient {
251
294
  private popular;
252
295
  constructor(options: BrowserClientOptions);
253
296
  private post;
297
+ private get;
298
+ /** Opens the connection to the API before the first search needs it, as a preconnect hint in the page
299
+ * would: call it when the shopper focuses a search box. Once per page; nothing outside a browser. */
300
+ warm(): void;
254
301
  search<T = Record<string, unknown>>(request: SearchRequest, options?: {
255
302
  signal?: AbortSignal;
256
303
  }): Promise<SearchResponse<T>>;
@@ -280,5 +327,12 @@ export declare class SearchBrowserClient {
280
327
  recommendations<T = Record<string, unknown>>(objectID: string, request?: RecommendationsRequest, options?: {
281
328
  signal?: AbortSignal;
282
329
  }): Promise<RecommendationsResponse<T>>;
330
+ /** One of the index's lists, by its ID: a row of products, such as Popular, or a facet field's values for
331
+ * a menu. Built ahead and answered from a cache near the shopper, so it isn't a search and isn't counted.
332
+ * Throws a FindlaneError with status 404 when the index has no such list. */
333
+ list<T = Record<string, unknown>>(id: string, options?: ListOptions): Promise<List<T>>;
334
+ /** Several of the index's lists in one request (up to 10), in the order asked; IDs the index has no list
335
+ * for are left out. Without IDs, all of its lists, in the owner's order. Not counted as searches. */
336
+ lists<T = Record<string, unknown>>(ids?: string[], options?: ListOptions): Promise<List<T>[]>;
283
337
  }
284
338
  export {};
package/dist/index.js CHANGED
@@ -156,8 +156,11 @@ function eventSender(url, fetcher, mode, ids) {
156
156
  flush,
157
157
  };
158
158
  }
159
+ /** Origins this page has opened a connection to ahead of a search (warm). */
160
+ const warmed = new Set();
159
161
  export class SearchBrowserClient {
160
162
  url;
163
+ origin;
161
164
  fetcher;
162
165
  /** Views, clicks, add-to-carts and orders, for the index's Events page and Bought together. */
163
166
  events;
@@ -168,6 +171,7 @@ export class SearchBrowserClient {
168
171
  if (!/^pub_[a-f0-9]{32}$/.test(options.publicId))
169
172
  throw new Error("A valid public index ID (pub_…) is required");
170
173
  this.url = `${(options.baseUrl ?? defaultBaseUrl).replace(/\/$/, "")}/api/public/indexes/${options.publicId}`;
174
+ this.origin = new URL(this.url).origin;
171
175
  this.fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
172
176
  this.events = eventSender(options.eventsUrl ?? `${this.url}/events`, options.fetch, options.events ?? true, options.visitorIds ? deviceIds(options.publicId) : null);
173
177
  this.recentSearches = new RecentSearches(options.publicId);
@@ -186,6 +190,28 @@ export class SearchBrowserClient {
186
190
  throw new FindlaneError(response.status, data.error ?? `HTTP ${response.status}`);
187
191
  return data;
188
192
  }
193
+ // Lists are read with GET, which browsers send without a preflight and may keep for 30 seconds.
194
+ async get(path, params, signal) {
195
+ const query = new URLSearchParams(params).toString();
196
+ const response = await this.fetcher(`${this.url}/${path}${query ? `?${query}` : ""}`, { method: "GET", signal });
197
+ const data = await response.json();
198
+ if (!response.ok)
199
+ throw new FindlaneError(response.status, data.error ?? `HTTP ${response.status}`);
200
+ return data;
201
+ }
202
+ /** Opens the connection to the API before the first search needs it, as a preconnect hint in the page
203
+ * would: call it when the shopper focuses a search box. Once per page; nothing outside a browser. */
204
+ warm() {
205
+ if (typeof document === "undefined" || warmed.has(this.origin))
206
+ return;
207
+ warmed.add(this.origin);
208
+ const link = document.createElement("link");
209
+ link.rel = "preconnect";
210
+ link.href = this.origin;
211
+ // Searches are sent without credentials, so they use the connection a crossorigin hint opens.
212
+ link.crossOrigin = "anonymous";
213
+ document.head.append(link);
214
+ }
189
215
  search(request, options = {}) {
190
216
  return this.post("search", request, options.signal);
191
217
  }
@@ -219,4 +245,16 @@ export class SearchBrowserClient {
219
245
  recommendations(objectID, request = {}, options = {}) {
220
246
  return this.post("recommendations", { objectID, ...request }, options.signal);
221
247
  }
248
+ /** One of the index's lists, by its ID: a row of products, such as Popular, or a facet field's values for
249
+ * a menu. Built ahead and answered from a cache near the shopper, so it isn't a search and isn't counted.
250
+ * Throws a FindlaneError with status 404 when the index has no such list. */
251
+ list(id, options = {}) {
252
+ return this.get(`lists/${encodeURIComponent(id)}`, options.limit ? { limit: String(options.limit) } : {}, options.signal);
253
+ }
254
+ /** Several of the index's lists in one request (up to 10), in the order asked; IDs the index has no list
255
+ * for are left out. Without IDs, all of its lists, in the owner's order. Not counted as searches. */
256
+ async lists(ids, options = {}) {
257
+ return (await this.get("lists", { ...(ids ? { ids: ids.join(",") } : {}),
258
+ ...(options.limit ? { limit: String(options.limit) } : {}) }, options.signal)).lists;
259
+ }
222
260
  }
@@ -65,8 +65,8 @@ export function SearchBox({ placeholder, label = "Search", action, renderHit, em
65
65
  }
66
66
  /** The query input of the surrounding SearchProvider. */
67
67
  export function SearchInput({ placeholder, label = "Search", className, autoFocus }) {
68
- const { query, setQuery } = useSearchBox();
69
- return (_jsx("form", { role: "search", className: className, "data-findlane-search-input": "", onSubmit: (event) => event.preventDefault(), children: _jsx("input", { type: "search", value: query, onChange: (event) => setQuery(event.target.value), placeholder: placeholder, "aria-label": label, autoFocus: autoFocus, autoComplete: "off", spellCheck: false, maxLength: 200 }) }));
68
+ const { query, setQuery, warm } = useSearchBox();
69
+ return (_jsx("form", { role: "search", className: className, "data-findlane-search-input": "", onSubmit: (event) => event.preventDefault(), children: _jsx("input", { type: "search", value: query, onChange: (event) => setQuery(event.target.value), onFocus: warm, placeholder: placeholder, "aria-label": label, autoFocus: autoFocus, autoComplete: "off", spellCheck: false, maxLength: 200 }) }));
70
70
  }
71
71
  /** Whether a click was on a link inside the element that handled it, such as a hit's link. */
72
72
  export function onLink(event) {
@@ -29,6 +29,8 @@ export declare function useSearchBox(): {
29
29
  setQuery: (q: string) => void;
30
30
  clear: () => void;
31
31
  isPending: boolean;
32
+ /** Opens the connection to the API ahead of the first search: call it when the box gets focus. */
33
+ warm: () => void;
32
34
  };
33
35
  export declare function useHits<T = Record<string, unknown>>(): {
34
36
  hits: import("./state.js").Hit<T>[];
@@ -73,7 +73,9 @@ export function useSearchState() {
73
73
  export function useSearchBox() {
74
74
  const [{ state, pending }, store] = useSnapshot();
75
75
  const setQuery = (q) => store.update((current) => ({ ...current, q, page: 0 }));
76
- return { query: state.q, setQuery, clear: () => setQuery(""), isPending: pending };
76
+ return { query: state.q, setQuery, clear: () => setQuery(""), isPending: pending,
77
+ /** Opens the connection to the API ahead of the first search: call it when the box gets focus. */
78
+ warm: () => store.warm() };
77
79
  }
78
80
  export function useHits() {
79
81
  const [{ results, pending, error }, store] = useSnapshot();
@@ -38,16 +38,21 @@ export declare class SearchStore<T = unknown> {
38
38
  private config;
39
39
  private snapshot;
40
40
  private listeners;
41
- private controller;
41
+ private controllers;
42
+ private waiting;
42
43
  private timer;
43
44
  private run;
45
+ private shown;
46
+ private cut;
47
+ private lastSent;
44
48
  /** Called after each change a visitor makes, not when the state is adopted from outside. */
45
49
  onChange: ((state: SearchState, change: StateChange) => void) | null;
46
50
  constructor(client: SearchBrowserClient, config: SearchConfig, state: SearchState, initial?: ServerResults<T>);
47
51
  subscribe: (listener: () => void) => () => void;
48
52
  getSnapshot: () => Snapshot<T>;
49
53
  private set;
50
- /** A visitor's change. Changes to the query wait for a pause in typing; the others search at once.
54
+ /** A visitor's change. A keystroke soon after the last search waits briefly for the next (typing.ts);
55
+ * other changes search at once.
51
56
  * `push` overrides how it reaches the history, as a map's moves replace the entry. */
52
57
  update(change: (state: SearchState) => SearchState, push?: boolean): void;
53
58
  /** A state from outside, such as the URL after Back. */
@@ -55,13 +60,20 @@ export declare class SearchStore<T = unknown> {
55
60
  /** A new client or config, from changed props. */
56
61
  configure(client: SearchBrowserClient, config: SearchConfig): void;
57
62
  get settings(): SearchConfig;
63
+ /** Opens the connection to the API before the first search needs it (the client's warm). */
64
+ warm(): void;
58
65
  /** Sends a click on a result: its place in the results, from 1, and the query they're for. */
59
66
  click(objectID: string): void;
60
67
  /** Searches unless the results already answer the current state; with a state, such as the
61
68
  * URL's, adopts it first. */
62
69
  start(state?: SearchState): void;
70
+ /** Stops and drops every search in flight. */
63
71
  stop(): void;
72
+ private unschedule;
64
73
  private resultsFor;
74
+ /** Searches for the current state. While typing, earlier searches keep going: their answers fill the
75
+ * cache (Backspace shows them at once) and show until a newer one's arrive, never after. Any other
76
+ * change drops them. */
65
77
  private search;
66
78
  /** A did-you-mean suggestion for a request that found nothing. */
67
79
  suggest(request: SearchRequest, signal: AbortSignal): Promise<DidYouMean | null>;
@@ -1,15 +1,21 @@
1
1
  import { cachedSearch, peek } from "./cache.js";
2
2
  import { canonical, hasMoreAfter, planSearch } from "./state.js";
3
- const typingPause = 150;
3
+ import { typingWait } from "./typing.js";
4
4
  export const planKey = (request) => canonical(request);
5
5
  export class SearchStore {
6
6
  client;
7
7
  config;
8
8
  snapshot;
9
9
  listeners = new Set();
10
- controller = null;
10
+ // Searches in flight, and the one waiting for a pause in typing.
11
+ controllers = new Set();
12
+ waiting = null;
11
13
  timer;
14
+ // Each search's number; `shown` is the one whose results show, and searches up to `cut` are dropped.
12
15
  run = 0;
16
+ shown = 0;
17
+ cut = 0;
18
+ lastSent = -Infinity;
13
19
  /** Called after each change a visitor makes, not when the state is adopted from outside. */
14
20
  onChange = null;
15
21
  constructor(client, config, state, initial) {
@@ -30,7 +36,8 @@ export class SearchStore {
30
36
  for (const listener of this.listeners)
31
37
  listener();
32
38
  }
33
- /** A visitor's change. Changes to the query wait for a pause in typing; the others search at once.
39
+ /** A visitor's change. A keystroke soon after the last search waits briefly for the next (typing.ts);
40
+ * other changes search at once.
34
41
  * `push` overrides how it reaches the history, as a map's moves replace the entry. */
35
42
  update(change, push) {
36
43
  const previous = this.snapshot.state;
@@ -58,6 +65,8 @@ export class SearchStore {
58
65
  this.search(false);
59
66
  }
60
67
  get settings() { return this.config; }
68
+ /** Opens the connection to the API before the first search needs it (the client's warm). */
69
+ warm() { this.client.warm(); }
61
70
  /** Sends a click on a result: its place in the results, from 1, and the query they're for. */
62
71
  click(objectID) {
63
72
  const results = this.snapshot.results;
@@ -74,46 +83,69 @@ export class SearchStore {
74
83
  if (this.snapshot.results?.key !== key || this.snapshot.error)
75
84
  this.search(false);
76
85
  }
86
+ /** Stops and drops every search in flight. */
77
87
  stop() {
88
+ this.unschedule();
89
+ for (const controller of this.controllers)
90
+ controller.abort();
91
+ this.controllers.clear();
92
+ this.cut = ++this.run;
93
+ }
94
+ unschedule() {
78
95
  clearTimeout(this.timer);
79
- this.controller?.abort();
80
- this.controller = null;
81
- this.run++;
96
+ if (this.waiting)
97
+ this.controllers.delete(this.waiting);
98
+ this.waiting = null;
82
99
  }
83
100
  resultsFor(request, response) {
84
101
  return { key: planKey(request), request, response, hits: response.hits, hasMore: hasMoreAfter(response, request.offset ?? 0) };
85
102
  }
86
- search(debounce) {
87
- this.stop();
88
- const run = this.run;
103
+ /** Searches for the current state. While typing, earlier searches keep going: their answers fill the
104
+ * cache (Backspace shows them at once) and show until a newer one's arrive, never after. Any other
105
+ * change drops them. */
106
+ search(typing) {
107
+ if (typing)
108
+ this.unschedule();
109
+ else
110
+ this.stop();
111
+ const run = ++this.run;
89
112
  const client = this.client;
90
113
  const request = planSearch(this.config, this.snapshot.state);
91
114
  // Results already in memory show at once, without a loading state.
92
115
  const cached = peek(client, request);
93
116
  if (cached) {
117
+ this.shown = run;
94
118
  this.set({ results: this.resultsFor(request, cached), pending: false, error: null, loadingMore: false });
95
119
  return;
96
120
  }
97
121
  this.set({ pending: true, loadingMore: false });
98
122
  const controller = new AbortController();
99
- this.controller = controller;
123
+ this.controllers.add(controller);
100
124
  const go = () => {
101
- const started = performance.now();
125
+ if (this.waiting === controller)
126
+ this.waiting = null;
127
+ this.lastSent = performance.now();
128
+ const started = this.lastSent;
102
129
  cachedSearch(client, request, controller.signal).then((response) => {
103
- if (run !== this.run)
130
+ this.controllers.delete(controller);
131
+ if (run <= this.cut || run < this.shown)
104
132
  return;
133
+ this.shown = run;
105
134
  const ms = Math.round(performance.now() - started);
106
- this.set({ results: { ...this.resultsFor(request, response), ms }, pending: false, error: null });
135
+ this.set({ results: { ...this.resultsFor(request, response), ms }, pending: run !== this.run, error: null });
107
136
  }, (error) => {
108
- if (run !== this.run)
137
+ this.controllers.delete(controller);
138
+ // Only the latest search's failure shows; an earlier one's answer is no longer wanted.
139
+ if (run !== this.run || run <= this.cut)
109
140
  return;
110
141
  this.set({ pending: false, error });
111
142
  });
112
143
  };
113
- if (debounce)
114
- this.timer = setTimeout(go, typingPause);
115
- else
116
- go();
144
+ const wait = typing ? typingWait(this.lastSent) : 0;
145
+ if (!wait)
146
+ return go();
147
+ this.waiting = controller;
148
+ this.timer = setTimeout(go, wait);
117
149
  }
118
150
  /** A did-you-mean suggestion for a request that found nothing. */
119
151
  suggest(request, signal) {
@@ -0,0 +1,6 @@
1
+ /** How long a keystroke that comes soon after the last search waits for the next, in milliseconds.
2
+ * People type a key every 240 to 330 ms on average, so after a pause this long a search goes out at
3
+ * once, and only quick bursts and pastes are merged. */
4
+ export declare const typingPause = 50;
5
+ /** How long a search for a keystroke waits: none when the last search went out at least `pause` ago. */
6
+ export declare function typingWait(lastSent: number, pause?: number, now?: number): number;
@@ -0,0 +1,9 @@
1
+ // When a search for what's typed goes out (docs/speed-and-cost-research-2026-10-16.md, phase 2).
2
+ /** How long a keystroke that comes soon after the last search waits for the next, in milliseconds.
3
+ * People type a key every 240 to 330 ms on average, so after a pause this long a search goes out at
4
+ * once, and only quick bursts and pastes are merged. */
5
+ export const typingPause = 50;
6
+ /** How long a search for a keystroke waits: none when the last search went out at least `pause` ago. */
7
+ export function typingWait(lastSent, pause = typingPause, now = performance.now()) {
8
+ return now - lastSent >= pause ? 0 : pause;
9
+ }
@@ -1,9 +1,10 @@
1
1
  "use client";
2
- import { useEffect, useId, useState, } from "react";
2
+ import { useEffect, useId, useRef, useState, } from "react";
3
3
  import { valuesAt } from "./highlight.js";
4
4
  import { useSearch } from "./use-search.js";
5
5
  import { useClient } from "./client.js";
6
6
  import { cachedQuerySuggestions, peekQuerySuggestions } from "./cache.js";
7
+ import { typingPause, typingWait } from "./typing.js";
7
8
  /** A hit's address from a template; links to other schemes, such as javascript:, are refused. */
8
9
  export function fillTemplate(template, hit) {
9
10
  const value = (field) => (field === "objectID" ? hit.id : valuesAt(hit.record, field)[0] ?? "");
@@ -46,10 +47,11 @@ export function useAutocomplete(options = {}) {
46
47
  const client = useClient({ publicId: options.publicId, baseUrl: options.baseUrl, client: options.client });
47
48
  const hits = search.data?.hits ?? [];
48
49
  const emptyBox = query.trim() === "";
49
- // Query suggestions for what's typed, after the same pause as the search; the last ones stay while
50
+ // Query suggestions for what's typed, on the same schedule as the search; the last ones stay while
50
51
  // the next load. Popular searches are asked for on the first focus (the client keeps them), and recent
51
52
  // ones read from this browser on each.
52
53
  const [suggestions, setSuggestions] = useState(noSuggestions);
54
+ const lastSuggested = useRef(-Infinity);
53
55
  const typed = request && suggestionLimit ? query : null;
54
56
  useEffect(() => {
55
57
  if (typed === null)
@@ -60,10 +62,11 @@ export function useAutocomplete(options = {}) {
60
62
  let active = true;
61
63
  const controller = new AbortController();
62
64
  const timer = setTimeout(() => {
65
+ lastSuggested.current = performance.now();
63
66
  cachedQuerySuggestions(client, typed, suggestionLimit, controller.signal)
64
67
  .then((items) => { if (active)
65
68
  setSuggestions(items); }, () => undefined);
66
- }, debounceMs ?? 150);
69
+ }, typingWait(lastSuggested.current, debounceMs ?? typingPause));
67
70
  return () => {
68
71
  active = false;
69
72
  clearTimeout(timer);
@@ -242,6 +245,8 @@ export function useAutocomplete(options = {}) {
242
245
  },
243
246
  onFocus: (event) => {
244
247
  props.onFocus?.(event);
248
+ // Opens the connection now, so the first keystroke's search doesn't wait for it.
249
+ client.warm();
245
250
  setFocused(true);
246
251
  setDismissed(false);
247
252
  },
@@ -1,7 +1,8 @@
1
1
  import type { SearchRequest, SearchResponse } from "../index.js";
2
2
  import { type ClientOptions } from "./client.js";
3
3
  export type UseSearchOptions<T> = ClientOptions & {
4
- /** How long to wait for a pause in typing, in milliseconds (default 150). */
4
+ /** How long a change that comes within this long of the last search waits for the next, in
5
+ * milliseconds (default 50). A change after a longer pause searches at once. */
5
6
  debounceMs?: number;
6
7
  /** Results for the first request, such as ones from the server. The hook doesn't search again
7
8
  * until the request changes. */
@@ -14,6 +15,7 @@ export type UseSearchResult<T> = {
14
15
  /** A search is waiting for a pause in typing or is in flight. */
15
16
  isPending: boolean;
16
17
  };
17
- /** Keeps one search current. A new request cancels the one in flight, and a slow response never
18
- * replaces a newer one. Pass `null` to not search (and clear the results). */
18
+ /** Keeps one search current. Earlier searches still in flight keep going, so their answers fill the
19
+ * cache and show while a newer one loads; a slow response never replaces a newer one. Pass `null` to
20
+ * not search (and clear the results). */
19
21
  export declare function useSearch<T = Record<string, unknown>>(request: SearchRequest | null, { debounceMs, initialData, ...clientOptions }?: UseSearchOptions<T>): UseSearchResult<T>;
@@ -2,10 +2,20 @@
2
2
  import { useEffect, useRef, useState } from "react";
3
3
  import { cachedSearch, peek, searchKey } from "./cache.js";
4
4
  import { useClient } from "./client.js";
5
- /** Keeps one search current. A new request cancels the one in flight, and a slow response never
6
- * replaces a newer one. Pass `null` to not search (and clear the results). */
7
- export function useSearch(request, { debounceMs = 150, initialData, ...clientOptions } = {}) {
5
+ import { typingPause, typingWait } from "./typing.js";
6
+ /** Keeps one search current. Earlier searches still in flight keep going, so their answers fill the
7
+ * cache and show while a newer one loads; a slow response never replaces a newer one. Pass `null` to
8
+ * not search (and clear the results). */
9
+ export function useSearch(request, { debounceMs = typingPause, initialData, ...clientOptions } = {}) {
8
10
  const client = useClient(clientOptions);
11
+ // Each search's number: the latest, the one whose results show, and when the last went out.
12
+ const runs = useRef({ latest: 0, shown: 0, lastSent: -Infinity, client });
13
+ const inFlight = useRef(new Set());
14
+ useEffect(() => () => {
15
+ for (const controller of inFlight.current)
16
+ controller.abort();
17
+ inFlight.current.clear();
18
+ }, []);
9
19
  const key = request ? searchKey(request) : null;
10
20
  const [result, setResult] = useState(() => ({
11
21
  data: initialData ?? null,
@@ -14,7 +24,17 @@ export function useSearch(request, { debounceMs = 150, initialData, ...clientOpt
14
24
  }));
15
25
  const initialKey = useRef(initialData ? key : null);
16
26
  useEffect(() => {
27
+ const ours = runs.current;
28
+ // Another client's searches (another index) are dropped.
29
+ if (ours.client !== client) {
30
+ for (const controller of inFlight.current)
31
+ controller.abort();
32
+ inFlight.current.clear();
33
+ ours.client = client;
34
+ }
35
+ const run = ++ours.latest;
17
36
  if (!request) {
37
+ ours.shown = run;
18
38
  setResult((current) => (current.data || current.error || current.isPending ? { data: null, error: null, isPending: false } : current));
19
39
  return;
20
40
  }
@@ -23,22 +43,33 @@ export function useSearch(request, { debounceMs = 150, initialData, ...clientOpt
23
43
  initialKey.current = null;
24
44
  const cached = peek(client, request);
25
45
  if (cached) {
46
+ ours.shown = run;
26
47
  setResult({ data: cached, error: null, isPending: false });
27
48
  return;
28
49
  }
29
50
  setResult((current) => (current.isPending ? current : { ...current, isPending: true }));
30
- let active = true;
31
- const controller = new AbortController();
32
- const timer = setTimeout(() => {
33
- cachedSearch(client, request, controller.signal).then((data) => { if (active)
34
- setResult({ data, error: null, isPending: false }); }, (error) => { if (active)
35
- setResult((current) => ({ ...current, error, isPending: false })); });
36
- }, debounceMs);
37
- return () => {
38
- active = false;
39
- clearTimeout(timer);
40
- controller.abort();
51
+ const go = () => {
52
+ const controller = new AbortController();
53
+ inFlight.current.add(controller);
54
+ ours.lastSent = performance.now();
55
+ cachedSearch(client, request, controller.signal).then((data) => {
56
+ inFlight.current.delete(controller);
57
+ if (run < ours.shown || ours.client !== client)
58
+ return;
59
+ ours.shown = run;
60
+ setResult({ data, error: null, isPending: run !== ours.latest });
61
+ }, (error) => {
62
+ inFlight.current.delete(controller);
63
+ if (run === ours.latest && ours.client === client)
64
+ setResult((current) => ({ ...current, error, isPending: false }));
65
+ });
41
66
  };
67
+ const wait = typingWait(ours.lastSent, debounceMs);
68
+ if (!wait)
69
+ return go();
70
+ const timer = setTimeout(go, wait);
71
+ // A search that hasn't gone out yet is replaced; one in flight keeps going.
72
+ return () => clearTimeout(timer);
42
73
  // The key stands for the request, so a new object with the same search doesn't search again.
43
74
  // eslint-disable-next-line react-hooks/exhaustive-deps
44
75
  }, [client, key, debounceMs]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@findlane/browser",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Findlane browser SDK: fast, typo-tolerant public search from the browser with an index's public ID, plus React hooks and components.",
5
5
  "keywords": ["findlane", "search", "full-text search", "typo tolerance", "instant search", "ecommerce search", "browser", "react", "autocomplete"],
6
6
  "license": "MIT",