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 7397d2e
Show file tree
Hide file tree
Showing 12 changed files with 247 additions and 91 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
13 changes: 9 additions & 4 deletions assets/js/phoenix_live_view/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
DEBOUNCE_TRIGGER,
FOCUSABLE_INPUTS,
PHX_COMPONENT,
PHX_VIEW_REF,
PHX_HAS_FOCUSED,
PHX_HAS_SUBMITTED,
PHX_MAIN,
Expand Down Expand Up @@ -55,8 +56,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 +137,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 +147,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 Down Expand Up @@ -368,6 +369,10 @@ let DOM = {
return this.isPhxChild(el) ? el : this.all(el, `[${PHX_PARENT_ID}]`)[0]
},

isPortalChild(el, phxPortal){
return el.tagName === "TEMPLATE" && el.hasAttribute(phxPortal)
},

dispatchEvent(target, name, opts = {}){
let defaultBubble = true
let isUploadTarget = target.nodeName === "INPUT" && target.type === "file"
Expand Down
86 changes: 57 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.isPortalChild(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 @@ -199,6 +197,12 @@ export default class DOMPatch {
}
if(this.maybePendingRemove(el)){ return false }
if(this.skipCIDSibling(el)){ return false }
if(DOM.isPortalChild(el, this.portal)){
// discard teleported element
const teleportedEl = DOM.byId(el.content.firstElementChild.id)
teleportedEl.remove()
morphCallbacks.onNodeDiscarded(teleportedEl)
}

return true
},
Expand All @@ -225,6 +229,10 @@ export default class DOMPatch {
this.maybeReOrderStream(fromEl)
return false
}
// don't patch portal containers
if(DOM.private(fromEl, "is-portal-container")){
return false
}
if(DOM.isPhxSticky(fromEl)){
[PHX_SESSION, PHX_STATIC, PHX_ROOT_ID]
.map(attr => [attr, fromEl.getAttribute(attr), toEl.getAttribute(attr)])
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.isPortalChild(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,34 @@ 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)
// we mark the container to prevent it from being morphed
// (which would most likely make it empty again)
DOM.putPrivate(portalContainer, "is-portal-container", true)
// phx-portal templates must have a single root element, so we assume this to be
// the case here
const toTeleport = el.content.firstElementChild
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)
}
}
12 changes: 10 additions & 2 deletions assets/js/phoenix_live_view/live_socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ import {
RELOAD_JITTER_MIN,
RELOAD_JITTER_MAX,
PHX_REF_SRC,
PHX_RELOAD_STATUS
PHX_RELOAD_STATUS,
PHX_TELEPORTED_REF
} from "./constants"

import {
Expand Down Expand Up @@ -455,7 +456,14 @@ export default class LiveSocket {
}

owner(childEl, callback){
let view = maybe(childEl.closest(PHX_VIEW_SELECTOR), el => this.getViewByEl(el)) || this.main
let view =
// find the nearest portal
maybe(childEl.closest(`[${PHX_TELEPORTED_REF}]`), el => this.getViewByEl(DOM.byId(el.getAttribute(PHX_TELEPORTED_REF))))
// find the nearest view
|| maybe(childEl.closest(PHX_VIEW_SELECTOR), el => this.getViewByEl(el))
// fallback to main
|| 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
26 changes: 19 additions & 7 deletions assets/js/phoenix_live_view/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ export default class View {
flash: this.flash,
}
})
this.portalElementIds = []
}

setHref(href){ this.href = href }
Expand Down Expand Up @@ -206,6 +207,7 @@ export default class View {

destroy(callback = function (){ }){
this.destroyAllChildren()
this.destroyPortalElements()
this.destroyed = true
delete this.root.children[this.id]
if(this.parent){ delete this.root.children[this.parent.id][this.id] }
Expand Down Expand Up @@ -275,7 +277,7 @@ export default class View {
// * a CID (Component ID), then we first search the component's element in the DOM
// * a selector, then we search the selector in the DOM and call the callback
// for each element found with the corresponding owner view
withinTargets(phxTarget, callback, dom = document, viewEl){
withinTargets(phxTarget, callback, dom = document){
// in the form recovery case we search in a template fragment instead of
// the real dom, therefore we optionally pass dom and viewEl

Expand All @@ -284,7 +286,7 @@ export default class View {
}

if(isCid(phxTarget)){
let targets = DOM.findComponentNodeList(viewEl || this.el, phxTarget)
let targets = DOM.findComponentNodeList(this.id, phxTarget, dom)
if(targets.length === 0){
logError(`no component found matching phx-target of ${phxTarget}`)
} else {
Expand Down Expand Up @@ -655,7 +657,7 @@ export default class View {
// Otherwise, patch entire LV container.
if(this.rendered.isComponentOnlyDiff(diff)){
this.liveSocket.time("component patch complete", () => {
let parentCids = DOM.findExistingParentCIDs(this.el, this.rendered.componentCIDs(diff))
let parentCids = DOM.findExistingParentCIDs(this.id, this.rendered.componentCIDs(diff))
parentCids.forEach(parentCID => {
if(this.componentPatch(this.rendered.getComponent(diff, parentCID), parentCID)){ phxChildrenAdded = true }
})
Expand Down Expand Up @@ -1382,7 +1384,7 @@ export default class View {

targetCtxElement(targetCtx){
if(isCid(targetCtx)){
let [target] = DOM.findComponentNodeList(this.el, targetCtx)
let [target] = DOM.findComponentNodeList(this.id, targetCtx)
return target
} else if(targetCtx){
return targetCtx
Expand Down Expand Up @@ -1417,7 +1419,7 @@ export default class View {
pending--
if(pending === 0){ callback() }
})
}, templateDom, templateDom)
}, templateDom)
}

pushLinkPatch(e, href, targetEl, callback){
Expand Down Expand Up @@ -1465,7 +1467,7 @@ export default class View {

maybePushComponentsDestroyed(destroyedCIDs){
let willDestroyCIDs = destroyedCIDs.filter(cid => {
return DOM.findComponentNodeList(this.el, cid).length === 0
return DOM.findComponentNodeList(this.id, cid).length === 0
})

if(willDestroyCIDs.length > 0){
Expand All @@ -1480,7 +1482,7 @@ export default class View {
// See if any of the cids we wanted to destroy were added back,
// if they were added back, we don't actually destroy them.
let completelyDestroyCIDs = willDestroyCIDs.filter(cid => {
return DOM.findComponentNodeList(this.el, cid).length === 0
return DOM.findComponentNodeList(this.id, cid).length === 0
})

if(completelyDestroyCIDs.length > 0){
Expand Down Expand Up @@ -1511,4 +1513,14 @@ export default class View {
}

binding(kind){ return this.liveSocket.binding(kind) }

// phx-portal
pushPortalElement(id){ this.portalElementIds.push(id) }

destroyPortalElements(){
this.portalElementIds.forEach(id => {
const el = document.getElementById(id)
if(el){ el.remove()}
})
}
}
Loading

0 comments on commit 7397d2e

Please sign in to comment.