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.
|
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
|
||||||
import "phoenix_html"
|
import "phoenix_html"
|
||||||
// Establish Phoenix Socket and LiveView configuration.
|
// Establish Phoenix Socket and LiveView configuration.
|
||||||
|
|
@ -12,54 +8,27 @@ import topbar from "../vendor/topbar"
|
||||||
import "../vendor/canvas-confetti"
|
import "../vendor/canvas-confetti"
|
||||||
|
|
||||||
import initLiveStash from "../../deps/live_stash/assets/js/live-stash.js"
|
import initLiveStash from "../../deps/live_stash/assets/js/live-stash.js"
|
||||||
import {PropagationMap} from "./propagation_map_hook"
|
import {lazyHook, lazyMapHook} from "./lazy_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 {LocateMe, CopyLink} from "./locate_me_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 {RoverSlider} from "./rover_slider_hook"
|
||||||
import {HorizontalWheelScroll} from "./horizontal_wheel_scroll_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
|
// Heavy / page-specific hooks are loaded as their own chunks the first
|
||||||
// of the main app bundle by dynamic-importing on first hook mount:
|
// time they mount. Leaflet is loaded once via lazyMapHook and shared
|
||||||
// esbuild's --splitting flag emits a separate chunk that the browser
|
// across every map page. Pages that never mount any of these (e.g.
|
||||||
// fetches when (and only when) a user lands on /eme.
|
// /submit, /algo, /about) never download these chunks.
|
||||||
interface LazyHook {
|
const PropagationMap = lazyMapHook(() => import("./propagation_map_hook"), "PropagationMap")
|
||||||
_impl?: any
|
const PathForecast = lazyHook(() => import("./path_forecast_hook"), "PathForecast")
|
||||||
_pendingUpdate?: boolean
|
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 EmeGlobe = {
|
const WeatherMap = lazyMapHook(() => import("./weather_map_hook"), "WeatherMap")
|
||||||
mounted(this: ViewHook & LazyHook) {
|
const RoverMap = lazyMapHook(() => import("./rover_map_hook"), "RoverMap")
|
||||||
const hook = this
|
const LocationMap = lazyMapHook(() => import("./location_map_hook"), "LocationMap")
|
||||||
import("./eme_globe_hook").then((mod) => {
|
const RoverLocationsMap = lazyMapHook(() => import("./rover_locations_map_hook"), "RoverLocationsMap")
|
||||||
const impl = mod.EmeGlobe
|
const BeaconMap = lazyMapHook(() => import("./beacon_map_hook"), "BeaconMap")
|
||||||
hook._impl = impl
|
const BeaconsListMap = lazyMapHook(() => import("./beacons_list_map_hook"), "BeaconsListMap")
|
||||||
for (const k of Object.keys(impl)) {
|
const EmeGlobe = lazyHook(() => import("./eme_globe_hook"), "EmeGlobe")
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UtcClockHook extends ViewHook {
|
interface UtcClockHook extends ViewHook {
|
||||||
timer: ReturnType<typeof setInterval>
|
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