From: Alexander Moisseev Date: Thu, 2 Jul 2026 15:03:04 +0000 (+0300) Subject: [Fix] WebUI: release scroll-preservation listeners X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=2925d1cc177124339e7d0730b87f016f8dddddbf;p=thirdparty%2Frspamd.git [Fix] WebUI: release scroll-preservation listeners installScrollPreservation bound listeners on the mount element and window without removing the previous set. Each column-options rebuild (destroy + re-init on the persistent mount element) stacked a new set that was never released — a listener/memory leak on the history and scan tables. Mirror bindRowClickToggle's WeakMap guard: key the set by mount element and tear it down before re-binding. --- diff --git a/interface/js/app/tab-utils.js b/interface/js/app/tab-utils.js index 80b260324a..d295150eed 100644 --- a/interface/js/app/tab-utils.js +++ b/interface/js/app/tab-utils.js @@ -14,6 +14,10 @@ define(["app/common"], // element (and listeners attached to it) in place, so a destroy+re-init // would stack handlers. Keyed by element to keep one handler per table. const rowToggleHandlers = new WeakMap(); + // Same leak mode as rowToggleHandlers, but installScrollPreservation also + // binds a window "scroll" listener and (optionally) arm-trigger listeners; + // all stored here so a rebuild can remove the previous set first. + const scrollPreservationHandlers = new WeakMap(); /** * Patch Element.prototype.scrollIntoView once so that calls for elements @@ -94,7 +98,20 @@ define(["app/common"], */ tabUtils.installScrollPreservation = function (table, options) { const t = common.tables[table]; + const el = t.element; const {clickMs = 250, renderMs = 400, armTriggers = []} = options || {}; + + // destroy()+re-init leaves the mount element (and window) in place, so + // each rebuild would stack a fresh listener set that is never released + // — a memory/listener leak. Tear down the previous set first. Mirrors + // bindRowClickToggle's WeakMap guard; keyed by the mount element. + const prev = scrollPreservationHandlers.get(el); + if (prev) { + ["click", "change", "input"].forEach((evt) => el.removeEventListener(evt, prev.arm, true)); + prev.armTriggerEls.forEach((trigger) => trigger.removeEventListener("click", prev.arm, true)); + window.removeEventListener("scroll", prev.onScroll, true); + } + let preserveY = 0; let preserveUntil = 0; let clickArmed = false; @@ -116,12 +133,17 @@ define(["app/common"], }); } + function onScroll() { + if (performance.now() >= preserveUntil) return; + if (window.scrollY !== preserveY) window.scrollTo(0, preserveY); + } + // Arm on click plus change/input. A header-filter