Subject: [PATCH links] Stop abusing tag autocomplete

From: Peter Sanchez <[email-masked]>
Date: Fri, 5 Sep 2025 12:50:08 -0600

---
 core/routes.go              |  11 ++-
 static/js/advancedsearch.js | 133 +++++++++++++++++++++++-------------
 static/js/autocomplete.js   |  85 ++++++++++++++++-------
 3 files changed, 155 insertions(+), 74 deletions(-)

diff --git a/core/routes.go b/core/routes.go
index 5e8fd53..39a13f6 100644
--- a/core/routes.go
+++ b/core/routes.go
@@ -1,6 +1,7 @@
 package core
 
 import (
+	"context"
 	"database/sql"
 	"errors"
 	"fmt"
@@ -30,6 +31,7 @@ import (
 	"netlandish.com/x/gobwebs/database"
 	"netlandish.com/x/gobwebs/messages"
 	"netlandish.com/x/gobwebs/server"
+	"netlandish.com/x/gobwebs/timezone"
 	"netlandish.com/x/gobwebs/validate"
 )
 
@@ -3447,7 +3449,14 @@ func (s *Service) TagAutocomplete(c echo.Context) error {
 		} else {
 			return c.JSON(http.StatusOK, "{}")
 		}
-		tags, err = models.GetTags(c.Request().Context(), opts)
+		
+		// Create sql.DB context with timezone to avoid DBI connection hoarding
+		dbi := database.DBIForContext(c.Request().Context())
+		tz := timezone.ForContext(c.Request().Context())
+		newCtx := database.Context(context.Background(), dbi.GetDB())
+		newCtx = timezone.Context(newCtx, tz)
+		
+		tags, err = models.GetTags(newCtx, opts)
 		if err != nil {
 			return err
 		}
diff --git a/static/js/advancedsearch.js b/static/js/advancedsearch.js
index f800ce2..21ff3b6 100644
--- a/static/js/advancedsearch.js
+++ b/static/js/advancedsearch.js
@@ -8,6 +8,10 @@ var tagInputDiv = document.getElementById("advanced-search-div")
 // Track highlighted index for each autocomplete dropdown
 var highlightedIndexes = new WeakMap();
 
+// Track debounce timers and active requests for each tag selector
+var debounceTimers = new WeakMap();
+var activeRequests = new WeakMap();
+
 // Track if we should allow form submission
 var allowFormSubmit = true;
 
@@ -92,6 +96,19 @@ function autocomplete(event) {
         return;
     }
     
+    // Cancel previous request for this tag selector if active
+    var activeRequest = activeRequests.get(tagSelector);
+    if (activeRequest) {
+        activeRequest.abort();
+        activeRequests.delete(tagSelector);
+    }
+    
+    // Clear existing debounce timer for this tag selector
+    var debounceTimer = debounceTimers.get(tagSelector);
+    if (debounceTimer) {
+        clearTimeout(debounceTimer);
+    }
+    
     tags = this.value.split(",")
     tag = tags[tags.length -1].trim()
 
@@ -100,56 +117,78 @@ function autocomplete(event) {
         autocompleteTags = this.nextElementSibling;
     }
     if (tag !== "") {
-        // Store the query we're searching for
-        var searchQuery = tag;
-        
-        fetch(url + "&q=" + tag)
-            .then(function (response) {
-                if (!response.ok) {
-                    console.log("Network response was not ok");
-                }
-                return response.json();
-            })
-            .then(function (data) {
-                // Check if the current input still matches what we searched for
-                var currentTags = tagSelector.value.split(",");
-                var currentTag = currentTags[currentTags.length - 1].trim();
-                
-                if (autocompleteTags !== undefined && currentTag === searchQuery) {
-                    showSuggestions(autocompleteTags, data);
+        // Debounce the request for 300ms
+        var newTimer = setTimeout(function() {
+            // Store the query we're searching for
+            var searchQuery = tag;
+            
+            // Create AbortController for request cancellation
+            var controller = new AbortController();
+            activeRequests.set(tagSelector, controller);
+            
+            fetch(url + "&q=" + tag, { signal: controller.signal })
+                .then(function (response) {
+                    if (!response.ok) {
+                        console.log("Network response was not ok");
+                    }
+                    return response.json();
+                })
+                .then(function (data) {
+                    // Clear active request on completion
+                    if (activeRequests.get(tagSelector) === controller) {
+                        activeRequests.delete(tagSelector);
+                    }
                     
-                    // Set up click handler for this dropdown
-                    autocompleteTags.onclick = function(event) {
-                        if (event.target.classList.contains("tag-suggested")) {
-                            var tags = tagSelector.value.split(",");
-                            var newTag = event.target.textContent + ", ";
-                            if (tags.length > 1) {
-                                newTag = " " + newTag;
+                    // Check if the current input still matches what we searched for
+                    var currentTags = tagSelector.value.split(",");
+                    var currentTag = currentTags[currentTags.length - 1].trim();
+                    
+                    if (autocompleteTags !== undefined && currentTag === searchQuery) {
+                        showSuggestions(autocompleteTags, data);
+                        
+                        // Set up click handler for this dropdown
+                        autocompleteTags.onclick = function(event) {
+                            if (event.target.classList.contains("tag-suggested")) {
+                                var tags = tagSelector.value.split(",");
+                                var newTag = event.target.textContent + ", ";
+                                if (tags.length > 1) {
+                                    newTag = " " + newTag;
+                                }
+                                tags[tags.length - 1] = newTag;
+                                tagSelector.value = tags.join(",");
+                                autocompleteTags.classList.add("d-none");
+                                highlightedIndexes.set(autocompleteTags, -1);
+                                tagSelector.focus();
                             }
-                            tags[tags.length - 1] = newTag;
-                            tagSelector.value = tags.join(",");
-                            autocompleteTags.classList.add("d-none");
-                            highlightedIndexes.set(autocompleteTags, -1);
-                            tagSelector.focus();
-                        }
-                    };
+                        };
+                        
+                        // Set up mouseover handler for this dropdown
+                        autocompleteTags.onmouseover = function(event) {
+                            if (event.target.classList.contains("tag-suggested")) {
+                                var index = parseInt(event.target.getAttribute("data-index"));
+                                highlightSuggestion(autocompleteTags, index);
+                            }
+                        };
+                    } else if (autocompleteTags !== undefined) {
+                        // Input has changed, hide the dropdown
+                        autocompleteTags.classList.add("d-none");
+                        highlightedIndexes.set(autocompleteTags, -1);
+                    }
+                })
+                .catch(function (error) {
+                    // Clear active request on error (unless aborted)
+                    if (activeRequests.get(tagSelector) === controller) {
+                        activeRequests.delete(tagSelector);
+                    }
                     
-                    // Set up mouseover handler for this dropdown
-                    autocompleteTags.onmouseover = function(event) {
-                        if (event.target.classList.contains("tag-suggested")) {
-                            var index = parseInt(event.target.getAttribute("data-index"));
-                            highlightSuggestion(autocompleteTags, index);
-                        }
-                    };
-                } else if (autocompleteTags !== undefined) {
-                    // Input has changed, hide the dropdown
-                    autocompleteTags.classList.add("d-none");
-                    highlightedIndexes.set(autocompleteTags, -1);
-                }
-            })
-            .catch(function (error) {
-                console.log("Fetch error:", error);
-            });
+                    // Don't log aborted requests as errors
+                    if (error.name !== 'AbortError') {
+                        console.log("Fetch error:", error);
+                    }
+                });
+        }, 300);
+        
+        debounceTimers.set(tagSelector, newTimer);
     } else {
         if (autocompleteTags !== undefined) {
             autocompleteTags.classList.add("d-none");
diff --git a/static/js/autocomplete.js b/static/js/autocomplete.js
index 5593795..30b079a 100644
--- a/static/js/autocomplete.js
+++ b/static/js/autocomplete.js
@@ -3,6 +3,8 @@ var tagSelector = document.getElementById("tag-selector");
 var autocompleteTags = document.getElementById("autocomplete-tags");
 var highlightedIndex = -1;
 var suggestions = [];
+var debounceTimer;
+var activeRequest;
 
 // Function to highlight a suggestion
 function highlightSuggestion(index) {
@@ -111,36 +113,67 @@ tagSelector.addEventListener("keyup", function(event) {
         return;
     }
     
+    // Cancel previous request if active
+    if (activeRequest) {
+        activeRequest.abort();
+        activeRequest = null;
+    }
+    
+    // Clear existing debounce timer
+    if (debounceTimer) {
+        clearTimeout(debounceTimer);
+    }
+    
     tags = this.value.split(",");
     tag = tags[tags.length - 1].trim();
 
     if (tag !== "") {
-        // Store the query we're searching for
-        var searchQuery = tag;
-        
-        fetch(url + "&q=" + tag)
-            .then(function (response) {
-                if (!response.ok) {
-                    console.log("Network response was not ok");
-                }
-                return response.json();
-            })
-            .then(function (data) {
-                // Check if the current input still matches what we searched for
-                var currentTags = tagSelector.value.split(",");
-                var currentTag = currentTags[currentTags.length - 1].trim();
-                
-                if (currentTag === searchQuery) {
-                    showSuggestions(data);
-                } else {
-                    // Input has changed, hide the dropdown
-                    autocompleteTags.classList.add("d-none");
-                    highlightedIndex = -1;
-                }
-            })
-            .catch(function (error) {
-                console.log("Fetch error:", error);
-            });
+        // Debounce the request for 300ms
+        debounceTimer = setTimeout(function() {
+            // Store the query we're searching for
+            var searchQuery = tag;
+            
+            // Create AbortController for request cancellation
+            var controller = new AbortController();
+            activeRequest = controller;
+            
+            fetch(url + "&q=" + tag, { signal: controller.signal })
+                .then(function (response) {
+                    if (!response.ok) {
+                        console.log("Network response was not ok");
+                    }
+                    return response.json();
+                })
+                .then(function (data) {
+                    // Clear active request on completion
+                    if (activeRequest === controller) {
+                        activeRequest = null;
+                    }
+                    
+                    // Check if the current input still matches what we searched for
+                    var currentTags = tagSelector.value.split(",");
+                    var currentTag = currentTags[currentTags.length - 1].trim();
+                    
+                    if (currentTag === searchQuery) {
+                        showSuggestions(data);
+                    } else {
+                        // Input has changed, hide the dropdown
+                        autocompleteTags.classList.add("d-none");
+                        highlightedIndex = -1;
+                    }
+                })
+                .catch(function (error) {
+                    // Clear active request on error (unless aborted)
+                    if (activeRequest === controller) {
+                        activeRequest = null;
+                    }
+                    
+                    // Don't log aborted requests as errors
+                    if (error.name !== 'AbortError') {
+                        console.log("Fetch error:", error);
+                    }
+                });
+        }, 300);
     } else {
         autocompleteTags.classList.add("d-none");
         highlightedIndex = -1;
-- 
2.49.1

---

From: "Peter Sanchez" <[email-masked]>
Date: Fri, 05 Sep 2025 12:52:14 -0600

Applied.

To [email-masked]:~netlandish/links
   4f1684a..5f89d68  master -> master