mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-07-23 01:53:05 +02:00
web: initial-load module-graph waterfall
This commit is contained in:
@@ -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 **2–3 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 **BP1–BP9**. `?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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user