Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

proof of concept: phx-portal #3478

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
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
3 changes: 3 additions & 0 deletions 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 @@ -54,6 +55,8 @@ export const PHX_THROTTLE = "throttle"
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_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
61 changes: 60 additions & 1 deletion assets/js/phoenix_live_view/dom_patch.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
PHX_STREAM_REF,
PHX_VIEWPORT_TOP,
PHX_VIEWPORT_BOTTOM,
PHX_PORTAL,
PHX_TELEPORTED_REF
} from "./constants"

import {
Expand Down Expand Up @@ -62,6 +64,7 @@ export default class DOMPatch {
this.cidPatch = isCid(this.targetCID)
this.pendingRemoves = []
this.phxRemove = this.liveSocket.binding("remove")
this.portal = this.liveSocket.binding(PHX_PORTAL)
this.targetContainer = this.isCIDPatch() ? this.targetCIDContainer(html) : container
this.callbacks = {
beforeadded: [], beforeupdated: [], beforephxChildAdded: [],
Expand Down Expand Up @@ -102,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 Down Expand Up @@ -159,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 @@ -183,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 @@ -266,6 +288,12 @@ export default class DOMPatch {
// input handling
DOM.copyPrivates(toEl, fromEl)

// phx-portal handling
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
if(isFocusedFormEl && fromEl.type !== "hidden" && !focusedSelectChanged){
this.trackBefore("updated", fromEl, toEl)
Expand Down Expand Up @@ -326,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 @@ -481,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 @@ -490,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
Loading