mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-10 10:52:55 +02:00
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:
@@ -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 (**AV1–AV5**, `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();
|
||||
}
|
||||
});
|
||||
@@ -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 FC1–FC7). 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. */
|
||||
|
||||
@@ -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 "/"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user