├── .gitignore ├── CONCEPT.md ├── LICENSE ├── README.md ├── dist ├── idiomorph-ext.js ├── idiomorph-ext.min.js ├── idiomorph-htmx.js ├── idiomorph.js ├── idiomorph.min.js └── idiomorph.min.js.gz ├── img └── comparison.png ├── package-lock.json ├── package.json ├── src ├── idiomorph-htmx.js └── idiomorph.js └── test ├── bootstrap.js ├── core.js ├── demo ├── demo.html ├── fullmorph.html ├── fullmorph2.html ├── ignoreActiveIdiomorph.html ├── rickroll-idiomorph.gif ├── scratch.html └── video.html ├── fidelity.js ├── head.js ├── htmx ├── above.html ├── below.html ├── htmx-demo.html └── htmx-demo2.html ├── index.html ├── lib └── morphdom.js ├── perf.js ├── perf ├── checkboxes.end ├── checkboxes.start ├── perf1.end ├── perf1.start ├── table.end └── table.start └── test-utilities.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | .idea -------------------------------------------------------------------------------- /CONCEPT.md: -------------------------------------------------------------------------------- 1 | # Concept 2 | 3 | This project will create a JavaScript DOM-merging algoritm that, given two nodes, `oldNode` and `newNode`, will merge information from `newNode` and its children into `oldNode` and it's children in a way that tries to minimize the number of nodes disconnected from the DOM. 4 | 5 | The reason we want to minimize node disconnection (either moves or replacements) is that browsers do not keep state well when nodes are moved or replaced in the DOM: focus is lost, video elements stop playing etc. A good merge algorithm should update the DOM to match `newNode` with as few disconnects as possible. 6 | 7 | ## Existing Solutions 8 | 9 | There are two major existing solutions to this problem: 10 | 11 | * Morphdom - https://github.com/patrick-steele-idem/morphdom 12 | * The original solution to this problem, heavily used by other libraries 13 | * Nanomorph 14 | * A simplified version of Morphdom, a bit easier to understand 15 | 16 | ## Overview Of Current Algorithms 17 | 18 | The fundamental DOM merging algorithm is difficult to innovate on because, in general, we want to minimize the number of moves in the DOM. This means we can't use a more general tree merge algorithm: rather than trying to find a best match, we need to match the new structure up with the old structure as much as is possible. 19 | 20 | The basic, high-level algorithm is as follows: 21 | 22 | ``` 23 | if the new node matches the old node 24 | merge the new node attributes onto the old node 25 | otherwise 26 | replace the old node with the new node 27 | for each child of the new node 28 | find the best match in the children of the old nodes and merge 29 | ``` 30 | 31 | The merging of children is the trickiest aspect of this algorithm and is easiest to see in the nanomorph algorithm: 32 | 33 | https://github.com/choojs/nanomorph/blob/b8088d03b1113bddabff8aa0e44bd8db88d023c7/index.js#L77 34 | 35 | Both nanomorph and morphdom attempt to minimize the runtime of their algorithms, which leads to some fast, but difficult to understand code. 36 | 37 | In the nanomorph code you can see a few different cases being handled. An important concept in the notion of "sameness", which nanomorph uses to determine if a new node can be merged into an existing older node. Here is the javascript for this function: 38 | 39 | ```js 40 | function same (a, b) { 41 | if (a.id) return a.id === b.id 42 | if (a.isSameNode) return a.isSameNode(b) 43 | if (a.tagName !== b.tagName) return false 44 | if (a.type === TEXT_NODE) return a.nodeValue === b.nodeValue 45 | return false 46 | } 47 | ``` 48 | 49 | Note that this is mostly a test of id equivalence between elements. 50 | 51 | This notion of sameness is used heavily in the `updateChildren`, and is an area where idiomorph will innovate. 52 | 53 | One last element of the nanomorph algorithm to note is that, if an element _doesn't_ have an ID and a potential match _doesn't_ have an id, the algorithm will attempt to merge or replace the current node: 54 | 55 | https://github.com/choojs/nanomorph/blob/b8088d03b1113bddabff8aa0e44bd8db88d023c7/index.js#L141 56 | 57 | This is another area where we believe idiomorph can improve on the existing behavior. 58 | 59 | ### Improvements 60 | 61 | The first area where we feel that we may be able to improve on the existing algorigthms is in the notion of "sameness". Currently, sameness is tied very closely to to elements having the same ID. However, it is very common for ids to be sparse in a given HTML tree, only inluded on major elements. 62 | 63 | In order to further improve the notion of sameness, we propose the following idea: 64 | 65 | ``` 66 | For each element in the new content 67 | compute the set of all ids contained within that element 68 | ``` 69 | 70 | This can be implemented as an efficient bottom-up algorithm: 71 | 72 | ``` 73 | For all elements with an id in new content 74 | Add the current elements id to its id set 75 | For each parent of the current element up to the root new content node 76 | Add the current elements id to the parents id set 77 | ``` 78 | 79 | With the correct coding, this should give us a map of elements to the id sets associated with those elements. 80 | 81 | Using this map of sets, we can now adopt a broader sense of "sameness" between nodes: two nodes are the same if they have a non-empty intersection of id sets. This allows children nodes to contribute to the sense of the sameness of a node without requiring a depth-first exploration of children while merging nodes. 82 | 83 | Furthermore, we can efficiently ask "does this node match any other nodes" efficiently: 84 | 85 | Given a old node N, we can ask if it might match _any_ child of a parent node P by computing the intersection of the id sets for N and P. If this is non-empty, then there is likely a match for N somewhere in P. This is because P's id set is a superset of all its childrens id sets. 86 | 87 | Given these two enhancements: 88 | 89 | * The ability to ask if two nodes are the same based on child information efficiently 90 | * The abilit to ask if a node has a match within an element efficiently 91 | 92 | We believe we can improve on the fidelity of DOM matching when compared with plain ID-based matching. 93 | 94 | ## Pseudocode 95 | 96 | Below is a rough sketch of the algorithm: 97 | 98 | ``` 99 | 100 | Let oldNode be the existing DOM node 101 | Let newNode be the new DOM to be merged in place of oldNode 102 | 103 | Sync the attributes of newNode onto oldNode 104 | 105 | Let insertionPoint be the first child of the oldNode 106 | while newNode has children 107 | let newChild be the result of shifting the first child from newNodes children 108 | if the newChild is the same as the insertionPoint 109 | recursively merge newChild into insertionPoint 110 | advance the insertionPoint to its next sibling 111 | else if the newChild has a match within the oldNode 112 | scan the insertionPoint forward to the match, discarding children of the old node 113 | recursively merge newChild into insertionPoint 114 | advance the insertionPoint to its next sibling 115 | else if the insertionPoint has a match within the newNode 116 | insert the newChild before the insertionPoint 117 | else if the insertionPoint and the newChild are compatible 118 | recursively merge newChild into insertionPoint 119 | advance the insertionPoint to its next sibling 120 | else 121 | insert the newChild before the insertionPoint 122 | end 123 | 124 | while insertionPoint is not null 125 | remove the insertion point and advance to the next sibling 126 | 127 | ``` -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2022, Big Sky Software 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Idiomorph 2 | 3 | Idiomorph is a javascript library for morphing one DOM tree to another. It is inspired by other libraries that 4 | pioneered this functionality: 5 | 6 | * [morphdom](https://github.com/patrick-steele-idem/morphdom) - the original DOM morphing library 7 | * [nanomorph](https://github.com/choojs/nanomorph) - an updated take on morphdom 8 | 9 | Both morphdom and nanomorph use the `id` property of a node to match up elements within a given set of sibling nodes. When 10 | an id match is found, the existing element is not removed from the DOM, but is instead morphed in place to the new content. 11 | This preserves the node in the DOM, and allows state (such as focus) to be retained. 12 | 13 | However, in both these algorithms, the structure of the _children_ of sibling nodes is not considered when morphing two 14 | nodes: only the ids of the nodes are considered. This is due to performance: it is not feasible to recurse through all 15 | the children of siblings when matching things up. 16 | 17 | ## id sets 18 | 19 | Idiomorph takes a different approach: before node-matching occurs, both the new content and the old content 20 | are processed to create _id sets_, a mapping of elements to _a set of all ids found within that element_. That is, the 21 | set of all ids in all children of the element, plus the element's id, if any. 22 | 23 | Id sets can be computed relatively efficiently via a query selector + a bottom up algorithm. 24 | 25 | Given an id set, you can now adopt a broader sense of "matching" than simply using id matching: if the intersection between 26 | the id sets of element 1 and element 2 is non-empty, they match. This allows Idiomorph to relatively quickly match elements 27 | based on structural information from children, who contribute to a parent's id set, which allows for better overall matching 28 | when compared with simple id-based matching. 29 | 30 | ## Usage 31 | 32 | Idiomorph is a small (1.7k min/gz'd), dependency free JavaScript library, and can be installed via NPM or your favorite 33 | dependency management system under the `Idiomorph` dependency name. You can also include it via a CDN like unpkg to 34 | load it directly in a browser: 35 | 36 | ```html 37 | 38 | ``` 39 | 40 | Or you can download the source to your local project. 41 | 42 | Idiomorph has a very simple usage: 43 | 44 | ```js 45 | Idiomorph.morph(existingNode, newNode); 46 | ``` 47 | 48 | This will morph the existingNode to have the same structure as the newNode. Note that this is a destructive operation 49 | with respect to both the existingNode and the newNode. 50 | 51 | You can also pass string content in: 52 | 53 | ```js 54 | Idiomorph.morph(existingNode, "
New Content
"); 55 | ``` 56 | 57 | And it will be parsed and merged into the new content. 58 | 59 | If you wish to target the `innerHTML` rather than the `outerHTML` of the content, you can pass in a `morphStyle` 60 | in a third config argument: 61 | 62 | ```js 63 | Idiomorph.morph(existingNode, "
New Content
", {morphStyle:'innerHTML'}); 64 | ``` 65 | 66 | This will replace the _inner_ content of the existing node with the new content. 67 | 68 | ### Options 69 | 70 | Idiomorph supports the following options: 71 | 72 | | option | meaning | example | 73 | |----------------|-------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------| 74 | | `morphstyle` | The style of morphing to use, either `innerHTML` or `outerHTML` | `Idiomorph.morph(..., {morphStyle:'innerHTML'})` | 75 | | `ignoreActive` | If set to `true`, idiomorph will skip the active element | `Idiomorph.morph(..., {ignoreActive:true})` | 76 | | `head` | Allows you to control how the `head` tag is merged. See the [head](#the-head-tag) section for more details | `Idiomorph.morph(..., {head:{style:merge}})` | 77 | | `callbacks` | Allows you to insert callbacks when events occur in the morph life cycle, see the callback table below | `Idiomorph.morph(..., {callbacks:{beforeNodeAdded:function(node){...}})` | 78 | 79 | #### Callbacks 80 | 81 | | callback | description | 82 | |-------------------|----------------------------------------------| 83 | | beforeNodeAdded | Called before a new node is added to the DOM | 84 | | afterNodeAdded | Called after a new node is added to the DOM | 85 | | beforeNodeMorphed | Called before a node is morphed in the DOM | 86 | | afterNodeMorphed | Called after a node is morphed in the DOM | 87 | | beforeNodeRemoved | Called before a node is removed from the DOM | 88 | | afterNodeRemoved | Called after a node is removed from the DOM | 89 | 90 | ### The `head` tag 91 | 92 | The head tag is treated specially by idiomorph because: 93 | 94 | * It typically only has one level of children within it 95 | * Those children often to not have `id` attributes associated with them 96 | * It is important to remove as few elements as possible from the head, in order to minimize network requests for things 97 | like style sheets 98 | * The order of elements in the head tag is (usually) not meaningful 99 | 100 | Because of this, by default, idiomorph adopts a `merge` algorithm between two head tags, `old` and `new`: 101 | 102 | * Elements that are in both `old` and `new` are ignored 103 | * Elements that are in `new` but not in `old` are added to the `old` 104 | * Elements that are in `old` but not in `new` are removed from `old` 105 | 106 | Thus the content of the two head tags will be the same, but the order of those elements will not be. 107 | 108 | #### Attribute Based Fine-Grained Head Control 109 | 110 | Sometimes you may want even more fine-grained control over head merging behavior. For example, you may want a script 111 | tag to re-evaluate, even though it is in both `old` and `new`. To do this, you can add the attribute `im-re-append='true'` 112 | to the script tag, and idiomorph will re-append the script tag even if it exists in both head tags, forcing re-evaluation 113 | of the script. 114 | 115 | Similarly, you may wish to preserve an element even if it is not in `new`. You can use the attribute `im-preserve='true'` 116 | in this case to retain the element. 117 | 118 | #### Additional Configuration 119 | 120 | You are also able to override these behaviors, see the `head` config object in the source code. 121 | 122 | You can set `head.style` to: 123 | 124 | * `merge` - the default algorithm outlined above 125 | * `append` - simply append all content in `new` to `old` 126 | * `morph` - adopt the normal idiomorph morphing algorithm for the head 127 | * `none` - ignore the head tag entirely 128 | 129 | For example, if you wanted to merge a whole page using the `morph` algorithm for the head tag, you would do this: 130 | 131 | ```js 132 | Idiomorph.morph(document.documentElement, newPageSource, {head:{style: 'morph'}}) 133 | ``` 134 | 135 | The `head` object also offers callbacks for configuring head merging specifics. 136 | 137 | ### htmx 138 | 139 | Idiomorph was created to integrate with [htmx](https://htmx.org) and can be used as a swapping mechanism by including 140 | the `Idiomorph-ext` file in your HTML: 141 | 142 | ```html 143 | 144 |
145 | 146 | 149 | 150 | 153 | 154 | 157 | 158 |
159 | ``` 160 | 161 | ## Performance 162 | 163 | Idiomorph is not designed to be as fast as either morphdom or nanomorph. Rather, its goals are: 164 | 165 | * Better DOM tree matching 166 | * Relatively simple code 167 | 168 | Performance is a consideration, but better matching is the reason Idiomorph was created. Initial tests indicate that 169 | it is approximately equal to 10% slower than morphdom for large DOM morphs, and equal to or faster than morphdom for 170 | smaller morphs. 171 | 172 | ## Example Morph 173 | 174 | Here is a simple example of some HTML in which Idiomorph does a better job of matching up than morphdom: 175 | 176 | *Initial HTML* 177 | ```html 178 |
179 |
180 |

