Packages
phoenix_live_view
1.0.10
1.2.7
1.2.6
1.2.5
1.2.4
1.2.3
1.2.2
1.2.1
1.2.0
1.2.0-rc.3
1.2.0-rc.2
1.2.0-rc.1
1.2.0-rc.0
1.1.32
1.1.31
1.1.30
1.1.29
1.1.28
1.1.27
1.1.26
1.1.25
1.1.24
1.1.23
1.1.22
1.1.21
1.1.20
1.1.19
1.1.18
1.1.17
1.1.16
1.1.15
1.1.14
1.1.13
1.1.12
1.1.11
1.1.10
1.1.9
1.1.8
1.1.7
1.1.6
retired
1.1.5
1.1.4
1.1.3
1.1.2
1.1.1
1.1.0
1.1.0-rc.4
1.1.0-rc.3
1.1.0-rc.2
1.1.0-rc.1
1.1.0-rc.0
1.0.18
1.0.17
1.0.16
1.0.15
1.0.14
1.0.13
1.0.12
1.0.11
1.0.10
1.0.9
1.0.8
retired
1.0.7
1.0.6
retired
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
1.0.0-rc.9
1.0.0-rc.8
1.0.0-rc.7
1.0.0-rc.6
1.0.0-rc.5
1.0.0-rc.4
1.0.0-rc.3
1.0.0-rc.2
1.0.0-rc.1
1.0.0-rc.0
0.20.17
0.20.16
0.20.15
0.20.14
0.20.13
0.20.12
0.20.11
0.20.10
0.20.9
0.20.8
0.20.7
0.20.6
0.20.5
0.20.4
0.20.3
0.20.2
0.20.1
0.20.0
0.19.5
0.19.4
0.19.3
0.19.2
0.19.1
0.19.0
0.18.18
0.18.17
0.18.16
0.18.15
0.18.14
0.18.13
0.18.12
0.18.11
0.18.10
0.18.9
0.18.8
0.18.7
0.18.6
0.18.5
0.18.4
0.18.3
0.18.2
0.18.1
0.18.0
0.17.14
0.17.13
0.17.12
0.17.11
0.17.10
0.17.9
0.17.8
0.17.7
0.17.6
0.17.5
0.17.4
0.17.3
0.17.2
0.17.1
0.17.0
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.7
0.15.6
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.8
0.14.7
0.14.6
0.14.5
0.14.4
0.14.3
0.14.2
0.14.1
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.1
0.12.0
0.11.1
0.11.0
0.10.0
0.9.0
0.8.1
0.8.0
0.7.1
0.7.0
0.6.0
0.6.0-dev
0.5.2
0.5.1
0.5.0
0.4.1
0.4.0
0.3.1
0.3.0
0.2.1
0.2.0
0.1.1
0.1.0
Rich, real-time user experiences with server-rendered HTML
Current section
Files
Jump to
Current section
Files
assets/js/phoenix_live_view/dom_patch.js
import {
PHX_COMPONENT,
PHX_PRUNE,
PHX_ROOT_ID,
PHX_SESSION,
PHX_SKIP,
PHX_MAGIC_ID,
PHX_STATIC,
PHX_TRIGGER_ACTION,
PHX_UPDATE,
PHX_REF_SRC,
PHX_REF_LOCK,
PHX_STREAM,
PHX_STREAM_REF,
PHX_VIEWPORT_TOP,
PHX_VIEWPORT_BOTTOM,
} from "./constants"
import {
detectDuplicateIds,
detectInvalidStreamInserts,
isCid
} from "./utils"
import ElementRef from "./element_ref"
import DOM from "./dom"
import DOMPostMorphRestorer from "./dom_post_morph_restorer"
import morphdom from "morphdom"
export default class DOMPatch {
constructor(view, container, id, html, streams, targetCID, opts={}){
this.view = view
this.liveSocket = view.liveSocket
this.container = container
this.id = id
this.rootID = view.root.id
this.html = html
this.streams = streams
this.streamInserts = {}
this.streamComponentRestore = {}
this.targetCID = targetCID
this.cidPatch = isCid(this.targetCID)
this.pendingRemoves = []
this.phxRemove = this.liveSocket.binding("remove")
this.targetContainer = this.isCIDPatch() ? this.targetCIDContainer(html) : container
this.callbacks = {
beforeadded: [], beforeupdated: [], beforephxChildAdded: [],
afteradded: [], afterupdated: [], afterdiscarded: [], afterphxChildAdded: [],
aftertransitionsDiscarded: []
}
this.withChildren = opts.withChildren || opts.undoRef || false
this.undoRef = opts.undoRef
}
before(kind, callback){ this.callbacks[`before${kind}`].push(callback) }
after(kind, callback){ this.callbacks[`after${kind}`].push(callback) }
trackBefore(kind, ...args){
this.callbacks[`before${kind}`].forEach(callback => callback(...args))
}
trackAfter(kind, ...args){
this.callbacks[`after${kind}`].forEach(callback => callback(...args))
}
markPrunableContentForRemoval(){
let phxUpdate = this.liveSocket.binding(PHX_UPDATE)
DOM.all(this.container, `[${phxUpdate}=append] > *, [${phxUpdate}=prepend] > *`, el => {
el.setAttribute(PHX_PRUNE, "")
})
}
perform(isJoinPatch){
let {view, liveSocket, html, container, targetContainer} = this
if(this.isCIDPatch() && !targetContainer){ return }
let focused = liveSocket.getActiveElement()
let {selectionStart, selectionEnd} = focused && DOM.hasSelectionRange(focused) ? focused : {}
let phxUpdate = liveSocket.binding(PHX_UPDATE)
let phxViewportTop = liveSocket.binding(PHX_VIEWPORT_TOP)
let phxViewportBottom = liveSocket.binding(PHX_VIEWPORT_BOTTOM)
let phxTriggerExternal = liveSocket.binding(PHX_TRIGGER_ACTION)
let added = []
let updates = []
let appendPrependUpdates = []
let externalFormTriggered = null
function morph(targetContainer, source, withChildren=this.withChildren){
let morphCallbacks = {
// normally, we are running with childrenOnly, as the patch HTML for a LV
// does not include the LV attrs (data-phx-session, etc.)
// when we are patching a live component, we do want to patch the root element as well;
// another case is the recursive patch of a stream item that was kept on reset (-> onBeforeNodeAdded)
childrenOnly: targetContainer.getAttribute(PHX_COMPONENT) === null && !withChildren,
getNodeKey: (node) => {
if(DOM.isPhxDestroyed(node)){ return null }
// If we have a join patch, then by definition there was no PHX_MAGIC_ID.
// This is important to reduce the amount of elements morphdom discards.
if(isJoinPatch){ return node.id }
return node.id || (node.getAttribute && node.getAttribute(PHX_MAGIC_ID))
},
// skip indexing from children when container is stream
skipFromChildren: (from) => { return from.getAttribute(phxUpdate) === PHX_STREAM },
// tell morphdom how to add a child
addChild: (parent, child) => {
let {ref, streamAt} = this.getStreamInsert(child)
if(ref === undefined){ return parent.appendChild(child) }
this.setStreamRef(child, ref)
// streaming
if(streamAt === 0){
parent.insertAdjacentElement("afterbegin", child)
} else if(streamAt === -1){
let lastChild = parent.lastElementChild
if(lastChild && !lastChild.hasAttribute(PHX_STREAM_REF)){
let nonStreamChild = Array.from(parent.children).find(c => !c.hasAttribute(PHX_STREAM_REF))
parent.insertBefore(child, nonStreamChild)
} else {
parent.appendChild(child)
}
} else if(streamAt > 0){
let sibling = Array.from(parent.children)[streamAt]
parent.insertBefore(child, sibling)
}
},
onBeforeNodeAdded: (el) => {
DOM.maintainPrivateHooks(el, el, phxViewportTop, phxViewportBottom)
this.trackBefore("added", el)
let morphedEl = el
// this is a stream item that was kept on reset, recursively morph it
if(this.streamComponentRestore[el.id]){
morphedEl = this.streamComponentRestore[el.id]
delete this.streamComponentRestore[el.id]
morph.call(this, morphedEl, el, true)
}
return morphedEl
},
onNodeAdded: (el) => {
if(el.getAttribute){ this.maybeReOrderStream(el, true) }
// hack to fix Safari handling of img srcset and video tags
if(el instanceof HTMLImageElement && el.srcset){
el.srcset = el.srcset
} else if(el instanceof HTMLVideoElement && el.autoplay){
el.play()
}
if(DOM.isNowTriggerFormExternal(el, phxTriggerExternal)){
externalFormTriggered = el
}
// nested view handling
if((DOM.isPhxChild(el) && view.ownsElement(el)) || DOM.isPhxSticky(el) && view.ownsElement(el.parentNode)){
this.trackAfter("phxChildAdded", el)
}
added.push(el)
},
onNodeDiscarded: (el) => this.onNodeDiscarded(el),
onBeforeNodeDiscarded: (el) => {
if(el.getAttribute && el.getAttribute(PHX_PRUNE) !== null){ return true }
if(el.parentElement !== null && el.id &&
DOM.isPhxUpdate(el.parentElement, phxUpdate, [PHX_STREAM, "append", "prepend"])){
return false
}
if(this.maybePendingRemove(el)){ return false }
if(this.skipCIDSibling(el)){ return false }
return true
},
onElUpdated: (el) => {
if(DOM.isNowTriggerFormExternal(el, phxTriggerExternal)){
externalFormTriggered = el
}
updates.push(el)
this.maybeReOrderStream(el, false)
},
onBeforeElUpdated: (fromEl, toEl) => {
// if we are patching the root target container and the id has changed, treat it as a new node
// by replacing the fromEl with the toEl, which ensures hooks are torn down and re-created
if(fromEl.id && fromEl.isSameNode(targetContainer) && fromEl.id !== toEl.id){
morphCallbacks.onNodeDiscarded(fromEl)
fromEl.replaceWith(toEl)
return morphCallbacks.onNodeAdded(toEl)
}
DOM.syncPendingAttrs(fromEl, toEl)
DOM.maintainPrivateHooks(fromEl, toEl, phxViewportTop, phxViewportBottom)
DOM.cleanChildNodes(toEl, phxUpdate)
if(this.skipCIDSibling(toEl)){
// if this is a live component used in a stream, we may need to reorder it
this.maybeReOrderStream(fromEl)
return false
}
if(DOM.isPhxSticky(fromEl)){
[PHX_SESSION, PHX_STATIC, PHX_ROOT_ID]
.map(attr => [attr, fromEl.getAttribute(attr), toEl.getAttribute(attr)])
.forEach(([attr, fromVal, toVal]) => {
if(toVal && fromVal !== toVal){ fromEl.setAttribute(attr, toVal) }
})
return false
}
if(DOM.isIgnored(fromEl, phxUpdate) || (fromEl.form && fromEl.form.isSameNode(externalFormTriggered))){
this.trackBefore("updated", fromEl, toEl)
DOM.mergeAttrs(fromEl, toEl, {isIgnored: DOM.isIgnored(fromEl, phxUpdate)})
updates.push(fromEl)
DOM.applyStickyOperations(fromEl)
return false
}
if(fromEl.type === "number" && (fromEl.validity && fromEl.validity.badInput)){ return false }
// If the element has PHX_REF_SRC, it is loading or locked and awaiting an ack.
// If it's locked, we clone the fromEl tree and instruct morphdom to use
// the cloned tree as the source of the morph for this branch from here on out.
// We keep a reference to the cloned tree in the element's private data, and
// on ack (view.undoRefs), we morph the cloned tree with the true fromEl in the DOM to
// apply any changes that happened while the element was locked.
let isFocusedFormEl = focused && fromEl.isSameNode(focused) && DOM.isFormInput(fromEl)
let focusedSelectChanged = isFocusedFormEl && this.isChangedSelect(fromEl, toEl)
if(fromEl.hasAttribute(PHX_REF_SRC)){
const ref = new ElementRef(fromEl)
// only perform the clone step if this is not a patch that unlocks
if(ref.lockRef && (!this.undoRef || !ref.isLockUndoneBy(this.undoRef))){
if(DOM.isUploadInput(fromEl)){
DOM.mergeAttrs(fromEl, toEl, {isIgnored: true})
this.trackBefore("updated", fromEl, toEl)
updates.push(fromEl)
}
DOM.applyStickyOperations(fromEl)
let isLocked = fromEl.hasAttribute(PHX_REF_LOCK)
let clone = isLocked ? DOM.private(fromEl, PHX_REF_LOCK) || fromEl.cloneNode(true) : null
if(clone){
DOM.putPrivate(fromEl, PHX_REF_LOCK, clone)
if(!isFocusedFormEl){
fromEl = clone
}
}
}
}
// nested view handling
if(DOM.isPhxChild(toEl)){
let prevSession = fromEl.getAttribute(PHX_SESSION)
DOM.mergeAttrs(fromEl, toEl, {exclude: [PHX_STATIC]})
if(prevSession !== ""){ fromEl.setAttribute(PHX_SESSION, prevSession) }
fromEl.setAttribute(PHX_ROOT_ID, this.rootID)
DOM.applyStickyOperations(fromEl)
return false
}
// if we are undoing a lock, copy potentially nested clones over
if(this.undoRef && DOM.private(toEl, PHX_REF_LOCK)){
DOM.putPrivate(fromEl, PHX_REF_LOCK, DOM.private(toEl, PHX_REF_LOCK))
}
// now copy regular DOM.private data
DOM.copyPrivates(toEl, fromEl)
// skip patching focused inputs unless focus is a select that has changed options
if(isFocusedFormEl && fromEl.type !== "hidden" && !focusedSelectChanged){
this.trackBefore("updated", fromEl, toEl)
DOM.mergeFocusedInput(fromEl, toEl)
DOM.syncAttrsToProps(fromEl)
updates.push(fromEl)
DOM.applyStickyOperations(fromEl)
return false
} else {
// blur focused select if it changed so native UI is updated (ie safari won't update visible options)
if(focusedSelectChanged){ fromEl.blur() }
if(DOM.isPhxUpdate(toEl, phxUpdate, ["append", "prepend"])){
appendPrependUpdates.push(new DOMPostMorphRestorer(fromEl, toEl, toEl.getAttribute(phxUpdate)))
}
DOM.syncAttrsToProps(toEl)
DOM.applyStickyOperations(toEl)
this.trackBefore("updated", fromEl, toEl)
return fromEl
}
}
}
morphdom(targetContainer, source, morphCallbacks)
}
this.trackBefore("added", container)
this.trackBefore("updated", container, container)
liveSocket.time("morphdom", () => {
this.streams.forEach(([ref, inserts, deleteIds, reset]) => {
inserts.forEach(([key, streamAt, limit]) => {
this.streamInserts[key] = {ref, streamAt, limit, reset}
})
if(reset !== undefined){
DOM.all(container, `[${PHX_STREAM_REF}="${ref}"]`, child => {
this.removeStreamChildElement(child)
})
}
deleteIds.forEach(id => {
let child = container.querySelector(`[id="${id}"]`)
if(child){ this.removeStreamChildElement(child) }
})
})
// clear stream items from the dead render if they are not inserted again
if(isJoinPatch){
DOM.all(this.container, `[${phxUpdate}=${PHX_STREAM}]`)
// it is important to filter the element before removing them, as
// it may happen that streams are nested and the owner check fails if
// a parent is removed before a child
.filter(el => this.view.ownsElement(el))
.forEach(el => {
Array.from(el.children).forEach(child => {
// we already performed the owner check, each child is guaranteed to be owned
// by the view. To prevent the nested owner check from failing in case of nested
// streams where the parent is removed before the child, we force the removal
this.removeStreamChildElement(child, true)
})
})
}
morph.call(this, targetContainer, html)
})
if(liveSocket.isDebugEnabled()){
detectDuplicateIds()
detectInvalidStreamInserts(this.streamInserts)
// warn if there are any inputs named "id"
Array.from(document.querySelectorAll("input[name=id]")).forEach(node => {
if(node.form){
console.error("Detected an input with name=\"id\" inside a form! This will cause problems when patching the DOM.\n", node)
}
})
}
if(appendPrependUpdates.length > 0){
liveSocket.time("post-morph append/prepend restoration", () => {
appendPrependUpdates.forEach(update => update.perform())
})
}
liveSocket.silenceEvents(() => DOM.restoreFocus(focused, selectionStart, selectionEnd))
DOM.dispatchEvent(document, "phx:update")
added.forEach(el => this.trackAfter("added", el))
updates.forEach(el => this.trackAfter("updated", el))
this.transitionPendingRemoves()
if(externalFormTriggered){
liveSocket.unload()
// use prototype's submit in case there's a form control with name or id of "submit"
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/submit
Object.getPrototypeOf(externalFormTriggered).submit.call(externalFormTriggered)
}
return true
}
onNodeDiscarded(el){
// nested view handling
if(DOM.isPhxChild(el) || DOM.isPhxSticky(el)){ this.liveSocket.destroyViewByEl(el) }
this.trackAfter("discarded", el)
}
maybePendingRemove(node){
if(node.getAttribute && node.getAttribute(this.phxRemove) !== null){
this.pendingRemoves.push(node)
return true
} else {
return false
}
}
removeStreamChildElement(child, force=false){
// make sure to only remove elements owned by the current view
// see https://github.com/phoenixframework/phoenix_live_view/issues/3047
// and https://github.com/phoenixframework/phoenix_live_view/issues/3681
if(!force && !this.view.ownsElement(child)){ return }
// we need to store the node if it is actually re-added in the same patch
// we do NOT want to execute phx-remove, we do NOT want to call onNodeDiscarded
if(this.streamInserts[child.id]){
this.streamComponentRestore[child.id] = child
child.remove()
} else {
// only remove the element now if it has no phx-remove binding
if(!this.maybePendingRemove(child)){
child.remove()
this.onNodeDiscarded(child)
}
}
}
getStreamInsert(el){
let insert = el.id ? this.streamInserts[el.id] : {}
return insert || {}
}
setStreamRef(el, ref){
DOM.putSticky(el, PHX_STREAM_REF, el => el.setAttribute(PHX_STREAM_REF, ref))
}
maybeReOrderStream(el, isNew){
let {ref, streamAt, reset} = this.getStreamInsert(el)
if(streamAt === undefined){ return }
// we need to set the PHX_STREAM_REF here as well as addChild is invoked only for parents
this.setStreamRef(el, ref)
if(!reset && !isNew){
// we only reorder if the element is new or it's a stream reset
return
}
// check if the element has a parent element;
// it doesn't if we are currently recursively morphing (restoring a saved stream child)
// because the element is not yet added to the real dom;
// reordering does not make sense in that case anyway
if(!el.parentElement){ return }
if(streamAt === 0){
el.parentElement.insertBefore(el, el.parentElement.firstElementChild)
} else if(streamAt > 0){
let children = Array.from(el.parentElement.children)
let oldIndex = children.indexOf(el)
if(streamAt >= children.length - 1){
el.parentElement.appendChild(el)
} else {
let sibling = children[streamAt]
if(oldIndex > streamAt){
el.parentElement.insertBefore(el, sibling)
} else {
el.parentElement.insertBefore(el, sibling.nextElementSibling)
}
}
}
this.maybeLimitStream(el)
}
maybeLimitStream(el){
let {limit} = this.getStreamInsert(el)
let children = limit !== null && Array.from(el.parentElement.children)
if(limit && limit < 0 && children.length > limit * -1){
children.slice(0, children.length + limit).forEach(child => this.removeStreamChildElement(child))
} else if(limit && limit >= 0 && children.length > limit){
children.slice(limit).forEach(child => this.removeStreamChildElement(child))
}
}
transitionPendingRemoves(){
let {pendingRemoves, liveSocket} = this
if(pendingRemoves.length > 0){
liveSocket.transitionRemoves(pendingRemoves, () => {
pendingRemoves.forEach(el => {
let child = DOM.firstPhxChild(el)
if(child){ liveSocket.destroyViewByEl(child) }
el.remove()
})
this.trackAfter("transitionsDiscarded", pendingRemoves)
})
}
}
isChangedSelect(fromEl, toEl){
if(!(fromEl instanceof HTMLSelectElement) || fromEl.multiple){ return false }
if(fromEl.options.length !== toEl.options.length){ return true }
// keep the current value
toEl.value = fromEl.value
// in general we have to be very careful with using isEqualNode as it does not a reliable
// DOM tree equality check, but for selection attributes and options it works fine
return !fromEl.isEqualNode(toEl)
}
isCIDPatch(){ return this.cidPatch }
skipCIDSibling(el){
return el.nodeType === Node.ELEMENT_NODE && el.hasAttribute(PHX_SKIP)
}
targetCIDContainer(html){
if(!this.isCIDPatch()){ return }
let [first, ...rest] = DOM.findComponentNodeList(this.container, this.targetCID)
if(rest.length === 0 && DOM.childNodeLength(html) === 1){
return first
} else {
return first && first.parentNode
}
}
indexOf(parent, child){ return Array.from(parent.children).indexOf(child) }
}