From 43b159c32714cb3359978286a31ced4fedd4a485 Mon Sep 17 00:00:00 2001 From: Paul Johnston Date: Tue, 5 May 2026 15:17:44 -0600 Subject: [PATCH] Dispose tabs on Select transition Select.hideCurrent now removes the outgoing tab from the DOM and disposes its component (firing exitDocument and disposeInternal), instead of just calling .hide() and leaving an inactive but live component in the document. This fixes unbounded DOM growth in long-running SPA sessions where users browse hundreds of routes and every previously-visited tab stays cached in the parent's content element with display:none. --- js/ui/BUILD.bazel | 1 + js/ui/route.js | 7 ------- js/ui/select.js | 39 ++++++++++++++++++++++++++++++--------- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/js/ui/BUILD.bazel b/js/ui/BUILD.bazel index 5e2858d..789245d 100644 --- a/js/ui/BUILD.bazel +++ b/js/ui/BUILD.bazel @@ -88,6 +88,7 @@ closure_js_library( "@stackb_rules_closure//closure/goog/dom:dataset", "@stackb_rules_closure//closure/goog/dom:tagname", "@stackb_rules_closure//closure/goog/events:event", + "@stackb_rules_closure//closure/goog/ui:component", ], ) diff --git a/js/ui/route.js b/js/ui/route.js index 6c66732..ba5c47f 100644 --- a/js/ui/route.js +++ b/js/ui/route.js @@ -140,13 +140,6 @@ class Route extends EventTarget { return this.path_.slice(0, this.index_); } - // /** Peek at the next path segment. - // * @return {string} - // */ - // next() { - // return this.path_[this.index_ + 1]; - // } - /** Get the current path segment. * @return {string} */ diff --git a/js/ui/select.js b/js/ui/select.js index 7e61321..2217158 100644 --- a/js/ui/select.js +++ b/js/ui/select.js @@ -3,6 +3,7 @@ */ goog.module('stack.ui.Select'); +const ComponentEventType = goog.require('goog.ui.Component.EventType'); const TabEvent = goog.require('stack.ui.TabEvent'); const Template = goog.require('stack.ui.Template'); const asserts = goog.require('goog.asserts'); @@ -97,7 +98,12 @@ class Select extends Component { showTab(name) { var tab = this.getTab(name); if (tab) { - this.hideCurrent(); + // Don't tear down the current tab when re-selecting it (e.g. + // navigating from /modules/foo/0.5.4 to /modules/foo/0.5.5 — at the + // outer BodySelect level the tab name is still "modules"). + if (this.current_ !== name) { + this.hideCurrent(); + } this.current_ = name; //var path = this.getPath(); //path.push(name); @@ -227,19 +233,34 @@ class Select extends Component { } /** - * Hide the current tab and make it the previous. + * Hide the current tab and make it the previous. The outgoing tab is + * removed from this Select's children, detached from the DOM, and + * disposed — so its component lifecycle reaches `exitDocument` and + * `disposeInternal`, releasing event handlers and any custom state. + * Subscribers that key off the tab name (e.g. SelectNav highlights) get + * a final HIDE event before the element goes away. + * + * Tabs are recreated on demand by `selectFail` if the user navigates + * back to them. + * * @return {?Component} */ hideCurrent() { - var prev = null; - if (this.current_) { - this.prev_ = this.current_; - prev = this.getTab(this.prev_); - if (prev) { - prev.hide(); - } + if (!this.current_) { + return null; } + const name = this.current_; + const prev = this.getTab(name); + this.prev_ = name; this.current_ = null; + if (prev) { + // Notify listeners (e.g. SelectNav) before the element is gone. + prev.dispatchEvent(ComponentEventType.HIDE); + // removeChild(c, true) detaches the element and fires exitDocument. + this.removeChild(prev, true); + delete this.name2id_[name]; + prev.dispose(); + } return prev; }