Skip to content
Closed
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
2 changes: 1 addition & 1 deletion assets/js/phoenix_live_view/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ export default class View {
!fromEl.isEqualNode(toEl) &&
!(isIgnored && isEqualObj(fromEl.dataset, toEl.dataset))
) {
hook.__beforeUpdate();
hook.__beforeUpdate(fromEl, toEl);
return hook;
}
}
Expand Down
8 changes: 4 additions & 4 deletions assets/js/phoenix_live_view/view_hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export interface HookInterface<E extends HTMLElement = HTMLElement> {
* Called when the element is about to be updated in the DOM.
* Note: any call here must be synchronous as the operation cannot be deferred or cancelled.
*/
beforeUpdate?: () => void;
beforeUpdate?: (fromEl: E, toEl: E) => void;

/**
* The updated callback.
Expand Down Expand Up @@ -339,7 +339,7 @@ export class ViewHook<E extends HTMLElement = HTMLElement>

// Default lifecycle methods
mounted(): void {}
beforeUpdate(): void {}
beforeUpdate(_from: E, _to: E): void {}
updated(): void {}
destroyed(): void {}
disconnected(): void {}
Expand All @@ -356,8 +356,8 @@ export class ViewHook<E extends HTMLElement = HTMLElement>
this.updated();
}
/** @internal */
__beforeUpdate() {
this.beforeUpdate();
__beforeUpdate(fromEl: E, toEl: E) {
this.beforeUpdate(fromEl, toEl);
}
/** @internal */
__destroyed() {
Expand Down
22 changes: 22 additions & 0 deletions guides/client/js-interop.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,28 @@ let liveSocket = new LiveSocket("/live", Socket, {

In the example above, all attributes starting with `data-js-` won't be replaced when the DOM is patched by LiveView.

While above option is useful for application-wide handling, the same behavior can be implemented at the hook level when you only need the logic to apply to a specific hook. To do this, add a `beforeUpdate(from, to)` callback to your hook object or class. This callback receives the `from` and `to` same as `onBeforeElUpdated` on the `dom` option.

For example, the hook below performs the same attribute-preservation logic as the example above, but scoped to the individual hook.

```javascript
class MyHook {
mounted() {
// ...
},

beforeUpdate(from, to) {
for (const attr of from.attributes) {
if (attr.name.startsWith("data-js-")) {
to.setAttribute(attr.name, attr.value);
}
}
},
}
```

Using `beforeUpdate` on the hook level is often preferable when you want to scope DOM update handling to a specific component or hook, avoiding the need to add a global `dom.onBeforeElUpdated` handler that runs for every DOM patch.

A hook can also be defined as a subclass of `ViewHook`:

```javascript
Expand Down
63 changes: 63 additions & 0 deletions test/e2e/support/hook_beforeupdate.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
defmodule Phoenix.LiveViewTest.E2E.HookBeforeupdateLive do
use Phoenix.LiveView, layout: {__MODULE__, :live}

def mount(_params, _session, socket) do
{:ok, socket |> assign(counter: 0)}
end

def handle_event("inc", _params, socket) do
{:noreply, socket |> assign(counter: socket.assigns.counter + 1)}
end

def render("live.html", assigns) do
~H"""
<meta name="csrf-token" content={Plug.CSRFProtection.get_csrf_token()} />
<script src="/assets/phoenix/phoenix.min.js">
</script>
<script type="module">
import {LiveSocket} from "/assets/phoenix_live_view/phoenix_live_view.esm.js"
import {default as colocated, hooks} from "/assets/colocated/index.js";
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
let liveSocket = new LiveSocket("/live", window.Phoenix.Socket, {
params: {_csrf_token: csrfToken},
reloadJitterMin: 50,
reloadJitterMax: 500,
hooks
})
liveSocket.connect()
window.liveSocket = liveSocket
// initialize js exec handler from colocated js
colocated.js_exec(liveSocket)
</script>

{@inner_content}
"""
end

def render(assigns) do
~H"""
<div id="hook-beforeupdate" phx-hook=".LocalHook">
<button phx-click="inc">
Inc {@counter}
</button>
</div>
<script :type={Phoenix.LiveView.ColocatedHook} name=".LocalHook">
export default {
mounted() {
this.el.setAttribute("aria-hidden", false)
},

beforeUpdate(from, to) {
const before = from.getAttribute("aria-hidden");
const after = to.getAttribute("aria-hidden");

if (before !== after) {
console.log("before update", {before, after})
to.setAttribute("aria-hidden", before)
}
},
}
</script>
"""
end
end
1 change: 1 addition & 0 deletions test/e2e/test_helper.exs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ defmodule Phoenix.LiveViewTest.E2E.Router do
live "/form/feedback", FormFeedbackLive
live "/errors", ErrorLive
live "/colocated", ColocatedLive
live "/beforeupdate", HookBeforeupdateLive

scope "/issues" do
live "/2965", Issue2965Live
Expand Down
21 changes: 21 additions & 0 deletions test/e2e/tests/hook_beforeupdate.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { test, expect } from "../test-fixtures";
import { syncLV } from "../utils";

test("beforeUpdate hook can be used to update attribute before dom-patch", async ({
page,
}) => {
await page.goto("/beforeupdate");
await syncLV(page);

const hook = page.locator("#hook-beforeupdate");
// aria-hidden attribute is set on client side hook mounted
await expect(hook).toHaveAttribute("aria-hidden", "false");

// Click the button will trigger live view update from server
await page.locator("button").click();

// when the liveview updated from server aria-hidden attribute will be removed
// since on server side does not have the attribute
// hook expect to set aria-hidden attribute to match previous value
await expect(hook).toHaveAttribute("aria-hidden", "false");
});