Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 17 additions & 3 deletions assets/js/phoenix_live_view/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -91,5 +104,6 @@ export {
createHook,
ViewHook,
Hook,
lazy,
getFileURLForUpload,
};
43 changes: 39 additions & 4 deletions assets/js/phoenix_live_view/live_socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<typeof setTimeout> | null;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 */
Expand Down
76 changes: 64 additions & 12 deletions assets/js/phoenix_live_view/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading