web: initial-load module-graph waterfall

This commit is contained in:
l5y
2026-06-28 21:33:52 +02:00
parent b5d9249918
commit 8915f8c4f3
5 changed files with 205 additions and 0 deletions
+60
View File
@@ -1179,6 +1179,66 @@ is **updated** to the `?v=` form, **not** removed.
---
## Bugfix: Initial-load module-graph waterfall (slow first data paint)
The dashboard's first `/api/*` fetch is gated behind the **entire** 89-module
ES-module graph loading, and that graph was discovered one import-tier at a time
(`index.js``{config,main,settings}` → main's 33 imports → … ≈ 5 serial round
trips) because nothing told the browser the deeper modules up-front. On a real
connection each tier costs a full RTT, so data did not paint for **23 s**
(measured: ~3.7 s to the first `/api/nodes` request at 150 ms RTT / 4× CPU; the
server itself answers every endpoint in <250 ms). The fix emits one
`<link rel="modulepreload">` per served app ES module in `<head>` — the **same
set the AV3 import map versions** — so the whole graph downloads in parallel
(one round trip over HTTP/2) instead of tier-by-tier. Native browser feature, no
build step or dependency (D7/AV4); read-side only (apex/privacy/parity untouched);
a module absent from the preloads still loads normally (AV3's degradation
property). Built by `PotatoMesh::App::AssetImportMap.preload_html`
(`web/lib/potato_mesh/application/helpers/asset_helpers.rb`), rendered after the
import map in `views/layouts/app.erb`.
*Run the server in public mode (as in AV-A1) and leave it running for the curl check.*
### MP-A1 — The head preloads the whole app ES-module graph (busted URLs)
```bash
curl -s http://127.0.0.1:41447/ \
| grep -oE '<link rel="modulepreload" href="/assets/js/app/[A-Za-z0-9/_.-]+\?v=[^"]+">' \
| grep -E 'app/(index|main)\.js'
```
**Expected:** matches a `<link rel="modulepreload">` for both the entry point
`index.js` and the transitively-imported `main.js`, each carrying the
`?v=<APP_VERSION>` query — i.e. the preloaded URL equals the import-map **target**,
so the preload and the eventual `import` resolve to the same cache entry. Every
served `/assets/js/app/**` module is preloaded; the classic non-module scripts
(`/assets/js/theme.js`, `/assets/js/background.js`) and `__tests__` files are
**not** preloaded.
### MP-A2 — Preloads sit after the import map, before the module entry; unit-tested
```bash
( cd web && bundle exec rspec spec/asset_versioning_spec.rb -e "modulepreload" \
spec/asset_import_map_spec.rb -e "preload" )
```
**Expected:** pass. The rendering spec asserts the modulepreload block is emitted
**after** the `<script type="importmap">` and **before** the
`<script type="module" src="…index.js">` entry (so resolution order is correct),
that classic scripts and `__tests__` are excluded, and the unit specs cover
`AssetImportMap.preload_paths` (app modules only) and `.preload_html` (one
version-stamped link per module, memoized).
### MP-R1 — Regression: prior acceptance still holds
```bash
( cd web && bundle exec rspec ) && ( cd web && npm test )
```
**Expected:** every prior check still passes. **At risk and explicitly required to
remain green:** **AV-A2** (still exactly one import map, still busting the deep
graph — the preloads are additive, not a replacement); **AV-A1/AV-A4** (asset
versioning + image-scope boundary unchanged); **D1** (the shared layout's
`/version`-fed config behavior is unchanged); **B1** (all suites). The preloads
are purely additive head markup — no existing asset URL, the import map, or any
`/api/*`/`/version` shape changes.
---
## Feature: Uniform backward pagination (`?before=`) for bulk collection APIs
Maps to SPEC decisions **BP1BP9**. `?before=<unix_seconds>` is added as an
@@ -57,6 +57,40 @@ module PotatoMesh
cache[[js_root, version]] ||= JSON.generate(document(js_root, version))
end
# List the served **ES-module** paths to preload — every
# +/assets/js/app/**+ module, excluding the classic top-level scripts
# (+theme.js+, +background.js+) which are loaded as ordinary
# +<script>+ tags, not modules. Preloading a classic script as a module
# would fetch it a second time, so the preload set is the app graph only.
#
# @param js_root [String] absolute path to the served +/assets/js+ dir.
# @return [Array<String>] sorted ``/assets/js/app/...`` module paths.
def preload_paths(js_root)
module_paths(js_root).select { |path| path.start_with?("/assets/js/app/") }
end
# Render one +<link rel="modulepreload">+ per app module so the browser
# fetches the **entire** transitive ES-module graph in parallel rather
# than discovering it one import-tier at a time (the waterfall that
# delayed the dashboard's first data paint). Each href is the
# version-stamped URL — i.e. the import-map **target** — so the preload
# and the eventual +import+ resolve to the same cache entry. Memoized per
# +[js_root, version]+ (both constant for the process), mirroring {json}.
#
# A module absent here still loads normally on demand, so a missing entry
# can never break a working import (the same degradation property as the
# import map, SPEC AV3).
#
# @param js_root [String] absolute path to the served +/assets/js+ dir.
# @param version [String] cache-busting token (the application version).
# @return [String] newline-joined +<link rel="modulepreload">+ tags.
def preload_html(js_root, version)
cache = (@preload_cache ||= {})
cache[[js_root, version]] ||= preload_paths(js_root)
.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.
#
@@ -100,6 +134,17 @@ 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
# resolution) and before the module entry point.
#
# @return [String] newline-joined modulepreload link tags.
def asset_modulepreload_tags
PotatoMesh::App::AssetImportMap.preload_html(asset_js_root, app_constant(:APP_VERSION))
end
# Absolute path to the served JavaScript asset directory.
#
# @return [String] the ``<public_folder>/assets/js`` directory.
+46
View File
@@ -57,6 +57,52 @@ RSpec.describe PotatoMesh::App::AssetImportMap do
end
end
describe ".preload_paths" do
it "lists only the app ES modules (excludes the classic top-level scripts)" do
# theme.js is a classic <script>, not an ES module, so it must never be
# emitted as <link rel="modulepreload"> (that would fetch it as a module
# and double-load it). Only /assets/js/app/** modules are preloaded.
expect(described_class.preload_paths(@js_root)).to eq(
[
"/assets/js/app/config.js",
"/assets/js/app/main.js",
],
)
end
it "excludes __tests__ files" do
expect(described_class.preload_paths(@js_root)).not_to include(
a_string_including("__tests__"),
)
end
it "returns an empty list when the directory is absent" do
expect(described_class.preload_paths(File.join(@js_root, "nope"))).to eq([])
end
end
describe ".preload_html" do
it "emits one version-stamped modulepreload link per app module" do
html = described_class.preload_html(@js_root, "1.2.3")
expect(html).to eq(
%(<link rel="modulepreload" href="/assets/js/app/config.js?v=1.2.3">\n) +
%(<link rel="modulepreload" href="/assets/js/app/main.js?v=1.2.3">),
)
end
it "never preloads the classic top-level scripts" do
expect(described_class.preload_html(@js_root, "1.2.3")).not_to include("theme.js")
end
it "is stable across repeated calls (memoized per root/version)" do
first = described_class.preload_html(@js_root, "7.0.0")
second = described_class.preload_html(@js_root, "7.0.0")
expect(second).to equal(first)
end
end
describe ".document" do
it "maps each module path to its version-stamped URL" do
doc = described_class.document(@js_root, "1.2.3")
+48
View File
@@ -102,4 +102,52 @@ RSpec.describe "Asset cache-busting" do
expect(map_at).to be < entry_at
end
end
# Regression: initial-load module-graph waterfall (slow first data paint).
# Without modulepreload hints the browser discovers the 89-module graph one
# import-tier at a time (≈5 serial round trips) before the app can fire its
# first /api fetch, so data does not paint for 2-3s on a real connection. The
# head must preload the whole ES-module graph so it downloads in parallel.
describe "modulepreload for the deep module graph (initial-load latency)" do
before { get "/" }
it "preloads a transitively-imported module so the graph loads in parallel" do
# main.js is reached only through a relative import inside index.js; a
# modulepreload for it proves the whole graph (not just entry points) is
# fetched up-front instead of tier-by-tier.
expect(last_response.body).to include(
%(<link rel="modulepreload" href="/assets/js/app/main.js?v=#{version}">),
)
end
it "preloads the module entry point itself" do
expect(last_response.body).to include(
%(<link rel="modulepreload" href="/assets/js/app/index.js?v=#{version}">),
)
end
it "does not preload the classic (non-module) scripts" do
expect(last_response.body).not_to include(
'<link rel="modulepreload" href="/assets/js/theme.js',
)
expect(last_response.body).not_to include(
'<link rel="modulepreload" href="/assets/js/background.js',
)
end
it "does not leak test files into the preloads" do
preloads = last_response.body.scan(/<link rel="modulepreload"[^>]*>/).join
expect(preloads).not_to include("__tests__")
end
it "places the preloads after the import map and before the module entry" do
body = last_response.body
map_at = body.index('<script type="importmap">')
preload_at = body.index('<link rel="modulepreload"')
entry_at = body.index('<script type="module" src="/assets/js/app/index.js')
expect(map_at).to be < preload_at
expect(preload_at).to be < entry_at
end
end
end
+6
View File
@@ -74,6 +74,12 @@
<%# 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 %>
<link rel="stylesheet" href="<%= asset_url("/assets/styles/base.css") %>" />
<script src="<%= asset_url("/assets/js/theme.js") %>" defer></script>
<script src="<%= asset_url("/assets/js/background.js") %>" defer></script>