From 65954576c6b0b20dc0a7e55de407deb7cff46421 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Mon, 23 Dec 2024 15:33:18 +0100 Subject: [PATCH] use templates for portals --- assets/js/phoenix_live_view/constants.js | 3 +- assets/js/phoenix_live_view/dom.js | 42 +++--- assets/js/phoenix_live_view/dom_patch.js | 85 ++++++++---- assets/js/phoenix_live_view/live_socket.js | 3 +- assets/js/phoenix_live_view/rendered.js | 3 +- assets/js/phoenix_live_view/view.js | 59 +++++--- assets/test/dom_test.js | 36 +++-- assets/test/integration/event_test.js | 2 +- assets/test/rendered_test.js | 4 +- assets/test/view_test.js | 6 +- lib/phoenix_live_view/tag_engine.ex | 20 ++- test/e2e/support/issues/issue_3496.ex | 2 - test/e2e/support/navigation.ex | 5 +- test/e2e/support/portal.ex | 154 +++++++++++++++++---- test/e2e/test_helper.exs | 11 +- test/e2e/tests/portal.spec.js | 28 ++-- 16 files changed, 321 insertions(+), 142 deletions(-) diff --git a/assets/js/phoenix_live_view/constants.js b/assets/js/phoenix_live_view/constants.js index e2ee897379..728ed62bca 100644 --- a/assets/js/phoenix_live_view/constants.js +++ b/assets/js/phoenix_live_view/constants.js @@ -9,6 +9,7 @@ export const PHX_EVENT_CLASSES = [ "phx-hook-loading" ] export const PHX_COMPONENT = "data-phx-component" +export const PHX_VIEW_REF = "data-phx-view" export const PHX_LIVE_LINK = "data-phx-link" export const PHX_TRACK_STATIC = "track-static" export const PHX_LINK_STATE = "data-phx-link-state" @@ -55,7 +56,7 @@ export const PHX_UPDATE = "update" export const PHX_STREAM = "stream" export const PHX_STREAM_REF = "data-phx-stream" export const PHX_PORTAL = "portal" -export const PHX_PORTAL_REF = "data-phx-portal" +export const PHX_TELEPORTED_REF = "data-phx-teleported" export const PHX_KEY = "key" export const PHX_PRIVATE = "phxPrivate" export const PHX_AUTO_RECOVER = "auto-recover" diff --git a/assets/js/phoenix_live_view/dom.js b/assets/js/phoenix_live_view/dom.js index 478a844581..160dbfef08 100644 --- a/assets/js/phoenix_live_view/dom.js +++ b/assets/js/phoenix_live_view/dom.js @@ -4,6 +4,8 @@ import { DEBOUNCE_TRIGGER, FOCUSABLE_INPUTS, PHX_COMPONENT, + PHX_VIEW_REF, + PHX_TELEPORTED_REF, PHX_HAS_FOCUSED, PHX_HAS_SUBMITTED, PHX_MAIN, @@ -55,8 +57,8 @@ let DOM = { return this.all(node, `input[type="file"][${PHX_UPLOAD_REF}]`).concat(inputsOutsideForm) }, - findComponentNodeList(node, cid){ - return this.filterWithinSameLiveView(this.all(node, `[${PHX_COMPONENT}="${cid}"]`), node) + findComponentNodeList(viewId, cid, doc=document){ + return this.all(doc, `[${PHX_VIEW_REF}="${viewId}"][${PHX_COMPONENT}="${cid}"]`) }, isPhxDestroyed(node){ @@ -136,7 +138,7 @@ let DOM = { return this.all(el, `${PHX_VIEW_SELECTOR}[${PHX_PARENT_ID}="${parentId}"]`) }, - findExistingParentCIDs(node, cids){ + findExistingParentCIDs(viewId, cids){ // we only want to find parents that exist on the page // if a cid is not on the page, the only way it can be added back to the page // is if a parent adds it back, therefore if a cid does not exist on the page, @@ -146,7 +148,7 @@ let DOM = { let childrenCids = new Set() cids.forEach(cid => { - this.filterWithinSameLiveView(this.all(node, `[${PHX_COMPONENT}="${cid}"]`), node).forEach(parent => { + this.all(document, `[${PHX_VIEW_REF}="${viewId}"][${PHX_COMPONENT}="${cid}"]`).forEach(parent => { parentCids.add(cid) this.all(parent, `[${PHX_COMPONENT}]`) .map(el => parseInt(el.getAttribute(PHX_COMPONENT))) @@ -159,21 +161,6 @@ let DOM = { return parentCids }, - filterWithinSameLiveView(nodes, parent){ - if(parent.querySelector(PHX_VIEW_SELECTOR)){ - return nodes.filter(el => this.withinSameLiveView(el, parent)) - } else { - return nodes - } - }, - - withinSameLiveView(node, parent){ - while(node = node.parentNode){ - if(node.isSameNode(parent)){ return true } - if(node.getAttribute(PHX_SESSION) !== null){ return false } - } - }, - private(el, key){ return el[PHX_PRIVATE] && el[PHX_PRIVATE][key] }, deletePrivate(el, key){ el[PHX_PRIVATE] && delete (el[PHX_PRIVATE][key]) }, @@ -368,6 +355,23 @@ let DOM = { return this.isPhxChild(el) ? el : this.all(el, `[${PHX_PARENT_ID}]`)[0] }, + isPortalTemplate(el, phxPortal){ + return el.tagName === "TEMPLATE" && el.hasAttribute(phxPortal) + }, + + closestViewEl(el){ + // find the closest portal or view element, whichever comes first + const portalOrViewEl = el.closest(`[${PHX_TELEPORTED_REF}],${PHX_VIEW_SELECTOR}`) + if(!portalOrViewEl){ return null } + if(portalOrViewEl.getAttribute(PHX_TELEPORTED_REF)){ + // PHX_TELEPORTED_REF is set to the id of the view that owns the portal element + return this.byId(portalOrViewEl.getAttribute(PHX_TELEPORTED_REF)) + } else if(portalOrViewEl.getAttribute(PHX_SESSION)){ + return portalOrViewEl + } + return null + }, + dispatchEvent(target, name, opts = {}){ let defaultBubble = true let isUploadTarget = target.nodeName === "INPUT" && target.type === "file" diff --git a/assets/js/phoenix_live_view/dom_patch.js b/assets/js/phoenix_live_view/dom_patch.js index bf14820fda..8b95243b49 100644 --- a/assets/js/phoenix_live_view/dom_patch.js +++ b/assets/js/phoenix_live_view/dom_patch.js @@ -15,7 +15,7 @@ import { PHX_VIEWPORT_TOP, PHX_VIEWPORT_BOTTOM, PHX_PORTAL, - PHX_PORTAL_REF + PHX_TELEPORTED_REF } from "./constants" import { @@ -105,6 +105,11 @@ export default class DOMPatch { let updates = [] let appendPrependUpdates = [] + // as the portal target itself could be at the end of the DOM, + // it may not be present while morphing previous parts; + // therefore we apply all teleports after the morphing is done+ + let portalCallbacks = [] + let externalFormTriggered = null function morph(targetContainer, source, withChildren=false){ @@ -126,18 +131,7 @@ export default class DOMPatch { // tell morphdom how to add a child addChild: (parent, child) => { let {ref, streamAt} = this.getStreamInsert(child) - if(ref === undefined){ - // phx-portal optimization - if(child.getAttribute && child.getAttribute(PHX_PORTAL_REF) !== null){ - const targetId = child.getAttribute(PHX_PORTAL_REF) - const portalTarget = DOM.byId(targetId) - child.removeAttribute(this.portal) - if(portalTarget.contains(child)){ return } - return portalTarget.appendChild(child) - } - // no special handling, we just append it to the parent - return parent.appendChild(child) - } + if(ref === undefined){ return parent.appendChild(child) } this.setStreamRef(child, ref) @@ -173,6 +167,10 @@ export default class DOMPatch { }, onNodeAdded: (el) => { if(el.getAttribute){ this.maybeReOrderStream(el, true) } + // phx-portal handling + if(DOM.isPortalTemplate(el, this.portal)){ + portalCallbacks.push(() => this.teleport(el, morph)) + } // hack to fix Safari handling of img srcset and video tags if(el instanceof HTMLImageElement && el.srcset){ @@ -197,8 +195,18 @@ export default class DOMPatch { DOM.isPhxUpdate(el.parentElement, phxUpdate, [PHX_STREAM, "append", "prepend"])){ return false } + // don't remove teleported elements + if(el.getAttribute && el.getAttribute(PHX_TELEPORTED_REF)){ return false } if(this.maybePendingRemove(el)){ return false } if(this.skipCIDSibling(el)){ return false } + if(DOM.isPortalTemplate(el, this.portal)){ + // if the portal template itself is removed, remove the teleported element as well + const teleportedEl = DOM.byId(el.content.firstElementChild.id) + if(teleportedEl){ + teleportedEl.remove() + morphCallbacks.onNodeDiscarded(teleportedEl) + } + } return true }, @@ -281,21 +289,9 @@ export default class DOMPatch { DOM.copyPrivates(toEl, fromEl) // phx-portal handling - if(fromEl.hasAttribute(this.portal) || toEl.hasAttribute(this.portal)){ - const targetId = toEl.getAttribute(this.portal) - const portalTarget = DOM.byId(targetId) - toEl.removeAttribute(this.portal) - toEl.setAttribute(PHX_PORTAL_REF, targetId) - const existing = document.getElementById(fromEl.id) - // if the child is already a descendent of the portal, - // keep it as is, to prevent unnecessary DOM operations - if(existing && portalTarget.contains(existing)){ - return existing - } else { - // appendChild will move the element to the portal - portalTarget.appendChild(fromEl) - return fromEl - } + if(DOM.isPortalTemplate(toEl, this.portal)){ + portalCallbacks.push(() => this.teleport(toEl, morph)) + return false } // skip patching focused inputs unless focus is a select that has changed options @@ -358,6 +354,8 @@ export default class DOMPatch { } morph.call(this, targetContainer, html) + // normal patch complete, teleport elements now + portalCallbacks.forEach(callback => callback()) }) if(liveSocket.isDebugEnabled()){ @@ -513,7 +511,7 @@ export default class DOMPatch { targetCIDContainer(html){ if(!this.isCIDPatch()){ return } - let [first, ...rest] = DOM.findComponentNodeList(this.container, this.targetCID) + let [first, ...rest] = DOM.findComponentNodeList(this.view.id, this.targetCID) if(rest.length === 0 && DOM.childNodeLength(html) === 1){ return first } else { @@ -522,4 +520,33 @@ export default class DOMPatch { } indexOf(parent, child){ return Array.from(parent.children).indexOf(child) } + + teleport(el, morph){ + const targetId = el.getAttribute(this.portal) + const portalContainer = DOM.byId(targetId) + // phx-portal templates must have a single root element, so we assume this to be + // the case here + const toTeleport = el.content.firstElementChild + // the PHX_SKIP optimization can also apply inside of the