@blotoutio/providers-evo-search-sdk 1.63.3 → 1.64.2
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/README.md +30 -0
- package/core.cjs.js +30 -2
- package/core.js +30 -2
- package/core.mjs +30 -2
- package/hooks.cjs.js +160 -3
- package/hooks.mjs +161 -4
- package/index.cjs.js +130 -3
- package/index.js +130 -3
- package/index.mjs +130 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,6 +54,36 @@ function HeaderSearch() {
|
|
|
54
54
|
}
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
## Tracking interactions
|
|
58
|
+
|
|
59
|
+
EvoSearch needs three interaction signals to power the dashboard: searches, result clicks, and cart/purchase events. **In a headless integration, only `trackClick` requires code on your side.**
|
|
60
|
+
|
|
61
|
+
### Searches — handled server-side
|
|
62
|
+
|
|
63
|
+
Search volume, top queries, and zero-result queries are measured automatically from the search API. No client code needed.
|
|
64
|
+
|
|
65
|
+
### Result clicks — `trackClick`
|
|
66
|
+
|
|
67
|
+
Call `trackClick(productId)` when a user clicks a result, passing the `id` field from the search result:
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
{
|
|
71
|
+
results.map((p) => (
|
|
72
|
+
<li key={p.id}>
|
|
73
|
+
<button type='button' onClick={() => trackClick(p.id)}>
|
|
74
|
+
{p.title}
|
|
75
|
+
</button>
|
|
76
|
+
</li>
|
|
77
|
+
))
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Add-to-cart / purchase — use your existing EdgeTag tags
|
|
82
|
+
|
|
83
|
+
If you already send EdgeTag `AddToCart` and `Purchase` events via the EdgeTag SDK, you're done — EvoSearch receives them server-side and attributes them to searches automatically.
|
|
84
|
+
|
|
85
|
+
**Important:** The product id in `trackClick` must match the product id in your EdgeTag commerce tags. If your commerce tags use SKUs or variant ids, normalize them to product id on both sides, or search-to-cart attribution will report zero.
|
|
86
|
+
|
|
57
87
|
For full API reference, see the design spec in the Blotout solutions repo or the docs site.
|
|
58
88
|
|
|
59
89
|
## Disabling the default UI (headless mode)
|
package/core.cjs.js
CHANGED
|
@@ -388,7 +388,31 @@ const usStates = new Map([
|
|
|
388
388
|
['US-UM', 'United States Minor Outlying Islands'],
|
|
389
389
|
['US-VI', 'Virgin Islands, U.S.'],
|
|
390
390
|
]);
|
|
391
|
-
|
|
391
|
+
/**
|
|
392
|
+
* ISO 3166-2:CA province and territory codes, keyed `CA-XX` to mirror
|
|
393
|
+
* `usStates`. Used by the consent banner's Canada region to offer province
|
|
394
|
+
* selection (Québec is treated specially by the regional consent config).
|
|
395
|
+
*/
|
|
396
|
+
const caProvinces = new Map([
|
|
397
|
+
['CA-AB', 'Alberta'],
|
|
398
|
+
['CA-BC', 'British Columbia'],
|
|
399
|
+
['CA-MB', 'Manitoba'],
|
|
400
|
+
['CA-NB', 'New Brunswick'],
|
|
401
|
+
['CA-NL', 'Newfoundland and Labrador'],
|
|
402
|
+
['CA-NS', 'Nova Scotia'],
|
|
403
|
+
['CA-NT', 'Northwest Territories'],
|
|
404
|
+
['CA-NU', 'Nunavut'],
|
|
405
|
+
['CA-ON', 'Ontario'],
|
|
406
|
+
['CA-PE', 'Prince Edward Island'],
|
|
407
|
+
['CA-QC', 'Quebec'],
|
|
408
|
+
['CA-SK', 'Saskatchewan'],
|
|
409
|
+
['CA-YT', 'Yukon'],
|
|
410
|
+
]);
|
|
411
|
+
new Set([
|
|
412
|
+
...isoCountries.keys(),
|
|
413
|
+
...usStates.keys(),
|
|
414
|
+
...caProvinces.keys(),
|
|
415
|
+
]);
|
|
392
416
|
|
|
393
417
|
/**
|
|
394
418
|
* Exact utm_source normalization (lowercase key → canonical name).
|
|
@@ -638,10 +662,12 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
638
662
|
throw new Error('sendTag missing');
|
|
639
663
|
}
|
|
640
664
|
const getHeaders = () => {
|
|
665
|
+
var _a;
|
|
641
666
|
const headers = new Headers({ EdgeTagUserId: userId });
|
|
642
667
|
if (sessionId) {
|
|
643
668
|
headers.set('EdgeTagSessionId', sessionId);
|
|
644
669
|
}
|
|
670
|
+
headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
|
|
645
671
|
return headers;
|
|
646
672
|
};
|
|
647
673
|
// Parsing `baseURL` once at API construction avoids re-parsing the host on
|
|
@@ -675,7 +701,9 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
675
701
|
if (!response.ok) {
|
|
676
702
|
const errorText = await response.text().catch(() => '');
|
|
677
703
|
logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
|
|
678
|
-
|
|
704
|
+
const error = new Error(`${label} failed - ${response.status}`);
|
|
705
|
+
error.status = response.status;
|
|
706
|
+
throw error;
|
|
679
707
|
}
|
|
680
708
|
return (await response.json());
|
|
681
709
|
};
|
package/core.js
CHANGED
|
@@ -389,7 +389,31 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
389
389
|
['US-UM', 'United States Minor Outlying Islands'],
|
|
390
390
|
['US-VI', 'Virgin Islands, U.S.'],
|
|
391
391
|
]);
|
|
392
|
-
|
|
392
|
+
/**
|
|
393
|
+
* ISO 3166-2:CA province and territory codes, keyed `CA-XX` to mirror
|
|
394
|
+
* `usStates`. Used by the consent banner's Canada region to offer province
|
|
395
|
+
* selection (Québec is treated specially by the regional consent config).
|
|
396
|
+
*/
|
|
397
|
+
const caProvinces = new Map([
|
|
398
|
+
['CA-AB', 'Alberta'],
|
|
399
|
+
['CA-BC', 'British Columbia'],
|
|
400
|
+
['CA-MB', 'Manitoba'],
|
|
401
|
+
['CA-NB', 'New Brunswick'],
|
|
402
|
+
['CA-NL', 'Newfoundland and Labrador'],
|
|
403
|
+
['CA-NS', 'Nova Scotia'],
|
|
404
|
+
['CA-NT', 'Northwest Territories'],
|
|
405
|
+
['CA-NU', 'Nunavut'],
|
|
406
|
+
['CA-ON', 'Ontario'],
|
|
407
|
+
['CA-PE', 'Prince Edward Island'],
|
|
408
|
+
['CA-QC', 'Quebec'],
|
|
409
|
+
['CA-SK', 'Saskatchewan'],
|
|
410
|
+
['CA-YT', 'Yukon'],
|
|
411
|
+
]);
|
|
412
|
+
new Set([
|
|
413
|
+
...isoCountries.keys(),
|
|
414
|
+
...usStates.keys(),
|
|
415
|
+
...caProvinces.keys(),
|
|
416
|
+
]);
|
|
393
417
|
|
|
394
418
|
/**
|
|
395
419
|
* Exact utm_source normalization (lowercase key → canonical name).
|
|
@@ -639,10 +663,12 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
639
663
|
throw new Error('sendTag missing');
|
|
640
664
|
}
|
|
641
665
|
const getHeaders = () => {
|
|
666
|
+
var _a;
|
|
642
667
|
const headers = new Headers({ EdgeTagUserId: userId });
|
|
643
668
|
if (sessionId) {
|
|
644
669
|
headers.set('EdgeTagSessionId', sessionId);
|
|
645
670
|
}
|
|
671
|
+
headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
|
|
646
672
|
return headers;
|
|
647
673
|
};
|
|
648
674
|
// Parsing `baseURL` once at API construction avoids re-parsing the host on
|
|
@@ -676,7 +702,9 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
676
702
|
if (!response.ok) {
|
|
677
703
|
const errorText = await response.text().catch(() => '');
|
|
678
704
|
logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
|
|
679
|
-
|
|
705
|
+
const error = new Error(`${label} failed - ${response.status}`);
|
|
706
|
+
error.status = response.status;
|
|
707
|
+
throw error;
|
|
680
708
|
}
|
|
681
709
|
return (await response.json());
|
|
682
710
|
};
|
package/core.mjs
CHANGED
|
@@ -386,7 +386,31 @@ const usStates = new Map([
|
|
|
386
386
|
['US-UM', 'United States Minor Outlying Islands'],
|
|
387
387
|
['US-VI', 'Virgin Islands, U.S.'],
|
|
388
388
|
]);
|
|
389
|
-
|
|
389
|
+
/**
|
|
390
|
+
* ISO 3166-2:CA province and territory codes, keyed `CA-XX` to mirror
|
|
391
|
+
* `usStates`. Used by the consent banner's Canada region to offer province
|
|
392
|
+
* selection (Québec is treated specially by the regional consent config).
|
|
393
|
+
*/
|
|
394
|
+
const caProvinces = new Map([
|
|
395
|
+
['CA-AB', 'Alberta'],
|
|
396
|
+
['CA-BC', 'British Columbia'],
|
|
397
|
+
['CA-MB', 'Manitoba'],
|
|
398
|
+
['CA-NB', 'New Brunswick'],
|
|
399
|
+
['CA-NL', 'Newfoundland and Labrador'],
|
|
400
|
+
['CA-NS', 'Nova Scotia'],
|
|
401
|
+
['CA-NT', 'Northwest Territories'],
|
|
402
|
+
['CA-NU', 'Nunavut'],
|
|
403
|
+
['CA-ON', 'Ontario'],
|
|
404
|
+
['CA-PE', 'Prince Edward Island'],
|
|
405
|
+
['CA-QC', 'Quebec'],
|
|
406
|
+
['CA-SK', 'Saskatchewan'],
|
|
407
|
+
['CA-YT', 'Yukon'],
|
|
408
|
+
]);
|
|
409
|
+
new Set([
|
|
410
|
+
...isoCountries.keys(),
|
|
411
|
+
...usStates.keys(),
|
|
412
|
+
...caProvinces.keys(),
|
|
413
|
+
]);
|
|
390
414
|
|
|
391
415
|
/**
|
|
392
416
|
* Exact utm_source normalization (lowercase key → canonical name).
|
|
@@ -636,10 +660,12 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
636
660
|
throw new Error('sendTag missing');
|
|
637
661
|
}
|
|
638
662
|
const getHeaders = () => {
|
|
663
|
+
var _a;
|
|
639
664
|
const headers = new Headers({ EdgeTagUserId: userId });
|
|
640
665
|
if (sessionId) {
|
|
641
666
|
headers.set('EdgeTagSessionId', sessionId);
|
|
642
667
|
}
|
|
668
|
+
headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
|
|
643
669
|
return headers;
|
|
644
670
|
};
|
|
645
671
|
// Parsing `baseURL` once at API construction avoids re-parsing the host on
|
|
@@ -673,7 +699,9 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
673
699
|
if (!response.ok) {
|
|
674
700
|
const errorText = await response.text().catch(() => '');
|
|
675
701
|
logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
|
|
676
|
-
|
|
702
|
+
const error = new Error(`${label} failed - ${response.status}`);
|
|
703
|
+
error.status = response.status;
|
|
704
|
+
throw error;
|
|
677
705
|
}
|
|
678
706
|
return (await response.json());
|
|
679
707
|
};
|
package/hooks.cjs.js
CHANGED
|
@@ -475,7 +475,31 @@ const usStates = new Map([
|
|
|
475
475
|
['US-UM', 'United States Minor Outlying Islands'],
|
|
476
476
|
['US-VI', 'Virgin Islands, U.S.'],
|
|
477
477
|
]);
|
|
478
|
-
|
|
478
|
+
/**
|
|
479
|
+
* ISO 3166-2:CA province and territory codes, keyed `CA-XX` to mirror
|
|
480
|
+
* `usStates`. Used by the consent banner's Canada region to offer province
|
|
481
|
+
* selection (Québec is treated specially by the regional consent config).
|
|
482
|
+
*/
|
|
483
|
+
const caProvinces = new Map([
|
|
484
|
+
['CA-AB', 'Alberta'],
|
|
485
|
+
['CA-BC', 'British Columbia'],
|
|
486
|
+
['CA-MB', 'Manitoba'],
|
|
487
|
+
['CA-NB', 'New Brunswick'],
|
|
488
|
+
['CA-NL', 'Newfoundland and Labrador'],
|
|
489
|
+
['CA-NS', 'Nova Scotia'],
|
|
490
|
+
['CA-NT', 'Northwest Territories'],
|
|
491
|
+
['CA-NU', 'Nunavut'],
|
|
492
|
+
['CA-ON', 'Ontario'],
|
|
493
|
+
['CA-PE', 'Prince Edward Island'],
|
|
494
|
+
['CA-QC', 'Quebec'],
|
|
495
|
+
['CA-SK', 'Saskatchewan'],
|
|
496
|
+
['CA-YT', 'Yukon'],
|
|
497
|
+
]);
|
|
498
|
+
new Set([
|
|
499
|
+
...isoCountries.keys(),
|
|
500
|
+
...usStates.keys(),
|
|
501
|
+
...caProvinces.keys(),
|
|
502
|
+
]);
|
|
479
503
|
|
|
480
504
|
/**
|
|
481
505
|
* Exact utm_source normalization (lowercase key → canonical name).
|
|
@@ -608,6 +632,11 @@ const uiActions = new Set([
|
|
|
608
632
|
new Set([...uiActions]);
|
|
609
633
|
|
|
610
634
|
const DEBOUNCE_MS = 200;
|
|
635
|
+
// How long a typed query must sit unchanged after its results render before
|
|
636
|
+
// the widget counts it as one real search (recents + server-side query
|
|
637
|
+
// metrics). Search fires per keystroke, so anything shorter than a genuine
|
|
638
|
+
// reading pause would count prefixes (`a`, `ap`, `app`) as searches.
|
|
639
|
+
const SEARCH_SETTLE_MS = 3000;
|
|
611
640
|
|
|
612
641
|
// Intentionally duplicated from @blotoutio/providers/evo-search/config to keep the hooks bundle lean — importing from the config barrel pulls the shared/universal geo data into dist/hooks.mjs (~4KB gzipped). Drift between the two values is caught by utils.test.ts.
|
|
613
642
|
const isHttpURL = (value) => {
|
|
@@ -666,6 +695,87 @@ const processAutocompleteResponse = (response) => {
|
|
|
666
695
|
};
|
|
667
696
|
};
|
|
668
697
|
|
|
698
|
+
// Resilience wrapper for /search requests. A transient network failure or a
|
|
699
|
+
// hung request must not wedge the UI: add a per-attempt timeout (a hang becomes
|
|
700
|
+
// a rejection), retry transient failures with backoff, and never retry a
|
|
701
|
+
// caller-superseded (aborted) request. Shared by the widget and the headless
|
|
702
|
+
// hook. See callers: search-widget.ts (performAutocomplete) and
|
|
703
|
+
// hooks/use-evo-search.ts.
|
|
704
|
+
const DEFAULTS = {
|
|
705
|
+
timeoutMs: 7000,
|
|
706
|
+
retries: 2,
|
|
707
|
+
backoffMs: [400, 1200],
|
|
708
|
+
};
|
|
709
|
+
// Deterministic HTTP errors (4xx other than 429) will never succeed on
|
|
710
|
+
// retry — only retry timeouts, network errors, 429, and 5xx. Errors without
|
|
711
|
+
// an attached `status` (network/timeout failures) are always retryable.
|
|
712
|
+
const isRetryable = (error) => {
|
|
713
|
+
const status = error === null || error === void 0 ? void 0 : error.status;
|
|
714
|
+
return status == null || status === 429 || status >= 500;
|
|
715
|
+
};
|
|
716
|
+
const delay = (ms, signal) => new Promise((resolve, reject) => {
|
|
717
|
+
if (signal.aborted)
|
|
718
|
+
return reject(new DOMException('Aborted', 'AbortError'));
|
|
719
|
+
const t = setTimeout(resolve, ms);
|
|
720
|
+
signal.addEventListener('abort', () => {
|
|
721
|
+
clearTimeout(t);
|
|
722
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
723
|
+
}, { once: true });
|
|
724
|
+
});
|
|
725
|
+
/**
|
|
726
|
+
* Run `attempt` with a timeout, retrying transient failures. `callerSignal`
|
|
727
|
+
* supersedes everything: if it aborts, we stop immediately without retrying
|
|
728
|
+
* (the query changed / the widget cleaned up). A timeout or network error is
|
|
729
|
+
* retried up to `retries` times; anything left over is rethrown.
|
|
730
|
+
*/
|
|
731
|
+
async function withSearchResilience(attempt, callerSignal, options = {}) {
|
|
732
|
+
var _a, _b;
|
|
733
|
+
const { timeoutMs, retries, backoffMs } = { ...DEFAULTS, ...options };
|
|
734
|
+
let lastError;
|
|
735
|
+
for (let i = 0; i <= retries; i++) {
|
|
736
|
+
if (callerSignal.aborted)
|
|
737
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
738
|
+
const local = new AbortController();
|
|
739
|
+
const onCallerAbort = () => local.abort();
|
|
740
|
+
callerSignal.addEventListener('abort', onCallerAbort, { once: true });
|
|
741
|
+
// Race the attempt against an explicit timeout that REJECTS. Aborting the
|
|
742
|
+
// signal alone isn't enough — an attempt that ignores its signal would
|
|
743
|
+
// otherwise hang forever and never retry. Reject with a TimeoutError (not
|
|
744
|
+
// an AbortError) so callers surface it instead of treating it as a
|
|
745
|
+
// caller-cancellation.
|
|
746
|
+
let timer;
|
|
747
|
+
const timeout = new Promise((_, reject) => {
|
|
748
|
+
timer = setTimeout(() => {
|
|
749
|
+
local.abort(); // still cancel the in-flight request to free the socket
|
|
750
|
+
reject(new DOMException('Search request timed out', 'TimeoutError'));
|
|
751
|
+
}, timeoutMs);
|
|
752
|
+
});
|
|
753
|
+
try {
|
|
754
|
+
return await Promise.race([attempt(local.signal), timeout]);
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
lastError = error;
|
|
758
|
+
// The caller superseded this request — do not retry, propagate the abort.
|
|
759
|
+
if (callerSignal.aborted)
|
|
760
|
+
throw error;
|
|
761
|
+
// Otherwise it was a timeout or a transient network error: retry if budget remains.
|
|
762
|
+
if (i < retries && isRetryable(error)) {
|
|
763
|
+
const wait = (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
|
|
764
|
+
// Rejects with an AbortError if the caller aborts mid-backoff —
|
|
765
|
+
// propagate that cancellation instead of the transient `error` above.
|
|
766
|
+
await delay(wait, callerSignal);
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
throw error;
|
|
770
|
+
}
|
|
771
|
+
finally {
|
|
772
|
+
clearTimeout(timer);
|
|
773
|
+
callerSignal.removeEventListener('abort', onCallerAbort);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
throw lastError;
|
|
777
|
+
}
|
|
778
|
+
|
|
669
779
|
const isDevEnv$1 = () => typeof process != 'undefined' && process.env['NODE_ENV'] != 'production';
|
|
670
780
|
const emptyReturn = (query, setQuery, trackClick) => ({
|
|
671
781
|
query,
|
|
@@ -686,6 +796,9 @@ const useEvoSearch = (options = {}) => {
|
|
|
686
796
|
const [suggestions, setSuggestions] = react.useState([]);
|
|
687
797
|
const [loading, setLoading] = react.useState(false);
|
|
688
798
|
const [error, setError] = react.useState(null);
|
|
799
|
+
// Bumped when the browser regains connectivity, to re-run the search effect
|
|
800
|
+
// and self-heal a query that failed while offline.
|
|
801
|
+
const [retryNonce, setRetryNonce] = react.useState(0);
|
|
689
802
|
const ready = (_a = ctx === null || ctx === void 0 ? void 0 : ctx.ready) !== null && _a !== void 0 ? _a : false;
|
|
690
803
|
const api = (_b = ctx === null || ctx === void 0 ? void 0 : ctx.api) !== null && _b !== void 0 ? _b : null;
|
|
691
804
|
react.useEffect(() => {
|
|
@@ -693,6 +806,25 @@ const useEvoSearch = (options = {}) => {
|
|
|
693
806
|
console.error('[EvoSearch] useEvoSearch called outside <EvoSearchProvider>. Wrap your app in <EvoSearchProvider> at the root.');
|
|
694
807
|
}
|
|
695
808
|
}, [ctx]);
|
|
809
|
+
// Re-running a successful search on every reconnect would refetch (and
|
|
810
|
+
// re-bill) work that already completed; only self-heal a query that's
|
|
811
|
+
// currently in an error state, mirroring the widget's `hasError` gate.
|
|
812
|
+
const hasErrorRef = react.useRef(false);
|
|
813
|
+
react.useEffect(() => {
|
|
814
|
+
hasErrorRef.current = error != null;
|
|
815
|
+
}, [error]);
|
|
816
|
+
react.useEffect(() => {
|
|
817
|
+
if (typeof window === 'undefined') {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
const onOnline = () => {
|
|
821
|
+
if (hasErrorRef.current) {
|
|
822
|
+
setRetryNonce((n) => n + 1);
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
window.addEventListener('online', onOnline);
|
|
826
|
+
return () => window.removeEventListener('online', onOnline);
|
|
827
|
+
}, []);
|
|
696
828
|
react.useEffect(() => {
|
|
697
829
|
if (!ready || !api) {
|
|
698
830
|
return;
|
|
@@ -715,7 +847,7 @@ const useEvoSearch = (options = {}) => {
|
|
|
715
847
|
const controller = new AbortController();
|
|
716
848
|
const timer = setTimeout(async () => {
|
|
717
849
|
try {
|
|
718
|
-
const res = await api.search(query, undefined, controller.signal);
|
|
850
|
+
const res = await withSearchResilience((signal) => api.search(query, undefined, signal), controller.signal);
|
|
719
851
|
setResults(processAutocompleteResponse(res).products);
|
|
720
852
|
if (wantsSuggestions) {
|
|
721
853
|
setSuggestions((prev) => {
|
|
@@ -744,7 +876,32 @@ const useEvoSearch = (options = {}) => {
|
|
|
744
876
|
clearTimeout(timer);
|
|
745
877
|
controller.abort();
|
|
746
878
|
};
|
|
747
|
-
}, [query, ready, api, mode]);
|
|
879
|
+
}, [query, ready, api, mode, retryNonce]);
|
|
880
|
+
// Widget parity: report a settled query as one real search after SEARCH_SETTLE_MS.
|
|
881
|
+
const lastFinalizedRef = react.useRef('');
|
|
882
|
+
react.useEffect(() => {
|
|
883
|
+
if (!api || mode === 'autocomplete' || loading || error) {
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
const cleaned = query.trim();
|
|
887
|
+
if (!cleaned) {
|
|
888
|
+
// Clearing the query lets the same term finalize again on re-entry.
|
|
889
|
+
lastFinalizedRef.current = '';
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (cleaned.toLowerCase() === lastFinalizedRef.current) {
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
const timer = setTimeout(() => {
|
|
896
|
+
lastFinalizedRef.current = cleaned.toLowerCase();
|
|
897
|
+
const actionData = {
|
|
898
|
+
query: cleaned,
|
|
899
|
+
resultCount: results.length,
|
|
900
|
+
};
|
|
901
|
+
api.sendEvent('evoSearchSearchIssued', undefined, actionData);
|
|
902
|
+
}, SEARCH_SETTLE_MS);
|
|
903
|
+
return () => clearTimeout(timer);
|
|
904
|
+
}, [api, mode, query, results, loading, error]);
|
|
748
905
|
const trackClick = react.useCallback((productId, extra) => {
|
|
749
906
|
if (!api) {
|
|
750
907
|
return;
|
package/hooks.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx } from 'react/jsx-runtime';
|
|
2
|
-
import { createContext, useSyncExternalStore, useEffect, useContext, useState, useCallback, useMemo } from 'react';
|
|
2
|
+
import { createContext, useSyncExternalStore, useEffect, useContext, useState, useRef, useCallback, useMemo } from 'react';
|
|
3
3
|
|
|
4
4
|
const EvoSearchContext = createContext(null);
|
|
5
5
|
|
|
@@ -473,7 +473,31 @@ const usStates = new Map([
|
|
|
473
473
|
['US-UM', 'United States Minor Outlying Islands'],
|
|
474
474
|
['US-VI', 'Virgin Islands, U.S.'],
|
|
475
475
|
]);
|
|
476
|
-
|
|
476
|
+
/**
|
|
477
|
+
* ISO 3166-2:CA province and territory codes, keyed `CA-XX` to mirror
|
|
478
|
+
* `usStates`. Used by the consent banner's Canada region to offer province
|
|
479
|
+
* selection (Québec is treated specially by the regional consent config).
|
|
480
|
+
*/
|
|
481
|
+
const caProvinces = new Map([
|
|
482
|
+
['CA-AB', 'Alberta'],
|
|
483
|
+
['CA-BC', 'British Columbia'],
|
|
484
|
+
['CA-MB', 'Manitoba'],
|
|
485
|
+
['CA-NB', 'New Brunswick'],
|
|
486
|
+
['CA-NL', 'Newfoundland and Labrador'],
|
|
487
|
+
['CA-NS', 'Nova Scotia'],
|
|
488
|
+
['CA-NT', 'Northwest Territories'],
|
|
489
|
+
['CA-NU', 'Nunavut'],
|
|
490
|
+
['CA-ON', 'Ontario'],
|
|
491
|
+
['CA-PE', 'Prince Edward Island'],
|
|
492
|
+
['CA-QC', 'Quebec'],
|
|
493
|
+
['CA-SK', 'Saskatchewan'],
|
|
494
|
+
['CA-YT', 'Yukon'],
|
|
495
|
+
]);
|
|
496
|
+
new Set([
|
|
497
|
+
...isoCountries.keys(),
|
|
498
|
+
...usStates.keys(),
|
|
499
|
+
...caProvinces.keys(),
|
|
500
|
+
]);
|
|
477
501
|
|
|
478
502
|
/**
|
|
479
503
|
* Exact utm_source normalization (lowercase key → canonical name).
|
|
@@ -606,6 +630,11 @@ const uiActions = new Set([
|
|
|
606
630
|
new Set([...uiActions]);
|
|
607
631
|
|
|
608
632
|
const DEBOUNCE_MS = 200;
|
|
633
|
+
// How long a typed query must sit unchanged after its results render before
|
|
634
|
+
// the widget counts it as one real search (recents + server-side query
|
|
635
|
+
// metrics). Search fires per keystroke, so anything shorter than a genuine
|
|
636
|
+
// reading pause would count prefixes (`a`, `ap`, `app`) as searches.
|
|
637
|
+
const SEARCH_SETTLE_MS = 3000;
|
|
609
638
|
|
|
610
639
|
// Intentionally duplicated from @blotoutio/providers/evo-search/config to keep the hooks bundle lean — importing from the config barrel pulls the shared/universal geo data into dist/hooks.mjs (~4KB gzipped). Drift between the two values is caught by utils.test.ts.
|
|
611
640
|
const isHttpURL = (value) => {
|
|
@@ -664,6 +693,87 @@ const processAutocompleteResponse = (response) => {
|
|
|
664
693
|
};
|
|
665
694
|
};
|
|
666
695
|
|
|
696
|
+
// Resilience wrapper for /search requests. A transient network failure or a
|
|
697
|
+
// hung request must not wedge the UI: add a per-attempt timeout (a hang becomes
|
|
698
|
+
// a rejection), retry transient failures with backoff, and never retry a
|
|
699
|
+
// caller-superseded (aborted) request. Shared by the widget and the headless
|
|
700
|
+
// hook. See callers: search-widget.ts (performAutocomplete) and
|
|
701
|
+
// hooks/use-evo-search.ts.
|
|
702
|
+
const DEFAULTS = {
|
|
703
|
+
timeoutMs: 7000,
|
|
704
|
+
retries: 2,
|
|
705
|
+
backoffMs: [400, 1200],
|
|
706
|
+
};
|
|
707
|
+
// Deterministic HTTP errors (4xx other than 429) will never succeed on
|
|
708
|
+
// retry — only retry timeouts, network errors, 429, and 5xx. Errors without
|
|
709
|
+
// an attached `status` (network/timeout failures) are always retryable.
|
|
710
|
+
const isRetryable = (error) => {
|
|
711
|
+
const status = error === null || error === void 0 ? void 0 : error.status;
|
|
712
|
+
return status == null || status === 429 || status >= 500;
|
|
713
|
+
};
|
|
714
|
+
const delay = (ms, signal) => new Promise((resolve, reject) => {
|
|
715
|
+
if (signal.aborted)
|
|
716
|
+
return reject(new DOMException('Aborted', 'AbortError'));
|
|
717
|
+
const t = setTimeout(resolve, ms);
|
|
718
|
+
signal.addEventListener('abort', () => {
|
|
719
|
+
clearTimeout(t);
|
|
720
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
721
|
+
}, { once: true });
|
|
722
|
+
});
|
|
723
|
+
/**
|
|
724
|
+
* Run `attempt` with a timeout, retrying transient failures. `callerSignal`
|
|
725
|
+
* supersedes everything: if it aborts, we stop immediately without retrying
|
|
726
|
+
* (the query changed / the widget cleaned up). A timeout or network error is
|
|
727
|
+
* retried up to `retries` times; anything left over is rethrown.
|
|
728
|
+
*/
|
|
729
|
+
async function withSearchResilience(attempt, callerSignal, options = {}) {
|
|
730
|
+
var _a, _b;
|
|
731
|
+
const { timeoutMs, retries, backoffMs } = { ...DEFAULTS, ...options };
|
|
732
|
+
let lastError;
|
|
733
|
+
for (let i = 0; i <= retries; i++) {
|
|
734
|
+
if (callerSignal.aborted)
|
|
735
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
736
|
+
const local = new AbortController();
|
|
737
|
+
const onCallerAbort = () => local.abort();
|
|
738
|
+
callerSignal.addEventListener('abort', onCallerAbort, { once: true });
|
|
739
|
+
// Race the attempt against an explicit timeout that REJECTS. Aborting the
|
|
740
|
+
// signal alone isn't enough — an attempt that ignores its signal would
|
|
741
|
+
// otherwise hang forever and never retry. Reject with a TimeoutError (not
|
|
742
|
+
// an AbortError) so callers surface it instead of treating it as a
|
|
743
|
+
// caller-cancellation.
|
|
744
|
+
let timer;
|
|
745
|
+
const timeout = new Promise((_, reject) => {
|
|
746
|
+
timer = setTimeout(() => {
|
|
747
|
+
local.abort(); // still cancel the in-flight request to free the socket
|
|
748
|
+
reject(new DOMException('Search request timed out', 'TimeoutError'));
|
|
749
|
+
}, timeoutMs);
|
|
750
|
+
});
|
|
751
|
+
try {
|
|
752
|
+
return await Promise.race([attempt(local.signal), timeout]);
|
|
753
|
+
}
|
|
754
|
+
catch (error) {
|
|
755
|
+
lastError = error;
|
|
756
|
+
// The caller superseded this request — do not retry, propagate the abort.
|
|
757
|
+
if (callerSignal.aborted)
|
|
758
|
+
throw error;
|
|
759
|
+
// Otherwise it was a timeout or a transient network error: retry if budget remains.
|
|
760
|
+
if (i < retries && isRetryable(error)) {
|
|
761
|
+
const wait = (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
|
|
762
|
+
// Rejects with an AbortError if the caller aborts mid-backoff —
|
|
763
|
+
// propagate that cancellation instead of the transient `error` above.
|
|
764
|
+
await delay(wait, callerSignal);
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
throw error;
|
|
768
|
+
}
|
|
769
|
+
finally {
|
|
770
|
+
clearTimeout(timer);
|
|
771
|
+
callerSignal.removeEventListener('abort', onCallerAbort);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
throw lastError;
|
|
775
|
+
}
|
|
776
|
+
|
|
667
777
|
const isDevEnv$1 = () => typeof process != 'undefined' && process.env['NODE_ENV'] != 'production';
|
|
668
778
|
const emptyReturn = (query, setQuery, trackClick) => ({
|
|
669
779
|
query,
|
|
@@ -684,6 +794,9 @@ const useEvoSearch = (options = {}) => {
|
|
|
684
794
|
const [suggestions, setSuggestions] = useState([]);
|
|
685
795
|
const [loading, setLoading] = useState(false);
|
|
686
796
|
const [error, setError] = useState(null);
|
|
797
|
+
// Bumped when the browser regains connectivity, to re-run the search effect
|
|
798
|
+
// and self-heal a query that failed while offline.
|
|
799
|
+
const [retryNonce, setRetryNonce] = useState(0);
|
|
687
800
|
const ready = (_a = ctx === null || ctx === void 0 ? void 0 : ctx.ready) !== null && _a !== void 0 ? _a : false;
|
|
688
801
|
const api = (_b = ctx === null || ctx === void 0 ? void 0 : ctx.api) !== null && _b !== void 0 ? _b : null;
|
|
689
802
|
useEffect(() => {
|
|
@@ -691,6 +804,25 @@ const useEvoSearch = (options = {}) => {
|
|
|
691
804
|
console.error('[EvoSearch] useEvoSearch called outside <EvoSearchProvider>. Wrap your app in <EvoSearchProvider> at the root.');
|
|
692
805
|
}
|
|
693
806
|
}, [ctx]);
|
|
807
|
+
// Re-running a successful search on every reconnect would refetch (and
|
|
808
|
+
// re-bill) work that already completed; only self-heal a query that's
|
|
809
|
+
// currently in an error state, mirroring the widget's `hasError` gate.
|
|
810
|
+
const hasErrorRef = useRef(false);
|
|
811
|
+
useEffect(() => {
|
|
812
|
+
hasErrorRef.current = error != null;
|
|
813
|
+
}, [error]);
|
|
814
|
+
useEffect(() => {
|
|
815
|
+
if (typeof window === 'undefined') {
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const onOnline = () => {
|
|
819
|
+
if (hasErrorRef.current) {
|
|
820
|
+
setRetryNonce((n) => n + 1);
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
window.addEventListener('online', onOnline);
|
|
824
|
+
return () => window.removeEventListener('online', onOnline);
|
|
825
|
+
}, []);
|
|
694
826
|
useEffect(() => {
|
|
695
827
|
if (!ready || !api) {
|
|
696
828
|
return;
|
|
@@ -713,7 +845,7 @@ const useEvoSearch = (options = {}) => {
|
|
|
713
845
|
const controller = new AbortController();
|
|
714
846
|
const timer = setTimeout(async () => {
|
|
715
847
|
try {
|
|
716
|
-
const res = await api.search(query, undefined, controller.signal);
|
|
848
|
+
const res = await withSearchResilience((signal) => api.search(query, undefined, signal), controller.signal);
|
|
717
849
|
setResults(processAutocompleteResponse(res).products);
|
|
718
850
|
if (wantsSuggestions) {
|
|
719
851
|
setSuggestions((prev) => {
|
|
@@ -742,7 +874,32 @@ const useEvoSearch = (options = {}) => {
|
|
|
742
874
|
clearTimeout(timer);
|
|
743
875
|
controller.abort();
|
|
744
876
|
};
|
|
745
|
-
}, [query, ready, api, mode]);
|
|
877
|
+
}, [query, ready, api, mode, retryNonce]);
|
|
878
|
+
// Widget parity: report a settled query as one real search after SEARCH_SETTLE_MS.
|
|
879
|
+
const lastFinalizedRef = useRef('');
|
|
880
|
+
useEffect(() => {
|
|
881
|
+
if (!api || mode === 'autocomplete' || loading || error) {
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const cleaned = query.trim();
|
|
885
|
+
if (!cleaned) {
|
|
886
|
+
// Clearing the query lets the same term finalize again on re-entry.
|
|
887
|
+
lastFinalizedRef.current = '';
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (cleaned.toLowerCase() === lastFinalizedRef.current) {
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
const timer = setTimeout(() => {
|
|
894
|
+
lastFinalizedRef.current = cleaned.toLowerCase();
|
|
895
|
+
const actionData = {
|
|
896
|
+
query: cleaned,
|
|
897
|
+
resultCount: results.length,
|
|
898
|
+
};
|
|
899
|
+
api.sendEvent('evoSearchSearchIssued', undefined, actionData);
|
|
900
|
+
}, SEARCH_SETTLE_MS);
|
|
901
|
+
return () => clearTimeout(timer);
|
|
902
|
+
}, [api, mode, query, results, loading, error]);
|
|
746
903
|
const trackClick = useCallback((productId, extra) => {
|
|
747
904
|
if (!api) {
|
|
748
905
|
return;
|
package/index.cjs.js
CHANGED
|
@@ -388,7 +388,31 @@ const usStates = new Map([
|
|
|
388
388
|
['US-UM', 'United States Minor Outlying Islands'],
|
|
389
389
|
['US-VI', 'Virgin Islands, U.S.'],
|
|
390
390
|
]);
|
|
391
|
-
|
|
391
|
+
/**
|
|
392
|
+
* ISO 3166-2:CA province and territory codes, keyed `CA-XX` to mirror
|
|
393
|
+
* `usStates`. Used by the consent banner's Canada region to offer province
|
|
394
|
+
* selection (Québec is treated specially by the regional consent config).
|
|
395
|
+
*/
|
|
396
|
+
const caProvinces = new Map([
|
|
397
|
+
['CA-AB', 'Alberta'],
|
|
398
|
+
['CA-BC', 'British Columbia'],
|
|
399
|
+
['CA-MB', 'Manitoba'],
|
|
400
|
+
['CA-NB', 'New Brunswick'],
|
|
401
|
+
['CA-NL', 'Newfoundland and Labrador'],
|
|
402
|
+
['CA-NS', 'Nova Scotia'],
|
|
403
|
+
['CA-NT', 'Northwest Territories'],
|
|
404
|
+
['CA-NU', 'Nunavut'],
|
|
405
|
+
['CA-ON', 'Ontario'],
|
|
406
|
+
['CA-PE', 'Prince Edward Island'],
|
|
407
|
+
['CA-QC', 'Quebec'],
|
|
408
|
+
['CA-SK', 'Saskatchewan'],
|
|
409
|
+
['CA-YT', 'Yukon'],
|
|
410
|
+
]);
|
|
411
|
+
new Set([
|
|
412
|
+
...isoCountries.keys(),
|
|
413
|
+
...usStates.keys(),
|
|
414
|
+
...caProvinces.keys(),
|
|
415
|
+
]);
|
|
392
416
|
|
|
393
417
|
/**
|
|
394
418
|
* Exact utm_source normalization (lowercase key → canonical name).
|
|
@@ -823,10 +847,12 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
823
847
|
throw new Error('sendTag missing');
|
|
824
848
|
}
|
|
825
849
|
const getHeaders = () => {
|
|
850
|
+
var _a;
|
|
826
851
|
const headers = new Headers({ EdgeTagUserId: userId });
|
|
827
852
|
if (sessionId) {
|
|
828
853
|
headers.set('EdgeTagSessionId', sessionId);
|
|
829
854
|
}
|
|
855
|
+
headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
|
|
830
856
|
return headers;
|
|
831
857
|
};
|
|
832
858
|
// Parsing `baseURL` once at API construction avoids re-parsing the host on
|
|
@@ -860,7 +886,9 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
860
886
|
if (!response.ok) {
|
|
861
887
|
const errorText = await response.text().catch(() => '');
|
|
862
888
|
logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
|
|
863
|
-
|
|
889
|
+
const error = new Error(`${label} failed - ${response.status}`);
|
|
890
|
+
error.status = response.status;
|
|
891
|
+
throw error;
|
|
864
892
|
}
|
|
865
893
|
return (await response.json());
|
|
866
894
|
};
|
|
@@ -8281,6 +8309,87 @@ const searchWidgetStyles = i$6 `
|
|
|
8281
8309
|
}
|
|
8282
8310
|
`;
|
|
8283
8311
|
|
|
8312
|
+
// Resilience wrapper for /search requests. A transient network failure or a
|
|
8313
|
+
// hung request must not wedge the UI: add a per-attempt timeout (a hang becomes
|
|
8314
|
+
// a rejection), retry transient failures with backoff, and never retry a
|
|
8315
|
+
// caller-superseded (aborted) request. Shared by the widget and the headless
|
|
8316
|
+
// hook. See callers: search-widget.ts (performAutocomplete) and
|
|
8317
|
+
// hooks/use-evo-search.ts.
|
|
8318
|
+
const DEFAULTS = {
|
|
8319
|
+
timeoutMs: 7000,
|
|
8320
|
+
retries: 2,
|
|
8321
|
+
backoffMs: [400, 1200],
|
|
8322
|
+
};
|
|
8323
|
+
// Deterministic HTTP errors (4xx other than 429) will never succeed on
|
|
8324
|
+
// retry — only retry timeouts, network errors, 429, and 5xx. Errors without
|
|
8325
|
+
// an attached `status` (network/timeout failures) are always retryable.
|
|
8326
|
+
const isRetryable = (error) => {
|
|
8327
|
+
const status = error === null || error === void 0 ? void 0 : error.status;
|
|
8328
|
+
return status == null || status === 429 || status >= 500;
|
|
8329
|
+
};
|
|
8330
|
+
const delay = (ms, signal) => new Promise((resolve, reject) => {
|
|
8331
|
+
if (signal.aborted)
|
|
8332
|
+
return reject(new DOMException('Aborted', 'AbortError'));
|
|
8333
|
+
const t = setTimeout(resolve, ms);
|
|
8334
|
+
signal.addEventListener('abort', () => {
|
|
8335
|
+
clearTimeout(t);
|
|
8336
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
8337
|
+
}, { once: true });
|
|
8338
|
+
});
|
|
8339
|
+
/**
|
|
8340
|
+
* Run `attempt` with a timeout, retrying transient failures. `callerSignal`
|
|
8341
|
+
* supersedes everything: if it aborts, we stop immediately without retrying
|
|
8342
|
+
* (the query changed / the widget cleaned up). A timeout or network error is
|
|
8343
|
+
* retried up to `retries` times; anything left over is rethrown.
|
|
8344
|
+
*/
|
|
8345
|
+
async function withSearchResilience(attempt, callerSignal, options = {}) {
|
|
8346
|
+
var _a, _b;
|
|
8347
|
+
const { timeoutMs, retries, backoffMs } = { ...DEFAULTS, ...options };
|
|
8348
|
+
let lastError;
|
|
8349
|
+
for (let i = 0; i <= retries; i++) {
|
|
8350
|
+
if (callerSignal.aborted)
|
|
8351
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
8352
|
+
const local = new AbortController();
|
|
8353
|
+
const onCallerAbort = () => local.abort();
|
|
8354
|
+
callerSignal.addEventListener('abort', onCallerAbort, { once: true });
|
|
8355
|
+
// Race the attempt against an explicit timeout that REJECTS. Aborting the
|
|
8356
|
+
// signal alone isn't enough — an attempt that ignores its signal would
|
|
8357
|
+
// otherwise hang forever and never retry. Reject with a TimeoutError (not
|
|
8358
|
+
// an AbortError) so callers surface it instead of treating it as a
|
|
8359
|
+
// caller-cancellation.
|
|
8360
|
+
let timer;
|
|
8361
|
+
const timeout = new Promise((_, reject) => {
|
|
8362
|
+
timer = setTimeout(() => {
|
|
8363
|
+
local.abort(); // still cancel the in-flight request to free the socket
|
|
8364
|
+
reject(new DOMException('Search request timed out', 'TimeoutError'));
|
|
8365
|
+
}, timeoutMs);
|
|
8366
|
+
});
|
|
8367
|
+
try {
|
|
8368
|
+
return await Promise.race([attempt(local.signal), timeout]);
|
|
8369
|
+
}
|
|
8370
|
+
catch (error) {
|
|
8371
|
+
lastError = error;
|
|
8372
|
+
// The caller superseded this request — do not retry, propagate the abort.
|
|
8373
|
+
if (callerSignal.aborted)
|
|
8374
|
+
throw error;
|
|
8375
|
+
// Otherwise it was a timeout or a transient network error: retry if budget remains.
|
|
8376
|
+
if (i < retries && isRetryable(error)) {
|
|
8377
|
+
const wait = (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
|
|
8378
|
+
// Rejects with an AbortError if the caller aborts mid-backoff —
|
|
8379
|
+
// propagate that cancellation instead of the transient `error` above.
|
|
8380
|
+
await delay(wait, callerSignal);
|
|
8381
|
+
continue;
|
|
8382
|
+
}
|
|
8383
|
+
throw error;
|
|
8384
|
+
}
|
|
8385
|
+
finally {
|
|
8386
|
+
clearTimeout(timer);
|
|
8387
|
+
callerSignal.removeEventListener('abort', onCallerAbort);
|
|
8388
|
+
}
|
|
8389
|
+
}
|
|
8390
|
+
throw lastError;
|
|
8391
|
+
}
|
|
8392
|
+
|
|
8284
8393
|
const MIN_QUERY_LENGTH = 3;
|
|
8285
8394
|
// @state fields whose change should be persisted to the session snapshot.
|
|
8286
8395
|
const SESSION_PERSIST_KEYS = [
|
|
@@ -8371,6 +8480,7 @@ class SearchWidget extends ElementWithStylesheet {
|
|
|
8371
8480
|
// autocomplete can't overwrite a fresh search's results.
|
|
8372
8481
|
this.requestGeneration = 0;
|
|
8373
8482
|
this.keydownListener = (event) => this.handleKeydown(event);
|
|
8483
|
+
this.onlineListener = () => this.handleReconnect();
|
|
8374
8484
|
// Streaming tokens are coalesced into one per-frame state update rather than
|
|
8375
8485
|
// reassigning `askAnswer` on every token — a ~220-token answer would
|
|
8376
8486
|
// otherwise trigger ~220 full widget renders.
|
|
@@ -8415,6 +8525,20 @@ class SearchWidget extends ElementWithStylesheet {
|
|
|
8415
8525
|
var _a, _b;
|
|
8416
8526
|
(_b = (_a = this.api) === null || _a === void 0 ? void 0 : _a.warm) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
8417
8527
|
}
|
|
8528
|
+
// A transient network failure leaves the results in an error state; when
|
|
8529
|
+
// connectivity returns, re-issue the current query so it self-heals without
|
|
8530
|
+
// the shopper having to retype.
|
|
8531
|
+
handleReconnect() {
|
|
8532
|
+
if (!this.hasError) {
|
|
8533
|
+
return;
|
|
8534
|
+
}
|
|
8535
|
+
const trimmed = this.searchQuery.trim();
|
|
8536
|
+
if (trimmed.length < MIN_QUERY_LENGTH) {
|
|
8537
|
+
return;
|
|
8538
|
+
}
|
|
8539
|
+
this.loading = true;
|
|
8540
|
+
void this.performAutocomplete(trimmed);
|
|
8541
|
+
}
|
|
8418
8542
|
connectedCallback() {
|
|
8419
8543
|
super.connectedCallback();
|
|
8420
8544
|
// The widget always opens in Search view; Ask AI is entered only by
|
|
@@ -8431,6 +8555,7 @@ class SearchWidget extends ElementWithStylesheet {
|
|
|
8431
8555
|
this.mobileMediaQuery = window.matchMedia('(max-width: 768px)');
|
|
8432
8556
|
this.isMobile = this.mobileMediaQuery.matches;
|
|
8433
8557
|
this.mobileMediaQuery.addEventListener('change', this.mobileMediaListener);
|
|
8558
|
+
window.addEventListener('online', this.onlineListener);
|
|
8434
8559
|
}
|
|
8435
8560
|
firstUpdated() {
|
|
8436
8561
|
this.focusSearchBox();
|
|
@@ -8446,6 +8571,7 @@ class SearchWidget extends ElementWithStylesheet {
|
|
|
8446
8571
|
this.resetAnswerBuffer();
|
|
8447
8572
|
this.removeEventListener('keydown', this.keydownListener);
|
|
8448
8573
|
(_c = this.mobileMediaQuery) === null || _c === void 0 ? void 0 : _c.removeEventListener('change', this.mobileMediaListener);
|
|
8574
|
+
window.removeEventListener('online', this.onlineListener);
|
|
8449
8575
|
super.disconnectedCallback();
|
|
8450
8576
|
}
|
|
8451
8577
|
willUpdate(changed) {
|
|
@@ -8864,7 +8990,8 @@ class SearchWidget extends ElementWithStylesheet {
|
|
|
8864
8990
|
this.loading = false;
|
|
8865
8991
|
return;
|
|
8866
8992
|
}
|
|
8867
|
-
const
|
|
8993
|
+
const api = this.api;
|
|
8994
|
+
const response = await withSearchResilience((signal) => api.search(query, undefined, signal), this.newResultsSignal());
|
|
8868
8995
|
if (generation !== this.requestGeneration) {
|
|
8869
8996
|
return;
|
|
8870
8997
|
}
|
package/index.js
CHANGED
|
@@ -389,7 +389,31 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
389
389
|
['US-UM', 'United States Minor Outlying Islands'],
|
|
390
390
|
['US-VI', 'Virgin Islands, U.S.'],
|
|
391
391
|
]);
|
|
392
|
-
|
|
392
|
+
/**
|
|
393
|
+
* ISO 3166-2:CA province and territory codes, keyed `CA-XX` to mirror
|
|
394
|
+
* `usStates`. Used by the consent banner's Canada region to offer province
|
|
395
|
+
* selection (Québec is treated specially by the regional consent config).
|
|
396
|
+
*/
|
|
397
|
+
const caProvinces = new Map([
|
|
398
|
+
['CA-AB', 'Alberta'],
|
|
399
|
+
['CA-BC', 'British Columbia'],
|
|
400
|
+
['CA-MB', 'Manitoba'],
|
|
401
|
+
['CA-NB', 'New Brunswick'],
|
|
402
|
+
['CA-NL', 'Newfoundland and Labrador'],
|
|
403
|
+
['CA-NS', 'Nova Scotia'],
|
|
404
|
+
['CA-NT', 'Northwest Territories'],
|
|
405
|
+
['CA-NU', 'Nunavut'],
|
|
406
|
+
['CA-ON', 'Ontario'],
|
|
407
|
+
['CA-PE', 'Prince Edward Island'],
|
|
408
|
+
['CA-QC', 'Quebec'],
|
|
409
|
+
['CA-SK', 'Saskatchewan'],
|
|
410
|
+
['CA-YT', 'Yukon'],
|
|
411
|
+
]);
|
|
412
|
+
new Set([
|
|
413
|
+
...isoCountries.keys(),
|
|
414
|
+
...usStates.keys(),
|
|
415
|
+
...caProvinces.keys(),
|
|
416
|
+
]);
|
|
393
417
|
|
|
394
418
|
/**
|
|
395
419
|
* Exact utm_source normalization (lowercase key → canonical name).
|
|
@@ -824,10 +848,12 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
824
848
|
throw new Error('sendTag missing');
|
|
825
849
|
}
|
|
826
850
|
const getHeaders = () => {
|
|
851
|
+
var _a;
|
|
827
852
|
const headers = new Headers({ EdgeTagUserId: userId });
|
|
828
853
|
if (sessionId) {
|
|
829
854
|
headers.set('EdgeTagSessionId', sessionId);
|
|
830
855
|
}
|
|
856
|
+
headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
|
|
831
857
|
return headers;
|
|
832
858
|
};
|
|
833
859
|
// Parsing `baseURL` once at API construction avoids re-parsing the host on
|
|
@@ -861,7 +887,9 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
861
887
|
if (!response.ok) {
|
|
862
888
|
const errorText = await response.text().catch(() => '');
|
|
863
889
|
logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
|
|
864
|
-
|
|
890
|
+
const error = new Error(`${label} failed - ${response.status}`);
|
|
891
|
+
error.status = response.status;
|
|
892
|
+
throw error;
|
|
865
893
|
}
|
|
866
894
|
return (await response.json());
|
|
867
895
|
};
|
|
@@ -8282,6 +8310,87 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
8282
8310
|
}
|
|
8283
8311
|
`;
|
|
8284
8312
|
|
|
8313
|
+
// Resilience wrapper for /search requests. A transient network failure or a
|
|
8314
|
+
// hung request must not wedge the UI: add a per-attempt timeout (a hang becomes
|
|
8315
|
+
// a rejection), retry transient failures with backoff, and never retry a
|
|
8316
|
+
// caller-superseded (aborted) request. Shared by the widget and the headless
|
|
8317
|
+
// hook. See callers: search-widget.ts (performAutocomplete) and
|
|
8318
|
+
// hooks/use-evo-search.ts.
|
|
8319
|
+
const DEFAULTS = {
|
|
8320
|
+
timeoutMs: 7000,
|
|
8321
|
+
retries: 2,
|
|
8322
|
+
backoffMs: [400, 1200],
|
|
8323
|
+
};
|
|
8324
|
+
// Deterministic HTTP errors (4xx other than 429) will never succeed on
|
|
8325
|
+
// retry — only retry timeouts, network errors, 429, and 5xx. Errors without
|
|
8326
|
+
// an attached `status` (network/timeout failures) are always retryable.
|
|
8327
|
+
const isRetryable = (error) => {
|
|
8328
|
+
const status = error === null || error === void 0 ? void 0 : error.status;
|
|
8329
|
+
return status == null || status === 429 || status >= 500;
|
|
8330
|
+
};
|
|
8331
|
+
const delay = (ms, signal) => new Promise((resolve, reject) => {
|
|
8332
|
+
if (signal.aborted)
|
|
8333
|
+
return reject(new DOMException('Aborted', 'AbortError'));
|
|
8334
|
+
const t = setTimeout(resolve, ms);
|
|
8335
|
+
signal.addEventListener('abort', () => {
|
|
8336
|
+
clearTimeout(t);
|
|
8337
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
8338
|
+
}, { once: true });
|
|
8339
|
+
});
|
|
8340
|
+
/**
|
|
8341
|
+
* Run `attempt` with a timeout, retrying transient failures. `callerSignal`
|
|
8342
|
+
* supersedes everything: if it aborts, we stop immediately without retrying
|
|
8343
|
+
* (the query changed / the widget cleaned up). A timeout or network error is
|
|
8344
|
+
* retried up to `retries` times; anything left over is rethrown.
|
|
8345
|
+
*/
|
|
8346
|
+
async function withSearchResilience(attempt, callerSignal, options = {}) {
|
|
8347
|
+
var _a, _b;
|
|
8348
|
+
const { timeoutMs, retries, backoffMs } = { ...DEFAULTS, ...options };
|
|
8349
|
+
let lastError;
|
|
8350
|
+
for (let i = 0; i <= retries; i++) {
|
|
8351
|
+
if (callerSignal.aborted)
|
|
8352
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
8353
|
+
const local = new AbortController();
|
|
8354
|
+
const onCallerAbort = () => local.abort();
|
|
8355
|
+
callerSignal.addEventListener('abort', onCallerAbort, { once: true });
|
|
8356
|
+
// Race the attempt against an explicit timeout that REJECTS. Aborting the
|
|
8357
|
+
// signal alone isn't enough — an attempt that ignores its signal would
|
|
8358
|
+
// otherwise hang forever and never retry. Reject with a TimeoutError (not
|
|
8359
|
+
// an AbortError) so callers surface it instead of treating it as a
|
|
8360
|
+
// caller-cancellation.
|
|
8361
|
+
let timer;
|
|
8362
|
+
const timeout = new Promise((_, reject) => {
|
|
8363
|
+
timer = setTimeout(() => {
|
|
8364
|
+
local.abort(); // still cancel the in-flight request to free the socket
|
|
8365
|
+
reject(new DOMException('Search request timed out', 'TimeoutError'));
|
|
8366
|
+
}, timeoutMs);
|
|
8367
|
+
});
|
|
8368
|
+
try {
|
|
8369
|
+
return await Promise.race([attempt(local.signal), timeout]);
|
|
8370
|
+
}
|
|
8371
|
+
catch (error) {
|
|
8372
|
+
lastError = error;
|
|
8373
|
+
// The caller superseded this request — do not retry, propagate the abort.
|
|
8374
|
+
if (callerSignal.aborted)
|
|
8375
|
+
throw error;
|
|
8376
|
+
// Otherwise it was a timeout or a transient network error: retry if budget remains.
|
|
8377
|
+
if (i < retries && isRetryable(error)) {
|
|
8378
|
+
const wait = (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
|
|
8379
|
+
// Rejects with an AbortError if the caller aborts mid-backoff —
|
|
8380
|
+
// propagate that cancellation instead of the transient `error` above.
|
|
8381
|
+
await delay(wait, callerSignal);
|
|
8382
|
+
continue;
|
|
8383
|
+
}
|
|
8384
|
+
throw error;
|
|
8385
|
+
}
|
|
8386
|
+
finally {
|
|
8387
|
+
clearTimeout(timer);
|
|
8388
|
+
callerSignal.removeEventListener('abort', onCallerAbort);
|
|
8389
|
+
}
|
|
8390
|
+
}
|
|
8391
|
+
throw lastError;
|
|
8392
|
+
}
|
|
8393
|
+
|
|
8285
8394
|
const MIN_QUERY_LENGTH = 3;
|
|
8286
8395
|
// @state fields whose change should be persisted to the session snapshot.
|
|
8287
8396
|
const SESSION_PERSIST_KEYS = [
|
|
@@ -8372,6 +8481,7 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
8372
8481
|
// autocomplete can't overwrite a fresh search's results.
|
|
8373
8482
|
this.requestGeneration = 0;
|
|
8374
8483
|
this.keydownListener = (event) => this.handleKeydown(event);
|
|
8484
|
+
this.onlineListener = () => this.handleReconnect();
|
|
8375
8485
|
// Streaming tokens are coalesced into one per-frame state update rather than
|
|
8376
8486
|
// reassigning `askAnswer` on every token — a ~220-token answer would
|
|
8377
8487
|
// otherwise trigger ~220 full widget renders.
|
|
@@ -8416,6 +8526,20 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
8416
8526
|
var _a, _b;
|
|
8417
8527
|
(_b = (_a = this.api) === null || _a === void 0 ? void 0 : _a.warm) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
8418
8528
|
}
|
|
8529
|
+
// A transient network failure leaves the results in an error state; when
|
|
8530
|
+
// connectivity returns, re-issue the current query so it self-heals without
|
|
8531
|
+
// the shopper having to retype.
|
|
8532
|
+
handleReconnect() {
|
|
8533
|
+
if (!this.hasError) {
|
|
8534
|
+
return;
|
|
8535
|
+
}
|
|
8536
|
+
const trimmed = this.searchQuery.trim();
|
|
8537
|
+
if (trimmed.length < MIN_QUERY_LENGTH) {
|
|
8538
|
+
return;
|
|
8539
|
+
}
|
|
8540
|
+
this.loading = true;
|
|
8541
|
+
void this.performAutocomplete(trimmed);
|
|
8542
|
+
}
|
|
8419
8543
|
connectedCallback() {
|
|
8420
8544
|
super.connectedCallback();
|
|
8421
8545
|
// The widget always opens in Search view; Ask AI is entered only by
|
|
@@ -8432,6 +8556,7 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
8432
8556
|
this.mobileMediaQuery = window.matchMedia('(max-width: 768px)');
|
|
8433
8557
|
this.isMobile = this.mobileMediaQuery.matches;
|
|
8434
8558
|
this.mobileMediaQuery.addEventListener('change', this.mobileMediaListener);
|
|
8559
|
+
window.addEventListener('online', this.onlineListener);
|
|
8435
8560
|
}
|
|
8436
8561
|
firstUpdated() {
|
|
8437
8562
|
this.focusSearchBox();
|
|
@@ -8447,6 +8572,7 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
8447
8572
|
this.resetAnswerBuffer();
|
|
8448
8573
|
this.removeEventListener('keydown', this.keydownListener);
|
|
8449
8574
|
(_c = this.mobileMediaQuery) === null || _c === void 0 ? void 0 : _c.removeEventListener('change', this.mobileMediaListener);
|
|
8575
|
+
window.removeEventListener('online', this.onlineListener);
|
|
8450
8576
|
super.disconnectedCallback();
|
|
8451
8577
|
}
|
|
8452
8578
|
willUpdate(changed) {
|
|
@@ -8865,7 +8991,8 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
8865
8991
|
this.loading = false;
|
|
8866
8992
|
return;
|
|
8867
8993
|
}
|
|
8868
|
-
const
|
|
8994
|
+
const api = this.api;
|
|
8995
|
+
const response = await withSearchResilience((signal) => api.search(query, undefined, signal), this.newResultsSignal());
|
|
8869
8996
|
if (generation !== this.requestGeneration) {
|
|
8870
8997
|
return;
|
|
8871
8998
|
}
|
package/index.mjs
CHANGED
|
@@ -386,7 +386,31 @@ const usStates = new Map([
|
|
|
386
386
|
['US-UM', 'United States Minor Outlying Islands'],
|
|
387
387
|
['US-VI', 'Virgin Islands, U.S.'],
|
|
388
388
|
]);
|
|
389
|
-
|
|
389
|
+
/**
|
|
390
|
+
* ISO 3166-2:CA province and territory codes, keyed `CA-XX` to mirror
|
|
391
|
+
* `usStates`. Used by the consent banner's Canada region to offer province
|
|
392
|
+
* selection (Québec is treated specially by the regional consent config).
|
|
393
|
+
*/
|
|
394
|
+
const caProvinces = new Map([
|
|
395
|
+
['CA-AB', 'Alberta'],
|
|
396
|
+
['CA-BC', 'British Columbia'],
|
|
397
|
+
['CA-MB', 'Manitoba'],
|
|
398
|
+
['CA-NB', 'New Brunswick'],
|
|
399
|
+
['CA-NL', 'Newfoundland and Labrador'],
|
|
400
|
+
['CA-NS', 'Nova Scotia'],
|
|
401
|
+
['CA-NT', 'Northwest Territories'],
|
|
402
|
+
['CA-NU', 'Nunavut'],
|
|
403
|
+
['CA-ON', 'Ontario'],
|
|
404
|
+
['CA-PE', 'Prince Edward Island'],
|
|
405
|
+
['CA-QC', 'Quebec'],
|
|
406
|
+
['CA-SK', 'Saskatchewan'],
|
|
407
|
+
['CA-YT', 'Yukon'],
|
|
408
|
+
]);
|
|
409
|
+
new Set([
|
|
410
|
+
...isoCountries.keys(),
|
|
411
|
+
...usStates.keys(),
|
|
412
|
+
...caProvinces.keys(),
|
|
413
|
+
]);
|
|
390
414
|
|
|
391
415
|
/**
|
|
392
416
|
* Exact utm_source normalization (lowercase key → canonical name).
|
|
@@ -821,10 +845,12 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
821
845
|
throw new Error('sendTag missing');
|
|
822
846
|
}
|
|
823
847
|
const getHeaders = () => {
|
|
848
|
+
var _a;
|
|
824
849
|
const headers = new Headers({ EdgeTagUserId: userId });
|
|
825
850
|
if (sessionId) {
|
|
826
851
|
headers.set('EdgeTagSessionId', sessionId);
|
|
827
852
|
}
|
|
853
|
+
headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
|
|
828
854
|
return headers;
|
|
829
855
|
};
|
|
830
856
|
// Parsing `baseURL` once at API construction avoids re-parsing the host on
|
|
@@ -858,7 +884,9 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
858
884
|
if (!response.ok) {
|
|
859
885
|
const errorText = await response.text().catch(() => '');
|
|
860
886
|
logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
|
|
861
|
-
|
|
887
|
+
const error = new Error(`${label} failed - ${response.status}`);
|
|
888
|
+
error.status = response.status;
|
|
889
|
+
throw error;
|
|
862
890
|
}
|
|
863
891
|
return (await response.json());
|
|
864
892
|
};
|
|
@@ -8279,6 +8307,87 @@ const searchWidgetStyles = i$6 `
|
|
|
8279
8307
|
}
|
|
8280
8308
|
`;
|
|
8281
8309
|
|
|
8310
|
+
// Resilience wrapper for /search requests. A transient network failure or a
|
|
8311
|
+
// hung request must not wedge the UI: add a per-attempt timeout (a hang becomes
|
|
8312
|
+
// a rejection), retry transient failures with backoff, and never retry a
|
|
8313
|
+
// caller-superseded (aborted) request. Shared by the widget and the headless
|
|
8314
|
+
// hook. See callers: search-widget.ts (performAutocomplete) and
|
|
8315
|
+
// hooks/use-evo-search.ts.
|
|
8316
|
+
const DEFAULTS = {
|
|
8317
|
+
timeoutMs: 7000,
|
|
8318
|
+
retries: 2,
|
|
8319
|
+
backoffMs: [400, 1200],
|
|
8320
|
+
};
|
|
8321
|
+
// Deterministic HTTP errors (4xx other than 429) will never succeed on
|
|
8322
|
+
// retry — only retry timeouts, network errors, 429, and 5xx. Errors without
|
|
8323
|
+
// an attached `status` (network/timeout failures) are always retryable.
|
|
8324
|
+
const isRetryable = (error) => {
|
|
8325
|
+
const status = error === null || error === void 0 ? void 0 : error.status;
|
|
8326
|
+
return status == null || status === 429 || status >= 500;
|
|
8327
|
+
};
|
|
8328
|
+
const delay = (ms, signal) => new Promise((resolve, reject) => {
|
|
8329
|
+
if (signal.aborted)
|
|
8330
|
+
return reject(new DOMException('Aborted', 'AbortError'));
|
|
8331
|
+
const t = setTimeout(resolve, ms);
|
|
8332
|
+
signal.addEventListener('abort', () => {
|
|
8333
|
+
clearTimeout(t);
|
|
8334
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
8335
|
+
}, { once: true });
|
|
8336
|
+
});
|
|
8337
|
+
/**
|
|
8338
|
+
* Run `attempt` with a timeout, retrying transient failures. `callerSignal`
|
|
8339
|
+
* supersedes everything: if it aborts, we stop immediately without retrying
|
|
8340
|
+
* (the query changed / the widget cleaned up). A timeout or network error is
|
|
8341
|
+
* retried up to `retries` times; anything left over is rethrown.
|
|
8342
|
+
*/
|
|
8343
|
+
async function withSearchResilience(attempt, callerSignal, options = {}) {
|
|
8344
|
+
var _a, _b;
|
|
8345
|
+
const { timeoutMs, retries, backoffMs } = { ...DEFAULTS, ...options };
|
|
8346
|
+
let lastError;
|
|
8347
|
+
for (let i = 0; i <= retries; i++) {
|
|
8348
|
+
if (callerSignal.aborted)
|
|
8349
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
8350
|
+
const local = new AbortController();
|
|
8351
|
+
const onCallerAbort = () => local.abort();
|
|
8352
|
+
callerSignal.addEventListener('abort', onCallerAbort, { once: true });
|
|
8353
|
+
// Race the attempt against an explicit timeout that REJECTS. Aborting the
|
|
8354
|
+
// signal alone isn't enough — an attempt that ignores its signal would
|
|
8355
|
+
// otherwise hang forever and never retry. Reject with a TimeoutError (not
|
|
8356
|
+
// an AbortError) so callers surface it instead of treating it as a
|
|
8357
|
+
// caller-cancellation.
|
|
8358
|
+
let timer;
|
|
8359
|
+
const timeout = new Promise((_, reject) => {
|
|
8360
|
+
timer = setTimeout(() => {
|
|
8361
|
+
local.abort(); // still cancel the in-flight request to free the socket
|
|
8362
|
+
reject(new DOMException('Search request timed out', 'TimeoutError'));
|
|
8363
|
+
}, timeoutMs);
|
|
8364
|
+
});
|
|
8365
|
+
try {
|
|
8366
|
+
return await Promise.race([attempt(local.signal), timeout]);
|
|
8367
|
+
}
|
|
8368
|
+
catch (error) {
|
|
8369
|
+
lastError = error;
|
|
8370
|
+
// The caller superseded this request — do not retry, propagate the abort.
|
|
8371
|
+
if (callerSignal.aborted)
|
|
8372
|
+
throw error;
|
|
8373
|
+
// Otherwise it was a timeout or a transient network error: retry if budget remains.
|
|
8374
|
+
if (i < retries && isRetryable(error)) {
|
|
8375
|
+
const wait = (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
|
|
8376
|
+
// Rejects with an AbortError if the caller aborts mid-backoff —
|
|
8377
|
+
// propagate that cancellation instead of the transient `error` above.
|
|
8378
|
+
await delay(wait, callerSignal);
|
|
8379
|
+
continue;
|
|
8380
|
+
}
|
|
8381
|
+
throw error;
|
|
8382
|
+
}
|
|
8383
|
+
finally {
|
|
8384
|
+
clearTimeout(timer);
|
|
8385
|
+
callerSignal.removeEventListener('abort', onCallerAbort);
|
|
8386
|
+
}
|
|
8387
|
+
}
|
|
8388
|
+
throw lastError;
|
|
8389
|
+
}
|
|
8390
|
+
|
|
8282
8391
|
const MIN_QUERY_LENGTH = 3;
|
|
8283
8392
|
// @state fields whose change should be persisted to the session snapshot.
|
|
8284
8393
|
const SESSION_PERSIST_KEYS = [
|
|
@@ -8369,6 +8478,7 @@ class SearchWidget extends ElementWithStylesheet {
|
|
|
8369
8478
|
// autocomplete can't overwrite a fresh search's results.
|
|
8370
8479
|
this.requestGeneration = 0;
|
|
8371
8480
|
this.keydownListener = (event) => this.handleKeydown(event);
|
|
8481
|
+
this.onlineListener = () => this.handleReconnect();
|
|
8372
8482
|
// Streaming tokens are coalesced into one per-frame state update rather than
|
|
8373
8483
|
// reassigning `askAnswer` on every token — a ~220-token answer would
|
|
8374
8484
|
// otherwise trigger ~220 full widget renders.
|
|
@@ -8413,6 +8523,20 @@ class SearchWidget extends ElementWithStylesheet {
|
|
|
8413
8523
|
var _a, _b;
|
|
8414
8524
|
(_b = (_a = this.api) === null || _a === void 0 ? void 0 : _a.warm) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
8415
8525
|
}
|
|
8526
|
+
// A transient network failure leaves the results in an error state; when
|
|
8527
|
+
// connectivity returns, re-issue the current query so it self-heals without
|
|
8528
|
+
// the shopper having to retype.
|
|
8529
|
+
handleReconnect() {
|
|
8530
|
+
if (!this.hasError) {
|
|
8531
|
+
return;
|
|
8532
|
+
}
|
|
8533
|
+
const trimmed = this.searchQuery.trim();
|
|
8534
|
+
if (trimmed.length < MIN_QUERY_LENGTH) {
|
|
8535
|
+
return;
|
|
8536
|
+
}
|
|
8537
|
+
this.loading = true;
|
|
8538
|
+
void this.performAutocomplete(trimmed);
|
|
8539
|
+
}
|
|
8416
8540
|
connectedCallback() {
|
|
8417
8541
|
super.connectedCallback();
|
|
8418
8542
|
// The widget always opens in Search view; Ask AI is entered only by
|
|
@@ -8429,6 +8553,7 @@ class SearchWidget extends ElementWithStylesheet {
|
|
|
8429
8553
|
this.mobileMediaQuery = window.matchMedia('(max-width: 768px)');
|
|
8430
8554
|
this.isMobile = this.mobileMediaQuery.matches;
|
|
8431
8555
|
this.mobileMediaQuery.addEventListener('change', this.mobileMediaListener);
|
|
8556
|
+
window.addEventListener('online', this.onlineListener);
|
|
8432
8557
|
}
|
|
8433
8558
|
firstUpdated() {
|
|
8434
8559
|
this.focusSearchBox();
|
|
@@ -8444,6 +8569,7 @@ class SearchWidget extends ElementWithStylesheet {
|
|
|
8444
8569
|
this.resetAnswerBuffer();
|
|
8445
8570
|
this.removeEventListener('keydown', this.keydownListener);
|
|
8446
8571
|
(_c = this.mobileMediaQuery) === null || _c === void 0 ? void 0 : _c.removeEventListener('change', this.mobileMediaListener);
|
|
8572
|
+
window.removeEventListener('online', this.onlineListener);
|
|
8447
8573
|
super.disconnectedCallback();
|
|
8448
8574
|
}
|
|
8449
8575
|
willUpdate(changed) {
|
|
@@ -8862,7 +8988,8 @@ class SearchWidget extends ElementWithStylesheet {
|
|
|
8862
8988
|
this.loading = false;
|
|
8863
8989
|
return;
|
|
8864
8990
|
}
|
|
8865
|
-
const
|
|
8991
|
+
const api = this.api;
|
|
8992
|
+
const response = await withSearchResilience((signal) => api.search(query, undefined, signal), this.newResultsSignal());
|
|
8866
8993
|
if (generation !== this.requestGeneration) {
|
|
8867
8994
|
return;
|
|
8868
8995
|
}
|