A

181 |
182 |
183 |

B

184 |
185 |
186 | ``` 187 | 188 | *Final HTML* 189 | 190 | ```html 191 |
192 |
193 |

B

194 |
195 |
196 |

A

197 |
198 |
199 | ``` 200 | 201 | Here we have a common situation: a parent div, with children divs and grand-children divs that have ids on them. This 202 | is a common situation when laying out code in HTML: parent divs often do not have ids on them (rather they have classes, 203 | for layout reasons) and the "leaf" nodes have ids associated with them. 204 | 205 | Given this example, morphdom will detach both #p1 and #p2 from the DOM because, when it is considering the order of the 206 | children, it does not see that the #p2 grandchild is now within the first child. 207 | 208 | Idiomorph, on the other hand, has an _id set_ for the (id-less) children, which includes the ids of the grandchildren. 209 | Therefore, it is able to detect the fact that the #p2 grandchild is now a child of the first id-less child. Because of 210 | this information it is able to only move/detach _one_ grandchild node, #p1. (This is unavoidable, since they changed order) 211 | 212 | So, you can see, by computing id sets for nodes, idiomoroph is able to achieve better DOM matching, with fewer node 213 | detachments. 214 | 215 | ## Demo 216 | 217 | You can see a practical demo of Idiomorph out-performing morphdom (with respect to DOM stability, _not_ performance) 218 | here: 219 | 220 | https://github.com/bigskysoftware/Idiomorph/blob/main/test/demo/video.html 221 | 222 | For both algorithms, this HTML: 223 | 224 | ```html 225 |
226 |
227 |

Above...

228 |
229 |
230 | 234 |
235 |
236 | ``` 237 | 238 | is moprhed into this HTML: 239 | 240 | ```html 241 |
242 |
243 | 247 |
248 |
249 |

