@blotoutio/providers-evo-search-sdk 1.58.0 → 1.58.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core.cjs.js CHANGED
@@ -693,6 +693,17 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
693
693
  logger.info('EvoSearch API: Fetching ask config');
694
694
  return getJSON('/config', 'Config', undefined, signal);
695
695
  };
696
+ // Fire-and-forget isolate warmer: tells the worker to prime its search
697
+ // index off the request path so the first keystroke doesn't pay the cold
698
+ // prime. Errors are irrelevant — autocomplete just falls back to priming
699
+ // inline like before.
700
+ const warm = () => {
701
+ fetchImpl(getURL('/warm'), {
702
+ method: 'GET',
703
+ headers: getHeaders(),
704
+ credentials: 'omit',
705
+ }).catch(() => undefined);
706
+ };
696
707
  const recents = (signal) => {
697
708
  logger.info('EvoSearch API: Fetching recent searches');
698
709
  return getJSON('/user/recents', 'Recents', undefined, signal);
@@ -823,6 +834,7 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
823
834
  autocomplete,
824
835
  trending,
825
836
  config,
837
+ warm,
826
838
  user: {
827
839
  recents,
828
840
  deleteRecent,
package/core.js CHANGED
@@ -694,6 +694,17 @@ var ProvidersEvoSearchSdk = (function () {
694
694
  logger.info('EvoSearch API: Fetching ask config');
695
695
  return getJSON('/config', 'Config', undefined, signal);
696
696
  };
697
+ // Fire-and-forget isolate warmer: tells the worker to prime its search
698
+ // index off the request path so the first keystroke doesn't pay the cold
699
+ // prime. Errors are irrelevant — autocomplete just falls back to priming
700
+ // inline like before.
701
+ const warm = () => {
702
+ fetchImpl(getURL('/warm'), {
703
+ method: 'GET',
704
+ headers: getHeaders(),
705
+ credentials: 'omit',
706
+ }).catch(() => undefined);
707
+ };
697
708
  const recents = (signal) => {
698
709
  logger.info('EvoSearch API: Fetching recent searches');
699
710
  return getJSON('/user/recents', 'Recents', undefined, signal);
@@ -824,6 +835,7 @@ var ProvidersEvoSearchSdk = (function () {
824
835
  autocomplete,
825
836
  trending,
826
837
  config,
838
+ warm,
827
839
  user: {
828
840
  recents,
829
841
  deleteRecent,
package/core.mjs CHANGED
@@ -691,6 +691,17 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
691
691
  logger.info('EvoSearch API: Fetching ask config');
692
692
  return getJSON('/config', 'Config', undefined, signal);
693
693
  };
694
+ // Fire-and-forget isolate warmer: tells the worker to prime its search
695
+ // index off the request path so the first keystroke doesn't pay the cold
696
+ // prime. Errors are irrelevant — autocomplete just falls back to priming
697
+ // inline like before.
698
+ const warm = () => {
699
+ fetchImpl(getURL('/warm'), {
700
+ method: 'GET',
701
+ headers: getHeaders(),
702
+ credentials: 'omit',
703
+ }).catch(() => undefined);
704
+ };
694
705
  const recents = (signal) => {
695
706
  logger.info('EvoSearch API: Fetching recent searches');
696
707
  return getJSON('/user/recents', 'Recents', undefined, signal);
@@ -821,6 +832,7 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
821
832
  autocomplete,
822
833
  trending,
823
834
  config,
835
+ warm,
824
836
  user: {
825
837
  recents,
826
838
  deleteRecent,
package/hooks.d.ts CHANGED
@@ -145,6 +145,7 @@ declare module '@blotoutio/providers-evo-search-sdk/hooks' {
145
145
  recommended_products: AutocompleteAPIProduct[]
146
146
  }>
147
147
  config: (signal?: AbortSignal) => Promise<AskConfigResponse>
148
+ warm: () => void
148
149
  user: EvoSearchUserAPI
149
150
  askStream: (query: string, signal?: AbortSignal) => AsyncIterable<AskEvent>
150
151
  sendEvent: (
package/index.cjs.js CHANGED
@@ -829,6 +829,17 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
829
829
  logger.info('EvoSearch API: Fetching ask config');
830
830
  return getJSON('/config', 'Config', undefined, signal);
831
831
  };
832
+ // Fire-and-forget isolate warmer: tells the worker to prime its search
833
+ // index off the request path so the first keystroke doesn't pay the cold
834
+ // prime. Errors are irrelevant — autocomplete just falls back to priming
835
+ // inline like before.
836
+ const warm = () => {
837
+ fetchImpl(getURL('/warm'), {
838
+ method: 'GET',
839
+ headers: getHeaders(),
840
+ credentials: 'omit',
841
+ }).catch(() => undefined);
842
+ };
832
843
  const recents = (signal) => {
833
844
  logger.info('EvoSearch API: Fetching recent searches');
834
845
  return getJSON('/user/recents', 'Recents', undefined, signal);
@@ -959,6 +970,7 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
959
970
  autocomplete,
960
971
  trending,
961
972
  config,
973
+ warm,
962
974
  user: {
963
975
  recents,
964
976
  deleteRecent,
@@ -8491,6 +8503,9 @@ const searchWidgetStyles = i$5 `
8491
8503
  `;
8492
8504
 
8493
8505
  const MIN_QUERY_LENGTH = 3;
8506
+ // An aborted/superseded request is expected, not an error.
8507
+ const isCancelled = (error) => error instanceof Error &&
8508
+ (error.message === 'Request cancelled' || error.name === 'AbortError');
8494
8509
  // Default "Try asking" prompts shown on the Ask AI empty state when the
8495
8510
  // merchant hasn't configured their own via `uiPreferences.askStarters`.
8496
8511
  const FALLBACK_ASK_STARTERS = [
@@ -8520,6 +8535,12 @@ class SearchWidget extends ElementWithStylesheet {
8520
8535
  this.askFollowUps = [];
8521
8536
  this.askLoading = false;
8522
8537
  this.askAbort = null;
8538
+ // In-flight autocomplete/search request. Each new keystroke aborts the
8539
+ // previous request at the network layer — the `requestGeneration` guard
8540
+ // already prevents stale results from rendering, but without an abort the
8541
+ // dead requests still hold browser connections and worker CPU, delaying
8542
+ // the response the shopper actually wants.
8543
+ this.resultsAbort = null;
8523
8544
  this.searchQuery = '';
8524
8545
  this.debouncedQuery = '';
8525
8546
  this.suggestions = [];
@@ -8591,6 +8612,12 @@ class SearchWidget extends ElementWithStylesheet {
8591
8612
  get askActive() {
8592
8613
  return this.askAiEnabled && !this.askDisabled;
8593
8614
  }
8615
+ newResultsSignal() {
8616
+ var _a;
8617
+ (_a = this.resultsAbort) === null || _a === void 0 ? void 0 : _a.abort();
8618
+ this.resultsAbort = new AbortController();
8619
+ return this.resultsAbort.signal;
8620
+ }
8594
8621
  connectedCallback() {
8595
8622
  var _a, _b;
8596
8623
  super.connectedCallback();
@@ -8622,13 +8649,14 @@ class SearchWidget extends ElementWithStylesheet {
8622
8649
  this.fetchRecents();
8623
8650
  }
8624
8651
  disconnectedCallback() {
8625
- var _a, _b, _c;
8652
+ var _a, _b, _c, _d;
8626
8653
  clearTimeout(this.autocompleteTimer);
8627
8654
  (_a = this.askAbort) === null || _a === void 0 ? void 0 : _a.abort();
8655
+ (_b = this.resultsAbort) === null || _b === void 0 ? void 0 : _b.abort();
8628
8656
  this.resetAnswerBuffer();
8629
8657
  this.removeEventListener('keydown', this.keydownListener);
8630
- (_b = this.collectionsResizeObserver) === null || _b === void 0 ? void 0 : _b.disconnect();
8631
- (_c = this.mobileMediaQuery) === null || _c === void 0 ? void 0 : _c.removeEventListener('change', this.mobileMediaListener);
8658
+ (_c = this.collectionsResizeObserver) === null || _c === void 0 ? void 0 : _c.disconnect();
8659
+ (_d = this.mobileMediaQuery) === null || _d === void 0 ? void 0 : _d.removeEventListener('change', this.mobileMediaListener);
8632
8660
  super.disconnectedCallback();
8633
8661
  }
8634
8662
  willUpdate(changed) {
@@ -9008,6 +9036,7 @@ class SearchWidget extends ElementWithStylesheet {
9008
9036
  ].slice(0, MAX_RECENT_SEARCHES);
9009
9037
  }
9010
9038
  handleSearchInput(e) {
9039
+ var _a;
9011
9040
  const { query } = e.detail;
9012
9041
  this.searchQuery = query;
9013
9042
  this.activeSuggestionIndex = -1;
@@ -9028,6 +9057,7 @@ class SearchWidget extends ElementWithStylesheet {
9028
9057
  else {
9029
9058
  clearTimeout(this.autocompleteTimer);
9030
9059
  this.requestGeneration += 1;
9060
+ (_a = this.resultsAbort) === null || _a === void 0 ? void 0 : _a.abort();
9031
9061
  if (this.loading)
9032
9062
  this.loading = false;
9033
9063
  if (this.debouncedQuery)
@@ -9060,7 +9090,7 @@ class SearchWidget extends ElementWithStylesheet {
9060
9090
  this.loading = false;
9061
9091
  return;
9062
9092
  }
9063
- const response = await this.api.autocomplete(query);
9093
+ const response = await this.api.autocomplete(query, undefined, this.newResultsSignal());
9064
9094
  if (generation !== this.requestGeneration) {
9065
9095
  return;
9066
9096
  }
@@ -9073,7 +9103,7 @@ class SearchWidget extends ElementWithStylesheet {
9073
9103
  this.loading = false;
9074
9104
  }
9075
9105
  catch (error) {
9076
- if (error instanceof Error && error.message === 'Request cancelled') {
9106
+ if (isCancelled(error)) {
9077
9107
  return;
9078
9108
  }
9079
9109
  if (generation !== this.requestGeneration) {
@@ -9097,7 +9127,7 @@ class SearchWidget extends ElementWithStylesheet {
9097
9127
  if (!((_a = this.api) === null || _a === void 0 ? void 0 : _a.search)) {
9098
9128
  return this.performAutocomplete(query);
9099
9129
  }
9100
- const response = await this.api.search(query);
9130
+ const response = await this.api.search(query, undefined, this.newResultsSignal());
9101
9131
  if (generation !== this.requestGeneration) {
9102
9132
  return;
9103
9133
  }
@@ -9108,7 +9138,7 @@ class SearchWidget extends ElementWithStylesheet {
9108
9138
  this.loading = false;
9109
9139
  }
9110
9140
  catch (error) {
9111
- if (error instanceof Error && error.message === 'Request cancelled') {
9141
+ if (isCancelled(error)) {
9112
9142
  return;
9113
9143
  }
9114
9144
  if (generation !== this.requestGeneration) {
@@ -9995,10 +10025,51 @@ let evoSearch;
9995
10025
  let trendingController;
9996
10026
  let trendingFetched = false;
9997
10027
  let configController;
10028
+ // Session cache for the trending rails. Storefronts are MPAs: every navigation
10029
+ // resets module state, so without this the trending fetch refires on each page
10030
+ // view and a shopper opening search right after a page load stares at empty
10031
+ // rails until it lands. A fresh entry paints instantly and skips the network
10032
+ // round-trip; a stale one still paints instantly while the normal fetch
10033
+ // revalidates it in the background.
10034
+ const TRENDING_CACHE_KEY = 'evs:trending';
10035
+ const TRENDING_CACHE_TTL_MS = 5 * 60000;
10036
+ const readTrendingCache = () => {
10037
+ try {
10038
+ const raw = window.sessionStorage.getItem(TRENDING_CACHE_KEY);
10039
+ const parsed = raw ? JSON.parse(raw) : null;
10040
+ return parsed && typeof parsed.at === 'number' && parsed.data
10041
+ ? parsed
10042
+ : null;
10043
+ }
10044
+ catch {
10045
+ // Storage access can throw (privacy modes); treat as a cache miss.
10046
+ return null;
10047
+ }
10048
+ };
10049
+ const writeTrendingCache = (data) => {
10050
+ try {
10051
+ window.sessionStorage.setItem(TRENDING_CACHE_KEY, JSON.stringify({ at: Date.now(), data }));
10052
+ }
10053
+ catch {
10054
+ // Best-effort: quota or privacy-mode failures just mean no cache.
10055
+ }
10056
+ };
10057
+ const applyTrending = (target, data) => {
10058
+ target.trendingSearches = data.trending;
10059
+ target.handpickedProducts = data.recommended_products.map(mapAPIProductToSDKProduct);
10060
+ };
9998
10061
  const tryFetchTrending = (target) => {
9999
10062
  if (!target.api || trendingFetched || trendingController) {
10000
10063
  return;
10001
10064
  }
10065
+ const cached = readTrendingCache();
10066
+ if (cached) {
10067
+ applyTrending(target, cached.data);
10068
+ if (Date.now() - cached.at < TRENDING_CACHE_TTL_MS) {
10069
+ trendingFetched = true;
10070
+ return;
10071
+ }
10072
+ }
10002
10073
  const controller = new AbortController();
10003
10074
  trendingController = controller;
10004
10075
  target.api
@@ -10006,12 +10077,8 @@ const tryFetchTrending = (target) => {
10006
10077
  .then((data) => {
10007
10078
  var _a, _b, _c, _d;
10008
10079
  trendingFetched = true;
10009
- if (data.trending && data.trending.length > 0) {
10010
- target.trendingSearches = data.trending;
10011
- }
10012
- if (data.recommended_products && data.recommended_products.length > 0) {
10013
- target.handpickedProducts = data.recommended_products.map(mapAPIProductToSDKProduct);
10014
- }
10080
+ writeTrendingCache(data);
10081
+ applyTrending(target, data);
10015
10082
  logger.info(`EvoSearch: Loaded ${(_b = (_a = data.trending) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0} trending, ${(_d = (_c = data.recommended_products) === null || _c === void 0 ? void 0 : _c.length) !== null && _d !== void 0 ? _d : 0} products`);
10016
10083
  })
10017
10084
  .catch((error) => {
@@ -10110,6 +10177,7 @@ if (typeof window != 'undefined' && typeof document != 'undefined') {
10110
10177
  logger.info('EvoSearch destroyed');
10111
10178
  },
10112
10179
  show() {
10180
+ var _a;
10113
10181
  if (!evoSearch) {
10114
10182
  logger.error('EvoSearch not initialized');
10115
10183
  return;
@@ -10117,6 +10185,10 @@ if (typeof window != 'undefined' && typeof document != 'undefined') {
10117
10185
  if (!evoSearch.parentNode) {
10118
10186
  document.body.append(evoSearch);
10119
10187
  }
10188
+ // Warm the worker's search index off the request path: the prime runs
10189
+ // while the shopper reaches for the keyboard, so the first keystroke's
10190
+ // autocomplete doesn't pay the cold-isolate prime inline.
10191
+ (_a = evoSearch.api) === null || _a === void 0 ? void 0 : _a.warm();
10120
10192
  tryFetchTrending(evoSearch);
10121
10193
  tryFetchConfig(evoSearch);
10122
10194
  evoSearch.open = true;
package/index.js CHANGED
@@ -830,6 +830,17 @@ var ProvidersEvoSearchSdk = (function () {
830
830
  logger.info('EvoSearch API: Fetching ask config');
831
831
  return getJSON('/config', 'Config', undefined, signal);
832
832
  };
833
+ // Fire-and-forget isolate warmer: tells the worker to prime its search
834
+ // index off the request path so the first keystroke doesn't pay the cold
835
+ // prime. Errors are irrelevant — autocomplete just falls back to priming
836
+ // inline like before.
837
+ const warm = () => {
838
+ fetchImpl(getURL('/warm'), {
839
+ method: 'GET',
840
+ headers: getHeaders(),
841
+ credentials: 'omit',
842
+ }).catch(() => undefined);
843
+ };
833
844
  const recents = (signal) => {
834
845
  logger.info('EvoSearch API: Fetching recent searches');
835
846
  return getJSON('/user/recents', 'Recents', undefined, signal);
@@ -960,6 +971,7 @@ var ProvidersEvoSearchSdk = (function () {
960
971
  autocomplete,
961
972
  trending,
962
973
  config,
974
+ warm,
963
975
  user: {
964
976
  recents,
965
977
  deleteRecent,
@@ -8492,6 +8504,9 @@ var ProvidersEvoSearchSdk = (function () {
8492
8504
  `;
8493
8505
 
8494
8506
  const MIN_QUERY_LENGTH = 3;
8507
+ // An aborted/superseded request is expected, not an error.
8508
+ const isCancelled = (error) => error instanceof Error &&
8509
+ (error.message === 'Request cancelled' || error.name === 'AbortError');
8495
8510
  // Default "Try asking" prompts shown on the Ask AI empty state when the
8496
8511
  // merchant hasn't configured their own via `uiPreferences.askStarters`.
8497
8512
  const FALLBACK_ASK_STARTERS = [
@@ -8521,6 +8536,12 @@ var ProvidersEvoSearchSdk = (function () {
8521
8536
  this.askFollowUps = [];
8522
8537
  this.askLoading = false;
8523
8538
  this.askAbort = null;
8539
+ // In-flight autocomplete/search request. Each new keystroke aborts the
8540
+ // previous request at the network layer — the `requestGeneration` guard
8541
+ // already prevents stale results from rendering, but without an abort the
8542
+ // dead requests still hold browser connections and worker CPU, delaying
8543
+ // the response the shopper actually wants.
8544
+ this.resultsAbort = null;
8524
8545
  this.searchQuery = '';
8525
8546
  this.debouncedQuery = '';
8526
8547
  this.suggestions = [];
@@ -8592,6 +8613,12 @@ var ProvidersEvoSearchSdk = (function () {
8592
8613
  get askActive() {
8593
8614
  return this.askAiEnabled && !this.askDisabled;
8594
8615
  }
8616
+ newResultsSignal() {
8617
+ var _a;
8618
+ (_a = this.resultsAbort) === null || _a === void 0 ? void 0 : _a.abort();
8619
+ this.resultsAbort = new AbortController();
8620
+ return this.resultsAbort.signal;
8621
+ }
8595
8622
  connectedCallback() {
8596
8623
  var _a, _b;
8597
8624
  super.connectedCallback();
@@ -8623,13 +8650,14 @@ var ProvidersEvoSearchSdk = (function () {
8623
8650
  this.fetchRecents();
8624
8651
  }
8625
8652
  disconnectedCallback() {
8626
- var _a, _b, _c;
8653
+ var _a, _b, _c, _d;
8627
8654
  clearTimeout(this.autocompleteTimer);
8628
8655
  (_a = this.askAbort) === null || _a === void 0 ? void 0 : _a.abort();
8656
+ (_b = this.resultsAbort) === null || _b === void 0 ? void 0 : _b.abort();
8629
8657
  this.resetAnswerBuffer();
8630
8658
  this.removeEventListener('keydown', this.keydownListener);
8631
- (_b = this.collectionsResizeObserver) === null || _b === void 0 ? void 0 : _b.disconnect();
8632
- (_c = this.mobileMediaQuery) === null || _c === void 0 ? void 0 : _c.removeEventListener('change', this.mobileMediaListener);
8659
+ (_c = this.collectionsResizeObserver) === null || _c === void 0 ? void 0 : _c.disconnect();
8660
+ (_d = this.mobileMediaQuery) === null || _d === void 0 ? void 0 : _d.removeEventListener('change', this.mobileMediaListener);
8633
8661
  super.disconnectedCallback();
8634
8662
  }
8635
8663
  willUpdate(changed) {
@@ -9009,6 +9037,7 @@ var ProvidersEvoSearchSdk = (function () {
9009
9037
  ].slice(0, MAX_RECENT_SEARCHES);
9010
9038
  }
9011
9039
  handleSearchInput(e) {
9040
+ var _a;
9012
9041
  const { query } = e.detail;
9013
9042
  this.searchQuery = query;
9014
9043
  this.activeSuggestionIndex = -1;
@@ -9029,6 +9058,7 @@ var ProvidersEvoSearchSdk = (function () {
9029
9058
  else {
9030
9059
  clearTimeout(this.autocompleteTimer);
9031
9060
  this.requestGeneration += 1;
9061
+ (_a = this.resultsAbort) === null || _a === void 0 ? void 0 : _a.abort();
9032
9062
  if (this.loading)
9033
9063
  this.loading = false;
9034
9064
  if (this.debouncedQuery)
@@ -9061,7 +9091,7 @@ var ProvidersEvoSearchSdk = (function () {
9061
9091
  this.loading = false;
9062
9092
  return;
9063
9093
  }
9064
- const response = await this.api.autocomplete(query);
9094
+ const response = await this.api.autocomplete(query, undefined, this.newResultsSignal());
9065
9095
  if (generation !== this.requestGeneration) {
9066
9096
  return;
9067
9097
  }
@@ -9074,7 +9104,7 @@ var ProvidersEvoSearchSdk = (function () {
9074
9104
  this.loading = false;
9075
9105
  }
9076
9106
  catch (error) {
9077
- if (error instanceof Error && error.message === 'Request cancelled') {
9107
+ if (isCancelled(error)) {
9078
9108
  return;
9079
9109
  }
9080
9110
  if (generation !== this.requestGeneration) {
@@ -9098,7 +9128,7 @@ var ProvidersEvoSearchSdk = (function () {
9098
9128
  if (!((_a = this.api) === null || _a === void 0 ? void 0 : _a.search)) {
9099
9129
  return this.performAutocomplete(query);
9100
9130
  }
9101
- const response = await this.api.search(query);
9131
+ const response = await this.api.search(query, undefined, this.newResultsSignal());
9102
9132
  if (generation !== this.requestGeneration) {
9103
9133
  return;
9104
9134
  }
@@ -9109,7 +9139,7 @@ var ProvidersEvoSearchSdk = (function () {
9109
9139
  this.loading = false;
9110
9140
  }
9111
9141
  catch (error) {
9112
- if (error instanceof Error && error.message === 'Request cancelled') {
9142
+ if (isCancelled(error)) {
9113
9143
  return;
9114
9144
  }
9115
9145
  if (generation !== this.requestGeneration) {
@@ -9996,10 +10026,51 @@ var ProvidersEvoSearchSdk = (function () {
9996
10026
  let trendingController;
9997
10027
  let trendingFetched = false;
9998
10028
  let configController;
10029
+ // Session cache for the trending rails. Storefronts are MPAs: every navigation
10030
+ // resets module state, so without this the trending fetch refires on each page
10031
+ // view and a shopper opening search right after a page load stares at empty
10032
+ // rails until it lands. A fresh entry paints instantly and skips the network
10033
+ // round-trip; a stale one still paints instantly while the normal fetch
10034
+ // revalidates it in the background.
10035
+ const TRENDING_CACHE_KEY = 'evs:trending';
10036
+ const TRENDING_CACHE_TTL_MS = 5 * 60000;
10037
+ const readTrendingCache = () => {
10038
+ try {
10039
+ const raw = window.sessionStorage.getItem(TRENDING_CACHE_KEY);
10040
+ const parsed = raw ? JSON.parse(raw) : null;
10041
+ return parsed && typeof parsed.at === 'number' && parsed.data
10042
+ ? parsed
10043
+ : null;
10044
+ }
10045
+ catch {
10046
+ // Storage access can throw (privacy modes); treat as a cache miss.
10047
+ return null;
10048
+ }
10049
+ };
10050
+ const writeTrendingCache = (data) => {
10051
+ try {
10052
+ window.sessionStorage.setItem(TRENDING_CACHE_KEY, JSON.stringify({ at: Date.now(), data }));
10053
+ }
10054
+ catch {
10055
+ // Best-effort: quota or privacy-mode failures just mean no cache.
10056
+ }
10057
+ };
10058
+ const applyTrending = (target, data) => {
10059
+ target.trendingSearches = data.trending;
10060
+ target.handpickedProducts = data.recommended_products.map(mapAPIProductToSDKProduct);
10061
+ };
9999
10062
  const tryFetchTrending = (target) => {
10000
10063
  if (!target.api || trendingFetched || trendingController) {
10001
10064
  return;
10002
10065
  }
10066
+ const cached = readTrendingCache();
10067
+ if (cached) {
10068
+ applyTrending(target, cached.data);
10069
+ if (Date.now() - cached.at < TRENDING_CACHE_TTL_MS) {
10070
+ trendingFetched = true;
10071
+ return;
10072
+ }
10073
+ }
10003
10074
  const controller = new AbortController();
10004
10075
  trendingController = controller;
10005
10076
  target.api
@@ -10007,12 +10078,8 @@ var ProvidersEvoSearchSdk = (function () {
10007
10078
  .then((data) => {
10008
10079
  var _a, _b, _c, _d;
10009
10080
  trendingFetched = true;
10010
- if (data.trending && data.trending.length > 0) {
10011
- target.trendingSearches = data.trending;
10012
- }
10013
- if (data.recommended_products && data.recommended_products.length > 0) {
10014
- target.handpickedProducts = data.recommended_products.map(mapAPIProductToSDKProduct);
10015
- }
10081
+ writeTrendingCache(data);
10082
+ applyTrending(target, data);
10016
10083
  logger.info(`EvoSearch: Loaded ${(_b = (_a = data.trending) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0} trending, ${(_d = (_c = data.recommended_products) === null || _c === void 0 ? void 0 : _c.length) !== null && _d !== void 0 ? _d : 0} products`);
10017
10084
  })
10018
10085
  .catch((error) => {
@@ -10111,6 +10178,7 @@ var ProvidersEvoSearchSdk = (function () {
10111
10178
  logger.info('EvoSearch destroyed');
10112
10179
  },
10113
10180
  show() {
10181
+ var _a;
10114
10182
  if (!evoSearch) {
10115
10183
  logger.error('EvoSearch not initialized');
10116
10184
  return;
@@ -10118,6 +10186,10 @@ var ProvidersEvoSearchSdk = (function () {
10118
10186
  if (!evoSearch.parentNode) {
10119
10187
  document.body.append(evoSearch);
10120
10188
  }
10189
+ // Warm the worker's search index off the request path: the prime runs
10190
+ // while the shopper reaches for the keyboard, so the first keystroke's
10191
+ // autocomplete doesn't pay the cold-isolate prime inline.
10192
+ (_a = evoSearch.api) === null || _a === void 0 ? void 0 : _a.warm();
10121
10193
  tryFetchTrending(evoSearch);
10122
10194
  tryFetchConfig(evoSearch);
10123
10195
  evoSearch.open = true;
package/index.mjs CHANGED
@@ -827,6 +827,17 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
827
827
  logger.info('EvoSearch API: Fetching ask config');
828
828
  return getJSON('/config', 'Config', undefined, signal);
829
829
  };
830
+ // Fire-and-forget isolate warmer: tells the worker to prime its search
831
+ // index off the request path so the first keystroke doesn't pay the cold
832
+ // prime. Errors are irrelevant — autocomplete just falls back to priming
833
+ // inline like before.
834
+ const warm = () => {
835
+ fetchImpl(getURL('/warm'), {
836
+ method: 'GET',
837
+ headers: getHeaders(),
838
+ credentials: 'omit',
839
+ }).catch(() => undefined);
840
+ };
830
841
  const recents = (signal) => {
831
842
  logger.info('EvoSearch API: Fetching recent searches');
832
843
  return getJSON('/user/recents', 'Recents', undefined, signal);
@@ -957,6 +968,7 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
957
968
  autocomplete,
958
969
  trending,
959
970
  config,
971
+ warm,
960
972
  user: {
961
973
  recents,
962
974
  deleteRecent,
@@ -8489,6 +8501,9 @@ const searchWidgetStyles = i$5 `
8489
8501
  `;
8490
8502
 
8491
8503
  const MIN_QUERY_LENGTH = 3;
8504
+ // An aborted/superseded request is expected, not an error.
8505
+ const isCancelled = (error) => error instanceof Error &&
8506
+ (error.message === 'Request cancelled' || error.name === 'AbortError');
8492
8507
  // Default "Try asking" prompts shown on the Ask AI empty state when the
8493
8508
  // merchant hasn't configured their own via `uiPreferences.askStarters`.
8494
8509
  const FALLBACK_ASK_STARTERS = [
@@ -8518,6 +8533,12 @@ class SearchWidget extends ElementWithStylesheet {
8518
8533
  this.askFollowUps = [];
8519
8534
  this.askLoading = false;
8520
8535
  this.askAbort = null;
8536
+ // In-flight autocomplete/search request. Each new keystroke aborts the
8537
+ // previous request at the network layer — the `requestGeneration` guard
8538
+ // already prevents stale results from rendering, but without an abort the
8539
+ // dead requests still hold browser connections and worker CPU, delaying
8540
+ // the response the shopper actually wants.
8541
+ this.resultsAbort = null;
8521
8542
  this.searchQuery = '';
8522
8543
  this.debouncedQuery = '';
8523
8544
  this.suggestions = [];
@@ -8589,6 +8610,12 @@ class SearchWidget extends ElementWithStylesheet {
8589
8610
  get askActive() {
8590
8611
  return this.askAiEnabled && !this.askDisabled;
8591
8612
  }
8613
+ newResultsSignal() {
8614
+ var _a;
8615
+ (_a = this.resultsAbort) === null || _a === void 0 ? void 0 : _a.abort();
8616
+ this.resultsAbort = new AbortController();
8617
+ return this.resultsAbort.signal;
8618
+ }
8592
8619
  connectedCallback() {
8593
8620
  var _a, _b;
8594
8621
  super.connectedCallback();
@@ -8620,13 +8647,14 @@ class SearchWidget extends ElementWithStylesheet {
8620
8647
  this.fetchRecents();
8621
8648
  }
8622
8649
  disconnectedCallback() {
8623
- var _a, _b, _c;
8650
+ var _a, _b, _c, _d;
8624
8651
  clearTimeout(this.autocompleteTimer);
8625
8652
  (_a = this.askAbort) === null || _a === void 0 ? void 0 : _a.abort();
8653
+ (_b = this.resultsAbort) === null || _b === void 0 ? void 0 : _b.abort();
8626
8654
  this.resetAnswerBuffer();
8627
8655
  this.removeEventListener('keydown', this.keydownListener);
8628
- (_b = this.collectionsResizeObserver) === null || _b === void 0 ? void 0 : _b.disconnect();
8629
- (_c = this.mobileMediaQuery) === null || _c === void 0 ? void 0 : _c.removeEventListener('change', this.mobileMediaListener);
8656
+ (_c = this.collectionsResizeObserver) === null || _c === void 0 ? void 0 : _c.disconnect();
8657
+ (_d = this.mobileMediaQuery) === null || _d === void 0 ? void 0 : _d.removeEventListener('change', this.mobileMediaListener);
8630
8658
  super.disconnectedCallback();
8631
8659
  }
8632
8660
  willUpdate(changed) {
@@ -9006,6 +9034,7 @@ class SearchWidget extends ElementWithStylesheet {
9006
9034
  ].slice(0, MAX_RECENT_SEARCHES);
9007
9035
  }
9008
9036
  handleSearchInput(e) {
9037
+ var _a;
9009
9038
  const { query } = e.detail;
9010
9039
  this.searchQuery = query;
9011
9040
  this.activeSuggestionIndex = -1;
@@ -9026,6 +9055,7 @@ class SearchWidget extends ElementWithStylesheet {
9026
9055
  else {
9027
9056
  clearTimeout(this.autocompleteTimer);
9028
9057
  this.requestGeneration += 1;
9058
+ (_a = this.resultsAbort) === null || _a === void 0 ? void 0 : _a.abort();
9029
9059
  if (this.loading)
9030
9060
  this.loading = false;
9031
9061
  if (this.debouncedQuery)
@@ -9058,7 +9088,7 @@ class SearchWidget extends ElementWithStylesheet {
9058
9088
  this.loading = false;
9059
9089
  return;
9060
9090
  }
9061
- const response = await this.api.autocomplete(query);
9091
+ const response = await this.api.autocomplete(query, undefined, this.newResultsSignal());
9062
9092
  if (generation !== this.requestGeneration) {
9063
9093
  return;
9064
9094
  }
@@ -9071,7 +9101,7 @@ class SearchWidget extends ElementWithStylesheet {
9071
9101
  this.loading = false;
9072
9102
  }
9073
9103
  catch (error) {
9074
- if (error instanceof Error && error.message === 'Request cancelled') {
9104
+ if (isCancelled(error)) {
9075
9105
  return;
9076
9106
  }
9077
9107
  if (generation !== this.requestGeneration) {
@@ -9095,7 +9125,7 @@ class SearchWidget extends ElementWithStylesheet {
9095
9125
  if (!((_a = this.api) === null || _a === void 0 ? void 0 : _a.search)) {
9096
9126
  return this.performAutocomplete(query);
9097
9127
  }
9098
- const response = await this.api.search(query);
9128
+ const response = await this.api.search(query, undefined, this.newResultsSignal());
9099
9129
  if (generation !== this.requestGeneration) {
9100
9130
  return;
9101
9131
  }
@@ -9106,7 +9136,7 @@ class SearchWidget extends ElementWithStylesheet {
9106
9136
  this.loading = false;
9107
9137
  }
9108
9138
  catch (error) {
9109
- if (error instanceof Error && error.message === 'Request cancelled') {
9139
+ if (isCancelled(error)) {
9110
9140
  return;
9111
9141
  }
9112
9142
  if (generation !== this.requestGeneration) {
@@ -9993,10 +10023,51 @@ let evoSearch;
9993
10023
  let trendingController;
9994
10024
  let trendingFetched = false;
9995
10025
  let configController;
10026
+ // Session cache for the trending rails. Storefronts are MPAs: every navigation
10027
+ // resets module state, so without this the trending fetch refires on each page
10028
+ // view and a shopper opening search right after a page load stares at empty
10029
+ // rails until it lands. A fresh entry paints instantly and skips the network
10030
+ // round-trip; a stale one still paints instantly while the normal fetch
10031
+ // revalidates it in the background.
10032
+ const TRENDING_CACHE_KEY = 'evs:trending';
10033
+ const TRENDING_CACHE_TTL_MS = 5 * 60000;
10034
+ const readTrendingCache = () => {
10035
+ try {
10036
+ const raw = window.sessionStorage.getItem(TRENDING_CACHE_KEY);
10037
+ const parsed = raw ? JSON.parse(raw) : null;
10038
+ return parsed && typeof parsed.at === 'number' && parsed.data
10039
+ ? parsed
10040
+ : null;
10041
+ }
10042
+ catch {
10043
+ // Storage access can throw (privacy modes); treat as a cache miss.
10044
+ return null;
10045
+ }
10046
+ };
10047
+ const writeTrendingCache = (data) => {
10048
+ try {
10049
+ window.sessionStorage.setItem(TRENDING_CACHE_KEY, JSON.stringify({ at: Date.now(), data }));
10050
+ }
10051
+ catch {
10052
+ // Best-effort: quota or privacy-mode failures just mean no cache.
10053
+ }
10054
+ };
10055
+ const applyTrending = (target, data) => {
10056
+ target.trendingSearches = data.trending;
10057
+ target.handpickedProducts = data.recommended_products.map(mapAPIProductToSDKProduct);
10058
+ };
9996
10059
  const tryFetchTrending = (target) => {
9997
10060
  if (!target.api || trendingFetched || trendingController) {
9998
10061
  return;
9999
10062
  }
10063
+ const cached = readTrendingCache();
10064
+ if (cached) {
10065
+ applyTrending(target, cached.data);
10066
+ if (Date.now() - cached.at < TRENDING_CACHE_TTL_MS) {
10067
+ trendingFetched = true;
10068
+ return;
10069
+ }
10070
+ }
10000
10071
  const controller = new AbortController();
10001
10072
  trendingController = controller;
10002
10073
  target.api
@@ -10004,12 +10075,8 @@ const tryFetchTrending = (target) => {
10004
10075
  .then((data) => {
10005
10076
  var _a, _b, _c, _d;
10006
10077
  trendingFetched = true;
10007
- if (data.trending && data.trending.length > 0) {
10008
- target.trendingSearches = data.trending;
10009
- }
10010
- if (data.recommended_products && data.recommended_products.length > 0) {
10011
- target.handpickedProducts = data.recommended_products.map(mapAPIProductToSDKProduct);
10012
- }
10078
+ writeTrendingCache(data);
10079
+ applyTrending(target, data);
10013
10080
  logger.info(`EvoSearch: Loaded ${(_b = (_a = data.trending) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0} trending, ${(_d = (_c = data.recommended_products) === null || _c === void 0 ? void 0 : _c.length) !== null && _d !== void 0 ? _d : 0} products`);
10014
10081
  })
10015
10082
  .catch((error) => {
@@ -10108,6 +10175,7 @@ if (typeof window != 'undefined' && typeof document != 'undefined') {
10108
10175
  logger.info('EvoSearch destroyed');
10109
10176
  },
10110
10177
  show() {
10178
+ var _a;
10111
10179
  if (!evoSearch) {
10112
10180
  logger.error('EvoSearch not initialized');
10113
10181
  return;
@@ -10115,6 +10183,10 @@ if (typeof window != 'undefined' && typeof document != 'undefined') {
10115
10183
  if (!evoSearch.parentNode) {
10116
10184
  document.body.append(evoSearch);
10117
10185
  }
10186
+ // Warm the worker's search index off the request path: the prime runs
10187
+ // while the shopper reaches for the keyboard, so the first keystroke's
10188
+ // autocomplete doesn't pay the cold-isolate prime inline.
10189
+ (_a = evoSearch.api) === null || _a === void 0 ? void 0 : _a.warm();
10118
10190
  tryFetchTrending(evoSearch);
10119
10191
  tryFetchConfig(evoSearch);
10120
10192
  evoSearch.open = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blotoutio/providers-evo-search-sdk",
3
- "version": "1.58.0",
3
+ "version": "1.58.1",
4
4
  "description": "Evo Search SDK for EdgeTag",
5
5
  "author": "Blotout",
6
6
  "license": "MIT",