diff --git a/assets/js/app.ts b/assets/js/app.ts index edb37608..d5d335f1 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -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 diff --git a/assets/js/lazy_hook.ts b/assets/js/lazy_hook.ts new file mode 100644 index 00000000..cb3bc075 --- /dev/null +++ b/assets/js/lazy_hook.ts @@ -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 +type ModuleLoader = () => Promise> + +interface LazyState { + _impl?: HookImpl + _pendingUpdate?: boolean + _destroyed?: boolean +} + +let leafletLoader: Promise | null = null + +function loadLeaflet(): Promise { + 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, 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) + }, + } +}