1(() => { 2 'use strict'; 3 4 if (window.dimeLocatorAreaData) return; 5 6 const installed = new WeakSet(); 7 const controllers = new WeakMap(); 8 // Leave room above the current catalog; prepareCatalog still rejects any 9 // truncated response rather than silently hiding retailers from an area. 10 const catalogLimit = 5000; 11 const listPageSize = 30; 12 const cacheLimit = 3; 13 const cacheLifetime = 5 * 60 * 1000; 14 const requestTimeout = 12000; 15 const coordinateKeys = ['lat', 'lng', 'radius', 'limit', 'list_limit', 'list_offset']; 16 const withoutCoordinates = params => { 17 const copy = { ...params }; 18 coordinateKeys.forEach(key => delete copy[key]); 19 return copy; 20 }; 21 const canonical = value => { 22 if (Array.isArray(value)) return value.map(canonical); 23 if (value && typeof value === 'object') { 24 return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])); 25 } 26 return value; 27 }; 28 const isZeroCoordinate = value => value !== null && value !== undefined && value !== '' && Number(value) === 0; 29 const handleIgnoredRequest = promise => { 30 const then = promise.then.bind(promise); 31 then(undefined, () => {}); 32 // StoreRocket's event handlers sometimes use .then(trackSearch) without a 33 // rejection callback. Keep native Promise rejection for our await/catch 34 // callers, but consume ignored child chains just as its Deferred did. 35 promise.then = (fulfilled, rejected) => handleIgnoredRequest(then(fulfilled, rejected)); 36 return promise; 37 }; 38 39 const prepareCatalog = response => { 40 const results = response?.results; 41 if (!results || !Array.isArray(results.locations) || typeof results.html?.locations !== 'string') { 42 throw new Error('The retailer catalog response was incomplete.'); 43 } 44 45 // Parse inert HTML once, keeping strings rather than a large detached DOM. 46 // StoreRocket binds list actions to data-index, not the retailer's ID. 47 const template = document.createElement('template'); 48 template.innerHTML = results.html.locations; 49 const rows = new Map(); 50 template.content.querySelectorAll('.storerocket-result[data-index]').forEach(row => { 51 const index = Number(row.getAttribute('data-index')); 52 if (!Number.isInteger(index) || index < 0 || rows.has(index)) { 53 throw new Error('The retailer catalog contained invalid list indexes.'); 54 } 55 rows.set(index, row.outerHTML); 56 }); 57 const total = Number(results.list?.total ?? results.locations.length); 58 if (rows.size !== results.locations.length || total > rows.size || 59 results.locations.some((_, index) => !rows.has(index))) { 60 throw new Error('The retailer catalog was truncated; the map cannot show a partial area.'); 61 } 62 return { 63 response: { ...response, results: { ...results, html: { ...results.html, locations: '' } } }, 64 rows, 65 }; 66 }; 67 68 const rowsForPage = (catalog, indexes, start, end) => indexes.slice(start, end).map((originalIndex, offset) => 69 catalog.rows.get(originalIndex).replace(/\bdata-index\s*=\s*(["'])\d+\1/i, `data-index="${start + offset}"`) 70 ).join(''); 71 72 const responseInBounds = (catalog, bounds, pages, state = null) => { 73 const locations = []; 74 const indexes = []; 75 catalog.response.results.locations.forEach((location, originalIndex) => { 76 if (state) { 77 const region = String(location.state || '').trim().toLowerCase(); 78 const country = String(location.country || '').trim().toLowerCase(); 79 if (state.type === 'country') { 80 if (!['ca', 'can', 'canada'].includes(country)) return; 81 } else if (![state.code.toLowerCase(), state.name.toLowerCase()].includes(region) || 82 !['us', 'usa', 'united states', 'united states of america'].includes(country)) return; 83 } 84 if (location.lat === null || location.lat === '' || location.lng === null || location.lng === '') return; 85 const lat = Number(location.lat); 86 const lng = Number(location.lng); 87 if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180 || 88 (!state && !bounds.contains({ lat, lng }))) return; 89 // The provider decorates results in place; never hand it cached objects. 90 locations.push({ ...location }); 91 indexes.push(originalIndex); 92 }); 93 const rendered = pages ? Math.min(listPageSize, locations.length) : locations.length; 94 if (pages) pages.set(locations, { catalog, indexes }); 95 return { 96 ...catalog.response, 97 results: { 98 ...catalog.response.results, 99 locations, 100 html: { ...catalog.response.results.html, locations: rowsForPage(catalog, indexes, 0, rendered) }, 101 // Every map location remains available. Only the sidebar's initial 102 // DOM is bounded; further rows use the provider's accessible button. 103 list: rendered < locations.length ? { total: locations.length, rendered } : null, 104 }, 105 }; 106 }; 107 108 const install = (instance, getArea) => { 109 if (!instance || installed.has(instance) || typeof instance.fetchLocations !== 'function' || typeof getArea !== 'function') return; 110 installed.add(instance); 111 const originalFetch = instance.fetchLocations; 112 const originalUpdate = instance.updateMap;
113 const originalMore = instance.fetchMoreResults; 114 let pages = typeof originalMore === 'function' && typeof instance.appendMoreResults === 'function' 115 ? new WeakMap() : null; 116 const caches = new Map(); 117 // LRU eviction only limits reuse; an evicted request may still be pending. 118 // Keep a separate set so closing the locator can abort every catalog load. 119 const pendingCatalogs = new Set(); 120 const pendingPages = new Set(); 121 let latestPage = null; 122 let releaseGeneration = 0; 123 let latestOperation = null; 124 let fetchingOperation = null; 125 126 const evict = (entry, abort = false) => { 127 if (entry && caches.get(entry.key) === entry) caches.delete(entry.key); 128 if (abort && entry?.pending) entry.controller?.abort(); 129 }; 130 const rememberCache = entry => { 131 caches.delete(entry.key); 132 caches.set(entry.key, entry); 133 while (caches.size > cacheLimit) caches.delete(caches.keys().next().value); 134 }; 135 136 const fetchNativePage = target => { 137 if (target.fetchingMoreResults || !target.listWindow) return;
138 if (!target.listRequestParams || typeof target.listRequestParams !== 'object') return originalMore.call(target); 139 const scope = target.selector?.querySelectorAll ? target.selector : null; 140 const page = { 141 generation: releaseGeneration, providerGeneration: target.generation, locations: target.locations, listWindow: target.listWindow, 142 offset: Number(target.listRenderedCount) || 0, request: null, 143 buttons: [...(scope?.querySelectorAll('.storerocket-show-more') || [])], 144 wrapper: scope?.querySelector('.storerocket-show-more-wrap') || null, 145 }; 146 const owns = () => latestPage === page && page.generation === releaseGeneration; 147 const valid = () => owns() && target.generation === page.providerGeneration && 148 target.locations === page.locations && target.listWindow === page.listWindow && 149 (Number(target.listRenderedCount) || 0) === page.offset; 150 latestPage = page; 151 pendingPages.add(page); 152 target.fetchingMoreResults = true; 153 page.buttons.forEach(button => { button.disabled = true; button.classList.add('is-loading'); }); 154 155 // Reuse the provider's public request builder rather than duplicating its 156 // account/embed/filter serializer. Its isolated receiver prevents the
157 // main-map spinner and listRequestParams writes during sidebar paging. 158 // The vendor's own fetchMoreResults has unguarded global DOM callbacks; 159 // owning this completion keeps late responses out after close/reopen. 160 const receiver = Object.create(target); 161 receiver.showLoadingSpinner = () => {}; 162 receiver.installation = Object.create(target.installation || null); 163 receiver.installation.contentPending = () => {}; 164 let request; 165 try { 166 request = originalFetch.call(receiver, { ...target.listRequestParams, list_offset: page.offset }); 167 page.request = request; 168 } catch (error) { request = Promise.reject(error); } 169 const result = Promise.resolve(request).then(response => { 170 if (!valid()) return; 171 const html = response?.results?.html?.locations; 172 if (html) target.appendMoreResults(html, response.results.list); 173 else page.wrapper?.remove(); 174 }, () => { 175 // Leave the existing button in place for retry; never clear newer UI. 176 }).finally(() => { 177 if (owns()) { 178 latestPage = null; 179 target.fetchingMoreResults = false; 180 page.buttons.forEach(button => { button.disabled = false; button.classList.remove('is-loading'); }); 181 } 182 pendingPages.delete(page); 183 page.request = page.locations = page.listWindow = page.wrapper = null; 184 page.buttons = []; 185 }); 186 return handleIgnoredRequest(result); 187 }; 188 189 if (pages) { 190 instance.fetchMoreResults = function () { 191 const page = pages.get(this.locations); 192 if (!page) return fetchNativePage(this); 193 if (this.fetchingMoreResults || !this.listWindow) return; 194 const start = Math.max(0, Number(this.listRenderedCount) || 0); 195 const end = Math.min(start + listPageSize, page.indexes.length); 196 if (start >= end) return; 197 this.fetchingMoreResults = true; 198 try { 199 // appendMoreResults retains native row decoration, data-index click 200 // handling and the keyboard-focusable Show more button, without a 201 // new request, map rebuild, or globally indexed off-screen rows. 202 return this.appendMoreResults(rowsForPage(page.catalog, page.indexes, start, end), { 203 total: page.indexes.length, 204 rendered: end, 205 }); 206 } finally { this.fetchingMoreResults = false; } 207 }; 208 } 209 210 const discardResponse = () => ({ results: { locations: [], html: { locations: '' }, list: null } }); 211 const protectFetch = (request, operation) => { 212 if (!operation) return request; 213 return Promise.resolve(request).catch(error => { 214 operation.fail(error); 215 // The provider never forwards rejection to updateMap's Deferred. 216 // Resolve its internal chain only after making its commit guard false. 217 return discardResponse(); 218 }); 219 }; 220 221 if (typeof originalUpdate === 'function') { 222 instance.updateMap = function (options, isCurrent = () => true) { 223 const target = this; 224 const operationGeneration = releaseGeneration; 225 let resolveResult; 226 let rejectResult; 227 const result = new Promise((resolve, reject) => { 228 resolveResult = resolve; 229 rejectResult = reject; 230 }); 231 let timer; 232 const operation = { 233 valid: true, 234 settled: false, 235 catalogEntry: null, 236 nativeRequest: null, 237 isCurrent: () => valid(), 238 cancel() { 239 if (this.settled) return; 240 this.valid = false; 241 this.settled = true; 242 clearTimeout(timer); 243 target._markerRun = (target._markerRun || 0) + 1; 244 this.nativeRequest?.abort?.(); 245 this.nativeRequest = null; 246 this.catalogEntry = null; 247 resolveResult(); 248 }, 249 fail(error) { 250 if (this.settled) return; 251 this.valid = false; 252 this.settled = true; 253 clearTimeout(timer); 254 this.nativeRequest?.abort?.(); 255 if (latestOperation === this) { 256 target._markerRun = (target._markerRun || 0) + 1; 257 target.hideLoadingSpinner?.(); 258 target.hideGeocodingSpinner?.(); 259 if (this.catalogEntry?.pending) evict(this.catalogEntry, true); 260 } 261 this.nativeRequest = null; 262 this.catalogEntry = null; 263 rejectResult(error instanceof Error ? error : new Error('The retailer request could not complete.')); 264 }, 265 complete(value) { 266 if (this.settled) return; 267 this.settled = true; 268 clearTimeout(timer); 269 if (latestOperation === this && !valid()) target.hideLoadingSpinner?.(); 270 this.nativeRequest = null; 271 this.catalogEntry = null; 272 resolveResult(value); 273 }, 274 }; 275 const previous = latestOperation; 276 latestOperation = operation; 277 previous?.cancel(); 278 const valid = () => { 279 if (!operation.valid || latestOperation !== operation || operationGeneration !== releaseGeneration) return false;
280 try { return typeof isCurrent !== 'function' || isCurrent(); } 281 catch (error) { operation.fail(error); return false; } 282 }; 283 timer = setTimeout(() => operation.fail(new Error('The retailer request timed out.')), requestTimeout); 284 const previousFetchOperation = fetchingOperation; 285 fetchingOperation = operation; 286 try { 287 Promise.resolve(originalUpdate.call(target, options, valid)) 288 .then(value => operation.complete(value), error => operation.fail(error)); 289 } catch (error) { 290 operation.fail(error); 291 } finally { 292 fetchingOperation = operationGeneration === releaseGeneration ? previousFetchOperation : null; 293 } 294 return handleIgnoredRequest(result); 295 }; 296 297 } 298 299 const invalidate = () => { 300 if (!latestOperation || latestOperation.settled) return false; 301 latestOperation.cancel(); 302 // StoreRocket schedules markers in batches after committing results. 303 // Its render loop checks _markerRun, not the request validity callback. 304 // cancel() stops that work immediately, before the next fetch commits. 305 instance.hideLoadingSpinner?.(); 306 instance.hideGeocodingSpinner?.(); 307 return true; 308 }; 309 const release = () => { 310 ++releaseGeneration; 311 const operation = latestOperation; 312 latestOperation = null; 313 fetchingOperation = null; 314 const wasPending = operation && !operation.settled; 315 operation?.cancel(); 316 if (operation) { 317 operation.nativeRequest = null; 318 operation.catalogEntry = null; 319 } 320 // Also stop any provider marker batches whose request has already settled. 321 if (!wasPending) instance._markerRun = (instance._markerRun || 0) + 1; 322 caches.clear(); 323 if (pages) pages = new WeakMap(); 324 latestPage = null; 325 instance.fetchingMoreResults = false; 326 const sidebarRequests = [...pendingPages]; 327 pendingPages.clear(); 328 sidebarRequests.forEach(page => { 329 page.request?.abort?.(); 330 page.request = page.locations = page.listWindow = page.wrapper = null; 331 page.buttons = []; 332 }); 333 const requests = [...pendingCatalogs]; 334 pendingCatalogs.clear(); 335 requests.forEach(entry => entry.controller?.abort()); 336 instance.hideLoadingSpinner?.(); 337 instance.hideGeocodingSpinner?.(); 338 return true; 339 }; 340 controllers.set(instance, { invalidate, release }); 341 342 instance.fetchLocations = function (params) { 343 const operation = fetchingOperation; 344 const requestGeneration = releaseGeneration; 345 const area = getArea(); 346 const zeroCenter = isZeroCoordinate(this.lat) || isZeroCoordinate(this.lng); 347 // Keep normal searches, selected retailers and supported local radii on 348 // the provider's native path, including native distance sorting/paging. 349 if (!area?.bounds || typeof area.bounds.contains !== 'function' || params?.location || 350 (!area.state && !zeroCenter && Number(area.radius) <= Number(area.maxRadius ?? 100))) { 351 const request = originalFetch.apply(this, arguments); 352 if (operation) operation.nativeRequest = request; 353 return protectFetch(request, operation); 354 } 355 356 const request = withoutCoordinates(params); 357 const key = JSON.stringify(canonical({ 358 request, 359 account: this.userId, 360 api: this.apiBaseUrl, 361 filters: this.filters, 362 lockedFilters: this.lockedFilters, 363 filterOperator: this.filterOperator, 364 units: this.units, 365 embedFilterOptions: this.embedFilterOptions, 366 })); 367 let cache = caches.get(key); 368 if (cache && !cache.pending && Date.now() >= cache.expiresAt) { evict(cache); cache = null; } 369 if (!cache) { 370 // Let StoreRocket construct its complete, scoped request. Its public 371 // radius endpoint caps at 100 miles; use an unbounded, filtered catalog 372 // only for larger views (and its falsy-coordinate edge case at zero). 373 const providerRequest = originalFetch.call(this, { ...request }); 374 Promise.resolve(providerRequest).catch(() => {});
375 const fullParams = { ...this.listRequestParams, list_limit: catalogLimit }; 376 // Parameter construction is synchronous; this metadata-only jqXHR 377 // need not download a second nationwide response alongside fetch(). 378 providerRequest?.abort?.(); 379 const url = new URL(`${this.apiBaseUrl.replace(/\/$/, '')}/user/${encodeURIComponent(this.userId)}/locations`); 380 Object.entries(fullParams).forEach(([name, value]) => { 381 if (value !== undefined && value !== null) url.searchParams.set(name, String(value)); 382 }); 383 const entry = { 384 key, params: fullParams, pending: true, expiresAt: 0, promise: null, 385 controller: typeof window.AbortController === 'function' ? new window.AbortController() : null, 386 }; 387 cache = entry; 388 rememberCache(entry); 389 pendingCatalogs.add(entry); 390 entry.promise = window.fetch(url.href, { credentials: 'omit', ...(entry.controller ? { signal: entry.controller.signal } : {}) }) 391 .then(response => { 392 if (requestGeneration !== releaseGeneration) return null; 393 if (!response.ok) throw new Error(`The retailer catalog could not load (${response.status}).`); 394 return response.json(); 395 }) 396 .then(response => requestGeneration === releaseGeneration ? prepareCatalog(response) : null) 397 .then(catalog => { 398 entry.pending = false; 399 pendingCatalogs.delete(entry); 400 if (requestGeneration !== releaseGeneration) return null; 401 entry.expiresAt = Date.now() + cacheLifetime; 402 return catalog; 403 }) 404 .catch(error => { 405 entry.pending = false; 406 pendingCatalogs.delete(entry); 407 evict(entry); 408 if (requestGeneration !== releaseGeneration) return null; 409 throw error; 410 }); 411 } else { 412 rememberCache(cache); 413 this.installation?.contentPending?.(); 414 this.showLoadingSpinner?.(); 415 }
416 this.listRequestParams = { ...cache.params }; 417 if (operation) operation.catalogEntry = cache; 418 return protectFetch(cache.promise.then(catalog => { 419 // A rapid wheel reversal can supersede a request before its shared 420 // catalog finishes. Do not build even a small DOM payload for it. 421 if (requestGeneration !== releaseGeneration || !catalog || (operation && !operation.isCurrent())) return discardResponse(); 422 return responseInBounds(catalog, area.bounds, pages, area.state); 423 }), operation); 424 }; 425 }; 426 427 window.dimeLocatorAreaData = { 428 install, 429 invalidate: instance => controllers.get(instance)?.invalidate() || false, 430 release: instance => controllers.get(instance)?.release() || false, 431 }; 432})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.