diff --git a/assets/js/phoenix_live_view/view.js b/assets/js/phoenix_live_view/view.js index 711cc8fe8e..857551a522 100644 --- a/assets/js/phoenix_live_view/view.js +++ b/assets/js/phoenix_live_view/view.js @@ -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; } } diff --git a/assets/js/phoenix_live_view/view_hook.ts b/assets/js/phoenix_live_view/view_hook.ts index 8375497fb8..a9eaf67f98 100644 --- a/assets/js/phoenix_live_view/view_hook.ts +++ b/assets/js/phoenix_live_view/view_hook.ts @@ -35,7 +35,7 @@ export interface HookInterface { * 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. @@ -339,7 +339,7 @@ export class ViewHook // Default lifecycle methods mounted(): void {} - beforeUpdate(): void {} + beforeUpdate(_from: E, _to: E): void {} updated(): void {} destroyed(): void {} disconnected(): void {} @@ -356,8 +356,8 @@ export class ViewHook this.updated(); } /** @internal */ - __beforeUpdate() { - this.beforeUpdate(); + __beforeUpdate(fromEl: E, toEl: E) { + this.beforeUpdate(fromEl, toEl); } /** @internal */ __destroyed() { diff --git a/guides/client/js-interop.md b/guides/client/js-interop.md index 4657ddd19b..7ab260854c 100644 --- a/guides/client/js-interop.md +++ b/guides/client/js-interop.md @@ -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 diff --git a/test/e2e/support/hook_beforeupdate.ex b/test/e2e/support/hook_beforeupdate.ex new file mode 100644 index 0000000000..2513e75b1a --- /dev/null +++ b/test/e2e/support/hook_beforeupdate.ex @@ -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""" + + + + + {@inner_content} + """ + end + + def render(assigns) do + ~H""" +
+ +
+ + """ + end +end diff --git a/test/e2e/test_helper.exs b/test/e2e/test_helper.exs index d21356d129..cf4b74e478 100644 --- a/test/e2e/test_helper.exs +++ b/test/e2e/test_helper.exs @@ -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 diff --git a/test/e2e/tests/hook_beforeupdate.spec.js b/test/e2e/tests/hook_beforeupdate.spec.js new file mode 100644 index 0000000000..bdf5e81469 --- /dev/null +++ b/test/e2e/tests/hook_beforeupdate.spec.js @@ -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"); +});