Add sessionStorage caching for AJAX requests and URL state restoration

- Add unified AjaxCache for all AJAX responses (5 min expiry)
- Cache key based on request params (minus nonce), coordinates rounded to 4 decimals
- Clean expired cache entries on page load
- URL hash stores page, scroll, lat/lng/zoom for state restoration
- Bulk load pages in parallel on restore, use cache when available
- Add min-height 100vh to property results in map view
- Change all scroll animations to instant
This commit is contained in:
Hanson.xyz Dev
2025-12-17 03:42:49 -06:00
parent 1c81fd6766
commit 63b8fec917
4 changed files with 540 additions and 32 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -9,6 +9,139 @@
(function($) {
'use strict';
/**
* AJAX Cache Manager
* Caches any AJAX response in sessionStorage based on request data
* Key: HOMEPROZ_AJAX_{hash of request data minus nonce}
* Expires after 5 minutes
*/
var AjaxCache = {
PREFIX: 'HOMEPROZ_AJAX_',
EXPIRY_MS: 5 * 60 * 1000, // 5 minutes
/**
* Initialize - clean up expired entries on page load
*/
init: function() {
this.cleanExpired();
},
/**
* Clean up all expired cache entries
*/
cleanExpired: function() {
try {
var now = Date.now();
var keysToRemove = [];
for (var i = 0; i < sessionStorage.length; i++) {
var key = sessionStorage.key(i);
if (key && key.indexOf(this.PREFIX) === 0) {
try {
var cached = JSON.parse(sessionStorage.getItem(key));
if (now - cached.time > this.EXPIRY_MS) {
keysToRemove.push(key);
}
} catch (e) {
keysToRemove.push(key);
}
}
}
keysToRemove.forEach(function(key) {
sessionStorage.removeItem(key);
});
} catch (e) {
// Ignore errors
}
},
/**
* Normalize request data for cache key (remove nonce, round coordinates)
*/
normalizeData: function(data) {
var normalized = {};
for (var key in data) {
if (key === 'nonce') continue; // Skip nonce
var value = data[key];
// Round coordinate arrays to 4 decimal places (~11m precision)
if (Array.isArray(value)) {
normalized[key] = value.map(function(v) {
return typeof v === 'number' ? Math.round(v * 10000) / 10000 : v;
});
} else {
normalized[key] = value;
}
}
return normalized;
},
/**
* Generate cache key from request data
*/
getKey: function(data) {
var normalized = this.normalizeData(data);
var str = JSON.stringify(normalized);
// Simple hash
var hash = 0;
for (var i = 0; i < str.length; i++) {
var char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return this.PREFIX + Math.abs(hash).toString(36);
},
/**
* Get cached response if valid
*/
get: function(data) {
try {
var key = this.getKey(data);
var stored = sessionStorage.getItem(key);
if (!stored) return null;
var cached = JSON.parse(stored);
var now = Date.now();
// Check expiry
if (now - cached.time > this.EXPIRY_MS) {
sessionStorage.removeItem(key);
return null;
}
return cached.data;
} catch (e) {
return null;
}
},
/**
* Store response in cache
*/
set: function(data, response) {
try {
var key = this.getKey(data);
var cached = {
time: Date.now(),
data: response
};
sessionStorage.setItem(key, JSON.stringify(cached));
} catch (e) {
// Storage full or disabled - ignore
}
}
};
// Initialize cache cleanup on load
AjaxCache.init();
/**
* Request Queue Manager
* Handles debouncing and cancellation of AJAX requests to prevent race conditions.
@@ -209,6 +342,10 @@
var self = this;
this.map.on('moveend zoomend', function() {
self.loadClusters();
// Update URL after a delay to let scroll reset happen first
setTimeout(function() {
PropertyFilters.updateUrlState();
}, 400);
});
// Bind card hover events
@@ -221,6 +358,7 @@
/**
* Load clusters/markers from server based on viewport
* Uses RequestQueue for debouncing and cancellation
* Caches responses in sessionStorage for 5 minutes
*/
loadClusters: function() {
if (!this.map) return;
@@ -254,6 +392,27 @@
// Also update the property list with the same viewport (queued separately)
PropertyFilters.updateFromMap(boundsArray, centerArray);
// Check cache first
var cached = AjaxCache.get(requestData);
if (cached && cached.success) {
// Use cached data immediately
var data = cached.data;
this.currentMode = data.type;
switch (data.type) {
case 'density':
this.renderDensity(data.dots);
break;
case 'clusters':
this.renderClusters(data.clusters);
break;
case 'markers':
this.renderMarkers(data.markers);
break;
}
return;
}
// Queue the cluster request with debounce and cancellation
RequestQueue.queue(
'clusters',
@@ -266,6 +425,9 @@
},
function(response, requestId) {
if (response.success) {
// Cache the full response
AjaxCache.set(requestData, response);
var data = response.data;
self.currentMode = data.type;
@@ -926,18 +1088,17 @@
},
/**
* Initialize filters from URL (hash for page)
* Initialize filters from URL (hash for page, scroll, map state)
*/
initFromUrl: function() {
var self = this;
var params = new URLSearchParams(window.location.search);
var hasFilters = false;
// Set form values from URL query params (filters only, not page)
this.$form.find('select').each(function() {
var name = $(this).attr('name');
if (params.has(name)) {
$(this).val(params.get(name));
hasFilters = true;
}
});
@@ -946,16 +1107,232 @@
var name = $(this).attr('name');
if (params.has(name)) {
$(this).val(params.get(name));
hasFilters = true;
}
});
// Check for page in hash
var page = this.getPageFromHash();
// Get full state from hash
var state = this.getStateFromHash();
// If we have a page > 1 in hash, load that page
if (page > 1) {
this.filterProperties(page, false);
// Store restoration state for map init to use
if (state && (state.lat !== null || state.page > 1)) {
this.pendingRestoreState = state;
}
},
/**
* Restore full page state (called after map is ready)
* Loads all pages up to saved page, then restores scroll position
*/
restoreState: function() {
var self = this;
var state = this.pendingRestoreState;
if (!state) return;
this.pendingRestoreState = null;
// If we have map coordinates, set map position first (without triggering load)
if (state.lat !== null && state.lng !== null && state.zoom !== null && PropertyMap.map) {
// Temporarily disable map events
PropertyMap.map.off('moveend zoomend');
// Set map view
PropertyMap.map.setView([state.lat, state.lng], state.zoom);
// Get bounds from restored map position for AJAX requests
var bounds = PropertyMap.map.getBounds();
var center = PropertyMap.map.getCenter();
this.mapBounds = [
bounds.getSouthWest().lat,
bounds.getSouthWest().lng,
bounds.getNorthEast().lat,
bounds.getNorthEast().lng
];
this.mapCenter = [center.lat, center.lng];
// Load map clusters for the restored position
PropertyMap.loadClusters();
// Re-enable map events after a tick
setTimeout(function() {
PropertyMap.map.on('moveend zoomend', function() {
PropertyMap.loadClusters();
setTimeout(function() {
PropertyFilters.updateUrlState();
}, 400);
});
}, 100);
}
// If we have a page > 1, bulk load all pages
if (state.page > 1) {
this.bulkLoadPages(state.page, state.scroll);
} else if (state.scroll > 0) {
// Just restore scroll position
window.scrollTo({ top: state.scroll, behavior: 'instant' });
$(window).trigger('scroll');
}
},
/**
* Bulk load pages 1 through targetPage, then render and restore scroll
* Uses sessionStorage cache for instant restores when data is cached
*/
bulkLoadPages: function(targetPage, scrollPosition) {
var self = this;
var formData = this.getFormData();
// Block infinite scroll during restoration
InfiniteScrollState.isRestoring = true;
// Create array of page numbers to load (1 through targetPage + 1 for buffer)
var pagesToLoad = [];
for (var i = 1; i <= targetPage + 1; i++) {
pagesToLoad.push(i);
}
// Build base request data
var baseRequestData = {
action: 'homeproz_filter_properties',
nonce: homeprozAjax.nonce,
property_type: formData.property_type,
property_location: formData.property_location,
zip: formData.zip,
min_price: formData.min_price,
max_price: formData.max_price,
beds: formData.beds,
cards_only: 'true'
};
// Add map bounds if available
if (this.mapBounds) {
baseRequestData.bounds = this.mapBounds;
}
if (this.mapCenter) {
baseRequestData.center = this.mapCenter;
}
// Check cache for all pages
var cachedResults = [];
var uncachedPages = [];
pagesToLoad.forEach(function(page) {
var requestData = $.extend({}, baseRequestData, { paged: page });
var cached = AjaxCache.get(requestData);
if (cached && cached.success && cached.data && cached.data.html) {
cachedResults.push({
page: page,
html: cached.data.html,
max_pages: cached.data.max_pages || 1
});
} else {
uncachedPages.push(page);
}
});
// If all pages are cached, render immediately
if (uncachedPages.length === 0) {
this.renderBulkResults(cachedResults, targetPage, scrollPosition);
return;
}
// Show loading spinner only if we need to fetch
this.$results.html('<div class="property-results-loading"><div class="spinner"></div></div>');
// Fire requests only for uncached pages
var requests = uncachedPages.map(function(page) {
var requestData = $.extend({}, baseRequestData, { paged: page });
return $.ajax({
url: homeprozAjax.ajaxUrl,
type: 'POST',
data: requestData
}).then(function(response) {
var result = {
page: page,
html: response.success ? response.data.html : '',
max_pages: response.success ? response.data.max_pages : 0
};
// Cache the response
if (response.success) {
AjaxCache.set(requestData, response);
}
return result;
});
});
// Wait for all to complete
$.when.apply($, requests).done(function() {
// Collect results (handle both single and multiple args)
var fetchedResults = requests.length === 1 ? [arguments[0]] :
Array.prototype.slice.call(arguments);
// Combine cached and fetched results
var allResults = cachedResults.concat(fetchedResults);
self.renderBulkResults(allResults, targetPage, scrollPosition);
}).fail(function() {
// On error, fall back to normal loading
InfiniteScrollState.isRestoring = false;
self.filterProperties(1, false);
});
},
/**
* Render bulk loaded results to the DOM
*/
renderBulkResults: function(results, targetPage, scrollPosition) {
var self = this;
// Sort by page number
results.sort(function(a, b) { return a.page - b.page; });
// Get max_pages from first result
var maxPages = results[0] ? results[0].max_pages : 1;
// Build the full HTML with page wrappers
var fullHtml = '<div class="properties-meta"><p class="properties-count">Loading...</p></div>';
fullHtml += '<div id="property-results-grid" class="properties-grid">';
results.forEach(function(result) {
if (result.html && result.page <= maxPages) {
fullHtml += '<div class="infinite-scroll-page" data-page="' + result.page + '" data-state="populated">';
fullHtml += result.html;
fullHtml += '</div>';
}
});
fullHtml += '</div>';
// Render all at once
this.$results.html(fullHtml);
// Update infinite scroll state
InfiniteScrollState.currentPage = Math.min(targetPage + 1, maxPages);
InfiniteScrollState.maxPages = maxPages;
InfiniteScrollState.pages = {};
// Register all loaded pages
results.forEach(function(result) {
if (result.page <= maxPages) {
InfiniteScrollState.pages[result.page] = { state: 'populated' };
}
});
// Restore scroll position
if (scrollPosition > 0) {
window.scrollTo({ top: scrollPosition, behavior: 'instant' });
}
// Unblock infinite scroll
InfiniteScrollState.isRestoring = false;
// Trigger scroll event for image loader and other listeners
$(window).trigger('scroll');
// Start image loader
if (typeof CardImageLoader !== 'undefined') {
CardImageLoader.process();
}
},
@@ -982,10 +1359,30 @@
InfiniteScroll.reset();
}
// Block scroll state until user actually scrolls
InfiniteScrollState.currentPage = 1;
this._scrollBlocked = true;
this.clearScrollFromUrl();
// Reset to page 1 when map viewport changes
this.filterProperties(1, false);
},
/**
* Remove scroll parameter from URL hash
*/
clearScrollFromUrl: function() {
var hash = window.location.hash.replace('#', '');
if (!hash) return;
var parts = hash.split('&').filter(function(part) {
return !part.startsWith('scroll=');
});
var newHash = parts.length ? '#' + parts.join('&') : '';
history.replaceState(null, '', window.location.pathname + window.location.search + newHash);
},
/**
* Clear pin selection state and visuals
*/
@@ -1002,9 +1399,8 @@
* Get page number from URL hash
*/
getPageFromHash: function() {
var hash = window.location.hash;
var match = hash.match(/#page=(\d+)/);
return match ? parseInt(match[1]) : 1;
var state = this.getStateFromHash();
return state ? state.page : 1;
},
/**
@@ -1093,20 +1489,14 @@
var $card = $('#property-' + PropertyMap.selectedPropertyId);
if ($card.length) {
// Scroll to the selected card
$('html, body').animate({
scrollTop: $card.offset().top - 120
}, 300, function() {
// Ensure card stays highlighted
$card.addClass('property-card-highlighted');
});
window.scrollTo({ top: $card.offset().top - 120, behavior: 'instant' });
$card.addClass('property-card-highlighted');
}
}, 150);
}
// Scroll to top of results on page change (non-pin triggered)
else if (page > 1) {
$('html, body').animate({
scrollTop: self.$filters.offset().top - 100
}, 300);
window.scrollTo({ top: self.$filters.offset().top - 100, behavior: 'instant' });
}
}
},
@@ -1152,7 +1542,8 @@
},
/**
* Update browser URL (filters in query, page in hash)
* Update browser URL (filters in query, state in hash)
* Stores: page, scroll position, map position/zoom
*/
updateUrl: function(formData, page) {
var url = new URL(homeprozAjax.archiveUrl);
@@ -1164,17 +1555,77 @@
}
}
// Add page to hash if > 1
// Build hash with all state info
var hashParts = [];
if (page > 1) {
url.hash = 'page=' + page;
} else {
url.hash = '';
hashParts.push('page=' + page);
}
// Add scroll position (unless blocked - requires user scroll to unblock)
if (!this._scrollBlocked) {
var scrollY = window.pageYOffset || document.documentElement.scrollTop;
if (scrollY > 0) {
hashParts.push('scroll=' + Math.round(scrollY));
}
}
// Add map state if map exists
if (PropertyMap.map) {
var center = PropertyMap.map.getCenter();
var zoom = PropertyMap.map.getZoom();
hashParts.push('lat=' + center.lat.toFixed(6));
hashParts.push('lng=' + center.lng.toFixed(6));
hashParts.push('zoom=' + zoom);
}
url.hash = hashParts.length ? hashParts.join('&') : '';
// Use replaceState to avoid adding history entries for every page
history.replaceState(null, '', url.toString());
},
/**
* Update URL with current state (called on scroll and map move)
* Debounced to avoid excessive history updates
*/
updateUrlState: function() {
var self = this;
// Debounce URL updates
clearTimeout(this._urlUpdateTimeout);
this._urlUpdateTimeout = setTimeout(function() {
// Get current page from infinite scroll state or default to 1
var page = InfiniteScrollState.currentPage || 1;
var formData = self.getFormData();
self.updateUrl(formData, page);
}, 300);
},
/**
* Parse state from URL hash
*/
getStateFromHash: function() {
var hash = window.location.hash.replace('#', '');
if (!hash) return null;
var state = {};
hash.split('&').forEach(function(part) {
var kv = part.split('=');
if (kv.length === 2) {
state[kv[0]] = kv[1];
}
});
return {
page: state.page ? parseInt(state.page) : 1,
scroll: state.scroll ? parseInt(state.scroll) : 0,
lat: state.lat ? parseFloat(state.lat) : null,
lng: state.lng ? parseFloat(state.lng) : null,
zoom: state.zoom ? parseInt(state.zoom) : null
};
},
/**
* Get page number from pagination link URL
*/
@@ -1223,6 +1674,11 @@
if (this.isAboveBreakpoint && this.isMapView && typeof homeprozMapData !== 'undefined') {
PropertyMap.init(homeprozMapData.initialFilters || {});
this.mapInitialized = true;
// Restore state if we have pending state to restore
if (PropertyFilters.pendingRestoreState) {
PropertyFilters.restoreState();
}
}
// Handle resize
@@ -1258,6 +1714,11 @@
if (!this.mapInitialized && typeof homeprozMapData !== 'undefined') {
PropertyMap.init(homeprozMapData.initialFilters || {});
this.mapInitialized = true;
// Restore state if we have pending state to restore
if (PropertyFilters.pendingRestoreState) {
PropertyFilters.restoreState();
}
} else if (PropertyMap.map) {
// Invalidate size to fix map rendering after show
setTimeout(function() {
@@ -1441,8 +1902,10 @@
pages: {}, // pageNum -> {state, height, html}
totalPages: 0,
totalPosts: 0,
currentPage: 1, // Current page based on scroll position
pendingPage: null, // Currently loading page number (only one at a time)
isEnabled: false,
isRestoring: false, // True during bulk page restoration
cardsPerPage: 12
};
@@ -1525,8 +1988,15 @@
clearTimeout(self.scrollTimeout);
self.scrollTimeout = setTimeout(function() {
self.syncPages();
// Update URL state on scroll
PropertyFilters.updateUrlState();
}, 100);
});
// Listen for actual user wheel events to unblock scroll state
$(window).on('wheel.infiniteScroll', function() {
PropertyFilters._scrollBlocked = false;
});
},
/**
@@ -1534,6 +2004,7 @@
*/
syncPages: function() {
if (!this.$grid || !InfiniteScrollState.isEnabled) return;
if (InfiniteScrollState.isRestoring) return; // Block during state restoration
var TP = InfiniteScrollState.totalPages;
@@ -1544,6 +2015,9 @@
if (CP > TP - 2) CP = TP - 2;
if (CP < 1) CP = 1;
// Track current page for URL state
InfiniteScrollState.currentPage = CP;
// 3. Calculate DLP (Desired Loaded Pages)
var DLP = [CP - 2, CP - 1, CP, CP + 1, CP + 2];
@@ -1763,14 +2237,12 @@
},
/**
* Load a page via AJAX
* Load a page via AJAX (with sessionStorage cache)
*/
loadPage: function(pageNum) {
var self = this;
InfiniteScrollState.pendingPage = pageNum;
var formData = PropertyFilters.getFormData();
var requestData = {
action: 'homeproz_filter_properties',
nonce: homeprozAjax.nonce,
@@ -1791,12 +2263,41 @@
requestData.center = PropertyFilters.mapCenter;
}
// Check cache first
var cached = AjaxCache.get(requestData);
if (cached && cached.success && cached.data && cached.data.html) {
// Use cached data immediately
if (!InfiniteScrollState.pages[pageNum]) {
InfiniteScrollState.pages[pageNum] = {};
}
InfiniteScrollState.pages[pageNum].state = 'populated';
var $page = self.$grid.find('.infinite-scroll-page[data-page="' + pageNum + '"]');
if ($page.length) {
$page.html(cached.data.html);
$page.attr('data-state', 'populated');
if (typeof CardImageLoader !== 'undefined') {
CardImageLoader.process();
}
}
// Continue loading more pages if needed
self.syncPages();
return;
}
InfiniteScrollState.pendingPage = pageNum;
$.ajax({
url: homeprozAjax.ajaxUrl,
type: 'POST',
data: requestData,
success: function(response) {
if (response.success && response.data.html) {
// Cache the response
AjaxCache.set(requestData, response);
// Update state
if (!InfiniteScrollState.pages[pageNum]) {
InfiniteScrollState.pages[pageNum] = {};
@@ -202,6 +202,13 @@
gap: 1.5rem;
}
}
// Min height in map view to prevent layout collapse during loading/restore
@media (min-width: 1024px) {
.is-map-view & #property-results {
min-height: 100vh;
}
}
}
// Grid view properties grid