Skip to content

Commit

Permalink
use templates for portals
Browse files Browse the repository at this point in the history
  • Loading branch information
SteffenDE committed Dec 23, 2024
1 parent 244533f commit 6595457
Show file tree
Hide file tree
Showing 16 changed files with 321 additions and 142 deletions.
3 changes: 2 additions & 1 deletion assets/js/phoenix_live_view/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
42 changes: 23 additions & 19 deletions assets/js/phoenix_live_view/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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){
Expand Down Expand Up @@ -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,
Expand All @@ -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)))
Expand All @@ -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]) },
Expand Down Expand Up @@ -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"
Expand Down
85 changes: 56 additions & 29 deletions assets/js/phoenix_live_view/dom_patch.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
PHX_VIEWPORT_TOP,
PHX_VIEWPORT_BOTTOM,
PHX_PORTAL,
PHX_PORTAL_REF
PHX_TELEPORTED_REF
} from "./constants"

import {
Expand Down Expand Up @@ -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){
Expand All @@ -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)

Expand Down Expand Up @@ -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){
Expand All @@ -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
},
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()){
Expand Down Expand Up @@ -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 {
Expand All @@ -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 <template> elements
if(this.skipCIDSibling(toTeleport)){ return }
if(!toTeleport?.id){ throw new Error("phx-portal template must have a single root element with ID!") }
const existing = document.getElementById(toTeleport.id)
let portalTarget
if(existing){
// we already teleported in a previous patch
if(!portalContainer.contains(existing)){ throw new Error(`expected ${id} to be a child of ${targetId}`) }
portalTarget = existing
} else {
// create empty target and morph it recursively
portalTarget = document.createElement(toTeleport.tagName)
portalContainer.appendChild(portalTarget)
}
morph.call(this, portalTarget, toTeleport, true)
// mark the target as teleported
portalTarget.setAttribute(PHX_TELEPORTED_REF, this.view.id)
// store a reference to the teleported element in the view
// to cleanup when the view is destroyed, in case the portal target
// is outside the view itself
this.view.pushPortalElement(toTeleport.id)
}
}
3 changes: 2 additions & 1 deletion assets/js/phoenix_live_view/live_socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,8 @@ export default class LiveSocket {
}

owner(childEl, callback){
let view = maybe(childEl.closest(PHX_VIEW_SELECTOR), el => this.getViewByEl(el)) || this.main
const viewEl = DOM.closestViewEl(childEl)
const view = viewEl ? this.getViewByEl(viewEl) : this.main
return view && callback ? callback(view) : view
}

Expand Down
3 changes: 2 additions & 1 deletion assets/js/phoenix_live_view/rendered.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
TEMPLATES,
EVENTS,
PHX_COMPONENT,
PHX_VIEW_REF,
PHX_SKIP,
PHX_MAGIC_ID,
REPLY,
Expand Down Expand Up @@ -382,7 +383,7 @@ export default class Rendered {

recursiveCIDToString(components, cid, onlyCids){
let component = components[cid] || logError(`no component for CID ${cid}`, components)
let attrs = {[PHX_COMPONENT]: cid}
let attrs = {[PHX_COMPONENT]: cid, [PHX_VIEW_REF]: this.viewId}
let skip = onlyCids && !onlyCids.has(cid)
// Two optimization paths apply here:
//
Expand Down
Loading

0 comments on commit 6595457

Please sign in to comment.