From f27fd260f7601f481e3e16c5658eed7b2369d6b4 Mon Sep 17 00:00:00 2001 From: Ron Pierce Date: Mon, 1 Jun 2026 09:07:40 -0700 Subject: [PATCH] fix(web-ui): load v3 tab content deterministically (#359) * fix(web-ui): load v3 tab content deterministically The v3 dashboard tab panels loaded content via hx-trigger="revealed", but the panels are shown/hidden with Alpine x-show (display toggling), which never produces the scroll event htmx's "revealed" handler waits for. loadTabContent tried to force it with htmx.trigger(el, 'revealed'), but "revealed" is a synthetic scroll/observer trigger, not a dispatchable event, so that call is a no-op. The result was an intermittently blank panel - content appeared only when htmx's native reveal scan happened to fire on its own. - Replace the trigger with a custom "loadtab" event that nothing fires spontaneously (0% native firing). - Load panels via htmx.ajax, which issues the request directly and works even before htmx has processed the element's triggers - unlike htmx.trigger, which is lost if dispatched before processing. - Poll for htmx when it hasn't finished loading from the CDN instead of relying on a one-shot htmx:ready event that can be missed. - Stamp data-loaded on the request promise so each panel loads once. Verified in the emulator web UI: overview loads on every reload, tabs lazy-load on demand, and revisiting a tab does not refetch. Co-Authored-By: Claude Opus 4.8 * fix(web-ui): guard tab loads against stale pollers and re-entry Address review feedback. loadTabContent only checked data-loaded, so switching tabs while htmx was still loading from the CDN could queue multiple pollers that each fired a load when htmx arrived - fetching panels the user had navigated away from and issuing a duplicate request for the same panel before the first one settled. Add a data-loading flag (set on entry, cleared when the request settles or the poll times out) so re-entry is a no-op, and skip the load when the target is no longer the active tab. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- web_interface/templates/v3/base.html | 92 ++++++++++++++++++---------- 1 file changed, 58 insertions(+), 34 deletions(-) diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 841ed4c6..f8a08295 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -352,15 +352,14 @@ } }); - // Set data-loaded on tab containers after HTMX settles their content, - // preventing repeated re-fetches on every tab switch. - // Scoped to elements with hx-trigger="revealed" (tab containers only) so - // modals and plugin config panels that legitimately reload are unaffected. + // Mark tab containers as loaded once their content settles, so switching + // away and back doesn't re-fetch. Scoped to the "loadtab" trigger (tab + // containers only) so modals and plugin config panels can still reload. document.body.addEventListener('htmx:afterSettle', function(event) { if (event.detail && event.detail.target) { var target = event.detail.target; var trigger = target.getAttribute('hx-trigger') || ''; - if (trigger.includes('revealed')) { + if (trigger.includes('loadtab')) { target.setAttribute('data-loaded', 'true'); } } @@ -1030,7 +1029,7 @@
-
+
@@ -1098,7 +1097,7 @@
-
+
@@ -1116,7 +1115,7 @@
@@ -1167,7 +1166,7 @@
-
+
@@ -1183,7 +1182,7 @@
-
+
@@ -1198,7 +1197,7 @@
-
+
@@ -1210,7 +1209,7 @@
-
+
@@ -1225,7 +1224,7 @@
-
@@ -1242,7 +1241,7 @@
-
+
@@ -1257,7 +1256,7 @@
-
+
@@ -1269,7 +1268,7 @@
-
+
@@ -1281,7 +1280,7 @@
-
+
@@ -1861,28 +1860,53 @@ }, loadTabContent(tab) { - // Try to load content for the active tab - if (typeof htmx !== 'undefined') { - const contentId = tab + '-content'; - const contentEl = document.getElementById(contentId); - if (contentEl && !contentEl.hasAttribute('data-loaded')) { - // Trigger HTMX load - htmx.trigger(contentEl, 'revealed'); + const contentEl = document.getElementById(tab + '-content'); + // data-loaded: already fetched. data-loading: a fetch is queued or in + // flight. Both guard against re-entry so a panel loads exactly once, even + // if the tab is reopened before an in-progress (or polling) load settles. + if (!contentEl || contentEl.hasAttribute('data-loaded') || contentEl.hasAttribute('data-loading')) return; + const url = contentEl.getAttribute('hx-get'); + if (!url) return; + + contentEl.setAttribute('data-loading', 'true'); + + // htmx.ajax issues the request and swaps the response into the panel + // directly, so it works even before htmx has wired up the element's + // hx-trigger listeners. data-loaded is stamped on success so the panel + // loads once; the activeTab check drops loads for a tab the user navigated + // away from while htmx was still loading (avoids fetching hidden panels). + const swap = contentEl.getAttribute('hx-swap') || 'innerHTML'; + const load = () => { + if (this.activeTab !== tab || contentEl.hasAttribute('data-loaded')) { + contentEl.removeAttribute('data-loading'); + return; } - } else { - // HTMX is still loading asynchronously — retry when it signals ready, - // or fall back to direct fetch if it fails to load entirely. - const self = this; - function onReady() { window.removeEventListener('htmx-load-failed', onFailed); self.loadTabContent(tab); } - function onFailed() { - window.removeEventListener('htmx:ready', onReady); + return htmx.ajax('GET', url, { target: contentEl, swap: swap }) + .then(() => contentEl.setAttribute('data-loaded', 'true')) + .catch(() => {}) // leave unstamped on failure so it can retry + .finally(() => contentEl.removeAttribute('data-loading')); + }; + + if (typeof htmx !== 'undefined') { + load(); + return; + } + + // htmx is loaded from a CDN and may not be ready yet. Poll until it is, + // then load; if it never arrives, fall back to a direct fetch. + let tries = 0; + const timer = setInterval(() => { + if (typeof htmx !== 'undefined') { + clearInterval(timer); + load(); + } else if (++tries > 100) { // ~10s + clearInterval(timer); + contentEl.removeAttribute('data-loading'); if (tab === 'overview' && typeof loadOverviewDirect === 'function') loadOverviewDirect(); else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect(); else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect(); } - window.addEventListener('htmx:ready', onReady, { once: true }); - window.addEventListener('htmx-load-failed', onFailed, { once: true }); - } + }, 100); }, async loadInstalledPlugins() {