From 053aef1994a95f2f0858e040d8a883606d826f41 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Fri, 1 May 2026 08:07:08 -0700 Subject: [PATCH] fix(spa): decouple navigate() from theme fetch + add data-loaded sync attributes (#955) (#958) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes the chained async init race identified in RCA #3 of #955. `navigate()` (which dispatches page handlers and fetches data) was gated behind `/api/config/theme` resolving via `.finally()`. Tests use `waitUntil: 'domcontentloaded'` which returns BEFORE theme fetch resolves, creating a race condition where 3+ serial network requests must complete before any DOM rows appear. ## Changes ### Decouple navigate() from theme fetch (public/app.js) - Move `navigate()` call out of the theme fetch `.finally()` block - Call it immediately on DOMContentLoaded — theme is purely cosmetic and applies in parallel ### Add data-loaded sync attributes (public/nodes.js, map.js, packets.js) - Set `data-loaded="true"` on the container element after each page's data fetch resolves and DOM renders - Nodes: set on `#nodesLeft` after `loadNodes()` renders rows - Map: set on `#leaflet-map` after `renderMarkers()` completes - Packets: set on `#pktLeft` after `loadPackets()` renders rows ### Update E2E tests (test-e2e-playwright.js) - Add `await page.waitForSelector('[data-loaded="true"]', { timeout: 15000 })` before row/marker assertions - Increase map marker timeout from 3s to 8s as additional safety margin - Tests now synchronize on data readiness rather than racing DOM appearance ## Verification - Spun up local server on port 13586 with e2e-fixture.db - Confirmed navigate() is called immediately (not gated on theme) - Confirmed data-loaded attributes are present in served JS - API returns data correctly (2 nodes from fixture) Closes #955 (RCA #3) Co-authored-by: you --- public/app.js | 7 ++++--- public/map.js | 4 ++++ public/nodes.js | 3 +++ public/packets.js | 3 +++ test-e2e-playwright.js | 9 +++++++-- 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/public/app.js b/public/app.js index 17a98c00..58c2f25a 100644 --- a/public/app.js +++ b/public/app.js @@ -965,10 +965,11 @@ window.addEventListener('DOMContentLoaded', () => { }).catch(() => { window.SITE_CONFIG = { timestamps: { defaultMode: 'ago', timezone: 'local', formatPreset: 'iso', customFormat: '', allowCustomFormat: false } }; if (window._customizerV2) window._customizerV2.init(window.SITE_CONFIG); - }).finally(() => { - if (!location.hash || location.hash === '#/') location.hash = '#/home'; - else navigate(); }); + + // Navigate immediately — don't gate data-fetching pages on cosmetic theme fetch + if (!location.hash || location.hash === '#/') location.hash = '#/home'; + else navigate(); }); /** diff --git a/public/map.js b/public/map.js index 742b273b..6ad94b0d 100644 --- a/public/map.js +++ b/public/map.js @@ -549,6 +549,10 @@ renderMarkers(); + // Signal that map data is loaded and markers rendered (used by E2E tests) + var mapContainer = document.getElementById('leaflet-map'); + if (mapContainer) mapContainer.setAttribute('data-loaded', 'true'); + // Restore heatmap if previously enabled if (localStorage.getItem('meshcore-map-heatmap') === 'true') { toggleHeatmap(true); diff --git a/public/nodes.js b/public/nodes.js index 5d3355e8..aa6ed645 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -951,6 +951,9 @@ } else { renderLeft(); } + // Signal that node data is loaded and rendered (used by E2E tests) + var nodesContainer = document.getElementById('nodesLeft') || document.getElementById('nodesBody'); + if (nodesContainer) nodesContainer.setAttribute('data-loaded', 'true'); } catch (e) { console.error('Failed to load nodes:', e); const tbody = document.getElementById('nodesBody'); diff --git a/public/packets.js b/public/packets.js index 5135d6d0..83054ff4 100644 --- a/public/packets.js +++ b/public/packets.js @@ -744,6 +744,9 @@ sortPacketsArray(); renderLeft(); + // Signal that packet data is loaded and rendered (used by E2E tests) + var pktContainer = document.getElementById('pktLeft') || document.getElementById('pktBody'); + if (pktContainer) pktContainer.setAttribute('data-loaded', 'true'); } catch (e) { console.error('Failed to load packets:', e); const tbody = document.getElementById('pktBody'); diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js index aba475ef..7d514efd 100644 --- a/test-e2e-playwright.js +++ b/test-e2e-playwright.js @@ -211,6 +211,7 @@ async function run() { // Test 2: Nodes page loads with data await test('Nodes page loads with data', async () => { await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('[data-loaded="true"]', { timeout: 15000 }); await page.waitForSelector('table tbody tr'); const headers = await page.$$eval('th', els => els.map(e => e.textContent.trim())); for (const col of ['Name', 'Public Key', 'Role']) { @@ -236,6 +237,7 @@ async function run() { // Test: Node side panel Details link navigates to full detail page (#778) await test('Node side panel Details link navigates', async () => { await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('[data-loaded="true"]', { timeout: 15000 }); await page.waitForSelector('table tbody tr'); await page.click('table tbody tr'); await page.waitForSelector('.node-detail'); @@ -257,6 +259,7 @@ async function run() { // Test: Nodes page has WebSocket auto-update listener (#131) await test('Nodes page has WebSocket auto-update', async () => { await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('[data-loaded="true"]', { timeout: 15000 }); await page.waitForSelector('table tbody tr'); // The live dot in navbar indicates WS connection status const liveDot = await page.$('#liveDot'); @@ -282,11 +285,12 @@ async function run() { // Test 3: Map page loads with markers await test('Map page loads with markers', async () => { await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('[data-loaded="true"]', { timeout: 15000 }); await page.waitForSelector('.leaflet-container'); await page.waitForSelector('.leaflet-tile-loaded'); // Wait for markers/overlays to render (may not exist with empty DB) try { - await page.waitForSelector('.leaflet-marker-icon, .leaflet-interactive, circle, .marker-cluster, .leaflet-marker-pane > *, .leaflet-overlay-pane svg path, .leaflet-overlay-pane svg circle', { timeout: 3000 }); + await page.waitForSelector('.leaflet-marker-icon, .leaflet-interactive, circle, .marker-cluster, .leaflet-marker-pane > *, .leaflet-overlay-pane svg path, .leaflet-overlay-pane svg circle', { timeout: 8000 }); } catch (_) { // No markers with empty DB \u2014 assertion below handles it } @@ -362,7 +366,7 @@ async function run() { await page.waitForSelector('.leaflet-container'); // Wait for markers (may not exist with empty DB) try { - await page.waitForSelector('.leaflet-marker-icon, .leaflet-interactive', { timeout: 3000 }); + await page.waitForSelector('.leaflet-marker-icon, .leaflet-interactive', { timeout: 8000 }); } catch (_) { // No markers with empty DB } @@ -394,6 +398,7 @@ async function run() { await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' }); await page.evaluate(() => localStorage.setItem('meshcore-time-window', '525600')); await page.reload({ waitUntil: 'load' }); + await page.waitForSelector('[data-loaded="true"]', { timeout: 15000 }); await page.waitForSelector('table tbody tr', { timeout: 15000 }); const rowsBefore = await page.$$('table tbody tr'); assert(rowsBefore.length > 0, 'No packets visible');