@findlane/browser 0.15.0 → 0.16.1
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 +4 -0
- package/dist/index.js +17 -0
- package/dist/react/cache.d.ts +3 -1
- package/dist/react/cache.js +5 -2
- 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
|
@@ -285,6 +285,7 @@ export type BrowserClientOptions = {
|
|
|
285
285
|
};
|
|
286
286
|
export declare class SearchBrowserClient {
|
|
287
287
|
private readonly url;
|
|
288
|
+
private readonly origin;
|
|
288
289
|
private readonly fetcher;
|
|
289
290
|
/** Views, clicks, add-to-carts and orders, for the index's Events page and Bought together. */
|
|
290
291
|
readonly events: BrowserEvents;
|
|
@@ -294,6 +295,9 @@ export declare class SearchBrowserClient {
|
|
|
294
295
|
constructor(options: BrowserClientOptions);
|
|
295
296
|
private post;
|
|
296
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;
|
|
297
301
|
search<T = Record<string, unknown>>(request: SearchRequest, options?: {
|
|
298
302
|
signal?: AbortSignal;
|
|
299
303
|
}): Promise<SearchResponse<T>>;
|
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);
|
|
@@ -195,6 +199,19 @@ export class SearchBrowserClient {
|
|
|
195
199
|
throw new FindlaneError(response.status, data.error ?? `HTTP ${response.status}`);
|
|
196
200
|
return data;
|
|
197
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
|
+
}
|
|
198
215
|
search(request, options = {}) {
|
|
199
216
|
return this.post("search", request, options.signal);
|
|
200
217
|
}
|
package/dist/react/cache.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { QuerySuggestion, RecommendationsRequest, RecommendationsResponse, SearchBrowserClient, SearchRequest, SearchResponse } from "../index.js";
|
|
2
2
|
/** Requests the API answers the same way share a key, as at the edge (workers/api/src/edge.ts):
|
|
3
|
-
* the query's first eight lowercase words, defaults left out, and list order ignored.
|
|
3
|
+
* the query's first eight lowercase words, defaults left out, and list order ignored. Words keep an
|
|
4
|
+
* apostrophe or period inside them (don't, 3.5), which the API matches as one word; the edge's exact
|
|
5
|
+
* rule needs lookbehind, which older Safari can't parse, so this keeps a little more apart than it. */
|
|
4
6
|
export declare function searchKey(request: SearchRequest): string;
|
|
5
7
|
/** A product's recommendations share a key when only defaults differ: the limit of 8 and empty filters. */
|
|
6
8
|
export declare function recommendationsKey(objectID: string, request?: RecommendationsRequest): string;
|
package/dist/react/cache.js
CHANGED
|
@@ -6,11 +6,14 @@ const searches = store();
|
|
|
6
6
|
const recommendations = store();
|
|
7
7
|
const suggestions = store();
|
|
8
8
|
/** Requests the API answers the same way share a key, as at the edge (workers/api/src/edge.ts):
|
|
9
|
-
* the query's first eight lowercase words, defaults left out, and list order ignored.
|
|
9
|
+
* the query's first eight lowercase words, defaults left out, and list order ignored. Words keep an
|
|
10
|
+
* apostrophe or period inside them (don't, 3.5), which the API matches as one word; the edge's exact
|
|
11
|
+
* rule needs lookbehind, which older Safari can't parse, so this keeps a little more apart than it. */
|
|
10
12
|
export function searchKey(request) {
|
|
11
13
|
const normal = { ...request };
|
|
12
14
|
if (typeof normal.q === "string" && normal.q.length <= 200)
|
|
13
|
-
normal.q = normal.q.toLowerCase().match(/[\p{L}\p{N}]+/gu)
|
|
15
|
+
normal.q = (normal.q.toLowerCase().replace(/[’‘]/gu, "'").match(/[\p{L}\p{M}\p{N}_'.:·,;]+/gu) ?? [])
|
|
16
|
+
.map((word) => word.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "")).filter(Boolean).slice(0, 8).join(" ");
|
|
14
17
|
const defaults = { q: "", limit: 20, offset: 0, countMode: "page", facetMode: "page" };
|
|
15
18
|
for (const [key, value] of Object.entries(defaults))
|
|
16
19
|
if (normal[key] === value)
|
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.1",
|
|
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",
|