fix(spa): decouple navigate() from theme fetch + add data-loaded sync attributes (#955) (#958)

## 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 <you@example.com>
This commit is contained in:
Kpa-clawbot
2026-05-01 08:07:08 -07:00
committed by GitHub
parent 7aef3c355c
commit 053aef1994
5 changed files with 21 additions and 5 deletions
+4 -3
View File
@@ -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();
});
/**
+4
View File
@@ -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);
+3
View File
@@ -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');
+3
View File
@@ -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');
+7 -2
View File
@@ -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');