@blotoutio/providers-evo-search-sdk 1.64.1 → 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 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
@@ -662,10 +662,12 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
662
662
  throw new Error('sendTag missing');
663
663
  }
664
664
  const getHeaders = () => {
665
+ var _a;
665
666
  const headers = new Headers({ EdgeTagUserId: userId });
666
667
  if (sessionId) {
667
668
  headers.set('EdgeTagSessionId', sessionId);
668
669
  }
670
+ headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
669
671
  return headers;
670
672
  };
671
673
  // Parsing `baseURL` once at API construction avoids re-parsing the host on
@@ -699,7 +701,9 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
699
701
  if (!response.ok) {
700
702
  const errorText = await response.text().catch(() => '');
701
703
  logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
702
- throw new Error(`${label} failed - ${response.status}`);
704
+ const error = new Error(`${label} failed - ${response.status}`);
705
+ error.status = response.status;
706
+ throw error;
703
707
  }
704
708
  return (await response.json());
705
709
  };
package/core.js CHANGED
@@ -663,10 +663,12 @@ var ProvidersEvoSearchSdk = (function () {
663
663
  throw new Error('sendTag missing');
664
664
  }
665
665
  const getHeaders = () => {
666
+ var _a;
666
667
  const headers = new Headers({ EdgeTagUserId: userId });
667
668
  if (sessionId) {
668
669
  headers.set('EdgeTagSessionId', sessionId);
669
670
  }
671
+ headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
670
672
  return headers;
671
673
  };
672
674
  // Parsing `baseURL` once at API construction avoids re-parsing the host on
@@ -700,7 +702,9 @@ var ProvidersEvoSearchSdk = (function () {
700
702
  if (!response.ok) {
701
703
  const errorText = await response.text().catch(() => '');
702
704
  logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
703
- throw new Error(`${label} failed - ${response.status}`);
705
+ const error = new Error(`${label} failed - ${response.status}`);
706
+ error.status = response.status;
707
+ throw error;
704
708
  }
705
709
  return (await response.json());
706
710
  };
package/core.mjs CHANGED
@@ -660,10 +660,12 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
660
660
  throw new Error('sendTag missing');
661
661
  }
662
662
  const getHeaders = () => {
663
+ var _a;
663
664
  const headers = new Headers({ EdgeTagUserId: userId });
664
665
  if (sessionId) {
665
666
  headers.set('EdgeTagSessionId', sessionId);
666
667
  }
668
+ headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
667
669
  return headers;
668
670
  };
669
671
  // Parsing `baseURL` once at API construction avoids re-parsing the host on
@@ -697,7 +699,9 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
697
699
  if (!response.ok) {
698
700
  const errorText = await response.text().catch(() => '');
699
701
  logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
700
- throw new Error(`${label} failed - ${response.status}`);
702
+ const error = new Error(`${label} failed - ${response.status}`);
703
+ error.status = response.status;
704
+ throw error;
701
705
  }
702
706
  return (await response.json());
703
707
  };
