Skip to content
Draft
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
29 changes: 28 additions & 1 deletion assets/js/phoenix_live_view/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand All @@ -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);
Expand All @@ -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);
}
});

Expand Down
108 changes: 108 additions & 0 deletions assets/test/view_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <HooksOptions>{
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: ['<h2 id="dyn">no hook yet</h2>'],
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: ['<h2 id="dyn" phx-hook="DynHook">now with hook</h2>'],
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 = <HooksOptions>{
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: ['<h2 id="dyn" phx-hook="DynHook">initial</h2>'],
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: ['<h2 id="dyn" phx-hook="OtherHook">now with different hook</h2>'],
fingerprint: 123,
},
[],
);
expect(dynDestroyed).toBe(true);
expect(otherMounted).toBe(true);
expect(Object.keys(view.viewHooks)).toHaveLength(1);

// update: hook removed altogether
view.update(
{
s: ['<h2 id="dyn">no hook any more</h2>'],
fingerprint: 123,
},
[],
);
expect(otherDestroyed).toBe(true);
expect(Object.keys(view.viewHooks)).toHaveLength(0);
});

test("class based hook", async () => {
let upcaseWasDestroyed = false;
let upcaseBeforeUpdate = false;
Expand Down
Loading