perf(post): lazy-link maplibre's stylesheet instead of bundling it

The engine was already a lazy import(), but its CSS was a static one — the
usual workaround for esbuild never emitting a <link> for a code-split chunk's
stylesheet (R10). That folded the whole vendor sheet into post-form.css, so
every /post load paid for a panel most submits never open, and ~78% of those
bytes were rules for controls this map never creates (popups, geolocate, zoom,
compass, fullscreen, terrain, scale).

Build the vendor sheet as its own css-compiled/maplibre-gl.css and have
location-map.js <link> it at panel-open, concurrently with the engine's
import(). Keeping the file intact rather than hand-picking the ~16 selectors
in use means a maplibre upgrade can't silently un-style the map.

  post-form.css  92,244 → 26,784 raw   (14,528 → 5,631 gzip)

The href resolves from import.meta.url, so it is correct under any Grav base
path without threading a URL through the template — the panel is built entirely
in JS, so there is no element to hang a data-attr on. A failed stylesheet load
resolves rather than rejects: the map degrades to unstyled, never to absent.

The existing lazy-load guard spec already matched every maplibre-gl URL, so it
covers the stylesheet too; its message now says so, and a new test asserts the
other half — that opening the panel does link and apply the sheet.
This commit is contained in:
2026-07-24 22:33:19 +02:00
parent e873a9cb23
commit e0e68a9ac3
6 changed files with 116 additions and 62 deletions
File diff suppressed because one or more lines are too long
+52 -3
View File
@@ -6,7 +6,8 @@
* needs (KTD1).
*
* maplibre-gl itself is lazy-imported on first call (KTD3) so an ordinary
* GPS-only submit, where the panel is never opened, never fetches it. The
* GPS-only submit, where the panel is never opened, never fetches it. Its
* STYLESHEET is lazy too — see ensureMaplibreCss below. The
* created map/marker are cached in module scope and reused on every
* subsequent call — see the panel's toggle-open handler in post-form.js,
* which calls this on every open and then always calls the returned
@@ -22,7 +23,48 @@
*/
import { MAP_STYLE } from './map-style.js';
var cached = null; // { container, promise }
var cached = null; // { container, promise }
var cssPromise = null; // in-flight/settled <link> load for maplibre's stylesheet
/**
* Inject maplibre-gl's stylesheet on demand, once.
*
* esbuild will not emit a <link> for a code-split chunk's CSS (R10), so the
* usual fix is a static `import 'maplibre-gl/dist/maplibre-gl.css'` up in
* post-form.js — but that defeats the lazy import it accompanies: every /post
* load would then pay ~9 KB gzip of vendor CSS for a panel most submits never
* open. So package.json builds the vendor stylesheet as its own
* css-compiled/maplibre-gl.css and we <link> it here instead, in parallel with
* the engine's import(). Keeping the vendor file intact (rather than
* hand-picking the ~16 selectors this panel actually uses) means a maplibre
* upgrade can't silently un-style the map.
*
* The href is resolved from import.meta.url — the bundle is ESM, so this is the
* URL of this chunk under `js/post/`, which makes the link correct under any
* Grav base path without threading a URL through the template. (The panel is
* built entirely in JS, so there is no Twig element to hang a data-attr on.)
*
* Resolves on error as well as load: a missing stylesheet must degrade to an
* unstyled-but-functional map, never block it. On error the dead <link> and the
* memo are dropped so a later retry re-attempts it, mirroring the map cache's
* clear-on-failure below.
*/
function ensureMaplibreCss() {
if (cssPromise) return cssPromise;
cssPromise = new Promise(function (resolve) {
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = new URL('../../css-compiled/maplibre-gl.css', import.meta.url).href;
link.onload = function () { resolve(); };
link.onerror = function () {
link.remove();
cssPromise = null;
resolve();
};
document.head.appendChild(link);
});
return cssPromise;
}
function buildPinElement() {
var el = document.createElement('div');
@@ -35,7 +77,14 @@ export function getOrCreateLocationMap(container, onDragEnd) {
return cached.promise;
}
var promise = import('maplibre-gl').then(function (mod) {
// Stylesheet and engine fetch concurrently; awaiting the CSS before
// constructing the Map means maplibre never measures the container against
// half-applied styles.
var promise = Promise.all([
import('maplibre-gl'),
ensureMaplibreCss()
]).then(function (loaded) {
var mod = loaded[0];
var maplibregl = mod.default || mod;
var map = new maplibregl.Map({
container: container,
+7 -3
View File
@@ -10,9 +10,13 @@
import EasyMDE from 'easymde';
import Sortable from 'sortablejs';
import 'easymde/dist/easymde.min.css';
// maplibre-gl's CSS is a static import — a dynamically-imported chunk's CSS is
// never auto-linked (R10). The JS itself stays a lazy import (see location-map.js).
import 'maplibre-gl/dist/maplibre-gl.css';
// NOTE: maplibre-gl's CSS is deliberately NOT imported here. esbuild never emits
// a <link> for a dynamically-imported chunk's CSS (R10), and a static import
// would have folded 68 KB / ~9 KB gzip of vendor stylesheet — ~78% of it rules
// for controls this panel never creates — into post-form.css on every /post
// load. location-map.js instead injects a <link> to the separately-built
// css-compiled/maplibre-gl.css at the moment it lazy-imports the engine, so an
// ordinary GPS-only submit pays nothing for either half.
import './post-form.css';
import { apiSend, apiErrorMsg } from './api-utils.js';
import { getOrCreateLocationMap } from './location-map.js';