package/hooks.cjs.js CHANGED
@@ -632,6 +632,11 @@ const uiActions = new Set([
632
632
  new Set([...uiActions]);
633
633
 
634
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;
635
640
 
636
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.
637
642
  const isHttpURL = (value) => {
@@ -690,6 +695,87 @@ const processAutocompleteResponse = (response) => {
690
695
  };
691
696
  };
692
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
+
693
779
  const isDevEnv$1 = () => typeof process != 'undefined' && process.env['NODE_ENV'] != 'production';
694
780
  const emptyReturn = (query, setQuery, trackClick) => ({
695
781
  query,
@@ -710,6 +796,9 @@ const useEvoSearch = (options = {}) => {
710
796
  const [suggestions, setSuggestions] = react.useState([]);
711
797
  const [loading, setLoading] = react.useState(false);
712
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);
713
802
  const ready = (_a = ctx === null || ctx === void 0 ? void 0 : ctx.ready) !== null && _a !== void 0 ? _a : false;
714
803
  const api = (_b = ctx === null || ctx === void 0 ? void 0 : ctx.api) !== null && _b !== void 0 ? _b : null;
715
804
  react.useEffect(() => {
@@ -717,6 +806,25 @@ const useEvoSearch = (options = {}) => {
717
806
  console.error('[EvoSearch] useEvoSearch called outside <EvoSearchProvider>. Wrap your app in <EvoSearchProvider> at the root.');
718
807
  }
719
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
+ }, []);
720
828
  react.useEffect(() => {
721
829
  if (!ready || !api) {
722
830
  return;
@@ -739,7 +847,7 @@ const useEvoSearch = (options = {}) => {
739
847
  const controller = new AbortController();
740
848
  const timer = setTimeout(async () => {
741
849
  try {
742
- const res = await api.search(query, undefined, controller.signal);
850
+ const res = await withSearchResilience((signal) => api.search(query, undefined, signal), controller.signal);
743
851
  setResults(processAutocompleteResponse(res).products);
744
852
  if (wantsSuggestions) {
745
853
  setSuggestions((prev) => {
@@ -768,7 +876,32 @@ const useEvoSearch = (options = {}) => {
768
876
  clearTimeout(timer);
769
877
  controller.abort();
770
878
  };
771
- }, [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]);
772
905
  const trackClick = react.useCallback((productId, extra) => {
773
906
  if (!api) {
774
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
 
@@ -630,6 +630,11 @@ const uiActions = new Set([
630
630
  new Set([...uiActions]);
631
631
 
632
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;
633
638
 
634
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.
635
640
  const isHttpURL = (value) => {
@@ -688,6 +693,87 @@ const processAutocompleteResponse = (response) => {
688
693
  };
689
694
  };
690
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
+
691
777
  const isDevEnv$1 = () => typeof process != 'undefined' && process.env['NODE_ENV'] != 'production';
692
778
  const emptyReturn = (query, setQuery, trackClick) => ({
693
779
  query,
@@ -708,6 +794,9 @@ const useEvoSearch = (options = {}) => {
708
794
  const [suggestions, setSuggestions] = useState([]);
709
795
  const [loading, setLoading] = useState(false);
710
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);
711
800
  const ready = (_a = ctx === null || ctx === void 0 ? void 0 : ctx.ready) !== null && _a !== void 0 ? _a : false;
712
801
  const api = (_b = ctx === null || ctx === void 0 ? void 0 : ctx.api) !== null && _b !== void 0 ? _b : null;
713
802
  useEffect(() => {
@@ -715,6 +804,25 @@ const useEvoSearch = (options = {}) => {
715
804
  console.error('[EvoSearch] useEvoSearch called outside <EvoSearchProvider>. Wrap your app in <EvoSearchProvider> at the root.');
716
805
  }
717
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
+ }, []);
718
826
  useEffect(() => {
719
827
  if (!ready || !api) {
720
828
  return;
@@ -737,7 +845,7 @@ const useEvoSearch = (options = {}) => {
737
845
  const controller = new AbortController();
738
846
  const timer = setTimeout(async () => {
739
847
  try {
740
- const res = await api.search(query, undefined, controller.signal);
848
+ const res = await withSearchResilience((signal) => api.search(query, undefined, signal), controller.signal);
741
849
  setResults(processAutocompleteResponse(res).products);
742
850
  if (wantsSuggestions) {
743
851
  setSuggestions((prev) => {
@@ -766,7 +874,32 @@ const useEvoSearch = (options = {}) => {
766
874
  clearTimeout(timer);
767
875
  controller.abort();
768
876
  };
769
- }, [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]);
770
903
  const trackClick = useCallback((productId, extra) => {
771
904
  if (!api) {
772
905
  return;
package/index.cjs.js CHANGED
@@ -847,10 +847,12 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
847
847
  throw new Error('sendTag missing');
848
848
  }
849
849
  const getHeaders = () => {
850
+ var _a;
850
851
  const headers = new Headers({ EdgeTagUserId: userId });
851
852
  if (sessionId) {
852
853
  headers.set('EdgeTagSessionId', sessionId);
853
854
  }
855
+ headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
854
856
  return headers;
855
857
  };
856
858
  // Parsing `baseURL` once at API construction avoids re-parsing the host on
@@ -884,7 +886,9 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
884
886
  if (!response.ok) {
885
887
  const errorText = await response.text().catch(() => '');
886
888
  logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
887
- throw new Error(`${label} failed - ${response.status}`);
889
+ const error = new Error(`${label} failed - ${response.status}`);
890
+ error.status = response.status;
891
+ throw error;
888
892
  }
889
893
  return (await response.json());
890
894
  };
@@ -8305,6 +8309,87 @@ const searchWidgetStyles = i$6 `
8305
8309
  }
8306
8310
  `;
8307
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
+
8308
8393
  const MIN_QUERY_LENGTH = 3;
8309
8394
  // @state fields whose change should be persisted to the session snapshot.
8310
8395
  const SESSION_PERSIST_KEYS = [
@@ -8395,6 +8480,7 @@ class SearchWidget extends ElementWithStylesheet {
8395
8480
  // autocomplete can't overwrite a fresh search's results.
8396
8481
  this.requestGeneration = 0;
8397
8482
  this.keydownListener = (event) => this.handleKeydown(event);
8483
+ this.onlineListener = () => this.handleReconnect();
8398
8484
  // Streaming tokens are coalesced into one per-frame state update rather than
8399
8485
  // reassigning `askAnswer` on every token — a ~220-token answer would
8400
8486
  // otherwise trigger ~220 full widget renders.
@@ -8439,6 +8525,20 @@ class SearchWidget extends ElementWithStylesheet {
8439
8525
  var _a, _b;
8440
8526
  (_b = (_a = this.api) === null || _a === void 0 ? void 0 : _a.warm) === null || _b === void 0 ? void 0 : _b.call(_a);
8441
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
+ }
8442
8542
  connectedCallback() {
8443
8543
  super.connectedCallback();
8444
8544
  // The widget always opens in Search view; Ask AI is entered only by
@@ -8455,6 +8555,7 @@ class SearchWidget extends ElementWithStylesheet {
8455
8555
  this.mobileMediaQuery = window.matchMedia('(max-width: 768px)');
8456
8556
  this.isMobile = this.mobileMediaQuery.matches;
8457
8557
  this.mobileMediaQuery.addEventListener('change', this.mobileMediaListener);
8558
+ window.addEventListener('online', this.onlineListener);
8458
8559
  }
8459
8560
  firstUpdated() {
8460
8561
  this.focusSearchBox();
@@ -8470,6 +8571,7 @@ class SearchWidget extends ElementWithStylesheet {
8470
8571
  this.resetAnswerBuffer();
8471
8572
  this.removeEventListener('keydown', this.keydownListener);
8472
8573
  (_c = this.mobileMediaQuery) === null || _c === void 0 ? void 0 : _c.removeEventListener('change', this.mobileMediaListener);
8574
+ window.removeEventListener('online', this.onlineListener);
8473
8575
  super.disconnectedCallback();
8474
8576
  }
8475
8577
  willUpdate(changed) {
@@ -8888,7 +8990,8 @@ class SearchWidget extends ElementWithStylesheet {
8888
8990
  this.loading = false;
8889
8991
  return;
8890
8992
  }
8891
- const response = await this.api.search(query, undefined, this.newResultsSignal());
8993
+ const api = this.api;
8994
+ const response = await withSearchResilience((signal) => api.search(query, undefined, signal), this.newResultsSignal());
8892
8995
  if (generation !== this.requestGeneration) {
8893
8996
  return;
8894
8997
  }
package/index.js CHANGED
@@ -848,10 +848,12 @@ var ProvidersEvoSearchSdk = (function () {
848
848
  throw new Error('sendTag missing');
849
849
  }
850
850
  const getHeaders = () => {
851
+ var _a;
851
852
  const headers = new Headers({ EdgeTagUserId: userId });
852
853
  if (sessionId) {
853
854
  headers.set('EdgeTagSessionId', sessionId);
854
855
  }
856
+ headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
855
857
  return headers;
856
858
  };
857
859
  // Parsing `baseURL` once at API construction avoids re-parsing the host on
@@ -885,7 +887,9 @@ var ProvidersEvoSearchSdk = (function () {
885
887
  if (!response.ok) {
886
888
  const errorText = await response.text().catch(() => '');
887
889
  logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
888
- throw new Error(`${label} failed - ${response.status}`);
890
+ const error = new Error(`${label} failed - ${response.status}`);
891
+ error.status = response.status;
892
+ throw error;
889
893
  }
890
894
  return (await response.json());
891
895
  };
@@ -8306,6 +8310,87 @@ var ProvidersEvoSearchSdk = (function () {
8306
8310
  }
8307
8311
  `;
8308
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
+
8309
8394
  const MIN_QUERY_LENGTH = 3;
8310
8395
  // @state fields whose change should be persisted to the session snapshot.
8311
8396
  const SESSION_PERSIST_KEYS = [
@@ -8396,6 +8481,7 @@ var ProvidersEvoSearchSdk = (function () {
8396
8481
  // autocomplete can't overwrite a fresh search's results.
8397
8482
  this.requestGeneration = 0;
8398
8483
  this.keydownListener = (event) => this.handleKeydown(event);
8484
+ this.onlineListener = () => this.handleReconnect();
8399
8485
  // Streaming tokens are coalesced into one per-frame state update rather than
8400
8486
  // reassigning `askAnswer` on every token — a ~220-token answer would
8401
8487
  // otherwise trigger ~220 full widget renders.
@@ -8440,6 +8526,20 @@ var ProvidersEvoSearchSdk = (function () {
8440
8526
  var _a, _b;
8441
8527
  (_b = (_a = this.api) === null || _a === void 0 ? void 0 : _a.warm) === null || _b === void 0 ? void 0 : _b.call(_a);
8442
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
+ }
8443
8543
  connectedCallback() {
8444
8544
  super.connectedCallback();
8445
8545
  // The widget always opens in Search view; Ask AI is entered only by
@@ -8456,6 +8556,7 @@ var ProvidersEvoSearchSdk = (function () {
8456
8556
  this.mobileMediaQuery = window.matchMedia('(max-width: 768px)');
8457
8557
  this.isMobile = this.mobileMediaQuery.matches;
8458
8558
  this.mobileMediaQuery.addEventListener('change', this.mobileMediaListener);
8559
+ window.addEventListener('online', this.onlineListener);
8459
8560
  }
8460
8561
  firstUpdated() {
8461
8562
  this.focusSearchBox();
@@ -8471,6 +8572,7 @@ var ProvidersEvoSearchSdk = (function () {
8471
8572
  this.resetAnswerBuffer();
8472
8573
  this.removeEventListener('keydown', this.keydownListener);
8473
8574
  (_c = this.mobileMediaQuery) === null || _c === void 0 ? void 0 : _c.removeEventListener('change', this.mobileMediaListener);
8575
+ window.removeEventListener('online', this.onlineListener);
8474
8576
  super.disconnectedCallback();
8475
8577
  }
8476
8578
  willUpdate(changed) {
@@ -8889,7 +8991,8 @@ var ProvidersEvoSearchSdk = (function () {
8889
8991
  this.loading = false;
8890
8992
  return;
8891
8993
  }
8892
- const response = await this.api.search(query, undefined, this.newResultsSignal());
8994
+ const api = this.api;
8995
+ const response = await withSearchResilience((signal) => api.search(query, undefined, signal), this.newResultsSignal());
8893
8996
  if (generation !== this.requestGeneration) {
8894
8997
  return;
8895
8998
  }
package/index.mjs CHANGED
@@ -845,10 +845,12 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
845
845
  throw new Error('sendTag missing');
846
846
  }
847
847
  const getHeaders = () => {
848
+ var _a;
848
849
  const headers = new Headers({ EdgeTagUserId: userId });
849
850
  if (sessionId) {
850
851
  headers.set('EdgeTagSessionId', sessionId);
851
852
  }
853
+ headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
852
854
  return headers;
853
855
  };
854
856
  // Parsing `baseURL` once at API construction avoids re-parsing the host on
@@ -882,7 +884,9 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
882
884
  if (!response.ok) {
883
885
  const errorText = await response.text().catch(() => '');
884
886
  logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
885
- throw new Error(`${label} failed - ${response.status}`);
887
+ const error = new Error(`${label} failed - ${response.status}`);
888
+ error.status = response.status;
889
+ throw error;
886
890
  }
887
891
  return (await response.json());
888
892
  };
@@ -8303,6 +8307,87 @@ const searchWidgetStyles = i$6 `
8303
8307
  }
8304
8308
  `;
8305
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
+
8306
8391
  const MIN_QUERY_LENGTH = 3;
8307
8392
  // @state fields whose change should be persisted to the session snapshot.
8308
8393
  const SESSION_PERSIST_KEYS = [
@@ -8393,6 +8478,7 @@ class SearchWidget extends ElementWithStylesheet {
8393
8478
  // autocomplete can't overwrite a fresh search's results.
8394
8479
  this.requestGeneration = 0;
8395
8480
  this.keydownListener = (event) => this.handleKeydown(event);
8481
+ this.onlineListener = () => this.handleReconnect();
8396
8482
  // Streaming tokens are coalesced into one per-frame state update rather than
8397
8483
  // reassigning `askAnswer` on every token — a ~220-token answer would
8398
8484
  // otherwise trigger ~220 full widget renders.
@@ -8437,6 +8523,20 @@ class SearchWidget extends ElementWithStylesheet {
8437
8523
  var _a, _b;
8438
8524
  (_b = (_a = this.api) === null || _a === void 0 ? void 0 : _a.warm) === null || _b === void 0 ? void 0 : _b.call(_a);
8439
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
+ }
8440
8540
  connectedCallback() {
8441
8541
  super.connectedCallback();
8442
8542
  // The widget always opens in Search view; Ask AI is entered only by
@@ -8453,6 +8553,7 @@ class SearchWidget extends ElementWithStylesheet {
8453
8553
  this.mobileMediaQuery = window.matchMedia('(max-width: 768px)');
8454
8554
  this.isMobile = this.mobileMediaQuery.matches;
8455
8555
  this.mobileMediaQuery.addEventListener('change', this.mobileMediaListener);
8556
+ window.addEventListener('online', this.onlineListener);
8456
8557
  }
8457
8558
  firstUpdated() {
8458
8559
  this.focusSearchBox();
@@ -8468,6 +8569,7 @@ class SearchWidget extends ElementWithStylesheet {
8468
8569
  this.resetAnswerBuffer();
8469
8570
  this.removeEventListener('keydown', this.keydownListener);
8470
8571
  (_c = this.mobileMediaQuery) === null || _c === void 0 ? void 0 : _c.removeEventListener('change', this.mobileMediaListener);
8572
+ window.removeEventListener('online', this.onlineListener);
8471
8573
  super.disconnectedCallback();
8472
8574
  }
8473
8575
  willUpdate(changed) {
@@ -8886,7 +8988,8 @@ class SearchWidget extends ElementWithStylesheet {
8886
8988
  this.loading = false;
8887
8989
  return;
8888
8990
  }
8889
- const response = await this.api.search(query, undefined, this.newResultsSignal());
8991
+ const api = this.api;
8992
+ const response = await withSearchResilience((signal) => api.search(query, undefined, signal), this.newResultsSignal());
8890
8993
  if (generation !== this.requestGeneration) {
8891
8994
  return;
8892
8995
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blotoutio/providers-evo-search-sdk",
3
- "version": "1.64.1",
3
+ "version": "1.64.2",
4
4
  "description": "Evo Search SDK for EdgeTag",
5
5
  "author": "Blotout",
6
6
  "license": "MIT",