Enhance search suggestions with request tracking and cleanup handlers

This commit is contained in:
pacnpal
2025-02-10 22:10:11 -05:00
parent 2756079010
commit 99b935da19

View File

@@ -27,15 +27,53 @@ document.addEventListener('alpine:init', () => {
this.showError('An error occurred while searching. Please try again.'); this.showError('An error occurred while searching. Please try again.');
}); });
// Bind to popstate for browser navigation // Store bound handlers for cleanup
window.addEventListener('popstate', () => { this.boundHandlers = new Map();
// Create handler functions
const popstateHandler = () => {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
this.searchQuery = urlParams.get('search') || ''; this.searchQuery = urlParams.get('search') || '';
this.syncFormWithUrl(); this.syncFormWithUrl();
}); };
this.boundHandlers.set('popstate', popstateHandler);
const errorHandler = (evt) => {
console.error('HTMX Error:', evt.detail.error);
this.showError('An error occurred while searching. Please try again.');
};
this.boundHandlers.set('htmx:error', errorHandler);
// Bind event listeners
window.addEventListener('popstate', popstateHandler);
document.body.addEventListener('htmx:error', errorHandler);
// Restore filters from localStorage if no URL params exist // Restore filters from localStorage if no URL params exist
const savedFilters = localStorage.getItem('rideFilters'); const savedFilters = localStorage.getItem('rideFilters');
// Set up destruction handler
this.$cleanup = () => {
// Remove all bound event listeners
this.boundHandlers.forEach((handler, event) => {
if (event === 'popstate') {
window.removeEventListener(event, handler);
} else {
document.body.removeEventListener(event, handler);
}
});
this.boundHandlers.clear();
// Cancel any pending requests
if (this.currentRequest) {
this.currentRequest.abort();
this.currentRequest = null;
}
// Clear any pending timeouts
if (this.suggestionTimeout) {
clearTimeout(this.suggestionTimeout);
}
};
if (savedFilters) { if (savedFilters) {
const filters = JSON.parse(savedFilters); const filters = JSON.parse(savedFilters);
Object.entries(filters).forEach(([key, value]) => { Object.entries(filters).forEach(([key, value]) => {
@@ -77,21 +115,37 @@ document.addEventListener('alpine:init', () => {
document.querySelector('form').dispatchEvent(new Event('change')); document.querySelector('form').dispatchEvent(new Event('change'));
}, },
// Get search suggestions // Get search suggestions with request tracking
lastRequestId: 0,
currentRequest: null,
async getSearchSuggestions() { async getSearchSuggestions() {
if (this.searchQuery.length < 2) { if (this.searchQuery.length < 2) {
this.showSuggestions = false; this.showSuggestions = false;
return; return;
} }
// Cancel any pending request
if (this.currentRequest) {
this.currentRequest.abort();
}
const requestId = ++this.lastRequestId;
const controller = new AbortController(); const controller = new AbortController();
this.currentRequest = controller;
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout
try { try {
const parkSlug = document.querySelector('input[name="park_slug"]')?.value; const parkSlug = document.querySelector('input[name="park_slug"]')?.value;
const url = `/rides/search-suggestions/?q=${encodeURIComponent(this.searchQuery)}${parkSlug ? '&park_slug=' + parkSlug : ''}`; const url = `/rides/search-suggestions/?q=${encodeURIComponent(this.searchQuery)}${parkSlug ? '&park_slug=' + parkSlug : ''}`;
const response = await fetch(url, { signal: controller.signal }); const response = await fetch(url, {
signal: controller.signal,
headers: {
'X-Request-ID': requestId.toString()
}
});
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
@@ -99,25 +153,42 @@ document.addEventListener('alpine:init', () => {
const html = await response.text(); const html = await response.text();
// Ensure the response is for the current query // Only update if this is still the most recent request
if (this.searchQuery === document.getElementById('search').value) { if (requestId === this.lastRequestId && this.searchQuery === document.getElementById('search').value) {
document.getElementById('search-suggestions').innerHTML = html; const suggestionsEl = document.getElementById('search-suggestions');
suggestionsEl.innerHTML = html;
this.showSuggestions = html.trim() ? true : false; this.showSuggestions = html.trim() ? true : false;
// Set proper ARIA attributes
const searchInput = document.getElementById('search');
searchInput.setAttribute('aria-expanded', this.showSuggestions.toString());
searchInput.setAttribute('aria-controls', 'search-suggestions');
if (this.showSuggestions) {
suggestionsEl.setAttribute('role', 'listbox');
suggestionsEl.querySelectorAll('button').forEach(btn => {
btn.setAttribute('role', 'option');
});
}
} }
} catch (error) { } catch (error) {
if (error.name === 'AbortError') { if (error.name === 'AbortError') {
console.warn('Search suggestion request timed out'); console.warn('Search suggestion request timed out or cancelled');
} else { } else {
console.error('Error fetching suggestions:', error); console.error('Error fetching suggestions:', error);
// Show error state in UI if (requestId === this.lastRequestId) {
document.getElementById('search-suggestions').innerHTML = ` const suggestionsEl = document.getElementById('search-suggestions');
<div class="p-2 text-sm text-red-600 dark:text-red-400"> suggestionsEl.innerHTML = `
Failed to load suggestions. Please try again. <div class="p-2 text-sm text-red-600 dark:text-red-400" role="alert">
</div>`; Failed to load suggestions. Please try again.
this.showSuggestions = true; </div>`;
this.showSuggestions = true;
}
} }
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (this.currentRequest === controller) {
this.currentRequest = null;
}
} }
}, },