prop/assets/js/lazy_hook.ts
Graham McIntire a4921029e5
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.
2026-05-07 11:55:01 -05:00

107 lines
3.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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)
},
}
}