Below...

250 |
251 |
252 | ``` 253 | 254 | Note that the iframe has an id on it, but the first-level divs do not have ids on them. This means 255 | that morphdom is unable to tell that the video element has moved up, and the first div should be discarded, rather than morphed into, to preserve the video element. 256 | 257 | Idiomorph, however, has an id-set for the top level divs, which includes the id of the embedded child, and can see that the video has moved to be a child of the first element in the top level children, so it correctly discards the first div and merges the video content with the second node. 258 | 259 | You can see visually that idiomoroph is able to keep the video running because of this, whereas morphdom is not: 260 | 261 | ![Rick Roll Demo](https://github.com/bigskysoftware/Idiomorph/raw/main/test/demo/rickroll-idiomorph.gif) 262 | 263 | To keep things stable with morphdom, you would need to add ids to at least one of the top level divs. 264 | 265 | Here is a diagram explaining how the two algorithms differ in this case: 266 | 267 | ![Comparison Diagram](https://github.com/bigskysoftware/Idiomorph/raw/main/img/comparison.png) 268 | -------------------------------------------------------------------------------- /dist/idiomorph-ext.min.js: -------------------------------------------------------------------------------- 1 | (function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else{e.Idiomorph=e.Idiomorph||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";let o=new Set;function e(e,t,n={}){if(e instanceof Document){e=e.documentElement}if(typeof t==="string"){t=v(t)}let l=S(t);let r=s(e,l,n);return d(e,l,r)}function d(r,i,o){if(o.head.block){let t=r.querySelector("head");let n=i.querySelector("head");if(t&&n){let e=f(n,t,o);Promise.all(e).then(function(){d(r,i,Object.assign(o,{head:{block:false,ignore:true}}))});return}}if(o.morphStyle==="innerHTML"){l(i,r,o);return r.children}else if(o.morphStyle==="outerHTML"||o.morphStyle==null){let e=A(i,r,o);let t=e?.previousSibling;let n=e?.nextSibling;let l=a(r,e,o);if(e){return y(t,l,n)}else{return[]}}else{throw"Do not understand how to morph style "+o.morphStyle}}function a(e,t,n){if(n.ignoreActive&&e===document.activeElement){}else if(t==null){n.callbacks.beforeNodeRemoved(e);e.remove();n.callbacks.afterNodeRemoved(e);return null}else if(!h(e,t)){n.callbacks.beforeNodeRemoved(e);n.callbacks.beforeNodeAdded(t);e.parentElement.replaceChild(t,e);n.callbacks.afterNodeAdded(t);n.callbacks.afterNodeRemoved(e);return t}else{n.callbacks.beforeNodeMorphed(e,t);if(e instanceof HTMLHeadElement&&n.head.ignore){}else if(e instanceof HTMLHeadElement&&n.head.style!=="morph"){f(t,e,n)}else{r(t,e);l(t,e,n)}n.callbacks.afterNodeMorphed(e,t);return e}}function l(n,l,r){let e=n.firstChild;let i=l.firstChild;while(e){let t=e;e=t.nextSibling;if(i==null){r.callbacks.beforeNodeAdded(t);l.appendChild(t);r.callbacks.afterNodeAdded(t)}else if(c(t,i,r)){a(i,t,r);i=i.nextSibling}else{let e=b(n,l,t,i,r);if(e){i=m(i,e,r);a(e,t,r)}else{let e=g(n,l,t,i,r);if(e){i=m(i,e,r);a(e,t,r)}else{r.callbacks.beforeNodeAdded(t);l.insertBefore(t,i);r.callbacks.afterNodeAdded(t)}}}T(r,t)}while(i!==null){let e=i;i=i.nextSibling;N(e,r)}}function r(n,l){let e=n.nodeType;if(e===1){const t=n.attributes;const r=l.attributes;for(const i of t){if(l.getAttribute(i.name)!==i.value){l.setAttribute(i.name,i.value)}}for(const o of r){if(!n.hasAttribute(o.name)){l.removeAttribute(o.name)}}}if(e===8||e===3){if(l.nodeValue!==n.nodeValue){l.nodeValue=n.nodeValue}}if(n instanceof HTMLInputElement&&l instanceof HTMLInputElement&&n.type!=="file"){let e=n.value;let t=l.value;u(n,l,"checked");u(n,l,"disabled");if(!n.hasAttribute("value")){l.value="";l.removeAttribute("value")}else if(e!==t){l.setAttribute("value",e);l.value=e}}else if(n instanceof HTMLOptionElement){u(n,l,"selected")}else if(n instanceof HTMLTextAreaElement&&l instanceof HTMLTextAreaElement){let e=n.value;let t=l.value;if(e!==t){l.value=e}if(l.firstChild&&l.firstChild.nodeValue!==e){l.firstChild.nodeValue=e}}}function u(e,t,n){if(e[n]!==t[n]){t[n]=e[n];if(e[n]){t.setAttribute(n,"")}else{t.removeAttribute(n)}}}function f(e,t,l){let r=[];let i=[];let o=[];let d=[];let a=l.head.style;let u=new Map;for(const n of e.children){u.set(n.outerHTML,n)}for(const s of t.children){let e=u.has(s.outerHTML);let t=l.head.shouldReAppend(s);let n=l.head.shouldPreserve(s);if(e||n){if(t){i.push(s)}else{u.delete(s.outerHTML);o.push(s)}}else{if(a==="append"){if(t){i.push(s);d.push(s)}}else{if(l.head.shouldRemove(s)!==false){i.push(s)}}}}d.push(...u.values());p("to append: ",d);let f=[];for(const c of d){p("adding: ",c);let n=document.createRange().createContextualFragment(c.outerHTML).firstChild;p(n);if(l.callbacks.beforeNodeAdded(n)!==false){if(n.href||n.src){let t=null;let e=new Promise(function(e){t=e});n.addEventListener("load",function(){t()});f.push(e)}t.appendChild(n);l.callbacks.afterNodeAdded(n);r.push(n)}}for(const h of i){if(l.callbacks.beforeNodeRemoved(h)!==false){t.removeChild(h);l.callbacks.afterNodeRemoved(h)}}l.head.afterHeadMorphed(t,{added:r,kept:o,removed:i});return f}function p(){}function i(){}function s(e,t,n){return{target:e,newContent:t,config:n,morphStyle:n.morphStyle,ignoreActive:n.ignoreActive,idMap:E(e,t),deadIds:new Set,callbacks:Object.assign({beforeNodeAdded:i,afterNodeAdded:i,beforeNodeMorphed:i,afterNodeMorphed:i,beforeNodeRemoved:i,afterNodeRemoved:i},n.callbacks),head:Object.assign({style:"merge",shouldPreserve:function(e){return e.getAttribute("im-preserve")==="true"},shouldReAppend:function(e){return e.getAttribute("im-re-append")==="true"},shouldRemove:i,afterHeadMorphed:i},n.head)}}function c(e,t,n){if(e==null||t==null){return false}if(e.nodeType===t.nodeType&&e.tagName===t.tagName){if(e.id!==""&&e.id===t.id){return true}else{return x(n,e,t)>0}}return false}function h(e,t){if(e==null||t==null){return false}return e.nodeType===t.nodeType&&e.tagName===t.tagName}function m(t,e,n){while(t!==e){let e=t;t=t.nextSibling;N(e,n)}T(n,e);return e.nextSibling}function b(n,e,l,r,i){let o=x(i,l,e);let t=null;if(o>0){let e=r;let t=0;while(e!=null){if(c(l,e,i)){return e}t+=x(i,e,n);if(t>o){return null}e=e.nextSibling}}return t}function g(e,t,n,l,r){let i=l;let o=n.nextSibling;let d=0;while(i!=null){if(x(r,i,e)>0){return null}if(h(n,i)){return i}if(h(o,i)){d++;o=o.nextSibling;if(d>=2){return null}}i=i.nextSibling}return i}function v(n){let l=new DOMParser;let e=n.replace(/]*>|>)([\s\S]*?)<\/svg>/gim,"");if(e.match(/<\/html>/)||e.match(/<\/head>/)||e.match(/<\/body>/)){let t=l.parseFromString(n,"text/html");if(e.match(/<\/html>/)){t.generatedByIdiomorph=true;return t}else{let e=t.firstChild;if(e){e.generatedByIdiomorph=true;return e}else{return null}}}else{let e=l.parseFromString("","text/html");let t=e.body.querySelector("template").content;t.generatedByIdiomorph=true;return t}}function S(e){if(e==null){const t=document.createElement("div");return t}else if(e.generatedByIdiomorph){return e}else if(e instanceof Node){const t=document.createElement("div");t.append(e);return t}else{const t=document.createElement("div");for(const n of[...e]){t.append(n)}return t}}function y(e,t,n){let l=[];let r=[];while(e!=null){l.push(e);e=e.previousSibling}while(l.length>0){let e=l.pop();r.push(e);t.parentElement.insertBefore(e,t)}r.push(t);while(n!=null){l.push(n);r.push(n);n=n.nextSibling}while(l.length>0){t.parentElement.insertBefore(l.pop(),t.nextSibling)}return r}function A(e,t,n){let l;l=e.firstChild;let r=l;let i=0;while(l){let e=M(l,t,n);if(e>i){r=l;i=e}l=l.nextSibling}return r}function M(e,t,n){if(h(e,t)){return.5+x(n,e,t)}return 0}function N(e,t){T(t,e);t.callbacks.beforeNodeRemoved(e);e.remove();t.callbacks.afterNodeRemoved(e)}function k(e,t){return!e.deadIds.has(t)}function w(e,t,n){let l=e.idMap.get(n)||o;return l.has(t)}function T(e,t){let n=e.idMap.get(t)||o;for(const l of n){e.deadIds.add(l)}}function x(e,t,n){let l=e.idMap.get(t)||o;let r=0;for(const i of l){if(k(e,i)&&w(e,i,n)){++r}}return r}function H(e,n){let l=e.parentElement;let t=e.querySelectorAll("[id]");for(const r of t){let t=r;while(t!==l&&t!=null){let e=n.get(t);if(e==null){e=new Set;n.set(t,e)}e.add(r.id);t=t.parentElement}}}function E(e,t){let n=new Map;H(e,n);H(t,n);return n}return{morph:e}}()});htmx.defineExtension("morph",{isInlineSwap:function(e){return e==="morph"},handleSwap:function(e,t,n){if(e==="morph"||e==="morph:outerHTML"){return Idiomorph.morph(t,n.children)}else if(e==="morph:innerHTML"){return Idiomorph.morph(t,n.children,{morphStyle:"innerHTML"})}}}); -------------------------------------------------------------------------------- /dist/idiomorph-htmx.js: -------------------------------------------------------------------------------- 1 | htmx.defineExtension('morph', { 2 | isInlineSwap: function(swapStyle) { 3 | return swapStyle === 'morph'; 4 | }, 5 | handleSwap: function (swapStyle, target, fragment) { 6 | if (swapStyle === 'morph' || swapStyle === 'morph:outerHTML') { 7 | return Idiomorph.morph(target, fragment.children); 8 | } else if (swapStyle === 'morph:innerHTML') { 9 | return Idiomorph.morph(target, fragment.children, {morphStyle:'innerHTML'}); 10 | } 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /dist/idiomorph.min.js: -------------------------------------------------------------------------------- 1 | (function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else{e.Idiomorph=e.Idiomorph||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";let o=new Set;function e(e,t,n={}){if(e instanceof Document){e=e.documentElement}if(typeof t==="string"){t=v(t)}let l=y(t);let r=s(e,l,n);return d(e,l,r)}function d(r,i,o){if(o.head.block){let t=r.querySelector("head");let n=i.querySelector("head");if(t&&n){let e=f(n,t,o);Promise.all(e).then(function(){d(r,i,Object.assign(o,{head:{block:false,ignore:true}}))});return}}if(o.morphStyle==="innerHTML"){l(i,r,o);return r.children}else if(o.morphStyle==="outerHTML"||o.morphStyle==null){let e=A(i,r,o);let t=e?.previousSibling;let n=e?.nextSibling;let l=a(r,e,o);if(e){return S(t,l,n)}else{return[]}}else{throw"Do not understand how to morph style "+o.morphStyle}}function a(e,t,n){if(n.ignoreActive&&e===document.activeElement){}else if(t==null){n.callbacks.beforeNodeRemoved(e);e.remove();n.callbacks.afterNodeRemoved(e);return null}else if(!h(e,t)){n.callbacks.beforeNodeRemoved(e);n.callbacks.beforeNodeAdded(t);e.parentElement.replaceChild(t,e);n.callbacks.afterNodeAdded(t);n.callbacks.afterNodeRemoved(e);return t}else{n.callbacks.beforeNodeMorphed(e,t);if(e instanceof HTMLHeadElement&&n.head.ignore){}else if(e instanceof HTMLHeadElement&&n.head.style!=="morph"){f(t,e,n)}else{r(t,e);l(t,e,n)}n.callbacks.afterNodeMorphed(e,t);return e}}function l(n,l,r){let e=n.firstChild;let i=l.firstChild;while(e){let t=e;e=t.nextSibling;if(i==null){r.callbacks.beforeNodeAdded(t);l.appendChild(t);r.callbacks.afterNodeAdded(t)}else if(c(t,i,r)){a(i,t,r);i=i.nextSibling}else{let e=b(n,l,t,i,r);if(e){i=m(i,e,r);a(e,t,r)}else{let e=g(n,l,t,i,r);if(e){i=m(i,e,r);a(e,t,r)}else{r.callbacks.beforeNodeAdded(t);l.insertBefore(t,i);r.callbacks.afterNodeAdded(t)}}}T(r,t)}while(i!==null){let e=i;i=i.nextSibling;M(e,r)}}function r(n,l){let e=n.nodeType;if(e===1){const t=n.attributes;const r=l.attributes;for(const i of t){if(l.getAttribute(i.name)!==i.value){l.setAttribute(i.name,i.value)}}for(const o of r){if(!n.hasAttribute(o.name)){l.removeAttribute(o.name)}}}if(e===8||e===3){if(l.nodeValue!==n.nodeValue){l.nodeValue=n.nodeValue}}if(n instanceof HTMLInputElement&&l instanceof HTMLInputElement&&n.type!=="file"){let e=n.value;let t=l.value;u(n,l,"checked");u(n,l,"disabled");if(!n.hasAttribute("value")){l.value="";l.removeAttribute("value")}else if(e!==t){l.setAttribute("value",e);l.value=e}}else if(n instanceof HTMLOptionElement){u(n,l,"selected")}else if(n instanceof HTMLTextAreaElement&&l instanceof HTMLTextAreaElement){let e=n.value;let t=l.value;if(e!==t){l.value=e}if(l.firstChild&&l.firstChild.nodeValue!==e){l.firstChild.nodeValue=e}}}function u(e,t,n){if(e[n]!==t[n]){t[n]=e[n];if(e[n]){t.setAttribute(n,"")}else{t.removeAttribute(n)}}}function f(e,t,l){let r=[];let i=[];let o=[];let d=[];let a=l.head.style;let u=new Map;for(const n of e.children){u.set(n.outerHTML,n)}for(const s of t.children){let e=u.has(s.outerHTML);let t=l.head.shouldReAppend(s);let n=l.head.shouldPreserve(s);if(e||n){if(t){i.push(s)}else{u.delete(s.outerHTML);o.push(s)}}else{if(a==="append"){if(t){i.push(s);d.push(s)}}else{if(l.head.shouldRemove(s)!==false){i.push(s)}}}}d.push(...u.values());p("to append: ",d);let f=[];for(const c of d){p("adding: ",c);let n=document.createRange().createContextualFragment(c.outerHTML).firstChild;p(n);if(l.callbacks.beforeNodeAdded(n)!==false){if(n.href||n.src){let t=null;let e=new Promise(function(e){t=e});n.addEventListener("load",function(){t()});f.push(e)}t.appendChild(n);l.callbacks.afterNodeAdded(n);r.push(n)}}for(const h of i){if(l.callbacks.beforeNodeRemoved(h)!==false){t.removeChild(h);l.callbacks.afterNodeRemoved(h)}}l.head.afterHeadMorphed(t,{added:r,kept:o,removed:i});return f}function p(){}function i(){}function s(e,t,n){return{target:e,newContent:t,config:n,morphStyle:n.morphStyle,ignoreActive:n.ignoreActive,idMap:H(e,t),deadIds:new Set,callbacks:Object.assign({beforeNodeAdded:i,afterNodeAdded:i,beforeNodeMorphed:i,afterNodeMorphed:i,beforeNodeRemoved:i,afterNodeRemoved:i},n.callbacks),head:Object.assign({style:"merge",shouldPreserve:function(e){return e.getAttribute("im-preserve")==="true"},shouldReAppend:function(e){return e.getAttribute("im-re-append")==="true"},shouldRemove:i,afterHeadMorphed:i},n.head)}}function c(e,t,n){if(e==null||t==null){return false}if(e.nodeType===t.nodeType&&e.tagName===t.tagName){if(e.id!==""&&e.id===t.id){return true}else{return x(n,e,t)>0}}return false}function h(e,t){if(e==null||t==null){return false}return e.nodeType===t.nodeType&&e.tagName===t.tagName}function m(t,e,n){while(t!==e){let e=t;t=t.nextSibling;M(e,n)}T(n,e);return e.nextSibling}function b(n,e,l,r,i){let o=x(i,l,e);let t=null;if(o>0){let e=r;let t=0;while(e!=null){if(c(l,e,i)){return e}t+=x(i,e,n);if(t>o){return null}e=e.nextSibling}}return t}function g(e,t,n,l,r){let i=l;let o=n.nextSibling;let d=0;while(i!=null){if(x(r,i,e)>0){return null}if(h(n,i)){return i}if(h(o,i)){d++;o=o.nextSibling;if(d>=2){return null}}i=i.nextSibling}return i}function v(n){let l=new DOMParser;let e=n.replace(/]*>|>)([\s\S]*?)<\/svg>/gim,"");if(e.match(/<\/html>/)||e.match(/<\/head>/)||e.match(/<\/body>/)){let t=l.parseFromString(n,"text/html");if(e.match(/<\/html>/)){t.generatedByIdiomorph=true;return t}else{let e=t.firstChild;if(e){e.generatedByIdiomorph=true;return e}else{return null}}}else{let e=l.parseFromString("","text/html");let t=e.body.querySelector("template").content;t.generatedByIdiomorph=true;return t}}function y(e){if(e==null){const t=document.createElement("div");return t}else if(e.generatedByIdiomorph){return e}else if(e instanceof Node){const t=document.createElement("div");t.append(e);return t}else{const t=document.createElement("div");for(const n of[...e]){t.append(n)}return t}}function S(e,t,n){let l=[];let r=[];while(e!=null){l.push(e);e=e.previousSibling}while(l.length>0){let e=l.pop();r.push(e);t.parentElement.insertBefore(e,t)}r.push(t);while(n!=null){l.push(n);r.push(n);n=n.nextSibling}while(l.length>0){t.parentElement.insertBefore(l.pop(),t.nextSibling)}return r}function A(e,t,n){let l;l=e.firstChild;let r=l;let i=0;while(l){let e=N(l,t,n);if(e>i){r=l;i=e}l=l.nextSibling}return r}function N(e,t,n){if(h(e,t)){return.5+x(n,e,t)}return 0}function M(e,t){T(t,e);t.callbacks.beforeNodeRemoved(e);e.remove();t.callbacks.afterNodeRemoved(e)}function k(e,t){return!e.deadIds.has(t)}function w(e,t,n){let l=e.idMap.get(n)||o;return l.has(t)}function T(e,t){let n=e.idMap.get(t)||o;for(const l of n){e.deadIds.add(l)}}function x(e,t,n){let l=e.idMap.get(t)||o;let r=0;for(const i of l){if(k(e,i)&&w(e,i,n)){++r}}return r}function E(e,n){let l=e.parentElement;let t=e.querySelectorAll("[id]");for(const r of t){let t=r;while(t!==l&&t!=null){let e=n.get(t);if(e==null){e=new Set;n.set(t,e)}e.add(r.id);t=t.parentElement}}}function H(e,t){let n=new Map;E(e,n);E(t,n);return n}return{morph:e}}()}); -------------------------------------------------------------------------------- /dist/idiomorph.min.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basecamp/idiomorph/026f8dc465b0485621adc54b79a37af4611f23ca/dist/idiomorph.min.js.gz -------------------------------------------------------------------------------- /img/comparison.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basecamp/idiomorph/026f8dc465b0485621adc54b79a37af4611f23ca/img/comparison.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "idiomorph", 3 | "description": "an id-based DOM morphing library", 4 | "keywords": [ 5 | "HTML" 6 | ], 7 | "version": "0.0.8", 8 | "homepage": "https://github.com/bigskysoftware/idiomorph", 9 | "bugs": { 10 | "url": "https://github.com/bigskysoftware/idiomorph/issues" 11 | }, 12 | "license": "BSD 2-Clause", 13 | "files": [ 14 | "LICENSE", 15 | "README.md", 16 | "dist/*.js" 17 | ], 18 | "main": "dist/idiomorph.js", 19 | "unpkg": "dist/idiomorph.min.js", 20 | "scripts": { 21 | "test": "mocha-chrome test/index.html", 22 | "dist": "cp -r src/* dist/ && cat src/idiomorph.js src/idiomorph-htmx.js > dist/idiomorph-ext.js && npm run-script uglify && gzip -9 -k -f dist/idiomorph.min.js > dist/idiomorph.min.js.gz && exit", 23 | "uglify": "uglifyjs -m eval -o dist/idiomorph.min.js dist/idiomorph.js && uglifyjs -m eval -o dist/idiomorph-ext.min.js dist/idiomorph-ext.js" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/bigskysoftware/idiomorph.git" 28 | }, 29 | "devDependencies": { 30 | "chai": "^4.3.6", 31 | "chai-dom": "^1.11.0", 32 | "fs-extra": "^9.1.0", 33 | "mocha": "^7.2.0", 34 | "mocha-chrome": "^2.2.0", 35 | "mocha-webdriver-runner": "^0.6.3", 36 | "uglify-js": "^3.15.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/idiomorph-htmx.js: -------------------------------------------------------------------------------- 1 | htmx.defineExtension('morph', { 2 | isInlineSwap: function(swapStyle) { 3 | return swapStyle === 'morph'; 4 | }, 5 | handleSwap: function (swapStyle, target, fragment) { 6 | if (swapStyle === 'morph' || swapStyle === 'morph:outerHTML') { 7 | return Idiomorph.morph(target, fragment.children); 8 | } else if (swapStyle === 'morph:innerHTML') { 9 | return Idiomorph.morph(target, fragment.children, {morphStyle:'innerHTML'}); 10 | } 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /test/bootstrap.js: -------------------------------------------------------------------------------- 1 | describe("Bootstrap test", function(){ 2 | 3 | beforeEach(function() { 4 | clearWorkArea(); 5 | }); 6 | 7 | // bootstrap test 8 | it('can morph content to content', function() 9 | { 10 | let btn1 = make('') 11 | let btn2 = make('') 12 | 13 | Idiomorph.morph(btn1, btn2); 14 | 15 | btn1.innerHTML.should.equal(btn2.innerHTML); 16 | }); 17 | 18 | it('can morph attributes', function() 19 | { 20 | let btn1 = make('') 21 | let btn2 = make('') 22 | 23 | Idiomorph.morph(btn1, btn2); 24 | 25 | btn1.getAttribute("class").should.equal("bar"); 26 | should.equal(null, btn1.getAttribute("disabled")); 27 | }); 28 | 29 | it('can morph children', function() 30 | { 31 | let div1 = make('
') 32 | let btn1 = div1.querySelector('button'); 33 | let div2 = make('
') 34 | let btn2 = div2.querySelector('button'); 35 | 36 | Idiomorph.morph(div1, div2); 37 | 38 | btn1.getAttribute("class").should.equal("bar"); 39 | should.equal(null, btn1.getAttribute("disabled")); 40 | btn1.innerHTML.should.equal(btn2.innerHTML); 41 | }); 42 | 43 | it('basic deep morph works', function(done) 44 | { 45 | let div1 = make('
A
B
C
') 46 | 47 | let d1 = div1.querySelector("#d1") 48 | let d2 = div1.querySelector("#d2") 49 | let d3 = div1.querySelector("#d3") 50 | 51 | let morphTo = '
E
F
D
'; 52 | let div2 = make(morphTo) 53 | 54 | print(div1); 55 | Idiomorph.morph(div1, div2); 56 | print(div1); 57 | 58 | // first paragraph should have been discarded in favor of later matches 59 | d1.innerHTML.should.equal("A"); 60 | 61 | // second and third paragraph should have morphed 62 | d2.innerHTML.should.equal("E"); 63 | d3.innerHTML.should.equal("F"); 64 | 65 | console.log(morphTo); 66 | console.log(div1.outerHTML); 67 | div1.outerHTML.should.equal(morphTo) 68 | 69 | setTimeout(()=> { 70 | console.log("idiomorph mutations : ", div1.mutations); 71 | done(); 72 | }, 0) 73 | }); 74 | 75 | it('deep morphdom does not work ideally', function(done) 76 | { 77 | let div1 = make('
A
B
C
') 78 | 79 | let d1 = div1.querySelector("#d1") 80 | let d2 = div1.querySelector("#d2") 81 | let d3 = div1.querySelector("#d3") 82 | 83 | morphdom(div1, '
E
F
D
', {}); 84 | 85 | setTimeout(()=> { 86 | console.log("morphdom mutations : ", div1.mutations); 87 | done(); 88 | }, 0) 89 | print(div1); 90 | }); 91 | 92 | }) 93 | -------------------------------------------------------------------------------- /test/core.js: -------------------------------------------------------------------------------- 1 | describe("Core morphing tests", function(){ 2 | 3 | beforeEach(function() { 4 | clearWorkArea(); 5 | }); 6 | 7 | it('morphs outerHTML as content properly when argument is null', function() 8 | { 9 | let initial = make(""); 10 | Idiomorph.morph(initial, null, {morphStyle:'outerHTML'}); 11 | initial.isConnected.should.equal(false); 12 | }); 13 | 14 | it('morphs outerHTML as content properly when argument is single node', function() 15 | { 16 | let initial = make(""); 17 | let finalSrc = ""; 18 | let final = make(finalSrc); 19 | Idiomorph.morph(initial, final, {morphStyle:'outerHTML'}); 20 | if (initial.outerHTML !== "") { 21 | console.log("HTML after morph: " + initial.outerHTML); 22 | console.log("Expected: " + finalSrc); 23 | } 24 | initial.outerHTML.should.equal(""); 25 | }); 26 | 27 | it('morphs outerHTML as content properly when argument is string', function() 28 | { 29 | let initial = make(""); 30 | let finalSrc = ""; 31 | Idiomorph.morph(initial, finalSrc, {morphStyle:'outerHTML'}); 32 | if (initial.outerHTML !== "") { 33 | console.log("HTML after morph: " + initial.outerHTML); 34 | console.log("Expected: " + finalSrc); 35 | } 36 | initial.outerHTML.should.equal(""); 37 | }); 38 | 39 | it('morphs outerHTML as content properly when argument is an HTMLElementCollection', function() 40 | { 41 | let initial = make(""); 42 | let finalSrc = "
"; 43 | let final = make(finalSrc).children; 44 | Idiomorph.morph(initial, final, {morphStyle:'outerHTML'}); 45 | if (initial.outerHTML !== "") { 46 | console.log("HTML after morph: " + initial.outerHTML); 47 | console.log("Expected: " + finalSrc); 48 | } 49 | initial.outerHTML.should.equal(""); 50 | }); 51 | 52 | it('morphs outerHTML as content properly when argument is an Array', function() 53 | { 54 | let initial = make(""); 55 | let finalSrc = "
"; 56 | let final = [...make(finalSrc).children]; 57 | Idiomorph.morph(initial, final, {morphStyle:'outerHTML'}); 58 | if (initial.outerHTML !== "") { 59 | console.log("HTML after morph: " + initial.outerHTML); 60 | console.log("Expected: " + finalSrc); 61 | } 62 | initial.outerHTML.should.equal(""); 63 | }); 64 | 65 | it('morphs outerHTML as content properly when argument is HTMLElementCollection with siblings', function() 66 | { 67 | let parent = make("
"); 68 | let initial = parent.querySelector("button"); 69 | let finalSrc = "

Foo

Bar

"; 70 | let final = makeElements(finalSrc); 71 | Idiomorph.morph(initial, final, {morphStyle:'outerHTML'}); 72 | if (initial.outerHTML !== "") { 73 | console.log("HTML after morph: " + initial.outerHTML); 74 | console.log("Expected: " + finalSrc); 75 | } 76 | initial.outerHTML.should.equal(""); 77 | initial.parentElement.innerHTML.should.equal("

Foo

Bar

"); 78 | }); 79 | 80 | it('morphs outerHTML as content properly when argument is an Array with siblings', function() 81 | { 82 | let parent = make("
"); 83 | let initial = parent.querySelector("button"); 84 | let finalSrc = "

Foo

Bar

"; 85 | let final = [...makeElements(finalSrc)]; 86 | Idiomorph.morph(initial, final, {morphStyle:'outerHTML'}); 87 | if (initial.outerHTML !== "") { 88 | console.log("HTML after morph: " + initial.outerHTML); 89 | console.log("Expected: " + finalSrc); 90 | } 91 | initial.outerHTML.should.equal(""); 92 | initial.parentElement.innerHTML.should.equal("

Foo

Bar

"); 93 | }); 94 | 95 | it('morphs outerHTML as content properly when argument is string', function() 96 | { 97 | let parent = make("
"); 98 | let initial = parent.querySelector("button"); 99 | let finalSrc = "

Foo

Bar

"; 100 | Idiomorph.morph(initial, finalSrc, {morphStyle:'outerHTML'}); 101 | if (initial.outerHTML !== "") { 102 | console.log("HTML after morph: " + initial.outerHTML); 103 | console.log("Expected: " + finalSrc); 104 | } 105 | initial.outerHTML.should.equal(""); 106 | initial.parentElement.innerHTML.should.equal("

Foo

Bar

"); 107 | }); 108 | 109 | it('morphs outerHTML as content properly when argument is string with multiple siblings', function() 110 | { 111 | let parent = make("
"); 112 | let initial = parent.querySelector("button"); 113 | let finalSrc = "

Doh

Foo

Bar

Ray

"; 114 | Idiomorph.morph(initial, finalSrc, {morphStyle:'outerHTML'}); 115 | if (initial.outerHTML !== "") { 116 | console.log("HTML after morph: " + initial.outerHTML); 117 | console.log("Expected: " + finalSrc); 118 | } 119 | initial.outerHTML.should.equal(""); 120 | initial.parentElement.innerHTML.should.equal("

Doh

Foo

Bar

Ray

"); 121 | }); 122 | 123 | it('morphs innerHTML as content properly when argument is null', function() 124 | { 125 | let initial = make("
Foo
"); 126 | Idiomorph.morph(initial, null, {morphStyle:'innerHTML'}); 127 | initial.outerHTML.should.equal("
"); 128 | }); 129 | 130 | it('morphs innerHTML as content properly when argument is single node', function() 131 | { 132 | let initial = make("
Foo
"); 133 | let finalSrc = ""; 134 | let final = make(finalSrc); 135 | Idiomorph.morph(initial, final, {morphStyle:'innerHTML'}); 136 | if (initial.outerHTML !== "") { 137 | console.log("HTML after morph: " + initial.outerHTML); 138 | console.log("Expected: " + finalSrc); 139 | } 140 | initial.outerHTML.should.equal("
"); 141 | }); 142 | 143 | it('morphs innerHTML as content properly when argument is string', function() 144 | { 145 | let initial = make(""); 146 | let finalSrc = ""; 147 | Idiomorph.morph(initial, finalSrc, {morphStyle:'innerHTML'}); 148 | if (initial.outerHTML !== "") { 149 | console.log("HTML after morph: " + initial.outerHTML); 150 | console.log("Expected: " + finalSrc); 151 | } 152 | initial.outerHTML.should.equal(""); 153 | }); 154 | 155 | it('morphs innerHTML as content properly when argument is an HTMLElementCollection', function() 156 | { 157 | let initial = make(""); 158 | let finalSrc = "
"; 159 | let final = make(finalSrc).children; 160 | Idiomorph.morph(initial, final, {morphStyle:'innerHTML'}); 161 | if (initial.outerHTML !== "") { 162 | console.log("HTML after morph: " + initial.outerHTML); 163 | console.log("Expected: " + finalSrc); 164 | } 165 | initial.outerHTML.should.equal(""); 166 | }); 167 | 168 | it('morphs innerHTML as content properly when argument is an Array', function() 169 | { 170 | let initial = make(""); 171 | let finalSrc = "
"; 172 | let final = [...make(finalSrc).children]; 173 | Idiomorph.morph(initial, final, {morphStyle:'innerHTML'}); 174 | if (initial.outerHTML !== "") { 175 | console.log("HTML after morph: " + initial.outerHTML); 176 | console.log("Expected: " + finalSrc); 177 | } 178 | initial.outerHTML.should.equal(""); 179 | }); 180 | 181 | it('morphs innerHTML as content properly when argument is empty array', function() 182 | { 183 | let initial = make("
Foo
"); 184 | Idiomorph.morph(initial, [], {morphStyle:'innerHTML'}); 185 | initial.outerHTML.should.equal("
"); 186 | }); 187 | 188 | it('ignores active element when ignoreActive set to true', function() 189 | { 190 | let initialSource = "
Foo
"; 191 | getWorkArea().innerHTML = initialSource; 192 | let i1 = document.getElementById('i1'); 193 | i1.focus(); 194 | let d1 = document.getElementById('d1'); 195 | i1.value = "asdf"; 196 | let finalSource = "
Bar
"; 197 | Idiomorph.morph(getWorkArea(), finalSource, {morphStyle:'innerHTML', ignoreActive:true}); 198 | d1.innerText.should.equal("Bar") 199 | i1.value.should.equal("asdf") 200 | }); 201 | 202 | it('can morph a body tag properly', function() 203 | { 204 | let initial = parseHTML("Foo"); 205 | let finalSrc = 'Foo'; 206 | let final = parseHTML(finalSrc); 207 | Idiomorph.morph(initial.body, final.body); 208 | initial.body.outerHTML.should.equal(finalSrc); 209 | 210 | }); 211 | 212 | it('can morph a full document properly', function() 213 | { 214 | let initial = parseHTML("Foo"); 215 | let finalSrc = 'Foo'; 216 | Idiomorph.morph(initial, finalSrc); 217 | initial.documentElement.outerHTML.should.equal(finalSrc); 218 | }); 219 | 220 | }) 221 | -------------------------------------------------------------------------------- /test/demo/demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 16 | 17 | 18 | 19 |
20 |

Idiomorph Demo

21 | 22 |

Inputs

23 | 24 |
25 |
26 |

Initial HTML

27 | 28 |
29 |
30 |

Final HTML

31 | 32 |
33 |
34 | 35 |
36 | 39 | 47 | 48 | 49 | 57 | 58 | 59 | 60 | 61 |
62 | 63 | 64 |
65 |
66 |
67 | 68 |

Work Area

69 |
76 |
77 |
78 |
79 | 80 |
81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /test/demo/fullmorph.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Initial Title 4 | 5 | 6 | 7 | 8 |
9 |

Above

10 |
11 | 12 |
13 |
14 | 15 | -------------------------------------------------------------------------------- /test/demo/fullmorph2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | new Title 4 | 5 | 6 | 7 | 8 |
9 | 10 |
11 |

Below

12 | 13 | -------------------------------------------------------------------------------- /test/demo/ignoreActiveIdiomorph.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 24 | 25 | 26 | 27 | 28 |

Ignore Active Demo...

29 | 30 |

Idiomorph w/o Ignore Active

31 | 32 | 33 |

Idiomorph w Ignore Active

34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /test/demo/rickroll-idiomorph.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basecamp/idiomorph/026f8dc465b0485621adc54b79a37af4611f23ca/test/demo/rickroll-idiomorph.gif -------------------------------------------------------------------------------- /test/demo/scratch.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 14 | 15 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /test/demo/video.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 |
13 |
14 |

Above...

15 |
16 |
17 | 21 |
22 |
23 | 24 | 27 | 28 | 29 | 42 | 43 |
44 | 45 |
46 |
47 |

Above...

48 |
49 |
50 | 54 |
55 |
56 | 57 | 60 | 61 | 62 | 75 | 76 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /test/fidelity.js: -------------------------------------------------------------------------------- 1 | describe("Tests to ensure that idiomorph merges properly", function(){ 2 | 3 | beforeEach(function() { 4 | clearWorkArea(); 5 | }); 6 | 7 | function testFidelity(start, end) { 8 | let initial = make(start); 9 | let final = make(end); 10 | Idiomorph.morph(initial, final); 11 | if (initial.outerHTML !== end) { 12 | console.log("HTML after morph: " + initial.outerHTML); 13 | console.log("Expected: " + end); 14 | } 15 | initial.outerHTML.should.equal(end); 16 | } 17 | 18 | // bootstrap test 19 | it('morphs text correctly', function() 20 | { 21 | testFidelity("", "") 22 | }); 23 | 24 | it('morphs attributes correctly', function() 25 | { 26 | testFidelity("", "") 27 | }); 28 | 29 | it('morphs children', function() 30 | { 31 | testFidelity("

A

B

", "

C

D

") 32 | }); 33 | 34 | it('morphs white space', function() 35 | { 36 | testFidelity("

A

B

", "

C

D

") 37 | }); 38 | 39 | it('drops content', function() 40 | { 41 | testFidelity("

A

B

", "
") 42 | }); 43 | 44 | it('adds content', function() 45 | { 46 | testFidelity("
", "

A

B

") 47 | }); 48 | 49 | it('should morph a node', function() 50 | { 51 | testFidelity("

hello world

", "

hello you

") 52 | }); 53 | 54 | it('should stay same if same', function() 55 | { 56 | testFidelity("

hello world

", "

hello world

") 57 | }); 58 | 59 | it('should replace a node', function() 60 | { 61 | testFidelity("

hello world

", "
hello you
") 62 | }); 63 | 64 | it('should append a node', function() 65 | { 66 | testFidelity("
", "

hello you

") 67 | }); 68 | 69 | 70 | 71 | 72 | }) 73 | -------------------------------------------------------------------------------- /test/head.js: -------------------------------------------------------------------------------- 1 | describe("Tests to ensure that the head tag merging works correctly", function() { 2 | 3 | beforeEach(function () { 4 | clearWorkArea(); 5 | }); 6 | 7 | it('adds a new element correctly', function () { 8 | let parser = new DOMParser(); 9 | let document = parser.parseFromString("Foo", "text/html"); 10 | let originalHead = document.head; 11 | Idiomorph.morph(document, "Foo"); 12 | 13 | originalHead.should.equal(document.head); 14 | originalHead.childNodes.length.should.equal(2); 15 | originalHead.childNodes[0].outerHTML.should.equal("Foo"); 16 | originalHead.childNodes[1].outerHTML.should.equal(""); 17 | }); 18 | 19 | it('removes a new element correctly', function () { 20 | let parser = new DOMParser(); 21 | let document = parser.parseFromString("Foo", "text/html"); 22 | let originalHead = document.head; 23 | Idiomorph.morph(document, "Foo"); 24 | originalHead.should.equal(document.head); 25 | originalHead.childNodes.length.should.equal(1); 26 | originalHead.childNodes[0].outerHTML.should.equal("Foo"); 27 | }); 28 | 29 | it('preserves an element correctly', function () { 30 | let parser = new DOMParser(); 31 | let document = parser.parseFromString("Foo", "text/html"); 32 | let originalHead = document.head; 33 | Idiomorph.morph(document, "Foo"); 34 | 35 | originalHead.should.equal(document.head); 36 | originalHead.childNodes.length.should.equal(1); 37 | originalHead.childNodes[0].outerHTML.should.equal("Foo"); 38 | }); 39 | 40 | it('head elements are preserved in order', function () { 41 | let parser = new DOMParser(); 42 | let document = parser.parseFromString("Foo", "text/html"); 43 | let originalHead = document.head; 44 | Idiomorph.morph(document, "Foo"); 45 | 46 | originalHead.should.equal(document.head); 47 | originalHead.childNodes.length.should.equal(2); 48 | originalHead.childNodes[0].outerHTML.should.equal("Foo"); 49 | originalHead.childNodes[1].outerHTML.should.equal(""); 50 | }); 51 | 52 | it('morph style reorders head', function () { 53 | let parser = new DOMParser(); 54 | let document = parser.parseFromString("Foo", "text/html"); 55 | let originalHead = document.head; 56 | Idiomorph.morph(document, "Foo", {head:{style:'morph'}}); 57 | 58 | originalHead.should.equal(document.head); 59 | originalHead.childNodes.length.should.equal(2); 60 | originalHead.childNodes[0].outerHTML.should.equal(""); 61 | originalHead.childNodes[1].outerHTML.should.equal("Foo"); 62 | }); 63 | 64 | it('append style appends to head', function () { 65 | let parser = new DOMParser(); 66 | let document = parser.parseFromString("Foo", "text/html"); 67 | let originalHead = document.head; 68 | Idiomorph.morph(document, "", {head:{style:'append'}}); 69 | 70 | originalHead.should.equal(document.head); 71 | originalHead.childNodes.length.should.equal(2); 72 | originalHead.childNodes[0].outerHTML.should.equal("Foo"); 73 | originalHead.childNodes[1].outerHTML.should.equal(""); 74 | }); 75 | 76 | it('im-preserve preserves', function () { 77 | let parser = new DOMParser(); 78 | let document = parser.parseFromString("Foo", "text/html"); 79 | let originalHead = document.head; 80 | Idiomorph.morph(document, ""); 81 | 82 | originalHead.should.equal(document.head); 83 | originalHead.childNodes.length.should.equal(2); 84 | originalHead.childNodes[0].outerHTML.should.equal("Foo"); 85 | originalHead.childNodes[1].outerHTML.should.equal(""); 86 | }); 87 | 88 | it('im-re-append re-appends', function () { 89 | let parser = new DOMParser(); 90 | let document = parser.parseFromString("Foo", "text/html"); 91 | let originalHead = document.head; 92 | let originalTitle = originalHead.children[0]; 93 | Idiomorph.morph(document, "Foo"); 94 | 95 | originalHead.should.equal(document.head); 96 | originalHead.childNodes.length.should.equal(2); 97 | originalHead.childNodes[0].outerHTML.should.equal("Foo"); 98 | originalHead.childNodes[0].should.not.equal(originalTitle); // original title should have been removed in place of a new, reappended title 99 | originalHead.childNodes[1].outerHTML.should.equal(""); 100 | }); 101 | 102 | 103 | 104 | }); -------------------------------------------------------------------------------- /test/htmx/above.html: -------------------------------------------------------------------------------- 1 |
2 |

Above...

3 |

4 | 5 |

6 |
7 |
8 | 12 |
-------------------------------------------------------------------------------- /test/htmx/below.html: -------------------------------------------------------------------------------- 1 |
2 | 6 |
7 |
8 |

Below...

9 |

10 | 11 |

12 |
-------------------------------------------------------------------------------- /test/htmx/htmx-demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | htmx/idiomorph demo 4 | 5 | 6 | 7 | 8 | 104 | 105 | 106 | 107 | 110 | 111 |
112 |
113 |

Above...

114 |
115 |
116 | 120 |
121 |
122 | 123 | 137 | 138 | -------------------------------------------------------------------------------- /test/htmx/htmx-demo2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 |
9 |

Above...

10 |

11 | 12 |

13 |
14 |
15 | 19 |
20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Mocha Tests 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |

idiomorph.js test suite

16 | 17 |

Scratch Page

18 | 23 | 24 | 25 |

Mocha Test Suite

26 | [ALL] 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |
51 | 52 | 55 | Work Area 56 |
57 |
58 |   Output Here...
59 | 
60 | 61 | 62 | -------------------------------------------------------------------------------- /test/lib/morphdom.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var DOCUMENT_FRAGMENT_NODE = 11; 4 | 5 | function morphAttrs(fromNode, toNode) { 6 | var toNodeAttrs = toNode.attributes; 7 | var attr; 8 | var attrName; 9 | var attrNamespaceURI; 10 | var attrValue; 11 | var fromValue; 12 | 13 | // document-fragments dont have attributes so lets not do anything 14 | if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE || fromNode.nodeType === DOCUMENT_FRAGMENT_NODE) { 15 | return; 16 | } 17 | 18 | // update attributes on original DOM element 19 | for (var i = toNodeAttrs.length - 1; i >= 0; i--) { 20 | attr = toNodeAttrs[i]; 21 | attrName = attr.name; 22 | attrNamespaceURI = attr.namespaceURI; 23 | attrValue = attr.value; 24 | 25 | if (attrNamespaceURI) { 26 | attrName = attr.localName || attrName; 27 | fromValue = fromNode.getAttributeNS(attrNamespaceURI, attrName); 28 | 29 | if (fromValue !== attrValue) { 30 | if (attr.prefix === 'xmlns'){ 31 | attrName = attr.name; // It's not allowed to set an attribute with the XMLNS namespace without specifying the `xmlns` prefix 32 | } 33 | fromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue); 34 | } 35 | } else { 36 | fromValue = fromNode.getAttribute(attrName); 37 | 38 | if (fromValue !== attrValue) { 39 | fromNode.setAttribute(attrName, attrValue); 40 | } 41 | } 42 | } 43 | 44 | // Remove any extra attributes found on the original DOM element that 45 | // weren't found on the target element. 46 | var fromNodeAttrs = fromNode.attributes; 47 | 48 | for (var d = fromNodeAttrs.length - 1; d >= 0; d--) { 49 | attr = fromNodeAttrs[d]; 50 | attrName = attr.name; 51 | attrNamespaceURI = attr.namespaceURI; 52 | 53 | if (attrNamespaceURI) { 54 | attrName = attr.localName || attrName; 55 | 56 | if (!toNode.hasAttributeNS(attrNamespaceURI, attrName)) { 57 | fromNode.removeAttributeNS(attrNamespaceURI, attrName); 58 | } 59 | } else { 60 | if (!toNode.hasAttribute(attrName)) { 61 | fromNode.removeAttribute(attrName); 62 | } 63 | } 64 | } 65 | } 66 | 67 | var range; // Create a range object for efficently rendering strings to elements. 68 | var NS_XHTML = 'http://www.w3.org/1999/xhtml'; 69 | 70 | var doc = typeof document === 'undefined' ? undefined : document; 71 | var HAS_TEMPLATE_SUPPORT = !!doc && 'content' in doc.createElement('template'); 72 | var HAS_RANGE_SUPPORT = !!doc && doc.createRange && 'createContextualFragment' in doc.createRange(); 73 | 74 | function createFragmentFromTemplate(str) { 75 | var template = doc.createElement('template'); 76 | template.innerHTML = str; 77 | return template.content.childNodes[0]; 78 | } 79 | 80 | function createFragmentFromRange(str) { 81 | if (!range) { 82 | range = doc.createRange(); 83 | range.selectNode(doc.body); 84 | } 85 | 86 | var fragment = range.createContextualFragment(str); 87 | return fragment.childNodes[0]; 88 | } 89 | 90 | function createFragmentFromWrap(str) { 91 | var fragment = doc.createElement('body'); 92 | fragment.innerHTML = str; 93 | return fragment.childNodes[0]; 94 | } 95 | 96 | /** 97 | * This is about the same 98 | * var html = new DOMParser().parseFromString(str, 'text/html'); 99 | * return html.body.firstChild; 100 | * 101 | * @method toElement 102 | * @param {String} str 103 | */ 104 | function toElement(str) { 105 | str = str.trim(); 106 | if (HAS_TEMPLATE_SUPPORT) { 107 | // avoid restrictions on content for things like `Hi` which 108 | // createContextualFragment doesn't support 109 | //