@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 +54 -0
- package/dist/index.js +38 -0
- package/dist/react/components.js +2 -2
- package/dist/react/search-provider.d.ts +2 -0
- package/dist/react/search-provider.js +3 -1
- package/dist/react/store.d.ts +14 -2
- package/dist/react/store.js +50 -18
- package/dist/react/typing.d.ts +6 -0
- package/dist/react/typing.js +9 -0
- package/dist/react/use-autocomplete.js +8 -3
- package/dist/react/use-search.d.ts +5 -3
- package/dist/react/use-search.js +45 -14
- package/package.json +1 -1
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
|
}
|
package/dist/react/components.js
CHANGED
|
@@ -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();
|
package/dist/react/store.d.ts
CHANGED
|
@@ -38,16 +38,21 @@ export declare class SearchStore<T = unknown> {
|
|
|
38
38
|
private config;
|
|
39
39
|
private snapshot;
|
|
40
40
|
private listeners;
|
|
41
|
-
private
|
|
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.
|
|
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>;
|
package/dist/react/store.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import { cachedSearch, peek } from "./cache.js";
|
|
2
2
|
import { canonical, hasMoreAfter, planSearch } from "./state.js";
|
|
3
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
80
|
-
|
|
81
|
-
this.
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
123
|
+
this.controllers.add(controller);
|
|
100
124
|
const go = () => {
|
|
101
|
-
|
|
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
|
-
|
|
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:
|
|
135
|
+
this.set({ results: { ...this.resultsFor(request, response), ms }, pending: run !== this.run, error: null });
|
|
107
136
|
}, (error) => {
|
|
108
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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,
|
|
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 ??
|
|
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
|
|
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.
|
|
18
|
-
*
|
|
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>;
|
package/dist/react/use-search.js
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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.
|
|
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",
|