mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-01 16:33:33 +00:00
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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 @@
|
||||
<div id="tab-content" class="space-y-6">
|
||||
<!-- Overview tab -->
|
||||
<div x-show="activeTab === 'overview'" x-transition>
|
||||
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="revealed" hx-swap="innerHTML" hx-on::htmx:response-error="loadOverviewDirect()">
|
||||
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="loadtab" hx-swap="innerHTML" hx-on::htmx:response-error="loadOverviewDirect()">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1098,7 +1097,7 @@
|
||||
|
||||
<!-- General tab -->
|
||||
<div x-show="activeTab === 'general'" x-transition>
|
||||
<div id="general-content" hx-get="/v3/partials/general" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div id="general-content" hx-get="/v3/partials/general" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1116,7 +1115,7 @@
|
||||
<div x-show="activeTab === 'wifi'" x-transition>
|
||||
<div id="wifi-content"
|
||||
hx-get="/v3/partials/wifi"
|
||||
hx-trigger="revealed"
|
||||
hx-trigger="loadtab"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::htmx:response-error="loadWifiDirect()">
|
||||
<div class="animate-pulse">
|
||||
@@ -1167,7 +1166,7 @@
|
||||
|
||||
<!-- Schedule tab -->
|
||||
<div x-show="activeTab === 'schedule'" x-transition>
|
||||
<div id="schedule-content" hx-get="/v3/partials/schedule" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div id="schedule-content" hx-get="/v3/partials/schedule" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1183,7 +1182,7 @@
|
||||
|
||||
<!-- Display tab -->
|
||||
<div x-show="activeTab === 'display'" x-transition>
|
||||
<div id="display-content" hx-get="/v3/partials/display" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div id="display-content" hx-get="/v3/partials/display" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1198,7 +1197,7 @@
|
||||
|
||||
<!-- Backup & Restore tab -->
|
||||
<div x-show="activeTab === 'backup-restore'" x-transition>
|
||||
<div id="backup-restore-content" hx-get="/v3/partials/backup-restore" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div id="backup-restore-content" hx-get="/v3/partials/backup-restore" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1210,7 +1209,7 @@
|
||||
|
||||
<!-- Config Editor tab -->
|
||||
<div x-show="activeTab === 'config-editor'" x-transition>
|
||||
<div id="config-editor-content" hx-get="/v3/partials/raw-json" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div id="config-editor-content" hx-get="/v3/partials/raw-json" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1225,7 +1224,7 @@
|
||||
|
||||
<!-- Plugins tab -->
|
||||
<div x-show="activeTab === 'plugins'" x-transition>
|
||||
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="revealed" hx-swap="innerHTML"
|
||||
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="loadtab" hx-swap="innerHTML"
|
||||
hx-on::response-error="loadPluginsDirect()">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
@@ -1242,7 +1241,7 @@
|
||||
|
||||
<!-- Fonts tab -->
|
||||
<div x-show="activeTab === 'fonts'" x-transition>
|
||||
<div id="fonts-content" hx-get="/v3/partials/fonts" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div id="fonts-content" hx-get="/v3/partials/fonts" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1257,7 +1256,7 @@
|
||||
|
||||
<!-- Logs tab -->
|
||||
<div x-show="activeTab === 'logs'" x-transition>
|
||||
<div id="logs-content" hx-get="/v3/partials/logs" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div id="logs-content" hx-get="/v3/partials/logs" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1269,7 +1268,7 @@
|
||||
|
||||
<!-- Cache tab -->
|
||||
<div x-show="activeTab === 'cache'" x-transition>
|
||||
<div id="cache-content" hx-get="/v3/partials/cache" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div id="cache-content" hx-get="/v3/partials/cache" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1281,7 +1280,7 @@
|
||||
|
||||
<!-- Operation History tab -->
|
||||
<div x-show="activeTab === 'operation-history'" x-transition>
|
||||
<div id="operation-history-content" hx-get="/v3/partials/operation-history" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div id="operation-history-content" hx-get="/v3/partials/operation-history" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user