Add request queue to prevent map/filter race conditions

- Add RequestQueue module with 200ms debounce and request cancellation
- Queue cluster requests to abort pending when new viewport/filter changes
- Queue property list requests to prevent stale data rendering
- Track request IDs to discard responses from cancelled requests
- Error handler ignores aborted requests (intentional cancellation)

Fixes issue where changing filters then zooming map before load complete
would render pins from previous filter state.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Hanson.xyz Dev
2025-12-16 14:02:53 -06:00
parent cf181d01b3
commit 761384ee1b
2 changed files with 164 additions and 48 deletions
File diff suppressed because one or more lines are too long
@@ -9,6 +9,126 @@
(function($) {
'use strict';
/**
* Request Queue Manager
* Handles debouncing and cancellation of AJAX requests to prevent race conditions.
* When filters, zoom, or viewport change, pending requests are cancelled and
* only the final requested state is fetched.
*/
var RequestQueue = {
// Pending XHR objects by type
pending: {
clusters: null,
properties: null
},
// Debounce timeouts by type
timeouts: {
clusters: null,
properties: null
},
// Request IDs to track staleness
requestIds: {
clusters: 0,
properties: 0
},
// Debounce delay in ms
debounceDelay: 200,
/**
* Queue a request with debouncing and cancellation
* @param {string} type - 'clusters' or 'properties'
* @param {function} requestFn - Function that returns jqXHR (receives requestId)
* @param {function} successFn - Success callback (receives response, requestId)
* @param {function} completeFn - Complete callback (optional)
* @param {function} errorFn - Error callback (optional, receives jqXHR, textStatus, errorThrown)
*/
queue: function(type, requestFn, successFn, completeFn, errorFn) {
var self = this;
// Cancel any pending debounce timeout
if (this.timeouts[type]) {
clearTimeout(this.timeouts[type]);
this.timeouts[type] = null;
}
// Abort any in-flight request
if (this.pending[type]) {
this.pending[type].abort();
this.pending[type] = null;
}
// Increment request ID
this.requestIds[type]++;
var requestId = this.requestIds[type];
// Schedule the request with debounce
this.timeouts[type] = setTimeout(function() {
self.timeouts[type] = null;
// Execute the request
var xhr = requestFn(requestId);
if (xhr && xhr.then) {
self.pending[type] = xhr;
xhr.done(function(response) {
// Only process if this is still the latest request
if (requestId === self.requestIds[type]) {
successFn(response, requestId);
}
});
xhr.fail(function(jqXHR, textStatus, errorThrown) {
// Only call error handler if this is still the latest request
// and it wasn't aborted (which is intentional)
if (errorFn && requestId === self.requestIds[type] && textStatus !== 'abort') {
errorFn(jqXHR, textStatus, errorThrown);
}
});
xhr.always(function() {
// Clear pending reference if this is the current request
if (self.pending[type] === xhr) {
self.pending[type] = null;
}
if (completeFn && requestId === self.requestIds[type]) {
completeFn();
}
});
}
}, this.debounceDelay);
},
/**
* Cancel all pending requests of a specific type
* @param {string} type - 'clusters' or 'properties' (optional, cancels all if omitted)
*/
cancel: function(type) {
var types = type ? [type] : ['clusters', 'properties'];
var self = this;
types.forEach(function(t) {
if (self.timeouts[t]) {
clearTimeout(self.timeouts[t]);
self.timeouts[t] = null;
}
if (self.pending[t]) {
self.pending[t].abort();
self.pending[t] = null;
}
});
},
/**
* Check if a request type is currently loading
* @param {string} type - 'clusters' or 'properties'
* @returns {boolean}
*/
isLoading: function(type) {
return !!(this.pending[type] || this.timeouts[type]);
}
};
/**
* Property Map Manager
* Uses server-side clustering for efficient rendering of 30k+ properties
@@ -23,8 +143,6 @@
hoveredPropertyId: null,
baseZIndex: 400,
currentFilters: {},
isLoading: false,
loadTimeout: null,
currentMode: null, // Track current visualization mode
/**
@@ -95,29 +213,16 @@
/**
* Load clusters/markers from server based on viewport
* Uses RequestQueue for debouncing and cancellation
*/
loadClusters: function() {
if (!this.map) return;
var self = this;
// Debounce rapid requests
clearTimeout(this.loadTimeout);
this.loadTimeout = setTimeout(function() {
self._doLoadClusters();
}, 150);
},
_doLoadClusters: function() {
if (this.isLoading) return;
var self = this;
var bounds = this.map.getBounds();
var center = this.map.getCenter();
var zoom = this.map.getZoom();
this.isLoading = true;
// Bounds array for both map clusters and property list
var boundsArray = [
bounds.getSouthWest().lat,
@@ -139,14 +244,20 @@
min_beds: this.currentFilters.min_beds || ''
};
// Also update the property list with the same viewport
// Also update the property list with the same viewport (queued separately)
PropertyFilters.updateFromMap(boundsArray, centerArray);
$.ajax({
url: homeprozMapData.clusterEndpoint,
type: 'GET',
data: requestData,
success: function(response) {
// Queue the cluster request with debounce and cancellation
RequestQueue.queue(
'clusters',
function(requestId) {
return $.ajax({
url: homeprozMapData.clusterEndpoint,
type: 'GET',
data: requestData
});
},
function(response, requestId) {
if (response.success) {
var data = response.data;
self.currentMode = data.type;
@@ -163,11 +274,8 @@
break;
}
}
},
complete: function() {
self.isLoading = false;
}
});
);
},
/**
@@ -525,7 +633,6 @@
// State
isFirstLoad: true,
isLoading: false,
mapBounds: null, // Current map viewport bounds
mapCenter: null, // Current map center for distance sorting
isMapUpdate: false, // Flag to prevent map->filter->map loop
@@ -646,20 +753,16 @@
/**
* Filter properties via AJAX
* Uses RequestQueue for debouncing and cancellation to prevent race conditions
*/
filterProperties: function(page, updateHistory) {
if (this.isLoading) {
return;
}
updateHistory = updateHistory !== false;
page = page || 1;
var self = this;
var formData = this.getFormData();
// Show loading state
this.isLoading = true;
// Show loading state immediately
this.$filters.addClass('is-loading');
// Show spinner only on first load
@@ -688,20 +791,29 @@
requestData.center = this.mapCenter;
}
$.ajax({
url: homeprozAjax.ajaxUrl,
type: 'POST',
data: requestData,
success: function(response) {
// Capture isMapUpdate state before it might change
var wasMapUpdate = this.isMapUpdate;
this.isMapUpdate = false;
// Queue the property list request with debounce and cancellation
RequestQueue.queue(
'properties',
function(requestId) {
return $.ajax({
url: homeprozAjax.ajaxUrl,
type: 'POST',
data: requestData
});
},
function(response, requestId) {
if (response.success) {
self.$results.html(response.data.html);
self.isFirstLoad = false;
// Update map with new filter params (but not if this was triggered by map move)
if (response.data.filters && !self.isMapUpdate) {
if (response.data.filters && !wasMapUpdate) {
PropertyMap.updateFilters(response.data.filters);
}
self.isMapUpdate = false;
// Recalculate layout after content update
if (typeof LayoutCalculator !== 'undefined') {
@@ -730,14 +842,15 @@
}
}
},
error: function() {
self.$results.html('<div class="no-properties"><h3>Error</h3><p>Something went wrong. Please try again.</p></div>');
},
complete: function() {
self.isLoading = false;
function() {
// Complete callback - remove loading state
self.$filters.removeClass('is-loading');
},
function() {
// Error callback - show error message
self.$results.html('<div class="no-properties"><h3>Error</h3><p>Something went wrong. Please try again.</p></div>');
}
});
);
},
/**
@@ -1486,13 +1599,16 @@
* Reset infinite scroll (called on filter/map change)
*/
reset: function() {
// Cancel all pending requests
// Cancel all pending infinite scroll requests
for (var page in InfiniteScrollState.pendingRequests) {
if (InfiniteScrollState.pendingRequests[page]) {
InfiniteScrollState.pendingRequests[page].abort();
}
}
// Also cancel pending property and cluster requests via RequestQueue
RequestQueue.cancel();
// Clear state
this.resetState();