diff --git a/CHANGELOG.md b/CHANGELOG.md index 727ab6e33f..f8bf4443cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,29 @@ by setting `phx-ignore-missing-id` or disable it globally with the `:missing_for See the module documentation or `Phoenix.LiveViewTest` for more information. +## v1.2.8 (Unreleased) + +### Enhancements + +#### Lazy-loading JavaScript hooks + +Hooks that pull in heavy dependencies can now be loaded on demand by wrapping a +loader function with `lazy`. The hook's module is only fetched the first time an +element using the hook is added to the page: + +```javascript +import { lazy } from "phoenix_live_view" + +let liveSocket = new LiveSocket("/live", Socket, { + hooks: { + Chart: lazy(() => import("./hooks/chart")) + } +}) +``` + +The loader must resolve to the hook definition or to a module with the hook +definition as its default export. + ## v1.2.7 (2026-07-13) ### Security fixes diff --git a/assets/js/phoenix_live_view/index.ts b/assets/js/phoenix_live_view/index.ts index 103b8112c2..96a1249d22 100644 --- a/assets/js/phoenix_live_view/index.ts +++ b/assets/js/phoenix_live_view/index.ts @@ -10,15 +10,28 @@ import LiveSocket, { type LiveSocketOptions, isUsedInput } from "./live_socket"; import DOM from "./dom"; -import { ViewHook } from "./view_hook"; +import { ViewHook, lazy } from "./view_hook"; import View from "./view"; import { logError } from "./utils"; import type { EncodedJS } from "./js_commands"; -import type { Hook, HooksOptions, HookInterface } from "./view_hook"; +import type { + Hook, + HooksOptions, + HookInterface, + LazyHook, + LazyHookLoader, +} from "./view_hook"; import LiveUploader from "./live_uploader"; -export type { LiveSocketOptions, HookInterface, HooksOptions, EncodedJS }; +export type { + LiveSocketOptions, + HookInterface, + HooksOptions, + LazyHook, + LazyHookLoader, + EncodedJS, +}; /** Creates a hook instance for the given element and callbacks. * @@ -91,5 +104,6 @@ export { createHook, ViewHook, Hook, + lazy, getFileURLForUpload, }; diff --git a/assets/js/phoenix_live_view/live_socket.ts b/assets/js/phoenix_live_view/live_socket.ts index 46fcb3f632..dc94a2d374 100644 --- a/assets/js/phoenix_live_view/live_socket.ts +++ b/assets/js/phoenix_live_view/live_socket.ts @@ -51,7 +51,13 @@ import LiveUploader from "./live_uploader"; import View from "./view"; import JS from "./js"; import jsCommands, { EncodedJS, LiveSocketJSCommands } from "./js_commands"; -import { HooksOptions } from "./view_hook"; +import { + HooksOptions, + HookOptionValue, + LazyHook, + isLazyHook, + loadLazyHook, +} from "./view_hook"; /** * Returns true if the given element was touched by a user. @@ -259,6 +265,10 @@ export default class LiveSocket { private pendingLink: string | null; private currentLocation: Location; private hooks: HooksOptions; + private lazyHookDefinitions: Map< + string, + Promise<{ definition: HookOptionValue }> + >; /** @internal */ loaderTimeout: number; private reloadWithJitterTimer: ReturnType | null; @@ -348,6 +358,7 @@ export default class LiveSocket { this.pendingLink = null; this.currentLocation = clone(window.location); this.hooks = opts.hooks || {}; + this.lazyHookDefinitions = new Map(); this.uploaders = opts.uploaders || {}; this.loaderTimeout = opts.loaderTimeout || LOADER_TIMEOUT; this.disconnectedTimeout = opts.disconnectedTimeout || DISCONNECTED_TIMEOUT; @@ -684,11 +695,35 @@ export default class LiveSocket { if (!name) { return; } - return ( + const definition = this.maybeInternalHook(name) || this.hooks[name] || - this.maybeRuntimeHook(name) - ); + this.maybeRuntimeHook(name); + if (isLazyHook(definition)) { + return this.loadLazyHookDefinition(name, definition); + } + return definition; + } + + /** @internal */ + loadLazyHookDefinition( + name: string, + lazyHook: LazyHook, + ): Promise<{ definition: HookOptionValue }> { + const cached = this.lazyHookDefinitions.get(name); + if (cached) { + return cached; + } + // cache by hook name so the loader runs once regardless of how many + // elements use the hook + const loading = loadLazyHook(name, lazyHook); + loading.catch((error) => { + // drop the cached failure so the next mounted element retries the load + this.lazyHookDefinitions.delete(name); + logError(`Failed to load lazy hook "${name}"`, error); + }); + this.lazyHookDefinitions.set(name, loading); + return loading; } /** @internal */ diff --git a/assets/js/phoenix_live_view/view.ts b/assets/js/phoenix_live_view/view.ts index 77fa0f10c1..21ecc70856 100644 --- a/assets/js/phoenix_live_view/view.ts +++ b/assets/js/phoenix_live_view/view.ts @@ -62,7 +62,13 @@ import ElementRef from "./element_ref"; import DOMPatch from "./dom_patch"; import LiveUploader from "./live_uploader"; import Rendered from "./rendered"; -import { ViewHook } from "./view_hook"; +import { + ViewHook, + PendingHook, + createHookFromDefinition, + isViewHookClass, + type HookOptionValue, +} from "./view_hook"; import JS from "./js"; import morphdom from "morphdom"; @@ -996,23 +1002,29 @@ export default class View { return; } + if (hookDefinition instanceof Promise) { + // lazy hook: register a pending hook and upgrade it to the real + // hook once the definition has loaded + const pending = new PendingHook(this, el, hookName); + this.viewHooks[ViewHook.elementID(pending.el)] = pending; + hookDefinition.then( + ({ definition }) => this.upgradePendingHook(pending, definition), + // load failures are logged by the LiveSocket; only unregister here + () => this.removePendingHook(pending), + ); + return pending; + } + let hookInstance; try { if ( - typeof hookDefinition === "function" && - hookDefinition.prototype instanceof ViewHook - ) { - // It's a class constructor (subclass of ViewHook) - hookInstance = new hookDefinition(this, el); // `this` is the View instance - } else if ( - typeof hookDefinition === "object" && - hookDefinition !== null + isViewHookClass(hookDefinition) || + (typeof hookDefinition === "object" && hookDefinition !== null) ) { - // It's an object literal, pass it to the ViewHook constructor for wrapping - hookInstance = new ViewHook(this, el, hookDefinition); + hookInstance = createHookFromDefinition(this, el, hookDefinition); } else { logError( - `Invalid hook definition for "${hookName}". Expected a class extending ViewHook or an object definition.`, + `Invalid hook definition for "${hookName}". Expected a class extending ViewHook, an object definition, or a lazy hook created with lazy().`, el, ); return; @@ -1040,6 +1052,46 @@ export default class View { delete this.viewHooks[hookId]; } + upgradePendingHook(pending: PendingHook, definition: HookOptionValue) { + const hookId = ViewHook.elementID(pending.el); + if ( + pending.cancelled || + this.isDestroyed() || + !hookId || + this.viewHooks[hookId] !== pending + ) { + return; + } + + let hook; + try { + hook = createHookFromDefinition(this, pending.el, definition); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + logError( + `Failed to create hook "${pending.hookName}": ${errorMessage}`, + pending.el, + ); + this.destroyHook(pending); + return; + } + + this.viewHooks[hookId] = hook; + // an exception in mounted() intentionally propagates as an unhandled + // rejection, mirroring how a sync hook's mounted() exception surfaces + hook.__mounted(); + if (pending.wasDisconnected) { + hook.__disconnected(); + } + } + + removePendingHook(pending: PendingHook) { + const hookId = ViewHook.elementID(pending.el); + if (hookId && this.viewHooks[hookId] === pending) { + this.destroyHook(pending); + } + } + applyPendingUpdates() { // To prevent race conditions where we might still be pending a new // navigation or the join is still pending, `this.update` returns false diff --git a/assets/js/phoenix_live_view/view_hook.ts b/assets/js/phoenix_live_view/view_hook.ts index d7be08f02d..59a3cbd454 100644 --- a/assets/js/phoenix_live_view/view_hook.ts +++ b/assets/js/phoenix_live_view/view_hook.ts @@ -318,7 +318,11 @@ export class ViewHook this.__attachView(view); this.__listeners = new Set(); this.__isDisconnected = false; - DOM.putPrivate(this.el, HOOK_ID, ViewHook.makeID()); + // the element may already carry an id from a PendingHook this instance + // replaces; keeping it stable keeps the View's registry key valid + if (ViewHook.elementID(this.el) == null) { + DOM.putPrivate(this.el, HOOK_ID, ViewHook.makeID()); + } if (view && view.isDead) { DOM.putPrivate(this.el, DEAD_HOOK, true); } @@ -567,8 +571,201 @@ export class ViewHook } /** + * A hook definition: either a class extending {@link ViewHook} or an object + * with lifecycle callbacks. + * + * @category JavaScript Hooks + */ +export type HookOptionValue = typeof ViewHook | Hook; + +const LAZY = Symbol("phx-lazy-hook"); + +/** + * The loader function passed to {@link lazy}. + * + * Must return a promise resolving to either a hook definition or a module + * whose default export is the hook definition (the `() => import(...)` case). + * + * @category JavaScript Hooks + */ +export type LazyHookLoader = () => Promise< + HookOptionValue | { default: HookOptionValue } +>; + +/** + * A lazily loaded hook definition, created with {@link lazy}. + * + * @category JavaScript Hooks + */ +export interface LazyHook { + /** @internal */ + [LAZY]: LazyHookLoader; +} + +/** + * Marks a hook as lazily loaded. + * + * The loader is called the first time an element using the hook is added + * and its result is cached per hook name, so the loader runs at most once + * per page no matter how many elements use the hook. If the load fails, the + * failure is logged and the next element added with the hook retries it. + * + * The loader must resolve to the hook definition itself (an object with + * lifecycle callbacks or a class extending {@link ViewHook}) or to a module + * that has the hook definition as its default export. + * + * A lazy hook's life begins once its definition finishes loading: its + * `mounted` callback is then invoked with the element's current DOM state. + * Server updates applied while the definition was still loading are not + * replayed - `updated` is not called. Likewise, events pushed by the server + * before `mounted` could register a `handleEvent` handler are not delivered. + * You should request initial state from `mounted` via `pushEvent` instead of + * pushing an event from the server that races the load. Hooks that must run + * synchronously with DOM insertion (e.g. to measure before first paint) should + * not be lazy. + * + * @example + * ```typescript + * import { lazy } from "phoenix_live_view" + * + * const hooks = { + * Chart: lazy(() => import("./chart")), + * } + * ``` + * + * @category JavaScript Hooks + */ +export function lazy(loader: LazyHookLoader): LazyHook { + return { [LAZY]: loader }; +} + +/** + * The `hooks` option of the `LiveSocket` constructor. + * + * Maps hook names to a class extending {@link ViewHook}, an object + * definition with lifecycle callbacks, or a lazily loaded hook created with + * {@link lazy}. + * * @category JavaScript Hooks */ -export type HooksOptions = Record>; +export type HooksOptions = Record; + +/** @internal */ +export function isLazyHook(definition: unknown): definition is LazyHook { + return ( + typeof definition === "object" && definition !== null && LAZY in definition + ); +} + +/** @internal */ +export function isViewHookClass( + definition: unknown, +): definition is typeof ViewHook { + return ( + typeof definition === "function" && definition.prototype instanceof ViewHook + ); +} + +/** @internal */ +export function createHookFromDefinition( + view: View, + el: HTMLElement, + definition: HookOptionValue, +): ViewHook { + if (isViewHookClass(definition)) { + return new definition(view, el); + } + return new ViewHook(view, el, definition as Hook); +} + +function resolveLazyDefinition( + hookName: string, + loaded: unknown, +): HookOptionValue { + if (isViewHookClass(loaded)) { + return loaded; + } + + if (typeof loaded === "object" && loaded !== null && !Array.isArray(loaded)) { + const mod = loaded as { [key: string]: unknown; default?: unknown }; + // bundlers differ in how they shape module namespace objects; besides a + // default export, treat the ESM toStringTag and the CJS-interop + // __esModule marker as module-shaped rather than as a hook object + const isModuleShaped = + Object.prototype.hasOwnProperty.call(mod, "default") || + Object.prototype.toString.call(mod) === "[object Module]" || + mod.__esModule === true; + + if (!isModuleShaped) { + // the loader resolved to the hook object itself + return loaded as Hook; + } + + const defaultExport = mod.default; + if ( + isViewHookClass(defaultExport) || + (typeof defaultExport === "object" && + defaultExport !== null && + !isLazyHook(defaultExport)) + ) { + return defaultExport as HookOptionValue; + } + + throw new Error( + `the module loaded for lazy hook "${hookName}" must export the hook definition as its default export`, + ); + } + + throw new Error( + `lazy hook "${hookName}" resolved to ${typeof loaded} instead of a hook definition`, + ); +} + +/** + * Runs the loader and resolves its result to a hook definition. + * + * The definition is returned wrapped in an object so a hook carrying a + * `then` method is not adopted as a thenable by the returned promise. + * + * @internal + */ +export async function loadLazyHook( + hookName: string, + hook: LazyHook, +): Promise<{ definition: HookOptionValue }> { + const loaded = await hook[LAZY](); + return { definition: resolveLazyDefinition(hookName, loaded) }; +} + +/** + * Registered in a View in place of a hook whose definition is still + * loading. Buffers no lifecycle calls; it only tracks the state the View + * needs when it upgrades the element to the real hook after the definition + * arrives. + * + * @internal + */ +export class PendingHook extends ViewHook { + hookName: string; + cancelled = false; + wasDisconnected = false; + + constructor(view: View, el: HTMLElement, hookName: string) { + super(view, el); + this.hookName = hookName; + } + + disconnected() { + this.wasDisconnected = true; + } + + reconnected() { + this.wasDisconnected = false; + } + + destroyed() { + this.cancelled = true; + } +} export default ViewHook; diff --git a/assets/test/view_test.ts b/assets/test/view_test.ts index d2e3c6a30b..dfee8e4f9a 100644 --- a/assets/test/view_test.ts +++ b/assets/test/view_test.ts @@ -3,7 +3,7 @@ import { createHook } from "phoenix_live_view/index"; import LiveSocket from "phoenix_live_view/live_socket"; import DOM from "phoenix_live_view/dom"; import View from "phoenix_live_view/view"; -import ViewHook, { HooksOptions } from "phoenix_live_view/view_hook"; +import ViewHook, { HooksOptions, lazy } from "phoenix_live_view/view_hook"; import { version as liveview_version } from "../../package.json"; @@ -28,6 +28,9 @@ const simulateUsedInput = (input) => { DOM.putPrivate(input, PHX_HAS_FOCUSED, true); }; +const flushPromises = () => + new Promise((resolve) => setTimeout(resolve, 0)); + describe("View + DOM", function () { let liveSocket; @@ -1523,6 +1526,354 @@ describe("View Hooks", function () { expect(Object.keys(view["viewHooks"])).toEqual([]); }); + test("lazy hook", async () => { + const values: Array = []; + let resolveHook: (definition: any) => void = () => {}; + let loadCount = 0; + const Hooks = { + Upcase: lazy(() => { + loadCount++; + return new Promise((resolve) => { + resolveHook = resolve; + }); + }), + }; + liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks }); + const el = liveViewDOM(); + + const view = simulateJoinedView(el, liveSocket); + + view.onJoin({ + rendered: { + s: ['

test mount

'], + fingerprint: 123, + }, + liveview_version, + }); + + expect((view.el.firstChild! as HTMLElement).innerHTML).toBe("test mount"); + expect(Object.keys(view["viewHooks"])).toHaveLength(1); + + // updates while the definition is still loading are not replayed + view.update( + { + s: ['

test update

'], + fingerprint: 123, + }, + [], + ); + expect(values).toEqual([]); + expect((view.el.firstChild! as HTMLElement).innerHTML).toBe("test update"); + + resolveHook({ + default: class extends ViewHook { + mounted() { + values.push("mounted"); + this.el.innerHTML = this.el.innerHTML.toUpperCase(); + } + beforeUpdate() { + values.push("beforeUpdate"); + } + updated() { + values.push("updated"); + this.el.innerHTML = this.el.innerHTML + " updated"; + } + disconnected() { + values.push("disconnected"); + this.el.innerHTML = "disconnected"; + } + reconnected() { + values.push("reconnected"); + this.el.innerHTML = "connected"; + } + destroyed() { + values.push("destroyed"); + } + }, + }); + await flushPromises(); + + expect(loadCount).toBe(1); + // mounted observes the element's current, already-updated DOM state + expect(values).toEqual(["mounted"]); + expect((view.el.firstChild! as HTMLElement).innerHTML).toBe("TEST UPDATE"); + + view.update( + { + s: ['

test again

'], + fingerprint: 123, + }, + [], + ); + expect(values).toEqual(["mounted", "beforeUpdate", "updated"]); + expect((view.el.firstChild! as HTMLElement).innerHTML).toBe( + "test again updated", + ); + + view.showLoader(); + expect(values).toEqual([ + "mounted", + "beforeUpdate", + "updated", + "disconnected", + ]); + expect((view.el.firstChild! as HTMLElement).innerHTML).toBe("disconnected"); + + view.triggerReconnected(); + expect(values).toEqual([ + "mounted", + "beforeUpdate", + "updated", + "disconnected", + "reconnected", + ]); + expect((view.el.firstChild! as HTMLElement).innerHTML).toBe("connected"); + + view.update({ s: ["
"], fingerprint: 123 }, []); + expect(values).toEqual([ + "mounted", + "beforeUpdate", + "updated", + "disconnected", + "reconnected", + "destroyed", + ]); + expect(Object.keys(view["viewHooks"])).toEqual([]); + }); + + test("lazy hook loads once for multiple elements", async () => { + const mountedIds: Array = []; + let loadCount = 0; + const Hooks = { + Check: lazy(async () => { + loadCount++; + return { + mounted(this: ViewHook) { + mountedIds.push(this.el.id); + }, + }; + }), + }; + liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks }); + const el = liveViewDOM(); + + const view = simulateJoinedView(el, liveSocket); + + view.onJoin({ + rendered: { + s: [ + '

a

b

', + ], + fingerprint: 123, + }, + liveview_version, + }); + await flushPromises(); + + expect(loadCount).toBe(1); + expect(mountedIds.sort()).toEqual(["a", "b"]); + expect(Object.keys(view["viewHooks"])).toHaveLength(2); + }); + + test("lazy hook resolving while disconnected mounts, then disconnects and reconnects", async () => { + const values: Array = []; + let resolveHook: (definition: any) => void = () => {}; + const Hooks = { + Check: lazy( + () => + new Promise((resolve) => { + resolveHook = resolve; + }), + ), + }; + liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks }); + const el = liveViewDOM(); + + const view = simulateJoinedView(el, liveSocket); + + view.onJoin({ + rendered: { + s: ['

x

'], + fingerprint: 123, + }, + liveview_version, + }); + + view.showLoader(); + + resolveHook({ + mounted(this: ViewHook) { + values.push("mounted"); + }, + disconnected() { + values.push("disconnected"); + }, + reconnected() { + values.push("reconnected"); + }, + }); + await flushPromises(); + + expect(values).toEqual(["mounted", "disconnected"]); + + view.triggerReconnected(); + expect(values).toEqual(["mounted", "disconnected", "reconnected"]); + }); + + test("lazy hook resolving after element removal does not mount", async () => { + const values: Array = []; + let resolveHook: (definition: any) => void = () => {}; + const Hooks = { + Check: lazy( + () => + new Promise((resolve) => { + resolveHook = resolve; + }), + ), + }; + liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks }); + const el = liveViewDOM(); + + const view = simulateJoinedView(el, liveSocket); + + view.onJoin({ + rendered: { + s: ['

x

'], + fingerprint: 123, + }, + liveview_version, + }); + expect(Object.keys(view["viewHooks"])).toHaveLength(1); + + view.update({ s: ["
"], fingerprint: 123 }, []); + expect(Object.keys(view["viewHooks"])).toEqual([]); + + resolveHook({ + mounted() { + values.push("mounted"); + }, + }); + await flushPromises(); + + expect(values).toEqual([]); + expect(Object.keys(view["viewHooks"])).toEqual([]); + }); + + test("lazy hook load failure is logged, cleaned up, and retried on next mount", async () => { + const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + const values: Array = []; + let loadCount = 0; + const Hooks = { + Check: lazy(() => { + loadCount++; + return loadCount === 1 + ? Promise.reject(new Error("chunk load failed")) + : Promise.resolve({ + mounted(this: ViewHook) { + values.push("mounted"); + }, + }); + }), + }; + liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks }); + const el = liveViewDOM(); + + const view = simulateJoinedView(el, liveSocket); + + view.onJoin({ + rendered: { + s: ['

x

'], + fingerprint: 123, + }, + liveview_version, + }); + await flushPromises(); + + expect(loadCount).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to load lazy hook "Check"'), + expect.anything(), + ); + expect(Object.keys(view["viewHooks"])).toEqual([]); + + // removing and re-adding the element retries the failed load + view.update({ s: ["
"], fingerprint: 123 }, []); + view.update( + { s: ['

x

'], fingerprint: 123 }, + [], + ); + await flushPromises(); + + expect(loadCount).toBe(2); + expect(values).toEqual(["mounted"]); + expect(Object.keys(view["viewHooks"])).toHaveLength(1); + errorSpy.mockRestore(); + }); + + test("lazy hook module without a default export logs an error", async () => { + const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + const Hooks = { + Check: lazy( + async () => ({ __esModule: true, Check: { mounted() {} } }) as any, + ), + }; + liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks }); + const el = liveViewDOM(); + + const view = simulateJoinedView(el, liveSocket); + + view.onJoin({ + rendered: { + s: ['

x

'], + fingerprint: 123, + }, + liveview_version, + }); + await flushPromises(); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to load lazy hook "Check"'), + expect.objectContaining({ + message: expect.stringContaining( + "must export the hook definition as its default export", + ), + }), + ); + expect(Object.keys(view["viewHooks"])).toEqual([]); + errorSpy.mockRestore(); + }); + + test("plain function hook definitions are rejected without being called", () => { + const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + let called = false; + const Hooks = { + Check: (() => { + called = true; + return Promise.resolve({}); + }) as any, + }; + liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks }); + const el = liveViewDOM(); + + const view = simulateJoinedView(el, liveSocket); + + view.onJoin({ + rendered: { + s: ['

x

'], + fingerprint: 123, + }, + liveview_version, + }); + + expect(called).toBe(false); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid hook definition for "Check"'), + expect.anything(), + ); + expect(Object.keys(view["viewHooks"])).toEqual([]); + errorSpy.mockRestore(); + }); + test("createHook", (done) => { const liveSocket = new LiveSocket("/live", Socket, {}); const el = liveViewDOM(); diff --git a/guides/client/js-interop.md b/guides/client/js-interop.md index 622cfcf8a6..66eaa9cce5 100644 --- a/guides/client/js-interop.md +++ b/guides/client/js-interop.md @@ -268,6 +268,54 @@ let liveSocket = new LiveSocket(..., { }) ``` +### Lazy-loading hooks + +Hooks that pull in heavy dependencies (charting libraries, editors, etc.) can be +loaded on demand by wrapping a loader function with `lazy`. The hook's module is +then only fetched the first time an element using the hook is added to the page: + +```javascript +import { lazy } from "phoenix_live_view" + +let liveSocket = new LiveSocket("/live", Socket, { + hooks: { + Chart: lazy(() => import("./hooks/chart")) + } +}) +``` + +The loader must resolve to the hook definition itself (an object with lifecycle +callbacks or a class extending `ViewHook`), or to a module that has the hook +definition as its **default export**: + +```javascript +// hooks/chart.js +export default { + mounted() { + ... + } +} +``` + +The loader runs at most once per hook name, no matter how many elements use the +hook. If the load fails (for example due to a network error), the failure is +logged and the load is retried the next time an element using the hook is added. + +Because the definition arrives asynchronously, a lazy hook's life begins once +loading completes: `mounted` is invoked with the element's DOM state at that +point, and server updates applied while the hook was still loading are not +replayed. Similarly, events pushed with `push_event` before `mounted` had a +chance to register a `handleEvent` handler are not delivered. Instead of pushing +initial state from the server, request it from the hook once it is ready: + +```javascript +export default { + mounted() { + this.pushEvent("chart-ready", {}, ({data}) => this.renderChart(data)) + } +} +``` + ### Colocated Hooks / Colocated JavaScript When writing components that require some more control over the DOM, it often feels inconvenient to