diff --git a/assets/js/phoenix_live_view/view.js b/assets/js/phoenix_live_view/view.js index 9f2e2b519d..d169d683b2 100644 --- a/assets/js/phoenix_live_view/view.js +++ b/assets/js/phoenix_live_view/view.js @@ -573,6 +573,7 @@ export default class View { const removedEls = []; let phxChildrenAdded = false; const updatedHookIds = new Set(); + const newHookIds = new Set(); this.liveSocket.triggerDOM("onPatchStart", [patch.targetContainer]); @@ -596,9 +597,33 @@ export default class View { }); patch.before("updated", (fromEl, toEl) => { + const hookAttr = this.binding(PHX_HOOK); const hook = this.triggerBeforeUpdateHook(fromEl, toEl); if (hook) { - updatedHookIds.add(fromEl.id); + if ( + fromEl.hasAttribute(hookAttr) && + fromEl.getAttribute(hookAttr) !== toEl.getAttribute(hookAttr) + ) { + // dynamically removed hook + // (data-phx-hook from createHook or viewport bindings cannot be removed) + this.destroyHook(hook); + if (toEl.getAttribute(hookAttr)) { + // changed hook + newHookIds.add(toEl.id); + } + } else { + updatedHookIds.add(fromEl.id); + } + } else { + // dynamically added hook + if ( + toEl.id && + toEl.getAttribute && + (toEl.getAttribute(hookAttr) || + toEl.getAttribute(`data-phx-${PHX_HOOK}`)) + ) { + newHookIds.add(toEl.id); + } } // trigger JS specific update logic (for example for JS.ignore_attributes) JS.onBeforeElUpdated(fromEl, toEl); @@ -607,6 +632,8 @@ export default class View { patch.after("updated", (el) => { if (updatedHookIds.has(el.id)) { this.getHook(el).__updated(); + } else if (newHookIds.has(el.id)) { + this.maybeAddNewHook(el); } }); diff --git a/assets/test/view_test.ts b/assets/test/view_test.ts index 1a78365389..283b27ed79 100644 --- a/assets/test/view_test.ts +++ b/assets/test/view_test.ts @@ -1395,6 +1395,114 @@ describe("View Hooks", function () { expect(Object.keys(view.viewHooks)).toEqual([]); }); + test("dynamically added phx-hook is mounted", async () => { + let mounted = false; + let updated = false; + const Hooks = { + DynHook: { + mounted() { + mounted = true; + }, + updated() { + updated = true; + }, + }, + }; + liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks }); + const el = liveViewDOM(); + + const view = simulateJoinedView(el, liveSocket); + + // initial render: element exists but has no phx-hook + view.onJoin({ + rendered: { + s: ['

no hook yet

'], + fingerprint: 123, + }, + liveview_version, + }); + expect(mounted).toBe(false); + expect(Object.keys(view.viewHooks)).toHaveLength(0); + + // update: element gains phx-hook attribute dynamically + view.update( + { + s: ['

now with hook

'], + fingerprint: 123, + }, + [], + ); + expect(mounted).toBe(true); + expect(Object.keys(view.viewHooks)).toHaveLength(1); + // mounted, not updated — this is the first time the hook is attached + expect(updated).toBe(false); + }); + + test("changing phx-hook", async () => { + let dynMounted = false; + let dynDestroyed = false; + let otherMounted = false; + let otherDestroyed = false; + const Hooks = { + DynHook: { + mounted() { + dynMounted = true; + }, + destroyed() { + dynDestroyed = true; + }, + }, + OtherHook: { + mounted() { + otherMounted = true; + }, + destroyed() { + otherDestroyed = true; + }, + }, + }; + liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks }); + const el = liveViewDOM(); + + const view = simulateJoinedView(el, liveSocket); + + // initial render: element exists but has no phx-hook + view.onJoin({ + rendered: { + s: ['

initial

'], + fingerprint: 123, + }, + liveview_version, + }); + expect(dynMounted).toBe(true); + expect(dynDestroyed).toBe(false); + expect(otherMounted).toBe(false); + expect(Object.keys(view.viewHooks)).toHaveLength(1); + + // update: element gains phx-hook attribute dynamically + view.update( + { + s: ['

now with different hook

'], + fingerprint: 123, + }, + [], + ); + expect(dynDestroyed).toBe(true); + expect(otherMounted).toBe(true); + expect(Object.keys(view.viewHooks)).toHaveLength(1); + + // update: hook removed altogether + view.update( + { + s: ['

no hook any more

'], + fingerprint: 123, + }, + [], + ); + expect(otherDestroyed).toBe(true); + expect(Object.keys(view.viewHooks)).toHaveLength(0); + }); + test("class based hook", async () => { let upcaseWasDestroyed = false; let upcaseBeforeUpdate = false;