From a5ff1a5099f0cffc7ad088b19b9c055801857927 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Thu, 13 Nov 2025 12:17:27 +0100 Subject: [PATCH 1/2] add attribute tracking (poc) Adds `phx-update="track-attributes` and `phx-track-attributes` attribute where the first one tracks attributes on the whole subtree and the second one only for the element that set the attribute. Using this makes LiveView keep clientside changes until a server overrides an attribute value with a new one. This is useful when clientside libraries rely on attributes they set, but `phx-update="ignore"` is too much. This is a proof of concept, I'm not sure we actually want to go with this. For simplicitly, I vendored morphdom into the repo. The important change there is a new `proxyFromEl` callback + changes to morphAttrs. Relates to: https://github.com/phoenixframework/phoenix_live_view/issues/4049 Relates to: https://github.com/phoenixframework/phoenix_live_view/pull/4050#issuecomment-3517518544 --- .prettierignore | 2 + assets/.prettierignore | 1 - assets/js/phoenix_live_view/dom_patch.js | 42 +- assets/js/phoenix_live_view/view.js | 2 +- assets/js/vendor/morphdom/LICENSE | 21 + assets/js/vendor/morphdom/index.js | 6 + assets/js/vendor/morphdom/morphAttrs.js | 77 +++ assets/js/vendor/morphdom/morphdom.js | 492 ++++++++++++++++++ .../js/vendor/morphdom/specialElHandlers.js | 115 ++++ assets/js/vendor/morphdom/util.js | 113 ++++ eslint.config.js | 1 + lib/phoenix_live_view/tag_engine/compiler.ex | 2 +- 12 files changed, 870 insertions(+), 4 deletions(-) create mode 100644 .prettierignore delete mode 100644 assets/.prettierignore create mode 100644 assets/js/vendor/morphdom/LICENSE create mode 100644 assets/js/vendor/morphdom/index.js create mode 100644 assets/js/vendor/morphdom/morphAttrs.js create mode 100644 assets/js/vendor/morphdom/morphdom.js create mode 100644 assets/js/vendor/morphdom/specialElHandlers.js create mode 100644 assets/js/vendor/morphdom/util.js diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000..007febe23a --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +assets/js/types/ +assets/js/vendor/ \ No newline at end of file diff --git a/assets/.prettierignore b/assets/.prettierignore deleted file mode 100644 index 700d00a068..0000000000 --- a/assets/.prettierignore +++ /dev/null @@ -1 +0,0 @@ -js/types/ diff --git a/assets/js/phoenix_live_view/dom_patch.js b/assets/js/phoenix_live_view/dom_patch.js index e5a4801c10..a6ecce16d1 100644 --- a/assets/js/phoenix_live_view/dom_patch.js +++ b/assets/js/phoenix_live_view/dom_patch.js @@ -24,7 +24,7 @@ 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"; +import morphdom from "../vendor/morphdom"; export default class DOMPatch { constructor(view, container, id, html, streams, targetCID, opts = {}) { @@ -116,6 +116,7 @@ export default class DOMPatch { const phxViewportTop = liveSocket.binding(PHX_VIEWPORT_TOP); const phxViewportBottom = liveSocket.binding(PHX_VIEWPORT_BOTTOM); const phxTriggerExternal = liveSocket.binding(PHX_TRIGGER_ACTION); + const phxTrackAttributes = liveSocket.binding("track-attributes"); const added = []; const updates = []; const appendPrependUpdates = []; @@ -127,6 +128,10 @@ export default class DOMPatch { let externalFormTriggered = null; + // if phx-update="track-attributes", we morph a subtree tracking clientside + // attribute changes + let trackAttributes = false; + const morph = ( targetContainer, source, @@ -238,6 +243,13 @@ export default class DOMPatch { this.handleRuntimeHook(el, source); } + if ( + trackAttributes || + (el.hasAttribute && el.hasAttribute(phxTrackAttributes)) + ) { + DOM.putPrivate(el, "phxAttrCopy", el.cloneNode(false)); + } + added.push(el); }, onNodeDiscarded: (el) => this.onNodeDiscarded(el), @@ -288,8 +300,27 @@ export default class DOMPatch { } updates.push(el); this.maybeReOrderStream(el, false); + if ( + trackAttributes || + (el.hasAttribute && el.hasAttribute(phxTrackAttributes)) + ) { + if (!DOM.private(el, "phxAttrCopy")) { + DOM.putPrivate(el, "phxAttrCopy", el.cloneNode(false)); + } + } }, onBeforeElUpdated: (fromEl, toEl) => { + // check if we switch to attribute tracking + if ( + !trackAttributes && + DOM.isPhxUpdate(fromEl, phxUpdate, "track-attributes") + ) { + trackAttributes = true; + morph(fromEl, toEl, true); + trackAttributes = false; + // the subtree was already morphed + return false; + } // 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 ( @@ -447,6 +478,15 @@ export default class DOMPatch { return fromEl; } }, + proxyFromEl: (el) => { + if ( + trackAttributes || + (el.hasAttribute && el.hasAttribute(phxTrackAttributes)) + ) { + return DOM.private(el, "phxAttrCopy") || el; + } + return el; + }, }; morphdom(targetContainer, source, morphCallbacks); diff --git a/assets/js/phoenix_live_view/view.js b/assets/js/phoenix_live_view/view.js index 067f21e02f..92d1497acb 100644 --- a/assets/js/phoenix_live_view/view.js +++ b/assets/js/phoenix_live_view/view.js @@ -63,7 +63,7 @@ import Rendered from "./rendered"; import { ViewHook } from "./view_hook"; import JS from "./js"; -import morphdom from "morphdom"; +import morphdom from "../vendor/morphdom"; export const prependFormDataKey = (key, prefix) => { const isArray = key.endsWith("[]"); diff --git a/assets/js/vendor/morphdom/LICENSE b/assets/js/vendor/morphdom/LICENSE new file mode 100644 index 0000000000..d4d35164b1 --- /dev/null +++ b/assets/js/vendor/morphdom/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Patrick Steele-Idem (psteeleidem.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/assets/js/vendor/morphdom/index.js b/assets/js/vendor/morphdom/index.js new file mode 100644 index 0000000000..07b9c4ba96 --- /dev/null +++ b/assets/js/vendor/morphdom/index.js @@ -0,0 +1,6 @@ +import morphAttrs from './morphAttrs'; +import morphdomFactory from './morphdom'; + +var morphdom = morphdomFactory(morphAttrs); + +export default morphdom; \ No newline at end of file diff --git a/assets/js/vendor/morphdom/morphAttrs.js b/assets/js/vendor/morphdom/morphAttrs.js new file mode 100644 index 0000000000..5f2e12b2f2 --- /dev/null +++ b/assets/js/vendor/morphdom/morphAttrs.js @@ -0,0 +1,77 @@ +var DOCUMENT_FRAGMENT_NODE = 11; + +export default function morphAttrs(fromNode, toNode, proxyFromEl=((el) => el)) { + var toNodeAttrs = toNode.attributes; + var attr; + var attrName; + var attrNamespaceURI; + var attrValue; + var fromValue; + + const proxiedFromNode = proxyFromEl(fromNode); + + // document-fragments dont have attributes so lets not do anything + if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE || fromNode.nodeType === DOCUMENT_FRAGMENT_NODE) { + return; + } + + // update attributes on original DOM element + for (var i = toNodeAttrs.length - 1; i >= 0; i--) { + attr = toNodeAttrs[i]; + attrName = attr.name; + attrNamespaceURI = attr.namespaceURI; + attrValue = attr.value; + + if (attrNamespaceURI) { + attrName = attr.localName || attrName; + fromValue = proxiedFromNode.getAttributeNS(attrNamespaceURI, attrName); + + if (fromValue !== attrValue) { + if (attr.prefix === 'xmlns'){ + attrName = attr.name; // It's not allowed to set an attribute with the XMLNS namespace without specifying the `xmlns` prefix + } + fromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue); + if (!proxiedFromNode.isSameNode(fromNode)) { + proxiedFromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue); + } + } + } else { + fromValue = proxiedFromNode.getAttribute(attrName); + + if (fromValue !== attrValue) { + fromNode.setAttribute(attrName, attrValue); + if (!proxiedFromNode.isSameNode(fromNode)) { + proxiedFromNode.setAttribute(attrName, attrValue); + } + } + } + } + + // Remove any extra attributes found on the original DOM element that + // weren't found on the target element. + var fromNodeAttrs = proxiedFromNode.attributes; + + for (var d = fromNodeAttrs.length - 1; d >= 0; d--) { + attr = fromNodeAttrs[d]; + attrName = attr.name; + attrNamespaceURI = attr.namespaceURI; + + if (attrNamespaceURI) { + attrName = attr.localName || attrName; + + if (!toNode.hasAttributeNS(attrNamespaceURI, attrName)) { + fromNode.removeAttributeNS(attrNamespaceURI, attrName); + if (!proxiedFromNode.isSameNode(fromNode)) { + proxiedFromNode.removeAttributeNS(attrNamespaceURI, attrName); + } + } + } else { + if (!toNode.hasAttribute(attrName)) { + fromNode.removeAttribute(attrName); + if (!proxiedFromNode.isSameNode(fromNode)) { + proxiedFromNode.removeAttribute(attrName); + } + } + } + } +} diff --git a/assets/js/vendor/morphdom/morphdom.js b/assets/js/vendor/morphdom/morphdom.js new file mode 100644 index 0000000000..8f74eb7c82 --- /dev/null +++ b/assets/js/vendor/morphdom/morphdom.js @@ -0,0 +1,492 @@ +'use strict'; + +import { compareNodeNames, toElement, moveChildren, createElementNS, doc } from './util'; +import specialElHandlers from './specialElHandlers'; + +var ELEMENT_NODE = 1; +var DOCUMENT_FRAGMENT_NODE = 11; +var TEXT_NODE = 3; +var COMMENT_NODE = 8; + +function noop() {} + +function defaultGetNodeKey(node) { + if (node) { + return (node.getAttribute && node.getAttribute('id')) || node.id; + } +} + +export default function morphdomFactory(morphAttrs) { + + return function morphdom(fromNode, toNode, options) { + if (!options) { + options = {}; + } + + if (typeof toNode === 'string') { + if (fromNode.nodeName === '#document' || fromNode.nodeName === 'HTML') { + var toNodeHtml = toNode; + toNode = doc.createElement('html'); + toNode.innerHTML = toNodeHtml; + } else if (fromNode.nodeName === 'BODY') { + var toNodeBody = toNode; + toNode = doc.createElement('html'); + toNode.innerHTML = toNodeBody; + // Extract the body element from the created HTML structure + var bodyElement = toNode.querySelector('body'); + if (bodyElement) { + toNode = bodyElement; + } + } else { + toNode = toElement(toNode); + } + } else if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE) { + toNode = toNode.firstElementChild; + } + + var getNodeKey = options.getNodeKey || defaultGetNodeKey; + var onBeforeNodeAdded = options.onBeforeNodeAdded || noop; + var onNodeAdded = options.onNodeAdded || noop; + var onBeforeElUpdated = options.onBeforeElUpdated || noop; + var onElUpdated = options.onElUpdated || noop; + var onBeforeNodeDiscarded = options.onBeforeNodeDiscarded || noop; + var onNodeDiscarded = options.onNodeDiscarded || noop; + var onBeforeElChildrenUpdated = options.onBeforeElChildrenUpdated || noop; + var skipFromChildren = options.skipFromChildren || noop; + var addChild = options.addChild || function(parent, child){ return parent.appendChild(child); }; + var childrenOnly = options.childrenOnly === true; + var proxyFromEl = options.proxyFromEl || ((el) => el); + + // This object is used as a lookup to quickly find all keyed elements in the original DOM tree. + var fromNodesLookup = Object.create(null); + var keyedRemovalList = []; + + function addKeyedRemoval(key) { + keyedRemovalList.push(key); + } + + function walkDiscardedChildNodes(node, skipKeyedNodes) { + if (node.nodeType === ELEMENT_NODE) { + var curChild = node.firstChild; + while (curChild) { + + var key = undefined; + + if (skipKeyedNodes && (key = getNodeKey(curChild))) { + // If we are skipping keyed nodes then we add the key + // to a list so that it can be handled at the very end. + addKeyedRemoval(key); + } else { + // Only report the node as discarded if it is not keyed. We do this because + // at the end we loop through all keyed elements that were unmatched + // and then discard them in one final pass. + onNodeDiscarded(curChild); + if (curChild.firstChild) { + walkDiscardedChildNodes(curChild, skipKeyedNodes); + } + } + + curChild = curChild.nextSibling; + } + } + } + + /** + * Removes a DOM node out of the original DOM + * + * @param {Node} node The node to remove + * @param {Node} parentNode The nodes parent + * @param {Boolean} skipKeyedNodes If true then elements with keys will be skipped and not discarded. + * @return {undefined} + */ + function removeNode(node, parentNode, skipKeyedNodes) { + if (onBeforeNodeDiscarded(node) === false) { + return; + } + + if (parentNode) { + parentNode.removeChild(node); + } + + onNodeDiscarded(node); + walkDiscardedChildNodes(node, skipKeyedNodes); + } + + // // TreeWalker implementation is no faster, but keeping this around in case this changes in the future + // function indexTree(root) { + // var treeWalker = document.createTreeWalker( + // root, + // NodeFilter.SHOW_ELEMENT); + // + // var el; + // while((el = treeWalker.nextNode())) { + // var key = getNodeKey(el); + // if (key) { + // fromNodesLookup[key] = el; + // } + // } + // } + + // // NodeIterator implementation is no faster, but keeping this around in case this changes in the future + // + // function indexTree(node) { + // var nodeIterator = document.createNodeIterator(node, NodeFilter.SHOW_ELEMENT); + // var el; + // while((el = nodeIterator.nextNode())) { + // var key = getNodeKey(el); + // if (key) { + // fromNodesLookup[key] = el; + // } + // } + // } + + function indexTree(node) { + if (node.nodeType === ELEMENT_NODE || node.nodeType === DOCUMENT_FRAGMENT_NODE) { + var curChild = node.firstChild; + while (curChild) { + var key = getNodeKey(curChild); + if (key) { + fromNodesLookup[key] = curChild; + } + + // Walk recursively + indexTree(curChild); + + curChild = curChild.nextSibling; + } + } + } + + indexTree(fromNode); + + function handleNodeAdded(el) { + onNodeAdded(el); + + var curChild = el.firstChild; + while (curChild) { + var nextSibling = curChild.nextSibling; + + var key = getNodeKey(curChild); + if (key) { + var unmatchedFromEl = fromNodesLookup[key]; + // if we find a duplicate #id node in cache, replace `el` with cache value + // and morph it to the child node. + if (unmatchedFromEl && compareNodeNames(curChild, unmatchedFromEl)) { + curChild.parentNode.replaceChild(unmatchedFromEl, curChild); + morphEl(unmatchedFromEl, curChild); + } else { + handleNodeAdded(curChild); + } + } else { + // recursively call for curChild and it's children to see if we find something in + // fromNodesLookup + handleNodeAdded(curChild); + } + + curChild = nextSibling; + } + } + + function cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey) { + // We have processed all of the "to nodes". If curFromNodeChild is + // non-null then we still have some from nodes left over that need + // to be removed + while (curFromNodeChild) { + var fromNextSibling = curFromNodeChild.nextSibling; + if ((curFromNodeKey = getNodeKey(curFromNodeChild))) { + // Since the node is keyed it might be matched up later so we defer + // the actual removal to later + addKeyedRemoval(curFromNodeKey); + } else { + // NOTE: we skip nested keyed nodes from being removed since there is + // still a chance they will be matched up later + removeNode(curFromNodeChild, fromEl, true /* skip keyed nodes */); + } + curFromNodeChild = fromNextSibling; + } + } + + function morphEl(fromEl, toEl, childrenOnly) { + var toElKey = getNodeKey(toEl); + + if (toElKey) { + // If an element with an ID is being morphed then it will be in the final + // DOM so clear it out of the saved elements collection + delete fromNodesLookup[toElKey]; + } + + if (!childrenOnly) { + // optional + var beforeUpdateResult = onBeforeElUpdated(fromEl, toEl); + if (beforeUpdateResult === false) { + return; + } else if (beforeUpdateResult instanceof HTMLElement) { + fromEl = beforeUpdateResult; + // reindex the new fromEl in case it's not in the same + // tree as the original fromEl + // (Phoenix LiveView sometimes returns a cloned tree, + // but keyed lookups would still point to the original tree) + indexTree(fromEl); + } + + // update attributes on original DOM element first + morphAttrs(fromEl, toEl, proxyFromEl); + // optional + onElUpdated(fromEl); + + if (onBeforeElChildrenUpdated(fromEl, toEl) === false) { + return; + } + } + + if (fromEl.nodeName !== 'TEXTAREA') { + morphChildren(fromEl, toEl); + } else { + specialElHandlers.TEXTAREA(fromEl, toEl); + } + } + + function morphChildren(fromEl, toEl) { + var skipFrom = skipFromChildren(fromEl, toEl); + var curToNodeChild = toEl.firstChild; + var curFromNodeChild = fromEl.firstChild; + var curToNodeKey; + var curFromNodeKey; + + var fromNextSibling; + var toNextSibling; + var matchingFromEl; + + // walk the children + outer: while (curToNodeChild) { + toNextSibling = curToNodeChild.nextSibling; + curToNodeKey = getNodeKey(curToNodeChild); + + // walk the fromNode children all the way through + while (!skipFrom && curFromNodeChild) { + fromNextSibling = curFromNodeChild.nextSibling; + + if (curToNodeChild.isSameNode && curToNodeChild.isSameNode(curFromNodeChild)) { + curToNodeChild = toNextSibling; + curFromNodeChild = fromNextSibling; + continue outer; + } + + curFromNodeKey = getNodeKey(curFromNodeChild); + + var curFromNodeType = curFromNodeChild.nodeType; + + // this means if the curFromNodeChild doesnt have a match with the curToNodeChild + var isCompatible = undefined; + + if (curFromNodeType === curToNodeChild.nodeType) { + if (curFromNodeType === ELEMENT_NODE) { + // Both nodes being compared are Element nodes + + if (curToNodeKey) { + // The target node has a key so we want to match it up with the correct element + // in the original DOM tree + if (curToNodeKey !== curFromNodeKey) { + // The current element in the original DOM tree does not have a matching key so + // let's check our lookup to see if there is a matching element in the original + // DOM tree + if ((matchingFromEl = fromNodesLookup[curToNodeKey])) { + if (fromNextSibling === matchingFromEl) { + // Special case for single element removals. To avoid removing the original + // DOM node out of the tree (since that can break CSS transitions, etc.), + // we will instead discard the current node and wait until the next + // iteration to properly match up the keyed target element with its matching + // element in the original tree + isCompatible = false; + } else { + // We found a matching keyed element somewhere in the original DOM tree. + // Let's move the original DOM node into the current position and morph + // it. + + // NOTE: We use insertBefore instead of replaceChild because we want to go through + // the `removeNode()` function for the node that is being discarded so that + // all lifecycle hooks are correctly invoked + fromEl.insertBefore(matchingFromEl, curFromNodeChild); + + // fromNextSibling = curFromNodeChild.nextSibling; + + if (curFromNodeKey) { + // Since the node is keyed it might be matched up later so we defer + // the actual removal to later + addKeyedRemoval(curFromNodeKey); + } else { + // NOTE: we skip nested keyed nodes from being removed since there is + // still a chance they will be matched up later + removeNode(curFromNodeChild, fromEl, true /* skip keyed nodes */); + } + + curFromNodeChild = matchingFromEl; + curFromNodeKey = getNodeKey(curFromNodeChild); + } + } else { + // The nodes are not compatible since the "to" node has a key and there + // is no matching keyed node in the source tree + isCompatible = false; + } + } + } else if (curFromNodeKey) { + // The original has a key + isCompatible = false; + } + + isCompatible = isCompatible !== false && compareNodeNames(curFromNodeChild, curToNodeChild); + if (isCompatible) { + // We found compatible DOM elements so transform + // the current "from" node to match the current + // target DOM node. + // MORPH + morphEl(curFromNodeChild, curToNodeChild); + } + + } else if (curFromNodeType === TEXT_NODE || curFromNodeType == COMMENT_NODE) { + // Both nodes being compared are Text or Comment nodes + isCompatible = true; + // Simply update nodeValue on the original node to + // change the text value + if (curFromNodeChild.nodeValue !== curToNodeChild.nodeValue) { + curFromNodeChild.nodeValue = curToNodeChild.nodeValue; + } + + } + } + + if (isCompatible) { + // Advance both the "to" child and the "from" child since we found a match + // Nothing else to do as we already recursively called morphChildren above + curToNodeChild = toNextSibling; + curFromNodeChild = fromNextSibling; + continue outer; + } + + // No compatible match so remove the old node from the DOM and continue trying to find a + // match in the original DOM. However, we only do this if the from node is not keyed + // since it is possible that a keyed node might match up with a node somewhere else in the + // target tree and we don't want to discard it just yet since it still might find a + // home in the final DOM tree. After everything is done we will remove any keyed nodes + // that didn't find a home + if (curFromNodeKey) { + // Since the node is keyed it might be matched up later so we defer + // the actual removal to later + addKeyedRemoval(curFromNodeKey); + } else { + // NOTE: we skip nested keyed nodes from being removed since there is + // still a chance they will be matched up later + removeNode(curFromNodeChild, fromEl, true /* skip keyed nodes */); + } + + curFromNodeChild = fromNextSibling; + } // END: while(curFromNodeChild) {} + + // If we got this far then we did not find a candidate match for + // our "to node" and we exhausted all of the children "from" + // nodes. Therefore, we will just append the current "to" node + // to the end + if (curToNodeKey && (matchingFromEl = fromNodesLookup[curToNodeKey]) && compareNodeNames(matchingFromEl, curToNodeChild)) { + // MORPH + if(!skipFrom){ addChild(fromEl, matchingFromEl); } + morphEl(matchingFromEl, curToNodeChild); + } else { + var onBeforeNodeAddedResult = onBeforeNodeAdded(curToNodeChild); + if (onBeforeNodeAddedResult !== false) { + if (onBeforeNodeAddedResult) { + curToNodeChild = onBeforeNodeAddedResult; + } + + if (curToNodeChild.actualize) { + curToNodeChild = curToNodeChild.actualize(fromEl.ownerDocument || doc); + } + addChild(fromEl, curToNodeChild); + handleNodeAdded(curToNodeChild); + } + } + + curToNodeChild = toNextSibling; + curFromNodeChild = fromNextSibling; + } + + cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey); + + var specialElHandler = specialElHandlers[fromEl.nodeName]; + if (specialElHandler) { + specialElHandler(fromEl, toEl); + } + } // END: morphChildren(...) + + var morphedNode = fromNode; + var morphedNodeType = morphedNode.nodeType; + var toNodeType = toNode.nodeType; + + if (!childrenOnly) { + // Handle the case where we are given two DOM nodes that are not + // compatible (e.g.
--> or
--> TEXT) + if (morphedNodeType === ELEMENT_NODE) { + if (toNodeType === ELEMENT_NODE) { + if (!compareNodeNames(fromNode, toNode)) { + onNodeDiscarded(fromNode); + morphedNode = moveChildren(fromNode, createElementNS(toNode.nodeName, toNode.namespaceURI)); + } + } else { + // Going from an element node to a text node + morphedNode = toNode; + } + } else if (morphedNodeType === TEXT_NODE || morphedNodeType === COMMENT_NODE) { // Text or comment node + if (toNodeType === morphedNodeType) { + if (morphedNode.nodeValue !== toNode.nodeValue) { + morphedNode.nodeValue = toNode.nodeValue; + } + + return morphedNode; + } else { + // Text node to something else + morphedNode = toNode; + } + } + } + + if (morphedNode === toNode) { + // The "to node" was not compatible with the "from node" so we had to + // toss out the "from node" and use the "to node" + onNodeDiscarded(fromNode); + } else { + if (toNode.isSameNode && toNode.isSameNode(morphedNode)) { + return; + } + + morphEl(morphedNode, toNode, childrenOnly); + + // We now need to loop over any keyed nodes that might need to be + // removed. We only do the removal if we know that the keyed node + // never found a match. When a keyed node is matched up we remove + // it out of fromNodesLookup and we use fromNodesLookup to determine + // if a keyed node has been matched up or not + if (keyedRemovalList) { + for (var i=0, len=keyedRemovalList.length; i element since it sets + * the initial value. Changing the "value" attribute without changing the + * "value" property will have no effect since it is only used to the set the + * initial value. Similar for the "checked" attribute, and "disabled". + */ + INPUT: function(fromEl, toEl) { + syncBooleanAttrProp(fromEl, toEl, 'checked'); + syncBooleanAttrProp(fromEl, toEl, 'disabled'); + + if (fromEl.value !== toEl.value) { + fromEl.value = toEl.value; + } + + if (!toEl.hasAttribute('value')) { + fromEl.removeAttribute('value'); + } + }, + + TEXTAREA: function(fromEl, toEl) { + var newValue = toEl.value; + if (fromEl.value !== newValue) { + fromEl.value = newValue; + } + + var firstChild = fromEl.firstChild; + if (firstChild) { + // Needed for IE. Apparently IE sets the placeholder as the + // node value and vise versa. This ignores an empty update. + var oldValue = firstChild.nodeValue; + + if (oldValue == newValue || (!newValue && oldValue == fromEl.placeholder)) { + return; + } + + firstChild.nodeValue = newValue; + } + }, + SELECT: function(fromEl, toEl) { + if (!toEl.hasAttribute('multiple')) { + var selectedIndex = -1; + var i = 0; + // We have to loop through children of fromEl, not toEl since nodes can be moved + // from toEl to fromEl directly when morphing. + // At the time this special handler is invoked, all children have already been morphed + // and appended to / removed from fromEl, so using fromEl here is safe and correct. + var curChild = fromEl.firstChild; + var optgroup; + var nodeName; + while(curChild) { + nodeName = curChild.nodeName && curChild.nodeName.toUpperCase(); + if (nodeName === 'OPTGROUP') { + optgroup = curChild; + curChild = optgroup.firstChild; + // handle empty optgroups + if (!curChild) { + curChild = optgroup.nextSibling; + optgroup = null; + } + } else { + if (nodeName === 'OPTION') { + if (curChild.hasAttribute('selected')) { + selectedIndex = i; + break; + } + i++; + } + curChild = curChild.nextSibling; + if (!curChild && optgroup) { + curChild = optgroup.nextSibling; + optgroup = null; + } + } + } + + fromEl.selectedIndex = selectedIndex; + } + } +}; diff --git a/assets/js/vendor/morphdom/util.js b/assets/js/vendor/morphdom/util.js new file mode 100644 index 0000000000..daaf12448e --- /dev/null +++ b/assets/js/vendor/morphdom/util.js @@ -0,0 +1,113 @@ +var range; // Create a range object for efficently rendering strings to elements. +var NS_XHTML = 'http://www.w3.org/1999/xhtml'; + +export var doc = typeof document === 'undefined' ? undefined : document; +var HAS_TEMPLATE_SUPPORT = !!doc && 'content' in doc.createElement('template'); +var HAS_RANGE_SUPPORT = !!doc && doc.createRange && 'createContextualFragment' in doc.createRange(); + +function createFragmentFromTemplate(str) { + var template = doc.createElement('template'); + template.innerHTML = str; + return template.content.childNodes[0]; +} + +function createFragmentFromRange(str) { + if (!range) { + range = doc.createRange(); + range.selectNode(doc.body); + } + + var fragment = range.createContextualFragment(str); + return fragment.childNodes[0]; +} + +function createFragmentFromWrap(str) { + var fragment = doc.createElement('body'); + fragment.innerHTML = str; + return fragment.childNodes[0]; +} + +/** + * This is about the same + * var html = new DOMParser().parseFromString(str, 'text/html'); + * return html.body.firstChild; + * + * @method toElement + * @param {String} str + */ +export function toElement(str) { + str = str.trim(); + if (HAS_TEMPLATE_SUPPORT) { + // avoid restrictions on content for things like `Hi` which + // createContextualFragment doesn't support + //