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:
parent
a0065e2f44
commit
a4921029e5
2 changed files with 124 additions and 48 deletions
|
|
@ -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
107
assets/js/lazy_hook.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue