Skip to content

Commit 32970d5

Browse files
committed
Allow dynamically updating phx-hook attribute
Closes #4058.
1 parent 4832063 commit 32970d5

2 files changed

Lines changed: 136 additions & 1 deletion

File tree

assets/js/phoenix_live_view/view.js

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,7 @@ export default class View {
573573
const removedEls = [];
574574
let phxChildrenAdded = false;
575575
const updatedHookIds = new Set();
576+
const newHookIds = new Set();
576577

577578
this.liveSocket.triggerDOM("onPatchStart", [patch.targetContainer]);
578579

@@ -596,9 +597,33 @@ export default class View {
596597
});
597598

598599
patch.before("updated", (fromEl, toEl) => {
600+
const hookAttr = this.binding(PHX_HOOK);
599601
const hook = this.triggerBeforeUpdateHook(fromEl, toEl);
600602
if (hook) {
601-
updatedHookIds.add(fromEl.id);
603+
if (
604+
fromEl.hasAttribute(hookAttr) &&
605+
fromEl.getAttribute(hookAttr) !== toEl.getAttribute(hookAttr)
606+
) {
607+
// dynamically removed hook
608+
// (data-phx-hook from createHook or viewport bindings cannot be removed)
609+
this.destroyHook(hook);
610+
if (toEl.getAttribute(hookAttr)) {
611+
// changed hook
612+
newHookIds.add(toEl.id);
613+
}
614+
} else {
615+
updatedHookIds.add(fromEl.id);
616+
}
617+
} else {
618+
// dynamically added hook
619+
if (
620+
toEl.id &&
621+
toEl.getAttribute &&
622+
(toEl.getAttribute(hookAttr) ||
623+
toEl.getAttribute(`data-phx-${PHX_HOOK}`))
624+
) {
625+
newHookIds.add(toEl.id);
626+
}
602627
}
603628
// trigger JS specific update logic (for example for JS.ignore_attributes)
604629
JS.onBeforeElUpdated(fromEl, toEl);
@@ -607,6 +632,8 @@ export default class View {
607632
patch.after("updated", (el) => {
608633
if (updatedHookIds.has(el.id)) {
609634
this.getHook(el).__updated();
635+
} else if (newHookIds.has(el.id)) {
636+
this.maybeAddNewHook(el);
610637
}
611638
});
612639

assets/test/view_test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1395,6 +1395,114 @@ describe("View Hooks", function () {
13951395
expect(Object.keys(view.viewHooks)).toEqual([]);
13961396
});
13971397

1398+
test("dynamically added phx-hook is mounted", async () => {
1399+
let mounted = false;
1400+
let updated = false;
1401+
const Hooks = <HooksOptions>{
1402+
DynHook: {
1403+
mounted() {
1404+
mounted = true;
1405+
},
1406+
updated() {
1407+
updated = true;
1408+
},
1409+
},
1410+
};
1411+
liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks });
1412+
const el = liveViewDOM();
1413+
1414+
const view = simulateJoinedView(el, liveSocket);
1415+
1416+
// initial render: element exists but has no phx-hook
1417+
view.onJoin({
1418+
rendered: {
1419+
s: ['<h2 id="dyn">no hook yet</h2>'],
1420+
fingerprint: 123,
1421+
},
1422+
liveview_version,
1423+
});
1424+
expect(mounted).toBe(false);
1425+
expect(Object.keys(view.viewHooks)).toHaveLength(0);
1426+
1427+
// update: element gains phx-hook attribute dynamically
1428+
view.update(
1429+
{
1430+
s: ['<h2 id="dyn" phx-hook="DynHook">now with hook</h2>'],
1431+
fingerprint: 123,
1432+
},
1433+
[],
1434+
);
1435+
expect(mounted).toBe(true);
1436+
expect(Object.keys(view.viewHooks)).toHaveLength(1);
1437+
// mounted, not updated — this is the first time the hook is attached
1438+
expect(updated).toBe(false);
1439+
});
1440+
1441+
test("changing phx-hook", async () => {
1442+
let dynMounted = false;
1443+
let dynDestroyed = false;
1444+
let otherMounted = false;
1445+
let otherDestroyed = false;
1446+
const Hooks = <HooksOptions>{
1447+
DynHook: {
1448+
mounted() {
1449+
dynMounted = true;
1450+
},
1451+
destroyed() {
1452+
dynDestroyed = true;
1453+
},
1454+
},
1455+
OtherHook: {
1456+
mounted() {
1457+
otherMounted = true;
1458+
},
1459+
destroyed() {
1460+
otherDestroyed = true;
1461+
},
1462+
},
1463+
};
1464+
liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks });
1465+
const el = liveViewDOM();
1466+
1467+
const view = simulateJoinedView(el, liveSocket);
1468+
1469+
// initial render: element exists but has no phx-hook
1470+
view.onJoin({
1471+
rendered: {
1472+
s: ['<h2 id="dyn" phx-hook="DynHook">initial</h2>'],
1473+
fingerprint: 123,
1474+
},
1475+
liveview_version,
1476+
});
1477+
expect(dynMounted).toBe(true);
1478+
expect(dynDestroyed).toBe(false);
1479+
expect(otherMounted).toBe(false);
1480+
expect(Object.keys(view.viewHooks)).toHaveLength(1);
1481+
1482+
// update: element gains phx-hook attribute dynamically
1483+
view.update(
1484+
{
1485+
s: ['<h2 id="dyn" phx-hook="OtherHook">now with different hook</h2>'],
1486+
fingerprint: 123,
1487+
},
1488+
[],
1489+
);
1490+
expect(dynDestroyed).toBe(true);
1491+
expect(otherMounted).toBe(true);
1492+
expect(Object.keys(view.viewHooks)).toHaveLength(1);
1493+
1494+
// update: hook removed altogether
1495+
view.update(
1496+
{
1497+
s: ['<h2 id="dyn">no hook any more</h2>'],
1498+
fingerprint: 123,
1499+
},
1500+
[],
1501+
);
1502+
expect(otherDestroyed).toBe(true);
1503+
expect(Object.keys(view.viewHooks)).toHaveLength(0);
1504+
});
1505+
13981506
test("class based hook", async () => {
13991507
let upcaseWasDestroyed = false;
14001508
let upcaseBeforeUpdate = false;

0 commit comments

Comments
 (0)