perf(assets): lazy-load page-specific hooks + Leaflet, app.js 968kb -> 300kb

Replaces eager imports of map hooks and Leaflet in app.ts with
dynamic-import wrappers (lazyHook / lazyMapHook). Each page-specific
hook now ships as its own esbuild chunk fetched on first mount;
Leaflet is loaded once and shared across every map page. Pages that
never mount these hooks (/submit, /algo, /about, etc.) no longer pay
for them at all.
This commit is contained in:
Graham McIntire 2026-05-07 11:55:01 -05:00
parent a0065e2f44
commit a4921029e5
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 124 additions and 48 deletions

View file

@ -1,7 +1,3 @@
// Leaflet map library (vendored)
import * as L from "../vendor/leaflet/leaflet.js"
window.L = L
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.
@ -12,54 +8,27 @@ import topbar from "../vendor/topbar"
import "../vendor/canvas-confetti"
import initLiveStash from "../../deps/live_stash/assets/js/live-stash.js"
import {PropagationMap} from "./propagation_map_hook"
import {PathForecast} from "./path_forecast_hook"
import {ContactMap} from "./contact_map_hook"
import {ContactsMap} from "./contacts_map_hook"
import {ElevationProfile} from "./elevation_profile_hook"
import {WeatherMap} from "./weather_map_hook"
import {lazyHook, lazyMapHook} from "./lazy_hook"
import {LocateMe, CopyLink} from "./locate_me_hook"
import {RoverMap} from "./rover_map_hook"
import {LocationMap} from "./location_map_hook"
import {RoverLocationsMap} from "./rover_locations_map_hook"
import {RoverSlider} from "./rover_slider_hook"
import {HorizontalWheelScroll} from "./horizontal_wheel_scroll_hook"
import {BeaconMap} from "./beacon_map_hook"
import {BeaconsListMap} from "./beacons_list_map_hook"
// /eme is the only page that uses Three.js (~600 kB). Keep that out
// of the main app bundle by dynamic-importing on first hook mount:
// esbuild's --splitting flag emits a separate chunk that the browser
// fetches when (and only when) a user lands on /eme.
interface LazyHook {
_impl?: any
_pendingUpdate?: boolean
}
const EmeGlobe = {
mounted(this: ViewHook & LazyHook) {
const hook = this
import("./eme_globe_hook").then((mod) => {
const impl = mod.EmeGlobe
hook._impl = impl
for (const k of Object.keys(impl)) {
if (k !== "mounted" && k !== "updated" && k !== "destroyed") {
;(hook as any)[k] = (impl as any)[k]
}
}
impl.mounted.call(hook)
if (hook._pendingUpdate && impl.updated) impl.updated.call(hook)
hook._pendingUpdate = false
})
},
updated(this: ViewHook & LazyHook) {
if (this._impl?.updated) this._impl.updated.call(this)
else this._pendingUpdate = true
},
destroyed(this: ViewHook & LazyHook) {
this._impl?.destroyed?.call(this)
}
}
// Heavy / page-specific hooks are loaded as their own chunks the first
// time they mount. Leaflet is loaded once via lazyMapHook and shared
// across every map page. Pages that never mount any of these (e.g.
// /submit, /algo, /about) never download these chunks.
const PropagationMap = lazyMapHook(() => import("./propagation_map_hook"), "PropagationMap")
const PathForecast = lazyHook(() => import("./path_forecast_hook"), "PathForecast")
const ContactMap = lazyMapHook(() => import("./contact_map_hook"), "ContactMap")
const ContactsMap = lazyMapHook(() => import("./contacts_map_hook"), "ContactsMap")
const ElevationProfile = lazyHook(() => import("./elevation_profile_hook"), "ElevationProfile")
const WeatherMap = lazyMapHook(() => import("./weather_map_hook"), "WeatherMap")
const RoverMap = lazyMapHook(() => import("./rover_map_hook"), "RoverMap")
const LocationMap = lazyMapHook(() => import("./location_map_hook"), "LocationMap")
const RoverLocationsMap = lazyMapHook(() => import("./rover_locations_map_hook"), "RoverLocationsMap")
const BeaconMap = lazyMapHook(() => import("./beacon_map_hook"), "BeaconMap")
const BeaconsListMap = lazyMapHook(() => import("./beacons_list_map_hook"), "BeaconsListMap")
const EmeGlobe = lazyHook(() => import("./eme_globe_hook"), "EmeGlobe")
interface UtcClockHook extends ViewHook {
timer: ReturnType<typeof setInterval>

107
assets/js/lazy_hook.ts Normal file
View file

@ -0,0 +1,107 @@
// Wraps a Phoenix LiveView hook so its implementation module is fetched
// over the network only when the hook actually mounts. esbuild's
// `--splitting --format=esm` emits the dynamic import as its own chunk,
// keeping it out of the core app.js bundle on pages that don't use it.
//
// Two flavors:
// * lazyHook(loader) plain dynamic-import wrapper.
// * lazyMapHook(loader) also ensures Leaflet is loaded and assigned
// to window.L once before the impl mounts,
// shared across every map page.
type HookImpl = Record<string, any>
type ModuleLoader = () => Promise<Record<string, HookImpl>>
interface LazyState {
_impl?: HookImpl
_pendingUpdate?: boolean
_destroyed?: boolean
}
let leafletLoader: Promise<void> | null = null
function loadLeaflet(): Promise<void> {
if (!leafletLoader) {
leafletLoader = import("../vendor/leaflet/leaflet.js").then((mod) => {
// UMD: when imported as ESM, the namespace itself is the L value.
// Some bundlers wrap it under .default — handle both.
window.L = (mod as any).default ?? mod
})
}
return leafletLoader
}
// Pick the first non-lifecycle export from the module as the hook impl,
// or use the named export matching `hookName` when given.
function pickImpl(mod: Record<string, HookImpl>, hookName?: string): HookImpl {
if (hookName && mod[hookName]) return mod[hookName]
for (const key of Object.keys(mod)) {
if (key === "default") continue
const v = mod[key]
if (v && typeof v === "object" && ("mounted" in v || "updated" in v || "destroyed" in v)) {
return v
}
}
if (mod.default) return mod.default
throw new Error("lazyHook: no hook export found in module")
}
function attach(hook: ViewHook & LazyState, impl: HookImpl) {
hook._impl = impl
for (const k of Object.keys(impl)) {
if (k === "mounted" || k === "updated" || k === "destroyed") continue
;(hook as any)[k] = impl[k]
}
}
export function lazyHook(loader: ModuleLoader, hookName?: string) {
return {
mounted(this: ViewHook & LazyState) {
const hook = this
loader().then((mod) => {
if (hook._destroyed) return
const impl = pickImpl(mod, hookName)
attach(hook, impl)
impl.mounted?.call(hook)
if (hook._pendingUpdate) {
impl.updated?.call(hook)
hook._pendingUpdate = false
}
})
},
updated(this: ViewHook & LazyState) {
if (this._impl?.updated) this._impl.updated.call(this)
else this._pendingUpdate = true
},
destroyed(this: ViewHook & LazyState) {
this._destroyed = true
this._impl?.destroyed?.call(this)
},
}
}
export function lazyMapHook(loader: ModuleLoader, hookName?: string) {
return {
mounted(this: ViewHook & LazyState) {
const hook = this
Promise.all([loadLeaflet(), loader()]).then(([, mod]) => {
if (hook._destroyed) return
const impl = pickImpl(mod, hookName)
attach(hook, impl)
impl.mounted?.call(hook)
if (hook._pendingUpdate) {
impl.updated?.call(hook)
hook._pendingUpdate = false
}
})
},
updated(this: ViewHook & LazyState) {
if (this._impl?.updated) this._impl.updated.call(this)
else this._pendingUpdate = true
},
destroyed(this: ViewHook & LazyState) {
this._destroyed = true
this._impl?.destroyed?.call(this)
},
}
}