web: frontent performance fixes (#864)

* web: de-jank cold-load backfill + scope module preload to the view

* web: defer Leaflet, lazy-load node overlay, skip data pipeline off-dashboard

* web: address review of frontend-perf fix
This commit is contained in:
l5y
2026-07-28 09:15:14 +02:00
committed by GitHub
parent 63aa227784
commit d501aeb4eb
10 changed files with 1135 additions and 49 deletions
+99
View File
@@ -4418,3 +4418,102 @@ bridge poll/handle suites (every node mock now serves the `%21` path), the
MB-A1 compacted-row suite, and the per-id node route specs (synthetic flag,
stale/fresh, since-filter, opt-out) — the fallback fires only on the
empty-result path, so none of their outcomes may change.
---
## Bugfix: Frontend load performance regression
Since the module-graph preload (#815/#832) and the bulk-collection backfill (#835),
amplified by the design/UX work (#855/#859/#860), the dashboard's cold-load cost
grew. Two root causes plus three first-paint levers are addressed, all
frontend/template only — no API/DB change; the C4/C7 window floors,
`MAX_QUERY_LIMIT`, privacy, and the FC persistent cache are untouched, and the full
7-/28-day history still backfills (only *when* it repaints changes, not *which*
rows are reachable):
- **(RC-A)** the layout preloaded *every* served JS module on every page even
though a page only runs the graph reachable from its own entries → scope the
modulepreload set to the current view's **static** import closure (the AV3 import
map still versions the whole graph).
- **(RC-B)** the one-shot backfill repainted the entire node table + map once per
streamed page (dozens of `/api/positions` pages on a busy instance), on the main
thread → coalesce the per-page repaints onto a bounded idle callback.
- **(FP-A3)** the render-blocking Leaflet CDN `<script>` blocked first paint on the
unpkg round-trip → `defer` it.
- **(FP-A4)** the dashboard's node overlay statically pulled the ~125 KB node-detail
renderer into the boot graph → dynamic-`import()` it on first open.
- **(FP-A5)** `/charts`, `/federation`, and node-detail pages ran the whole
dashboard data pipeline (fetch + backfill + SSE) on top of their own module →
skip that pipeline on those views.
### FP-A1 — The dashboard preloads only its own module graph (RC-A)
```bash
( cd web && bundle exec rspec spec/app_spec.rb -e "modulepreloads the dashboard's own module graph" )
( cd web && bundle exec rspec spec/asset_import_map_spec.rb )
```
**Expected:** pass. `GET /` emits `<link rel="modulepreload">` for the dashboard's
own graph (e.g. `main.js`) but **not** for other pages' entry modules
(`charts-page.js`, `federation-page.js`). The `<script type="importmap">` still
version-stamps the **whole** served graph (AV3), so navigating to those pages still
receives cache-busted modules. `AssetImportMap.import_closure` walks each entry's
**static** `import` / `export … from` graph (dynamic `import()` is excluded — it is
lazy, so it must not be eagerly preloaded); a module absent from a preload set still
loads on demand (AV3 degradation).
### FP-A2 — The bulk-collection backfill coalesces its repaints (RC-B)
```bash
( cd web && node --test public/assets/js/app/__tests__/main-collection-backfill.test.js )
```
**Expected:** pass. Streaming N backward pages into a bulk collection triggers a
**bounded** number of full `renderFilteredOutputs` repaints (≤1 coalesced repaint
for the test's three node pages), not one per page — the merge stays immediate so
`getLoadedNodeCount()` still reflects every paged-in row, but the table + map
repaint is coalesced onto an idle callback. The existing #832 backfill behaviour
(every collection pages backward past the newest 1000-row page; a short page fires
no request; a failed page is swallowed) is unchanged.
### FP-A3 — Leaflet is deferred so it does not block first paint
```bash
( cd web && bundle exec rspec spec/app_spec.rb -e "loads the CDN Leaflet script deferred" )
```
**Expected:** pass. The Leaflet CDN `<script>` in the layout head carries `defer`,
so it no longer blocks first paint on the unpkg round-trip. The map init runs on
`DOMContentLoaded` (after deferred scripts execute in document order), so
`window.L` is ready in time; a missing `L` still degrades to the "map unavailable"
placeholder (unchanged).
### FP-A4 — The node-detail overlay subtree is lazy-loaded, not in the boot preload
```bash
( cd web && bundle exec rspec spec/app_spec.rb -e "keeps the lazily-loaded node-detail overlay subtree out" )
( cd web && node --test public/assets/js/app/__tests__/main-node-overlay-lazy.test.js )
```
**Expected:** pass. The dashboard boot preload contains **no** `modulepreload` for
`node-page.js` / `node-detail-overlay.js` (the ~125 KB node-detail renderer + charts
subtree); `main.js` dynamic-`import()`s it on first `.node-long-link` open and
memoises it (a concurrent open reuses the single in-flight import). The module stays
in the import map for the on-demand load (AV3). The overlay's own behaviour
(`node-detail-overlay.test.js`) is unchanged.
### FP-A5 — Self-rendering pages skip the shared dashboard data pipeline
```bash
( cd web && node --test public/assets/js/app/__tests__/main-view-gating.test.js )
```
**Expected:** pass. On a `view-charts`, `view-federation`, or `view-node_detail`
body, `initializeApp` wires the shared header (mobile menu, instance selector, node
overlay) but issues **no** `/api/*` fetch, backfill, or SSE — that data pipeline
(which previously fired the whole bulk-collection backfill on every node-detail
view) is skipped. The dashboard view still fetches and loads its data.
### FP-R1 — Regression: prior acceptance still holds
```bash
( cd web && npm test ) && ( cd web && bundle exec rspec )
```
**Expected:** every prior check still passes. At risk and explicitly required to
remain green: **PL-A1/PL-A2** (progressive chat load — same coalescing family),
the **#832** collection-backfill guards (`main-collection-backfill.test.js`), the
asset cache-busting specs (**AV1AV5**, `asset_import_map_spec.rb` /
`asset_versioning_spec.rb` — the import map is unchanged), and **B1** (all suites).
The map still initialises on the dashboard/map views (Leaflet defer), node overlays
still open (now lazily), and `/charts`, `/federation`, node-detail pages still
render via their own modules. No API/event contract changes, so **C2** and the
Python suite are unaffected.
@@ -91,6 +91,105 @@ module PotatoMesh
.join("\n")
end
# Regexes matching each **static** way an ES module names another module:
# an +import … from '…'+ / +export … from '…'+ re-export, and a bare
# side-effect +import '…'+. Dynamic +import('…')+ is deliberately **not**
# matched — a dynamically-imported module is loaded lazily on demand, so it
# is not part of the synchronous boot graph and must not be eagerly
# preloaded (that would fetch bytes the first paint does not need). Only the
# quoted specifier is captured; a heuristic scan is safe here because a false
# positive merely preloads an extra module and a false negative merely loads
# one on demand — neither can break a working import (SPEC AV3).
IMPORT_SPECIFIER_PATTERNS = [
/(?:import|export)\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]/m,
/(?<![.(])\bimport\s*['"]([^'"]+)['"]/m,
].freeze
# Extract the **relative static** module specifiers (``./x.js`` /
# ``../y.js``) a module imports or re-exports synchronously. Dynamic
# +import('…')+ specifiers are excluded (lazy, loaded on demand), and bare
# specifiers (a global dependency such as Leaflet) are ignored.
#
# @param source [String] the module's JavaScript source.
# @return [Array<String>] unique relative static import specifiers.
def import_specifiers(source)
IMPORT_SPECIFIER_PATTERNS.flat_map { |re| source.scan(re) }
.flatten
.select { |spec| spec.start_with?("./") || spec.start_with?("../") }
.uniq
end
# Resolve a relative import specifier against the importing module's
# +/assets/js/...+ path, returning the imported module's +/assets/js/...+
# path (or +nil+ when it escapes the served tree).
#
# @param from_path [String] importer path, e.g. ``/assets/js/app/index.js``.
# @param spec [String] relative specifier, e.g. ``./main.js`` or ``../x.js``.
# @return [String, nil] the resolved ``/assets/js/...`` path, or +nil+.
def resolve_relative_module(from_path, spec)
resolved = File.expand_path(spec, File.dirname(from_path))
resolved.start_with?("/assets/js/") ? resolved : nil
end
# Compute the transitive closure of ES modules reachable from +entry_paths+
# by following each module's static/dynamic +import+ and +export … from+
# specifiers (breadth-first, cycle-safe). Only files that exist under
# +js_root+ (excluding +__tests__+) are included, so a stale or missing
# import is silently skipped rather than emitted.
#
# This scopes the module preload to the graph a given page actually loads,
# instead of the whole served tree — a landing page no longer downloads the
# node-detail / charts / federation page graphs it never executes.
#
# @param js_root [String] absolute path to the served +/assets/js+ dir.
# @param entry_paths [Array<String>] entry ``/assets/js/...`` module paths.
# @return [Array<String>] sorted ``/assets/js/...`` paths in the closure.
def import_closure(js_root, entry_paths)
return [] unless Dir.exist?(js_root)
visited = {}
queue = Array(entry_paths).dup
until queue.empty?
path = queue.shift
next unless path.is_a?(String) && path.start_with?("/assets/js/")
next if visited.key?(path)
abs = File.join(js_root, path.delete_prefix("/assets/js/"))
next if abs.include?("/__tests__/") || !File.file?(abs)
visited[path] = true
import_specifiers(File.read(abs)).each do |spec|
resolved = resolve_relative_module(path, spec)
queue << resolved if resolved
end
end
visited.keys.sort
end
# Render the +<link rel="modulepreload">+ tags for exactly the module graph
# reachable from +entry_paths+ (the current view's entries), rather than the
# whole served tree. Only +/assets/js/app/**+ modules are emitted (the
# classic top-level scripts are loaded as ordinary +<script>+ tags, so
# preloading them as modules would double-load — same rule as {preload_paths}).
# Memoized per +[js_root, version, sorted entries]+.
#
# A page-specific module absent from this scoped set still loads on demand
# (SPEC AV3), and the import map ({json}) still versions the whole graph, so
# a later navigation to another page receives cache-busted modules.
#
# @param js_root [String] absolute path to the served +/assets/js+ dir.
# @param version [String] cache-busting token (the application version).
# @param entry_paths [Array<String>] entry ``/assets/js/...`` module paths.
# @return [String] newline-joined +<link rel="modulepreload">+ tags.
def preload_html_for(js_root, version, entry_paths)
cache = (@scoped_preload_cache ||= {})
key = [js_root, version, Array(entry_paths).uniq.sort]
cache[key] ||= import_closure(js_root, entry_paths)
.select { |path| path.start_with?("/assets/js/app/") }
.map { |path| %(<link rel="modulepreload" href="#{path}?v=#{version}">) }
.join("\n")
end
# List the absolute asset paths (``/assets/js/...``) of every served
# module, excluding test files, in a stable sorted order.
#
@@ -134,15 +233,41 @@ module PotatoMesh
PotatoMesh::App::AssetImportMap.json(asset_js_root, app_constant(:APP_VERSION))
end
# Render the +<link rel="modulepreload">+ tags that preload the whole
# served ES-module graph in parallel (so the browser does not walk the
# import waterfall before the app can fetch its first data). Emitted in the
# layout head **after** the import map (which must precede any module
# Render the +<link rel="modulepreload">+ tags that preload — in parallel,
# so the browser does not walk the import waterfall before the app can
# fetch its first data — the ES-module graph the **current view** actually
# loads. Scoping to the view's own graph keeps a page from downloading the
# JS of the other pages (frontend perf); the import map still versions the
# whole graph (AV3), so a later navigation is still cache-busted. Emitted in
# the layout head **after** the import map (which must precede any module
# resolution) and before the module entry point.
#
# @param entry_paths [Array<String>] the view's entry ``/assets/js/...``
# module paths (see {#asset_preload_entry_modules}).
# @return [String] newline-joined modulepreload link tags.
def asset_modulepreload_tags
PotatoMesh::App::AssetImportMap.preload_html(asset_js_root, app_constant(:APP_VERSION))
def asset_modulepreload_tags(entry_paths)
PotatoMesh::App::AssetImportMap.preload_html_for(
asset_js_root, app_constant(:APP_VERSION), entry_paths
)
end
# The entry ES modules a given view loads, whose transitive closure is the
# module preload set. Every view boots +index.js+ (the shared layout entry)
# and the cold-load +boot-prefetch.js+; the charts / federation / node-detail
# views additionally boot their own page entry module (their views +import+
# it inline). Anything else falls back to the shared base only.
#
# @param view_mode [#to_s, nil] the current view mode (e.g. ``:dashboard``,
# ``:charts``, ``:node_detail``).
# @return [Array<String>] entry ``/assets/js/...`` module paths.
def asset_preload_entry_modules(view_mode)
entries = ["/assets/js/app/index.js", "/assets/js/app/main/boot-prefetch.js"]
case view_mode.to_s
when "charts" then entries << "/assets/js/app/charts-page.js"
when "federation" then entries << "/assets/js/app/federation-page.js"
when "node_detail" then entries << "/assets/js/app/node-page.js"
end
entries
end
# Absolute path to the served JavaScript asset directory.
@@ -166,6 +166,143 @@ test('every bulk collection pages backward past the first 1000-row page (#832)',
}
});
test('the streamed backfill coalesces its pages into a bounded repaint count (frontend perf regression)', async () => {
const now = Math.floor(Date.now() / 1000);
// One collection (nodes) walks three backward pages; the others are short so
// only the node backfill streams. Before the de-jank fix, commitBackfillPage
// repaints the whole table + map once per page, so a busy instance (dozens of
// position pages) janks the main thread throughout the cold load. The fix
// coalesces the streamed pages into a single idle repaint.
const newestNodes = Array.from({ length: NODE_LIMIT }, (_, i) => ({
node_id: nid(0x10000 + i), last_heard: now - i, short_name: `N${i}`, role: 'CLIENT',
}));
const olderPage1 = Array.from({ length: NODE_LIMIT }, (_, i) => ({
node_id: nid(0x80000 + i), last_heard: now - NODE_LIMIT - i, short_name: `O${i}`, role: 'CLIENT',
}));
const olderPage2 = Array.from({ length: NODE_LIMIT }, (_, i) => ({
node_id: nid(0xa0000 + i), last_heard: now - 2 * NODE_LIMIT - i, short_name: `P${i}`, role: 'CLIENT',
}));
const olderPage3 = [{ node_id: '!ffff0001', last_heard: now - 3 * NODE_LIMIT - 5, short_name: 'LAST', role: 'CLIENT' }];
let nodeBeforeCount = 0;
function stubFetch(url) {
if (url.startsWith('/api/nodes/')) return jsonResponse(null);
if (url.startsWith('/api/nodes')) {
if (url.includes('before=')) {
nodeBeforeCount += 1;
if (nodeBeforeCount === 1) return jsonResponse(olderPage1); // full → keep paging
if (nodeBeforeCount === 2) return jsonResponse(olderPage2); // full → keep paging
return jsonResponse(olderPage3); // short → stop
}
return jsonResponse(newestNodes);
}
if (url.startsWith('/api/messages')) return jsonResponse([]);
return jsonResponse([]); // positions / telemetry / neighbors / traces / stats short
}
const env = createDomEnvironment({ includeBody: true });
const originalFetch = globalThis.fetch;
globalThis.fetch = url => stubFetch(url);
try {
const { _testUtils } = initializeApp(BASE_CONFIG);
await _testUtils.initialLoad;
// Count only the repaints the background backfill triggers, not the first
// paint from the initial refresh.
_testUtils.resetRenderCount();
await _testUtils.flushCollectionBackfills();
await settle();
// Sanity: the node backfill really streamed three pages (so an
// un-coalesced path would repaint three times).
assert.equal(nodeBeforeCount, 3, 'the node backfill must page backward three times');
// The three streamed pages must fold into at most one coalesced repaint —
// not one full table + map repaint per page.
const renders = _testUtils.getRenderCount();
assert.ok(
renders <= 1,
`backfill repaints must be coalesced; rendered ${renders}× for ${nodeBeforeCount} streamed pages`,
);
// ...and the coalesced repaint still reflects the whole paged-in window.
assert.equal(
_testUtils.getLoadedNodeCount(), NODE_LIMIT * 3 + 1,
'every backfilled page must still be merged into the rendered state',
);
} finally {
globalThis.fetch = originalFetch;
env.cleanup();
}
});
test('the backfill schedules its coalesced repaint via requestIdleCallback when available', async () => {
const now = Math.floor(Date.now() / 1000);
const newestNodes = Array.from({ length: NODE_LIMIT }, (_, i) => ({
node_id: nid(0x10000 + i), last_heard: now - i, short_name: `N${i}`, role: 'CLIENT',
}));
const olderPage1 = Array.from({ length: NODE_LIMIT }, (_, i) => ({
node_id: nid(0x80000 + i), last_heard: now - NODE_LIMIT - i, short_name: `O${i}`, role: 'CLIENT',
}));
const olderPage2 = [{ node_id: '!ffff0001', last_heard: now - 2 * NODE_LIMIT - 5, short_name: 'LAST', role: 'CLIENT' }];
let nodeBeforeCount = 0;
function stubFetch(url) {
if (url.startsWith('/api/nodes/')) return jsonResponse(null);
if (url.startsWith('/api/nodes')) {
if (url.includes('before=')) {
nodeBeforeCount += 1;
return jsonResponse(nodeBeforeCount === 1 ? olderPage1 : olderPage2);
}
return jsonResponse(newestNodes);
}
if (url.startsWith('/api/messages')) return jsonResponse([]);
return jsonResponse([]);
}
// Install a spy requestIdleCallback / cancelIdleCallback pair so the scheduler
// takes its idle-callback branch (the browser path) instead of the setTimeout
// fallback. The queued callbacks are run explicitly on flush.
const idleQueue = new Map();
let idleSeq = 0;
const scheduled = [];
const cancelled = [];
const hadRIC = 'requestIdleCallback' in globalThis;
const hadCIC = 'cancelIdleCallback' in globalThis;
const originalRIC = globalThis.requestIdleCallback;
const originalCIC = globalThis.cancelIdleCallback;
globalThis.requestIdleCallback = cb => {
const id = ++idleSeq;
idleQueue.set(id, cb);
scheduled.push(id);
return id;
};
globalThis.cancelIdleCallback = id => {
cancelled.push(id);
idleQueue.delete(id);
};
const env = createDomEnvironment({ includeBody: true });
const originalFetch = globalThis.fetch;
globalThis.fetch = url => stubFetch(url);
try {
const { _testUtils } = initializeApp(BASE_CONFIG);
await _testUtils.initialLoad;
_testUtils.resetRenderCount();
await _testUtils.flushCollectionBackfills();
await settle();
// The idle scheduler was used (browser branch), and the pending idle handle
// was cancelled by the final flush (cancel branch).
assert.ok(scheduled.length >= 1, 'the coalesced repaint must be scheduled via requestIdleCallback');
assert.ok(cancelled.length >= 1, 'the final flush must cancel the pending idle callback');
// Still coalesced to a single repaint, and every page merged in.
assert.ok(_testUtils.getRenderCount() <= 1, `expected a coalesced repaint, got ${_testUtils.getRenderCount()}`);
assert.equal(_testUtils.getLoadedNodeCount(), NODE_LIMIT * 2 + 1);
} finally {
globalThis.fetch = originalFetch;
if (hadRIC) globalThis.requestIdleCallback = originalRIC; else delete globalThis.requestIdleCallback;
if (hadCIC) globalThis.cancelIdleCallback = originalCIC; else delete globalThis.cancelIdleCallback;
env.cleanup();
}
});
test('a short newest page records no frontier and fires no backward request (#832)', async () => {
const now = Math.floor(Date.now() / 1000);
// Every newest page is *short* (< the per-collection cap) ⇒ the window is
@@ -0,0 +1,173 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Regression guard for the frontend perf fix that lazy-loads the node-detail
* overlay. The overlay reuses the heavy node-detail renderer (node-page +
* charts, ~125 KB), so main.js dynamic-`import()`s it on first open instead of
* pulling it into the dashboard's synchronous boot graph. This test pins the
* loader's memoisation and the click-path wiring.
*
* @module app/__tests__/main-node-overlay-lazy
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDomEnvironment } from './dom-environment.js';
import { initializeApp } from '../main.js';
/** Minimal config that disables the auto-refresh timer so timing is ours. */
const BASE_CONFIG = Object.freeze({
channel: 'Primary',
frequency: '915MHz',
refreshMs: 0,
refreshIntervalSeconds: 0,
chatEnabled: true,
mapCenter: { lat: 0, lon: 0 },
mapZoom: null,
maxDistanceKm: 0,
instancesFeatureEnabled: false,
instanceDomain: null,
snapshotWindowSeconds: 3600,
});
/** Resolve a fetch-style JSON response. */
function jsonResponse(body) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) });
}
/** Yield so the dynamic import + open settle. */
const settle = (ms = 40) => new Promise(r => setTimeout(r, ms));
/** Register a minimal but functional #nodeDetailOverlay so the lazily-imported
* factory returns a real manager (mirrors node-detail-overlay.test.js). */
function registerOverlay(env) {
const noop = () => {};
const dialog = { focus: noop, addEventListener: noop, setAttribute: noop, removeAttribute: noop };
const closeButton = { addEventListener: noop };
const content = { innerHTML: '', addEventListener: noop, replaceChildren: noop };
const overlay = {
hidden: true,
style: { removeProperty: noop },
addEventListener: noop,
setAttribute: noop,
removeAttribute: noop,
querySelector(selector) {
if (selector === '.node-detail-overlay__dialog') return dialog;
if (selector === '.node-detail-overlay__close') return closeButton;
if (selector === '.node-detail-overlay__content') return content;
return null;
},
};
env.registerElement('nodeDetailOverlay', overlay);
return overlay;
}
test('lazily imports and memoizes the node-detail overlay manager (frontend perf)', async () => {
const env = createDomEnvironment({ includeBody: true });
registerOverlay(env);
const originalFetch = globalThis.fetch;
globalThis.fetch = () => jsonResponse([]);
try {
const { _testUtils } = initializeApp(BASE_CONFIG);
await _testUtils.initialLoad;
// Two calls before the first resolves must share the in-flight import promise
// (no double import); a call after resolution returns the cached manager.
const p1 = _testUtils.loadNodeDetailOverlayManager();
const p2 = _testUtils.loadNodeDetailOverlayManager();
assert.strictEqual(p1, p2, 'concurrent opens reuse the single in-flight import');
const manager = await p1;
assert.ok(manager, 'the manager is created from the lazily-imported module');
const cached = await _testUtils.loadNodeDetailOverlayManager();
assert.strictEqual(cached, manager, 'the manager is memoized across subsequent opens');
} finally {
globalThis.fetch = originalFetch;
env.cleanup();
}
});
test('a .node-long-link click lazy-opens the overlay (frontend perf)', async () => {
const env = createDomEnvironment({ includeBody: true });
registerOverlay(env);
const originalFetch = globalThis.fetch;
const fetched = [];
globalThis.fetch = url => {
fetched.push(url);
// The overlay open fetches the node-detail fragment; any 200 body suffices.
return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('<div>detail</div>'), json: () => Promise.resolve({}) });
};
try {
initializeApp(BASE_CONFIG);
// A synthetic click on a node long-link: dataset.nodeId drives the identifier,
// and the registered overlay makes the guard pass so the lazy open fires.
const link = {
dataset: { nodeId: '!abcd0001' },
textContent: 'Node ABCD',
closest(selector) {
return selector === '.node-long-link' ? this : null;
},
};
let prevented = false;
globalThis.document.dispatchEvent({
type: 'click',
target: link,
preventDefault() {
prevented = true;
},
stopPropagation() {},
});
await settle();
assert.ok(prevented, 'the long-link click is handled (default prevented)');
assert.ok(
fetched.some(u => String(u).includes('/api/nodes/')),
'the lazily-loaded overlay fetched the node detail on open',
);
} finally {
globalThis.fetch = originalFetch;
env.cleanup();
}
});
test('a failed overlay import is not cached — the next open retries (frontend perf)', async () => {
const env = createDomEnvironment({ includeBody: true });
registerOverlay(env);
const originalFetch = globalThis.fetch;
globalThis.fetch = () => jsonResponse([]);
try {
const { _testUtils } = initializeApp(BASE_CONFIG);
await _testUtils.initialLoad;
// A transient chunk-load failure on the first open must not permanently
// disable node links: the rejected import is dropped, not memoised.
let attempts = 0;
_testUtils._setNodeDetailOverlayImporter(() => {
attempts += 1;
return attempts === 1
? Promise.reject(new Error('chunk load failed'))
: import('../node-detail-overlay.js');
});
await assert.rejects(_testUtils.loadNodeDetailOverlayManager(), /chunk load failed/);
const manager = await _testUtils.loadNodeDetailOverlayManager();
assert.ok(manager, 'a later open retries the import and resolves the manager');
assert.equal(attempts, 2, 'the failed import was retried, not served from a cached rejection');
} finally {
globalThis.fetch = originalFetch;
env.cleanup();
}
});
@@ -0,0 +1,106 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Regression guard for the frontend perf fix that stops the shared dashboard app
* from running its data pipeline on pages that render via their own module. The
* layout loads `index.js` ( initializeApp) on every page for the shared header
* (mobile menu, instance selector, node overlay); on `/charts`, `/federation`,
* and a node-detail page which have no dashboard data surface and fetch their
* own data the fetch + backfill + auto-refresh/SSE pipeline is pure waste (it
* even fired the whole bulk-collection backfill on every node-detail view). This
* test pins that the pipeline is skipped there but still runs on data views.
*
* @module app/__tests__/main-view-gating
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDomEnvironment } from './dom-environment.js';
import { initializeApp } from '../main.js';
/** Minimal config that disables the auto-refresh timer so timing is ours. */
const BASE_CONFIG = Object.freeze({
channel: 'Primary',
frequency: '915MHz',
refreshMs: 0,
refreshIntervalSeconds: 0,
chatEnabled: true,
mapCenter: { lat: 0, lon: 0 },
mapZoom: null,
maxDistanceKm: 0,
instancesFeatureEnabled: false,
instanceDomain: null,
snapshotWindowSeconds: 3600,
});
/** Resolve a fetch-style JSON response. */
function jsonResponse(body) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) });
}
/** Yield so any (unwanted) async pipeline work would have started. */
const settle = (ms = 60) => new Promise(r => setTimeout(r, ms));
for (const view of ['view-charts', 'view-federation', 'view-node_detail']) {
test(`skips the dashboard data pipeline on ${view} (renders via its own module)`, async () => {
const env = createDomEnvironment({ includeBody: true });
env.document.body.classList.add(view);
const apiCalls = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = url => {
if (String(url).includes('/api/')) apiCalls.push(String(url));
return jsonResponse([]);
};
try {
const { _testUtils } = initializeApp(BASE_CONFIG);
await _testUtils.initialLoad;
await settle();
assert.deepEqual(
apiCalls, [],
`no /api/* fetch, backfill, or SSE should run on ${view} (fired: ${apiCalls.join(', ')})`,
);
assert.equal(_testUtils.getLoadedNodeCount(), 0, 'no nodes are fetched into the shared app on a self-rendering page');
} finally {
globalThis.fetch = originalFetch;
env.cleanup();
}
});
}
test('still runs the data pipeline on the dashboard view', async () => {
const env = createDomEnvironment({ includeBody: true });
env.document.body.classList.add('view-dashboard');
const apiCalls = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = url => {
if (String(url).includes('/api/')) apiCalls.push(String(url));
if (String(url).includes('/api/nodes')) {
return jsonResponse([{ node_id: '!abcd0001', last_heard: Math.floor(Date.now() / 1000), short_name: 'A', role: 'CLIENT' }]);
}
return jsonResponse([]);
};
try {
const { _testUtils } = initializeApp(BASE_CONFIG);
await _testUtils.initialLoad;
await settle();
assert.ok(apiCalls.some(u => u.includes('/api/nodes')), 'the dashboard still fetches its data');
assert.equal(_testUtils.getLoadedNodeCount(), 1, 'the fetched node is loaded on the dashboard');
} finally {
globalThis.fetch = originalFetch;
env.cleanup();
}
});
+241 -37
View File
@@ -65,7 +65,9 @@ import { createMapFocusHandler, DEFAULT_NODE_FOCUS_ZOOM } from './nodes-map-focu
import { createMapCenterResetHandler } from './map-center-reset.js';
import { enhanceCoordinateCell } from './nodes-coordinate-links.js';
import { createShortInfoOverlayStack } from './short-info-overlay-manager.js';
import { createNodeDetailOverlayManager } from './node-detail-overlay.js';
// createNodeDetailOverlayManager is dynamic-`import()`ed on first overlay open
// (see loadNodeDetailOverlayManager) to keep its heavy node-detail renderer
// subtree out of the dashboard boot graph (frontend perf).
import { refreshNodeInformation } from './node-details.js';
import { extractModemMetadata, formatLoraFrequencyMHz, formatModemDisplay, formatPresetDisplay } from './node-modem-metadata.js';
import {
@@ -424,6 +426,14 @@ export function initializeApp(config) {
let collectionsBackfilled = false;
/** Settles when the one-shot bulk-collection backfill finishes (test hook). */
let collectionBackfillPromise = Promise.resolve();
/**
* Count of full {@link renderFilteredOutputs} repaints. Instrumentation for the
* backfill de-jank regression guard: a cold-load backfill streams many pages
* (dozens for positions on a busy instance), and each full table + map repaint
* is main-thread work, so the pages must be coalesced into a bounded number of
* repaints rather than one repaint per page (issue: frontend perf regression).
*/
let renderFilteredOutputsCount = 0;
// Persistent read-side cache (SPEC FC1FC7). The IndexedDB backend is null
// when storage is unavailable, and PRIVATE mode disables + wipes the cache —
@@ -2060,16 +2070,57 @@ export function initializeApp(config) {
setLegendVisibility(false);
}
const nodeDetailOverlayManager = createNodeDetailOverlayManager({
document,
privateMode: isPrivateMode,
});
// Lazily import + create the node-detail overlay manager on first open, then
// cache it. The overlay reuses the heavy node-detail renderer (node-page +
// charts, ~125 KB) which the dashboard only needs when a user opens a node, so
// keeping it behind a dynamic import removes that subtree from the synchronous
// boot graph (frontend perf). The import map still versions the module, so the
// on-demand load is cache-busted (AV3).
let nodeDetailOverlayManager = null;
let nodeDetailOverlayManagerPromise = null;
// Indirection so a test can drive the import-failure retry path; production
// always dynamic-imports the real module.
let importNodeDetailOverlayModule = () => import('./node-detail-overlay.js');
/**
* Resolve the (memoized) node-detail overlay manager, dynamically importing its
* module on first use. A **failed** import is not cached a transient
* chunk-load failure must not leave node links permanently inert so the next
* open re-imports instead of re-hitting the rejected promise.
*
* @returns {Promise<Object|null>} the overlay manager, or ``null`` when the
* overlay DOM is unavailable.
*/
function loadNodeDetailOverlayManager() {
if (nodeDetailOverlayManager) return Promise.resolve(nodeDetailOverlayManager);
if (!nodeDetailOverlayManagerPromise) {
nodeDetailOverlayManagerPromise = importNodeDetailOverlayModule()
.then(({ createNodeDetailOverlayManager }) => {
nodeDetailOverlayManager = createNodeDetailOverlayManager({
document,
privateMode: isPrivateMode,
});
return nodeDetailOverlayManager;
})
.catch(err => {
// Drop the rejected promise so a later open retries the import rather
// than permanently failing (the click already preventDefaulted).
nodeDetailOverlayManagerPromise = null;
throw err;
});
}
return nodeDetailOverlayManagerPromise;
}
document.addEventListener('click', event => {
const longNameLink = event.target.closest('.node-long-link');
if (
longNameLink &&
nodeDetailOverlayManager &&
// The overlay root lives in the shared layout; when it is absent the
// manager would be null, so fall through to normal link handling. (The
// shipped layout always renders the root plus its dialog/close/content
// children; the pathological root-present-but-children-missing case is not
// reachable here — it resolves the manager to null below and no-ops.)
document.getElementById('nodeDetailOverlay') &&
shouldHandleNodeLongLink(longNameLink) &&
!(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
) {
@@ -2079,7 +2130,8 @@ export function initializeApp(config) {
event.stopPropagation();
overlayStack.closeAll();
const label = typeof longNameLink.textContent === 'string' ? longNameLink.textContent.trim() : '';
nodeDetailOverlayManager.open({ nodeId: identifier }, { trigger: longNameLink, label })
loadNodeDetailOverlayManager()
.then(manager => (manager ? manager.open({ nodeId: identifier }, { trigger: longNameLink, label }) : undefined))
.catch(err => console.error('Failed to open node detail overlay', err));
return;
}
@@ -3752,6 +3804,11 @@ export function initializeApp(config) {
*
* @type {ReadonlyArray<Object>}
*/
// Shared refine references so a coalesced flush dedups them by identity: the
// three node-derived collections share the same rebuildNodeDerivedState
// function object (so the pending-refine Set collapses them to one call), and
// neighbors/traces share a single no-op.
const noopRefine = () => {};
const COLLECTION_BACKFILLS = [
{
name: 'nodes',
@@ -3759,7 +3816,7 @@ export function initializeApp(config) {
idOf: row => row && row.node_id,
cursorOf: row => row && row.last_heard,
merge: batch => { allNodes = mergeById(allNodes, batch, 'node_id'); },
refine: () => rebuildNodeDerivedState(),
refine: rebuildNodeDerivedState,
},
{
name: 'positions',
@@ -3769,7 +3826,7 @@ export function initializeApp(config) {
merge: batch => {
allPositionEntries = trimToWindow(mergeById(allPositionEntries, batch, 'id'), recentBackfillFloor());
},
refine: () => rebuildNodeDerivedState(),
refine: rebuildNodeDerivedState,
},
{
name: 'telemetry',
@@ -3779,7 +3836,7 @@ export function initializeApp(config) {
merge: batch => {
allTelemetryEntries = trimToWindow(mergeById(allTelemetryEntries, batch, 'id'), recentBackfillFloor());
},
refine: () => rebuildNodeDerivedState(),
refine: rebuildNodeDerivedState,
},
{
name: 'neighbors',
@@ -3797,7 +3854,7 @@ export function initializeApp(config) {
// Neighbors stay RAW (like traces) so the Log keeps every per-pair snapshot
// and the map / overlay consumers dedupe internally; re-aggregating here
// would erode history exactly as the refresh path used to (bugfix A1).
refine: () => {},
refine: noopRefine,
},
{
name: 'traces',
@@ -3810,18 +3867,104 @@ export function initializeApp(config) {
merge: batch => {
allTraces = trimToWindow(mergeById(allTraces, batch, 'id'), longBackfillFloor());
},
refine: () => {},
refine: noopRefine,
},
];
// --- Backfill repaint coalescing (frontend perf regression) ---
// The one-shot backfill streams many pages (dozens of `/api/positions` on a
// busy instance). Each page's re-derive (`rebuildNodeDerivedState` re-aggregates
// the whole growing accumulator) + full table/map repaint is main-thread work,
// so repainting once per page janks the cold load and competes with user
// interaction and the live-refresh flash. Instead, each page's merge stays
// immediate (so state is always current) but the re-derive + repaint are
// coalesced onto an idle callback: a burst of pages folds into a single
// repaint, and the repaint runs in idle time rather than blocking input. The
// same rows render — only *when* and *how often* the repaint fires changes.
/** Distinct pending refine callbacks (deduped by identity across specs). */
const pendingBackfillRefines = new Set();
/** True when merged-but-not-yet-repainted backfill rows are waiting. */
let backfillRepaintDirty = false;
/** Scheduled idle-callback handle, or ``null`` when none is pending. */
let backfillRepaintHandle = null;
/**
* Merge one streamed backward page into the module state, re-derive what it
* feeds, and repaint progressively, one page at a time, mirroring the chat
* history backfill (issue #802). The merge + re-render is synchronous (no
* ``await``) so it cannot interleave with a concurrent refresh or another
* collection's commit. Each page is network-spaced, so the per-page render is
* not a hot loop; the stats fetch is skipped (the authoritative count is
* server-computed and unchanged by how many rows the client has paged in).
* Schedule work for the next idle slot, degrading to ``setTimeout`` where
* ``requestIdleCallback`` is unavailable (e.g. Safari, unit-test envs). The
* short timeout bounds how long a repaint can be deferred under sustained load.
*
* @param {Function} callback Work to run when the main thread is idle.
* @returns {*} A handle understood by {@link cancelIdleWork}.
*/
function requestIdleWork(callback) {
return typeof requestIdleCallback === 'function'
? requestIdleCallback(callback, { timeout: 250 })
: setTimeout(callback, 16);
}
/**
* Cancel a handle returned by {@link requestIdleWork}, matching the scheduler
* that produced it.
*
* @param {*} handle The scheduled handle.
* @returns {void}
*/
function cancelIdleWork(handle) {
if (typeof requestIdleCallback === 'function' && typeof cancelIdleCallback === 'function') {
cancelIdleCallback(handle);
} else {
clearTimeout(handle);
}
}
/**
* Run the coalesced backfill re-derive(s) and a single repaint, then clear the
* pending state. Idempotent: a no-op when nothing is pending, so calling it
* again after a flush (or after the trailing idle callback) is harmless.
*
* @returns {void}
*/
function flushBackfillRepaint() {
if (backfillRepaintHandle !== null) {
cancelIdleWork(backfillRepaintHandle);
backfillRepaintHandle = null;
}
if (pendingBackfillRefines.size > 0) {
// Each distinct refine runs at most once per flush — the three
// node-derived collections share `rebuildNodeDerivedState`, so identity
// dedup collapses them into a single re-aggregation over the merged state.
for (const refine of pendingBackfillRefines) refine();
pendingBackfillRefines.clear();
}
if (backfillRepaintDirty) {
backfillRepaintDirty = false;
renderFilteredOutputs();
}
}
/**
* Ensure exactly one idle repaint is scheduled; further pages that arrive
* before it fires are folded into the same repaint (the guard makes repeated
* calls a no-op until the pending callback runs).
*
* @returns {void}
*/
function scheduleBackfillRepaint() {
if (backfillRepaintHandle !== null) return;
backfillRepaintHandle = requestIdleWork(() => {
backfillRepaintHandle = null;
flushBackfillRepaint();
});
}
/**
* Merge one streamed backward page into the module state immediately, then
* queue a coalesced re-derive + repaint (see the coalescing note above). The
* merge is synchronous so it cannot interleave with a concurrent refresh or
* another collection's commit and so ``getLoaded*Count`` always reflects every
* paged-in row; the repaint is deferred to idle time. The stats fetch is
* skipped (the authoritative count is server-computed and unchanged by how many
* rows the client has paged in).
*
* @param {Object} spec One {@link COLLECTION_BACKFILLS} entry.
* @param {Array<Object>} batch Freshly-seen rows for this page (the pager only
@@ -3830,8 +3973,9 @@ export function initializeApp(config) {
*/
function commitBackfillPage(spec, batch) {
spec.merge(batch);
spec.refine();
renderFilteredOutputs();
pendingBackfillRefines.add(spec.refine);
backfillRepaintDirty = true;
scheduleBackfillRepaint();
}
/**
@@ -3865,16 +4009,22 @@ export function initializeApp(config) {
* One-shot background backfill of every bulk collection (issue #832). After
* the first paint has rendered each collection's newest page, page the five
* collections backward through their visibility windows concurrently, each
* committing+repainting its pages as they arrive. Each {@link backfillCollection}
* self-gates on its frontier, so a collection with none (a short newest page, or
* a warm-cache load) returns without a request and the fan-out is a clean no-op
* when there is nothing to page no pointless request, no empty long-load.
* Invoked once (guarded by ``collectionsBackfilled`` in {@link refresh}).
* merging its pages as they arrive and coalescing the repaints onto idle time
* (see {@link commitBackfillPage}). Each {@link backfillCollection} self-gates on
* its frontier, so a collection with none (a short newest page, or a warm-cache
* load) returns without a request and the fan-out is a clean no-op when there is
* nothing to page no pointless request, no empty long-load. Once every window
* is exhausted, a final {@link flushBackfillRepaint} paints the complete state
* immediately rather than waiting on the trailing idle callback. Invoked once
* (guarded by ``collectionsBackfilled`` in {@link refresh}).
*
* @returns {Promise<void>} Resolves when every collection's window is exhausted.
*/
async function backfillAllCollections() {
await Promise.all(COLLECTION_BACKFILLS.map(spec => backfillCollection(spec)));
// The whole window is now merged; render the final coalesced state at once
// instead of waiting for the pending idle repaint.
flushBackfillRepaint();
}
/**
@@ -4815,6 +4965,9 @@ export function initializeApp(config) {
* @returns {void}
*/
function renderFilteredOutputs(filterQuery = filterInput ? filterInput.value : '') {
// Instrumentation for the backfill de-jank guard (see
// {@link renderFilteredOutputsCount}); a plain increment, no behaviour change.
renderFilteredOutputsCount += 1;
// Text and role filters apply only to the node table and map; the chat log
// always receives the full node collection so reply-thread lookups succeed
// even for nodes that are currently hidden by the active filter.
@@ -5120,15 +5273,34 @@ export function initializeApp(config) {
}
}
// Kick off the first data load immediately then start the silent background
// auto-refresh timer. Paint from the persistent cache first (instant first
// paint, SPEC FC2), then refresh fetches only the delta; a disabled/empty
// cache makes seedFromCache a no-op so this is the normal cold load.
const initialLoadPromise = seedFromCache()
.catch(() => false)
.then(() => refresh());
void initialLoadPromise;
restartAutoRefresh();
// The layout loads this shared app on every page for the header UI wired above
// (mobile menu, instance selector, node-detail overlay). Pages that render via
// their own module — /charts, /federation, and a node-detail page — have no
// dashboard data surface and fetch their own data, so skip the whole data
// pipeline there: the fetch + backfill + auto-refresh/SSE would pull and
// backfill rows nothing on the page displays, and previously fired the entire
// bulk-collection backfill on every node-detail view (frontend perf).
const runsOwnPageModule = Boolean(
bodyClassList &&
(bodyClassList.contains("view-charts") ||
bodyClassList.contains("view-federation") ||
bodyClassList.contains("view-node_detail")),
);
let initialLoadPromise;
if (runsOwnPageModule) {
// Header UI is already wired above; there is nothing to fetch or refresh.
initialLoadPromise = Promise.resolve();
} else {
// Kick off the first data load immediately then start the silent background
// auto-refresh timer. Paint from the persistent cache first (instant first
// paint, SPEC FC2), then refresh fetches only the delta; a disabled/empty
// cache makes seedFromCache a no-op so this is the normal cold load.
initialLoadPromise = seedFromCache()
.catch(() => false)
.then(() => refresh());
void initialLoadPromise;
restartAutoRefresh();
}
// --- Auto-refresh play/pause toggle ---
// Live vs. paused is visible text, not a glyph-only secret (SPEC UX6):
@@ -5400,6 +5572,31 @@ export function initializeApp(config) {
getLoadedNeighborCount: () => allNeighbors.length,
/** Number of trace entries currently loaded (test use only). */
getLoadedTraceCount: () => allTraces.length,
/**
* Cumulative count of full {@link renderFilteredOutputs} repaints (test
* use only) the backfill de-jank guard resets this after first paint and
* asserts the streamed backfill coalesces into a bounded repaint count.
*/
getRenderCount: () => renderFilteredOutputsCount,
/** Reset the repaint counter (test use only). */
resetRenderCount: () => {
renderFilteredOutputsCount = 0;
},
/**
* Resolve the lazily-imported, memoized node-detail overlay manager (test
* use only) the same loader the ``.node-long-link`` click path uses.
*/
loadNodeDetailOverlayManager,
/**
* Override the overlay-module importer (test use only) so the import-failure
* retry path can be exercised.
*
* @param {() => Promise<Object>} importer Replacement dynamic importer.
* @returns {void}
*/
_setNodeDetailOverlayImporter(importer) {
importNodeDetailOverlayModule = importer;
},
/** The persistent data cache instance (test use only). */
dataCache,
/** Seed in-memory state from the persistent cache (test use only). */
@@ -5410,8 +5607,15 @@ export function initializeApp(config) {
flushCacheWrites: () => pendingCacheWrite,
/** Promise resolving once the one-shot chat-history backfill finishes (test hook). */
flushBackfill: () => backfillPromise,
/** Promise resolving once the one-shot bulk-collection backfill finishes (test hook). */
flushCollectionBackfills: () => collectionBackfillPromise,
/**
* Await the one-shot bulk-collection backfill, then flush any pending
* coalesced repaint so the rendered state is settled and deterministic for
* tests (test hook).
*/
flushCollectionBackfills: async () => {
await collectionBackfillPromise;
flushBackfillRepaint();
},
/** Empty the persistent cache — the "clear cached data" control (FC4). */
clearDataCache,
/** Project an original lat/lon + pixel offset into a display LatLng. */
+45
View File
@@ -1372,6 +1372,51 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response).to be_ok
end
it "modulepreloads the dashboard's own module graph but not other pages' entries" do
# Frontend perf regression: the layout preloaded *every* served module on
# every page, so a page eagerly downloaded the entry graphs of the other
# pages it never runs. The preload set must be scoped to the current view's
# own module graph; the import map (AV3) still versions the whole graph, so
# cache-busting is unaffected.
get "/"
expect(last_response).to be_ok
# The dashboard's own graph (index.js → main.js → …) is still preloaded.
expect(last_response.body).to include('rel="modulepreload" href="/assets/js/app/main.js?v=')
# Other pages' entry modules — not reachable from the dashboard graph — must
# NOT be preloaded on the dashboard (the /charts and /federation entries).
expect(last_response.body).not_to include('rel="modulepreload" href="/assets/js/app/charts-page.js?v=')
expect(last_response.body).not_to include('rel="modulepreload" href="/assets/js/app/federation-page.js?v=')
# AV3 unchanged: the import map still version-stamps the *whole* graph, so a
# later navigation to those pages still gets cache-busted modules.
expect(last_response.body).to include('"/assets/js/app/charts-page.js":"/assets/js/app/charts-page.js?v=')
end
it "keeps the lazily-loaded node-detail overlay subtree out of the boot preload" do
# Frontend perf: the click-to-open node overlay reuses the heavy node-detail
# renderer (node-page.js → node-page-charts). It is dynamic-`import()`ed on
# first open, so it must NOT be in the dashboard's synchronous boot preload
# (it still loads on demand, and the import map still versions it).
get "/"
expect(last_response.body).not_to include('rel="modulepreload" href="/assets/js/app/node-page.js?v=')
expect(last_response.body).not_to include('rel="modulepreload" href="/assets/js/app/node-detail-overlay.js?v=')
# Still versioned in the import map for the on-demand load (AV3).
expect(last_response.body).to include('"/assets/js/app/node-page.js":"/assets/js/app/node-page.js?v=')
end
it "loads the CDN Leaflet script deferred so it does not block first paint" do
# Frontend perf regression: Leaflet was a synchronous external <script> in
# <head>, so first paint blocked on the round-trip to unpkg. It must be
# `defer` — the map init runs on DOMContentLoaded, after deferred scripts,
# so Leaflet is still ready in time.
get "/"
leaflet_tag = last_response.body[%r{<script[^>]*leaflet[^>]*>}i]
expect(leaflet_tag).not_to be_nil
expect(leaflet_tag).to include("defer")
end
it "does not render the Refresh button or last-updated field" do
get "/"
+142
View File
@@ -131,4 +131,146 @@ RSpec.describe PotatoMesh::App::AssetImportMap do
expect(second).to equal(first)
end
end
# ---------------------------------------------------------------------------
# View-scoped module preload (frontend perf regression). A tree with import
# edges so the closure walk has something to follow.
# ---------------------------------------------------------------------------
describe "view-scoped preload" do
around do |example|
Dir.mktmpdir("potato-mesh-closure-") do |dir|
@root = dir
FileUtils.mkdir_p(File.join(dir, "app", "sub"))
FileUtils.mkdir_p(File.join(dir, "app", "__tests__"))
File.write(File.join(dir, "background.js"), "// classic top-level script")
# index → main (static). main statically imports ../background (a classic
# top-level script, one dir up), re-exports ./config, imports a specifier
# that escapes the served tree (dropped), and dynamic-imports ./sub/lazy
# (excluded — lazy). other-page is unreachable from index.
File.write(File.join(dir, "app", "index.js"), "import { boot } from './main.js';\n")
File.write(
File.join(dir, "app", "main.js"),
"import '../background.js';\nexport { cfg } from './config.js';\n" \
"import '../../../escapes-tree.js';\nimport('./sub/lazy.js');\n",
)
File.write(File.join(dir, "app", "config.js"), "// leaf module")
File.write(File.join(dir, "app", "sub", "lazy.js"), "// lazily imported leaf")
File.write(File.join(dir, "app", "other-page.js"), "import './config.js';\n")
File.write(File.join(dir, "app", "__tests__", "index.test.js"), "import '../index.js';\n")
example.run
end
end
describe ".import_specifiers" do
it "captures static, re-export, and bare side-effect relative specifiers" do
source = <<~JS
import a from './static.js';
export { b } from './reexport.js';
import './side-effect.js';
import x from 'leaflet';
JS
expect(described_class.import_specifiers(source)).to contain_exactly(
"./static.js", "./reexport.js", "./side-effect.js"
)
# A bare specifier (a global dependency, e.g. Leaflet) is not relative and
# is therefore excluded.
expect(described_class.import_specifiers(source)).not_to include("leaflet")
end
it "excludes dynamic import() specifiers (lazy, not part of the boot graph)" do
source = <<~JS
import a from './static.js';
const c = import('./dynamic.js');
if (x) { import("./conditional.js"); }
JS
expect(described_class.import_specifiers(source)).to eq(["./static.js"])
expect(described_class.import_specifiers(source)).not_to include("./dynamic.js")
expect(described_class.import_specifiers(source)).not_to include("./conditional.js")
end
it "de-duplicates repeated specifiers" do
source = "import { a } from './x.js';\nimport { b } from './x.js';\n"
expect(described_class.import_specifiers(source)).to eq(["./x.js"])
end
end
describe ".resolve_relative_module" do
it "resolves ./ and ../ against the importer's directory" do
expect(described_class.resolve_relative_module("/assets/js/app/index.js", "./main.js")).to eq("/assets/js/app/main.js")
expect(described_class.resolve_relative_module("/assets/js/app/main.js", "../background.js")).to eq("/assets/js/background.js")
end
it "returns nil when the specifier escapes the served tree" do
expect(described_class.resolve_relative_module("/assets/js/app/index.js", "../../etc/passwd")).to be_nil
end
end
describe ".import_closure" do
it "returns the transitive static closure reachable from the entry, cycle-safe" do
closure = described_class.import_closure(@root, ["/assets/js/app/index.js"])
expect(closure).to contain_exactly(
"/assets/js/app/index.js",
"/assets/js/app/main.js",
"/assets/js/app/config.js",
"/assets/js/background.js",
)
end
it "excludes a dynamically-imported module (lazy, loaded on demand — not preloaded)" do
# main.js does `import('./sub/lazy.js')`, so the lazy module is not part of
# the synchronous boot graph and must stay out of the preload closure.
closure = described_class.import_closure(@root, ["/assets/js/app/index.js"])
expect(closure).not_to include("/assets/js/app/sub/lazy.js")
end
it "excludes modules not reachable from the entry (other pages' graphs)" do
closure = described_class.import_closure(@root, ["/assets/js/app/index.js"])
expect(closure).not_to include("/assets/js/app/other-page.js")
end
it "skips a relative import that escapes the served tree" do
# main.js imports '../../../escapes-tree.js', which resolves outside
# /assets/js; resolve_relative_module returns nil so it is never queued.
closure = described_class.import_closure(@root, ["/assets/js/app/index.js"])
expect(closure).not_to include(a_string_including("escapes-tree"))
end
it "never follows an import into a __tests__ module" do
closure = described_class.import_closure(@root, ["/assets/js/app/index.js"])
expect(closure).not_to include(a_string_including("__tests__"))
end
it "skips entries outside the served tree and missing files" do
closure = described_class.import_closure(
@root, ["/elsewhere/x.js", "/assets/js/app/missing.js", "/assets/js/app/config.js"]
)
expect(closure).to eq(["/assets/js/app/config.js"])
end
it "returns an empty list when the js root is absent" do
expect(described_class.import_closure(File.join(@root, "nope"), ["/assets/js/app/index.js"])).to eq([])
end
end
describe ".preload_html_for" do
it "emits version-stamped modulepreload links for the entry's app-module closure only" do
html = described_class.preload_html_for(@root, "1.2.3", ["/assets/js/app/index.js"])
expect(html).to include(%(<link rel="modulepreload" href="/assets/js/app/index.js?v=1.2.3">))
expect(html).to include(%(<link rel="modulepreload" href="/assets/js/app/config.js?v=1.2.3">))
# A dynamically-imported (lazy) module is not preloaded.
expect(html).not_to include("sub/lazy.js")
# An unreachable page module is not preloaded (the fix).
expect(html).not_to include("other-page.js")
# A classic top-level script pulled in transitively is still not emitted
# as a module preload (it is loaded as an ordinary <script>).
expect(html).not_to include("background.js")
end
it "is stable across repeated calls (memoized per root/version/entries)" do
first = described_class.preload_html_for(@root, "7.0.0", ["/assets/js/app/index.js"])
second = described_class.preload_html_for(@root, "7.0.0", ["/assets/js/app/index.js"])
expect(second).to equal(first)
end
end
end
end
+47
View File
@@ -50,4 +50,51 @@ RSpec.describe PotatoMesh::App::Helpers do
expect(helper.asset_url("/assets/js/theme.js")).to end_with("?v=1.2.3")
end
end
# ---------------------------------------------------------------------------
# asset_preload_entry_modules — scopes the module preload to the view's graph
# (frontend perf regression).
# ---------------------------------------------------------------------------
describe "#asset_preload_entry_modules" do
let(:base) { ["/assets/js/app/index.js", "/assets/js/app/main/boot-prefetch.js"] }
it "preloads only the shared layout entry + cold-load prefetch on the dashboard-family views" do
%i[dashboard map chat nodes].each do |view|
expect(helper.asset_preload_entry_modules(view)).to eq(base)
end
end
it "adds the /charts page entry only on the charts view" do
expect(helper.asset_preload_entry_modules(:charts)).to eq(base + ["/assets/js/app/charts-page.js"])
end
it "adds the /federation page entry only on the federation view" do
expect(helper.asset_preload_entry_modules(:federation)).to eq(base + ["/assets/js/app/federation-page.js"])
end
it "adds the node-detail page entry only on the node_detail view" do
expect(helper.asset_preload_entry_modules(:node_detail)).to eq(base + ["/assets/js/app/node-page.js"])
end
it "falls back to the shared base for a nil or unrecognised view" do
expect(helper.asset_preload_entry_modules(nil)).to eq(base)
expect(helper.asset_preload_entry_modules(:something_else)).to eq(base)
end
end
# ---------------------------------------------------------------------------
# asset_modulepreload_tags — delegates to the scoped preload builder.
# ---------------------------------------------------------------------------
describe "#asset_modulepreload_tags" do
it "renders the scoped preload for the js root, version, and given entries" do
allow(helper).to receive(:app_constant).with(:APP_VERSION).and_return("9.9.9")
allow(helper).to receive(:asset_js_root).and_return("/srv/assets/js")
entries = ["/assets/js/app/index.js"]
expect(PotatoMesh::App::AssetImportMap).to receive(:preload_html_for)
.with("/srv/assets/js", "9.9.9", entries).and_return("<link rel=\"modulepreload\">")
expect(helper.asset_modulepreload_tags(entries)).to eq("<link rel=\"modulepreload\">")
end
end
end
+14 -6
View File
@@ -80,12 +80,14 @@
<%# Import map versions the entire served JS module graph (SPEC AV3); must
precede any module load so relative imports resolve to the busted URLs. %>
<script type="importmap"><%= asset_import_map_json %></script>
<%# Preload the whole ES-module graph in parallel so the browser fetches it in
one round trip instead of discovering it import-tier by import-tier; this
removes the waterfall that delayed the first /api data paint. Same set the
import map versions; emitted after it so module URLs resolve to the busted
targets. A module absent here still loads normally on demand. %>
<%= asset_modulepreload_tags %>
<%# Preload in parallel, so the browser skips the import-tier waterfall that
delayed the first /api data paint — only the module graph THIS view loads,
not the whole served tree (a landing page must not download the node-detail
/ charts / federation page graphs it never runs). The import map above still
versions the whole graph (AV3), so a later navigation stays cache-busted, and
a module absent from this scoped set still loads on demand. %>
<% preload_view_mode = (defined?(current_view_mode) ? current_view_mode : nil) %>
<%= asset_modulepreload_tags(asset_preload_entry_modules(preload_view_mode)) %>
<%# Cold-load data prefetch: an early async module fires the first-load API
requests in parallel with the module graph so data paints without waiting
for the whole bundle to boot. Runs only on cold loads (the cache module
@@ -106,10 +108,16 @@
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
/>
<%# `defer` so this external script does not block first paint on the unpkg
round-trip. Deferred scripts run in document order after parsing but before
DOMContentLoaded, and the map init runs on DOMContentLoaded, so `window.L`
is ready by the time the app initialises (a missing L still degrades to the
"map unavailable" placeholder). %>
<script
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""
defer
></script>
</head>
<% body_classes = ["dark"]