├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierignore ├── CHANGELOG.md ├── CONCEPT.md ├── LICENSE ├── PERFORMANCE.md ├── README.md ├── RELEASE.md ├── ROADMAP.md ├── TESTING.md ├── dist ├── idiomorph-ext.esm.js ├── idiomorph-ext.js ├── idiomorph-ext.min.js ├── idiomorph-htmx.js ├── idiomorph.cjs.js ├── idiomorph.esm.js ├── idiomorph.js ├── idiomorph.min.js └── idiomorph.min.js.gz ├── img └── comparison.png ├── package-lock.json ├── package.json ├── perf ├── benchmarks │ ├── checkboxes.new.html │ ├── checkboxes.old.html │ ├── html5.new.html │ ├── html5.old.html │ ├── table.new.html │ └── table.old.html ├── runner.html └── runner.js ├── 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 ├── hooks.js ├── htmx-integration.js ├── htmx │ ├── above.html │ ├── below.html │ ├── htmx-demo.html │ └── htmx-demo2.html ├── index.html ├── lib │ ├── ensure-full-coverage.js │ ├── fail-only.mjs │ ├── fixture.js │ ├── morphdom.js │ ├── utilities.js │ └── wait-for.js ├── ops.js ├── preserve-focus.js ├── restore-focus.js └── retain-hidden-state.js ├── tmp └── .gitkeep ├── tsconfig.json └── web-test-runner.config.mjs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test-chromium: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout code 10 | uses: actions/checkout@v4 11 | - name: Use Node.js 12 | uses: actions/setup-node@v4 13 | with: 14 | cache: 'npm' 15 | - name: Install dependencies 16 | run: npm install 17 | - name: Install browser 18 | run: npx playwright install --with-deps chromium 19 | - name: Run tests 20 | run: npm run test:chrome -- --fail-only 21 | 22 | test-firefox: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout code 26 | uses: actions/checkout@v4 27 | - name: Use Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | cache: 'npm' 31 | - name: Install dependencies 32 | run: npm install 33 | - name: Install browser 34 | run: npx playwright install --with-deps firefox 35 | - name: Run tests 36 | run: npm run test:firefox -- --fail-only 37 | 38 | test-webkit: 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Checkout code 42 | uses: actions/checkout@v4 43 | - name: Use Node.js 44 | uses: actions/setup-node@v4 45 | with: 46 | cache: 'npm' 47 | - name: Install dependencies 48 | run: npm install 49 | - name: Install browser 50 | run: npx playwright install --with-deps webkit 51 | - name: Run tests 52 | run: npm run test:webkit -- --fail-only 53 | 54 | coverage: 55 | runs-on: ubuntu-latest 56 | steps: 57 | - name: Checkout code 58 | uses: actions/checkout@v4 59 | - name: Use Node.js 60 | uses: actions/setup-node@v4 61 | with: 62 | cache: 'npm' 63 | - name: Install dependencies 64 | run: npm install 65 | - name: Install browsers 66 | run: npx playwright install --with-deps chromium 67 | - name: Run tests 68 | run: npm run test:coverage 69 | - name: Upload coverage report 70 | if: always() 71 | uses: actions/upload-artifact@v4 72 | with: 73 | name: coverage-report 74 | path: coverage/ 75 | 76 | typecheck: 77 | runs-on: ubuntu-latest 78 | steps: 79 | - name: Checkout code 80 | uses: actions/checkout@v4 81 | - name: Use Node.js 82 | uses: actions/setup-node@v4 83 | with: 84 | cache: 'npm' 85 | - name: Install dependencies 86 | run: npm install 87 | - name: Run typecheck 88 | run: npm run typecheck 89 | 90 | format: 91 | runs-on: ubuntu-latest 92 | steps: 93 | - name: Checkout code 94 | uses: actions/checkout@v4 95 | - name: Use Node.js 96 | uses: actions/setup-node@v4 97 | with: 98 | cache: 'npm' 99 | - name: Install dependencies 100 | run: npm install 101 | - name: Run formatter 102 | run: npm run format:check 103 | 104 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /coverage 3 | /test/chrome-profile 4 | /tmp/* 5 | !tmp/.gitkeep 6 | .idea 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | test/*/* 5 | !test/lib 6 | package.json 7 | package-lock.json 8 | tsconfig.json 9 | *.md 10 | .github 11 | 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | * Removed: 6 | * Remove AMD publish target since its EOL: https://github.com/requirejs/requirejs/issues/1816#issuecomment-707503323 7 | 8 | ## [0.7.3] - 2025-03-05 9 | 10 | * Fixed: 11 | * Fix error when morphing elements with numeric ids (@botandrose, @ksbrooksjr) 12 | * Fix issue with outerHTML morphing an IDed node that gets moved (@botandrose, @MichaelWest22) 13 | * Fix incorrect return value when root element gets moved or replaced in an outerHTML morph (@botandrose, @MichaelWest22) 14 | 15 | ## [0.7.2] - 2025-02-20 16 | 17 | * Fixed: 18 | * Restore direct imports and add named export for ESM htmx extension (@botandrose, @MichaelWest22) 19 | * Update license key in package.json to match LICENSE. (@MichaelWest22) 20 | * Prevent unnecesary selection restoration when it wasn't actually lost (@MichaelWest22) 21 | * Prevent focus & selection loss in more situations (@MichaelWest22) 22 | 23 | ## [0.7.1] - 2025-02-13 24 | 25 | * Removed: 26 | * Remove `twoPass` option. There is only one single morphing algorithm now, which is more correct than both previous versions. (@botandrose, @MichaelWest22) 27 | * Remove `beforeNodePantried` callback option. This addition in v0.4.0 was an unfortunate necessity of the old `twoPass` mode, but is no longer needed with the new algorithm. (@botandrose) 28 | 29 | * Added: 30 | * New on-by-default `restoreFocus` option. On older browsers, moving the focused element (or one of its parents) can result in loss of focus and selection state. This option restores this state for IDed elements, at the cost of firing extra `focus` and `selection` events. (@botandrose) 31 | 32 | * Fixed: 33 | * Boolean attributes are now correctly set to `""` instead of `"true"`. https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML (@MichaelWest22) 34 | 35 | ## [0.4.0] - 2024-12-23 36 | 37 | * Introduced a [two pass](README.md#two-pass-mode) mode that will make a second pass over the DOM rewiring elements 38 | that were removed but have stable IDs back into the DOM 39 | * Uses the new `moveBefore()` API if it is available 40 | * Firmed up the implementation of softMatching to not soft match when elements have conflicting ids, which should allow 41 | developers to avoid accidentally sliding behavior between noded 42 | * Fixed up the `package.json` file to properly show the esm file as the module 43 | * Main contributors to this release were @botandrose & @MichaelWest22, thank you! 44 | 45 | Sorry, I didn't keep track of earlier changes! 46 | 47 | -------------------------------------------------------------------------------- /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 ability 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 matches the insertionPoint 109 | recursively merge newChild into insertionPoint 110 | advance the insertionPoint to its next sibling 111 | else if the newChild has a match among oldNodes children 112 | move the match before the insertionPoint 113 | recursively merge newChild into the match 114 | else if the newChild and insertionPoint are compatible 115 | recursively merge newChild into insertionPoint 116 | advance the insertionPoint to its next sibling 117 | else if the newChild has a compatible match among oldNodes children 118 | move the match before the insertionPoint 119 | recursively merge newChild into the match 120 | else if the newChild is IDed and has a match later in the oldTree or pantry 121 | move the match before the insertionPoint 122 | recursively merge newChild into the match 123 | else 124 | insert a clone of the newChild before the insertionPoint 125 | end 126 | 127 | while insertionPoint is not null 128 | if the insertionPoint is IDed and has a match later in the newTree 129 | move the insertionPoint into a temporary pantry div for later reuse 130 | else 131 | delete the insertionPoint 132 | advance the insertionPoint to its next sibling 133 | 134 | ``` 135 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Zero-Clause BSD 2 | ============= 3 | 4 | Permission to use, copy, modify, and/or distribute this software for 5 | any purpose with or without fee is hereby granted. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL 8 | WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES 9 | OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE 10 | FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY 11 | DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN 12 | AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT 13 | OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /PERFORMANCE.md: -------------------------------------------------------------------------------- 1 | # Idiomorph Performance Benchmarks 2 | 3 | ## Overview 4 | We have performance benchmarks to compare the performance of the current state of `src/idiomorph.js` with morphdom and previous Idiomorph releases. These benchmarks and support files are located in the `perf` directory, are not included in the coverage report, and are not run in CI. Instead they are run manually during development to ensure that performance is not regressing. 5 | 6 | ## Running 7 | To run the benchmarks, use: 8 | 9 | ```bash 10 | npm run perf [versus=morphdom] [benchmarks...] 11 | ``` 12 | 13 | ### Arguments 14 | * The optional `versus` argument can be used to compare with morphdom (the default), or previous Idiomorph releases specified by the git release tag, e.g. `v0.3.0`. 15 | * The optional `benchmarks` argument can be used to run specific benchmarks, defaulting to all of them. 16 | 17 | Examples: 18 | Running only the `table` and `checkboxes` benchmarks against morphdom: 19 | ```bash 20 | npm run perf table checkboxes 21 | ``` 22 | 23 | Running all benchmarks against Idiomorph v0.3.0: 24 | ```bash 25 | npm run perf v0.3.0 26 | ``` 27 | 28 | Running just the `html5` benchmark against Idiomorph v0.4.0: 29 | ```bash 30 | npm run perf v0.4.0 html5 31 | ``` 32 | 33 | ## Adding Benchmarks 34 | You can add more benchmarks by creating new `benchmark-name.old.html` and `benchmark-name.new.html` files in the `perf/benchmarks` directory, containing the starting and final morph HTML respectively. 35 | 36 | -------------------------------------------------------------------------------- /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 | A testimonial: 31 | 32 | > We are indeed using idiomorph and we'll include it officially as part of [Turbo 8](https://turbo.hotwired.dev/). We 33 | > started with morphdom, but eventually switched to idiomorph as we found it way more suitable. It just worked great 34 | > with all the tests we threw at it, while morphdom was incredibly picky about "ids" to match nodes. Also, we noticed 35 | > it's at least as fast. 36 | > 37 | > -- [Jorge Marubia](https://www.jorgemanrubia.com/) / [37Signals](https://37signals.com/) 38 | 39 | ## Installing 40 | 41 | Idiomorph is a small (3.2k min/gz'd), dependency free JavaScript library. The `/dist/idiomorph.js` file can be included 42 | directly in a browser: 43 | 44 | ```html 45 | 46 | ``` 47 | 48 | For production systems we recommend downloading and vendoring the library. 49 | 50 | If you are using [JavaScript Modules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules), we provide 51 | two additional files: 52 | 53 | * `dist/idiomorph.cjs.js` - for [CommonJS-style modules](https://wiki.commonjs.org/wiki/Modules) 54 | * `dist/idiomorph.esm.js` - for [ESM-style modules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules) 55 | 56 | Idiomorph can be installed via NPM or your favorite dependency management system under the `idiomorph` dependency 57 | name. 58 | 59 | ```js 60 | require("idiomorph"); // CommonJS 61 | import "idiomorph"; // ESM 62 | ``` 63 | 64 | ## Usage 65 | 66 | Idiomorph has a very simple API: 67 | 68 | ```js 69 | Idiomorph.morph(existingNode, newNode); 70 | ``` 71 | 72 | This will morph the existingNode to have the same structure as the newNode. Note that this is a destructive operation 73 | with respect to both the existingNode and the newNode. 74 | 75 | You can also pass string content in as the second argument, and Idiomorph will parse the string into nodes: 76 | 77 | ```js 78 | Idiomorph.morph(existingNode, "
New Content
"); 79 | ``` 80 | 81 | And it will be parsed and merged into the new content. 82 | 83 | If you wish to target the `innerHTML` rather than the `outerHTML` of the content, you can pass in a `morphStyle` 84 | in a third config argument: 85 | 86 | ```js 87 | Idiomorph.morph(existingNode, "
New Content
", {morphStyle:'innerHTML'}); 88 | ``` 89 | 90 | This will replace the _inner_ content of the existing node with the new content. 91 | 92 | ### Options 93 | 94 | Idiomorph supports the following options: 95 | 96 | | option (with default) | meaning | example | 97 | |-------------------------------|------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------| 98 | | `morphStyle: 'outerHTML'` | The style of morphing to use, either `outerHTML` or `innerHTML` | `Idiomorph.morph(..., {morphStyle:'innerHTML'})` | 99 | | `ignoreActive: false` | If `true`, idiomorph will skip the active element | `Idiomorph.morph(..., {ignoreActive:true})` | 100 | | `ignoreActiveValue: false` | If `true`, idiomorph will not update the active element's value | `Idiomorph.morph(..., {ignoreActiveValue:true})` | 101 | | `restoreFocus: true` | If `true`, idiomorph will attempt to restore any lost focus and selection state after the morph. | `Idiomorph.morph(..., {restoreFocus:true})` | 102 | | `head: {style: 'merge', ...}` | 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'}})` | 103 | | `callbacks: {...}` | Allows you to insert callbacks when events occur in the morph lifecycle. See the callback table below | `Idiomorph.morph(..., {callbacks:{beforeNodeAdded:function(node){...}})` | 104 | 105 | #### Callbacks 106 | 107 | Idiomorph provides the following callbacks, which can be used to intercept and, for some callbacks, modify the swapping behavior 108 | of the algorithm. 109 | 110 | | callback | description | return value meaning | 111 | |-----------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|----------------------------------------------------| 112 | | beforeNodeAdded(node) | Called before a new node is added to the DOM | return false to not add the node | 113 | | afterNodeAdded(node) | Called after a new node is added to the DOM | none | 114 | | beforeNodeMorphed(oldNode, newNode) | Called before a node is morphed in the DOM | return false to skip morphing the node | 115 | | afterNodeMorphed(oldNode, newNode) | Called after a node is morphed in the DOM | none | 116 | | beforeNodeRemoved(node) | Called before a node is removed from the DOM | return false to not remove the node | 117 | | afterNodeRemoved(node) | Called after a node is removed from the DOM | none | 118 | | beforeAttributeUpdated(attributeName, node, mutationType) | Called before an attribute on an element is updated or removed (`mutationType` is either "update" or "remove") | return false to not update or remove the attribute | 119 | 120 | ### The `head` tag 121 | 122 | The head tag is treated specially by idiomorph because: 123 | 124 | * It typically only has one level of children within it 125 | * Those children often do not have `id` attributes associated with them 126 | * It is important to remove as few elements as possible from the head, in order to minimize network requests for things 127 | like style sheets 128 | * The order of elements in the head tag is (usually) not meaningful 129 | 130 | Because of this, by default, idiomorph adopts a `merge` algorithm between two head tags, `old` and `new`: 131 | 132 | * Elements that are in both `old` and `new` are ignored 133 | * Elements that are in `new` but not in `old` are added to `old` 134 | * Elements that are in `old` but not in `new` are removed from `old` 135 | 136 | Thus the content of the two head tags will be the same, but the order of those elements will not be. 137 | 138 | #### Attribute Based Fine-Grained Head Control 139 | 140 | Sometimes you may want even more fine-grained control over head merging behavior. For example, you may want a script 141 | 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'` 142 | to the script tag, and idiomorph will re-append the script tag even if it exists in both head tags, forcing re-evaluation 143 | of the script. 144 | 145 | Similarly, you may wish to preserve an element even if it is not in `new`. You can use the attribute `im-preserve='true'` 146 | in this case to retain the element. 147 | 148 | #### Additional Configuration 149 | 150 | You are also able to override these behaviors, see the `head` config object in the source code. 151 | 152 | You can set `head.style` to: 153 | 154 | * `merge` - the default algorithm outlined above 155 | * `append` - simply append all content in `new` to `old` 156 | * `morph` - adopt the normal idiomorph morphing algorithm for the head 157 | * `none` - ignore the head tag entirely 158 | 159 | For example, if you wanted to merge a whole page using the `morph` algorithm for the head tag, you would do this: 160 | 161 | ```js 162 | Idiomorph.morph(document.documentElement, newPageSource, {head:{style: 'morph'}}) 163 | ``` 164 | 165 | The `head` object also offers callbacks for configuring head merging specifics. 166 | 167 | ### Setting Defaults 168 | 169 | All the behaviors specified above can be set to a different default by mutating the `Idiomorph.defaults` object, including 170 | the `Idiomorph.defaults.callbacks` and `Idiomorph.defaults.head` objects. 171 | 172 | ### htmx 173 | 174 | Idiomorph was created to integrate with [htmx](https://htmx.org) and can be used as a swapping mechanism by including 175 | the `dist/idiomorph-ext.js` file in your HTML: 176 | 177 | ```html 178 | 179 |
180 | 181 | 184 | 185 | 188 | 189 | 192 | 193 |
194 | ``` 195 | 196 | or by importing the "idiomorph/htmx" module: 197 | 198 | ```html 199 | import "idiomorph/htmx"; 200 | ``` 201 | 202 | Note that this file includes both Idiomorph and the htmx extension. 203 | 204 | #### Configuring Morphing Behavior in htmx 205 | 206 | The Idiomorph extension for htmx supports three different syntaxes for specifying behavior: 207 | 208 | * `hx-swap='morph'` - This will perform a morph on the outerHTML of the target 209 | * `hx-swap='morph:outerHTML'` - This will perform a morph on the outerHTML of the target (explicit) 210 | * `hx-swap='morph:innerHTML'` - This will perform a morph on the innerHTML of the target (i.e. the children) 211 | * `hx-swap='morph:'` - In this form, `` can be any valid JavaScript expression. The results of the expression 212 | will be passed into the `Idiomorph.morph()` method as the configuration. 213 | 214 | The last form gives you access to all the configuration options of Idiomorph. So, for example, if you wanted to ignore 215 | the input value in a given morph, you could use the following swap specification: 216 | 217 | ```html 218 | 223 | ``` 224 | 225 | ## Performance 226 | 227 | Idiomorph is not designed to be as fast as either morphdom or nanomorph. Rather, its goals are: 228 | 229 | * Better DOM tree matching 230 | * Relatively simple code 231 | 232 | Performance is a consideration, but better matching is the reason Idiomorph was created. Our benchmarks indicate that 233 | it is approximately equal to 10% slower than morphdom for large DOM morphs, and equal to or faster than morphdom for 234 | smaller morphs. See the [Performance](PERFORMANCE.md) document for more details. 235 | 236 | ## Example Morph 237 | 238 | Here is a simple example of some HTML in which Idiomorph does a better job of matching up than morphdom: 239 | 240 | *Initial HTML* 241 | ```html 242 |
243 |
244 |

A

245 |
246 |
247 |

B

248 |
249 |
250 | ``` 251 | 252 | *Final HTML* 253 | 254 | ```html 255 |
256 |
257 |

B

258 |
259 |
260 |

A

261 |
262 |
263 | ``` 264 | 265 | Here we have a common situation: a parent div, with children divs and grand-children divs that have ids on them. This 266 | is a common situation when laying out code in HTML: parent divs often do not have ids on them (rather they have classes, 267 | for layout reasons) and the "leaf" nodes have ids associated with them. 268 | 269 | Given this example, morphdom will detach both #p1 and #p2 from the DOM because, when it is considering the order of the 270 | children, it does not see that the #p2 grandchild is now within the first child. 271 | 272 | Idiomorph, on the other hand, has an _id set_ for the (id-less) children, which includes the ids of the grandchildren. 273 | Therefore, it is able to detect the fact that the #p2 grandchild is now a child of the first id-less child. Because of 274 | this information it is able to only move/detach _one_ grandchild node, #p1. (This is unavoidable, since they changed order) 275 | 276 | So, you can see, by computing id sets for nodes, idiomorph is able to achieve better DOM matching, with fewer node 277 | detachments. 278 | 279 | ## Demo 280 | 281 | You can see a practical demo of Idiomorph out-performing morphdom (with respect to DOM stability, _not_ performance) 282 | here: 283 | 284 | https://github.com/bigskysoftware/Idiomorph/blob/main/test/demo/video.html 285 | 286 | For both algorithms, this HTML: 287 | 288 | ```html 289 |
290 |
291 |

Above...

292 |
293 |
294 | 298 |
299 |
300 | ``` 301 | 302 | is morphed into this HTML: 303 | 304 | ```html 305 |
306 |
307 | 311 |
312 |
313 |

Below...

314 |
315 |
316 | ``` 317 | 318 | Note that the iframe has an id on it, but the first-level divs do not have ids on them. This means 319 | 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. 320 | 321 | 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. 322 | 323 | You can see visually that idiomorph is able to keep the video running because of this, whereas morphdom is not: 324 | 325 | ![Rick Roll Demo](https://github.com/bigskysoftware/Idiomorph/raw/main/test/demo/rickroll-idiomorph.gif) 326 | 327 | To keep things stable with morphdom, you would need to add ids to at least one of the top level divs. 328 | 329 | Here is a diagram explaining how the two algorithms differ in this case: 330 | 331 | ![Comparison Diagram](https://github.com/bigskysoftware/Idiomorph/raw/main/img/comparison.png) 332 | 333 | ## Usage in the wild 334 | 335 | * [Datastar](https://data-star.dev) - uses idiomorph as its default merging strategy and embeds a Typescript port as part of its backend integration layer. 336 | * [Turbo](https://turbo.hotwired.dev/handbook/page_refreshes#morphing) - uses idiomorph to perform full page refreshing. 337 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Idiomorph Release Guide 2 | 3 | This guide outlines how to release Idiomorph, focusing on the steps to take to prepare a release, how to publish it, and other release concerns. 4 | 5 | ## Steps 6 | 7 | 1. `npm run test:ci` 8 | 2. Update the version number in package.json 9 | 2. `npm install` 10 | 3. `npm run dist` 11 | 4. Update the documented gzipped filesize and version number in README.md 12 | 5. Update CHANGELOG.md 13 | 6. Update ROADMAP.md 14 | 7. `git add . && git commit -m"Release vX.Y.Z"` 15 | 8. `git tag vX.Y.Z` 16 | 9. `git push origin main --tags` 17 | 10. `npm publish` 18 | 11. Update the Github Releases page: https://github.com/bigskysoftware/idiomorph/releases 19 | 12. Make announcement on the htmx Discord in the #idiomorph and #announcements channels 20 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Idiomorph Roadmap 2 | 3 | ## Overview 4 | This document outlines the development roadmap for Idiomorph. It provides a high-level view of the project's goals, milestones, and planned features. Anything with a question mark is a potential feature that may or may not be implemented. 5 | 6 | ## Goals (in descending order of priority) 7 | - Correct production of expected HTML 8 | - Preservation of non-HTML state 9 | - Performance 10 | 11 | ## Milestones 12 | 13 | ### 0.8.0 14 | - [x] Remove AMD dist target 15 | - [ ] Plugin system? https://github.com/bigskysoftware/idiomorph/issues/109 16 | - [ ] Move idiomorph/htmx.js out of tree into an htmx extension? https://github.com/bigskysoftware/idiomorph/issues/111 17 | - [ ] Narrow support for `newContent` types? https://github.com/bigskysoftware/idiomorph/issues/103 18 | - [ ] Warn if duplicate ids are detected in the new content 19 | - [ ] Restore or preserve scroll state? https://github.com/bigskysoftware/idiomorph/issues/26 20 | - [ ] Improve anonymous node matching, perhaps using Merkle trees, or fuzzy synthetic ids? 21 | - [ ] Natively preserve focus, selection, scroll state by morphing around currently focused element https://github.com/bigskysoftware/idiomorph/pull/85 22 | - [ ] Can we improve the iframe morphing situation without `moveBefore`? 23 | 24 | ### 1.0.0 25 | - [ ] Performance improvements 26 | 27 | ### 2.0.0 (when `Element#moveBefore` is widely available) 28 | - [ ] Remove all pre-`moveBefore` workarounds 29 | 30 | -------------------------------------------------------------------------------- /TESTING.md: -------------------------------------------------------------------------------- 1 | # Idiomorph Testing Guide 2 | 3 | This guide outlines how to test Idiomorph, focusing on running tests headlessly or in a browser environment, running individual tests, and other testing concerns. 4 | 5 | ## Prerequisites 6 | 7 | 1. Ensure you have a currently supported Node.js and npm installed. 8 | 2. Install dependencies by running: 9 | ```bash 10 | npm install 11 | npx playwright install 12 | ``` 13 | 14 | ## Running All Tests 15 | 16 | To run all tests in headless mode, execute: 17 | ```bash 18 | npm test 19 | ``` 20 | This will run all the tests using headless Chrome. 21 | 22 | To run all tests against all browsers in headless mode, execute: 23 | ```bash 24 | npm run test:all 25 | ``` 26 | This will run the tests using Playwright’s headless browser setup across Chrome, Firefox, and WebKit (Safari-adjacent). 27 | 28 | To run all tests against a specific browser, execute: 29 | ```bash 30 | npm run test:chrome 31 | npm run test:firefox 32 | npm run test:webkit 33 | ``` 34 | 35 | ## Running Individual Tests 36 | 37 | ### Headless Mode 38 | To run a specific test file headlessly, for example `test/core.js`, use the following command: 39 | ```bash 40 | npm test test/core.js 41 | ``` 42 | If you want to run only one specific test, you can temporarily change `it("...` to `it.only("...` in the test file, and then specify the test file as above. Don't forget to undo this before you commit! 43 | 44 | ### Browser Mode 45 | To run tests directly in the browser, simply `open test/index.html` in a browser. 46 | On Ubuntu you can run: 47 | ```bash 48 | xdg-open test/index.html 49 | ``` 50 | This runs all the tests in the browser using Mocha instead of web-test-runner for easier debugging. 51 | 52 | If you really want to open web-test-runner in headed mode, you can run: 53 | ```bash 54 | npm run test:debug 55 | ``` 56 | This will start the server, and open the test runner in a browser. From there you can choose a test file to run. 57 | 58 | ## Code Coverage Report 59 | After a test run completes, you can open `coverage/lcov-report/index.html` to view the code coverage report. On Ubuntu you can run: 60 | ```bash 61 | xdg-open coverage/lcov-report/index.html 62 | ``` 63 | 64 | ## Test Locations 65 | - All tests are located in the `test/` directory. Only .js files in this directory will be discovered by the test runner, so support files can go in subdirectories. 66 | - The `web-test-runner.config.mjs` file in the root directory contains the boilerplate HTML for the test runs, including ` 4 | 5 | 18 | 19 | 40 | -------------------------------------------------------------------------------- /perf/runner.js: -------------------------------------------------------------------------------- 1 | const { spawnSync } = require("child_process"); 2 | const fs = require("fs"); 3 | const path = require("path"); 4 | 5 | let benchmarks = process.argv.slice(2); 6 | let versus; 7 | 8 | if (benchmarks[0] === "morphdom" || /^v[\d.]+$/.test(benchmarks[0])) { 9 | versus = benchmarks.shift(); 10 | } else { 11 | versus = "morphdom"; 12 | } 13 | 14 | if (benchmarks.length === 0) { 15 | benchmarks = fs 16 | .readdirSync(`${__dirname}/benchmarks`) 17 | .map((file) => file.split(".")[0]) // Remove file extension 18 | .filter((name, i, self) => i === self.indexOf(name)); // Remove duplicates 19 | } 20 | 21 | benchmarks.forEach((benchmark) => { 22 | const config = { 23 | root: "..", 24 | benchmarks: [ 25 | { 26 | name: `${benchmark}: ${versus}`, 27 | url: `../perf/runner.html?using=${versus}&benchmark=${benchmark}`, 28 | browser: "chrome-headless", 29 | }, 30 | { 31 | name: `${benchmark}: src/idiomorph.js`, 32 | url: `../perf/runner.html?using=idiomorph&benchmark=${benchmark}`, 33 | browser: "chrome-headless", 34 | }, 35 | ], 36 | }; 37 | fs.writeFileSync( 38 | path.resolve(__dirname, "../tmp/tachometer.json"), 39 | JSON.stringify(config), 40 | "utf8", 41 | ); 42 | 43 | spawnSync("npx", ["tachometer", "--config=tmp/tachometer.json"], { 44 | stdio: "inherit", 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/idiomorph-htmx.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | function createMorphConfig(swapStyle) { 3 | if (swapStyle === "morph" || swapStyle === "morph:outerHTML") { 4 | return { morphStyle: "outerHTML" }; 5 | } else if (swapStyle === "morph:innerHTML") { 6 | return { morphStyle: "innerHTML" }; 7 | } else if (swapStyle.startsWith("morph:")) { 8 | return Function("return (" + swapStyle.slice(6) + ")")(); 9 | } 10 | } 11 | 12 | htmx.defineExtension("morph", { 13 | isInlineSwap: function (swapStyle) { 14 | let config = createMorphConfig(swapStyle); 15 | return config?.morphStyle === "outerHTML" || config?.morphStyle == null; 16 | }, 17 | handleSwap: function (swapStyle, target, fragment) { 18 | let config = createMorphConfig(swapStyle); 19 | if (config) { 20 | return Idiomorph.morph(target, fragment.children, config); 21 | } 22 | }, 23 | }); 24 | })(); 25 | -------------------------------------------------------------------------------- /test/bootstrap.js: -------------------------------------------------------------------------------- 1 | describe("Bootstrap test", function () { 2 | setup(); 3 | 4 | it("can morph content to content", function () { 5 | let btn1 = make(""); 6 | let btn2 = make(""); 7 | 8 | Idiomorph.morph(btn1, btn2); 9 | 10 | btn1.innerHTML.should.equal(btn2.innerHTML); 11 | }); 12 | 13 | it("can morph attributes", function () { 14 | let btn1 = make(''); 15 | let btn2 = make(''); 16 | 17 | Idiomorph.morph(btn1, btn2); 18 | 19 | btn1.getAttribute("class").should.equal("bar"); 20 | should.equal(null, btn1.getAttribute("disabled")); 21 | }); 22 | 23 | it("can morph children", function () { 24 | let div1 = make('
'); 25 | let btn1 = div1.querySelector("button"); 26 | let div2 = make('
'); 27 | let btn2 = div2.querySelector("button"); 28 | 29 | Idiomorph.morph(div1, div2); 30 | 31 | btn1.getAttribute("class").should.equal("bar"); 32 | should.equal(null, btn1.getAttribute("disabled")); 33 | btn1.innerHTML.should.equal(btn2.innerHTML); 34 | }); 35 | 36 | it("basic deep morph works", function (done) { 37 | let div1 = make( 38 | ` 39 |
40 |
41 |
A
42 |
43 |
44 |
B
45 |
46 |
47 |
C
48 |
49 |
`.trim(), 50 | ); 51 | 52 | let d1 = div1.querySelector("#d1"); 53 | let d2 = div1.querySelector("#d2"); 54 | let d3 = div1.querySelector("#d3"); 55 | 56 | let morphTo = ` 57 |
58 |
59 |
E
60 |
61 |
62 |
F
63 |
64 |
65 |
D
66 |
67 |
`.trim(); 68 | let div2 = make(morphTo); 69 | 70 | print(div1); 71 | Idiomorph.morph(div1, div2); 72 | print(div1); 73 | 74 | // all three paragraphs should have been morphed 75 | d1.innerHTML.should.equal("D"); 76 | d2.innerHTML.should.equal("E"); 77 | d3.innerHTML.should.equal("F"); 78 | 79 | div1.outerHTML.should.equal(morphTo); 80 | 81 | // // debugging output 82 | // console.log(morphTo); 83 | // console.log(div1.outerHTML); 84 | 85 | // setTimeout(()=> { 86 | // console.log("idiomorph mutations : ", div1.mutations); 87 | done(); 88 | // }, 0) 89 | }); 90 | 91 | it("deep morphdom does not work ideally", function (done) { 92 | let div1 = make( 93 | '
A
B
C
', 94 | ); 95 | 96 | let d1 = div1.querySelector("#d1"); 97 | let d2 = div1.querySelector("#d2"); 98 | let d3 = div1.querySelector("#d3"); 99 | 100 | morphdom( 101 | div1, 102 | '
E
F
D
', 103 | {}, 104 | ); 105 | 106 | // // debugging output 107 | // setTimeout(()=> { 108 | // console.log("morphdom mutations : ", div1.mutations); 109 | done(); 110 | // }, 0) 111 | // print(div1); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /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 | 27 | 28 | 29 | 30 | 31 |

Ignore Active Value Demo...

32 | 33 |

Idiomorph w/o Ignore Active Value

34 | 35 | 36 |

Idiomorph w Ignore Active Value

37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /test/demo/rickroll-idiomorph.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bigskysoftware/idiomorph/a6ada993e8c8d7cb30f417983d3b003982d2e9d0/test/demo/rickroll-idiomorph.gif -------------------------------------------------------------------------------- /test/demo/scratch.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 34 | 35 | 38 | 39 |

Edit This:

40 | 41 | Some Initial Content... 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /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 | setup(); 3 | 4 | function testFidelity(start, end) { 5 | getWorkArea().innerHTML = start; 6 | let startElement = getWorkArea().firstElementChild; 7 | let ret = Idiomorph.morph(startElement, end); 8 | getWorkArea().innerHTML.should.equal(end); 9 | ret.map((e) => e.outerHTML).should.eql([end]); 10 | } 11 | 12 | // bootstrap test 13 | it("morphs text correctly", function () { 14 | testFidelity("", ""); 15 | }); 16 | 17 | it("morphs attributes correctly", function () { 18 | testFidelity( 19 | '', 20 | '', 21 | ); 22 | }); 23 | 24 | it("morphs multiple attributes correctly twice", function () { 25 | const a = `
A
`; 26 | const b = `
B
`; 27 | const expectedA = make(a); 28 | const expectedB = make(b); 29 | const initial = make(a); 30 | 31 | Idiomorph.morph(initial, expectedB); 32 | initial.outerHTML.should.equal(b); 33 | 34 | Idiomorph.morph(initial, expectedA); 35 | initial.outerHTML.should.equal(a); 36 | }); 37 | 38 | it("morphs children", function () { 39 | testFidelity("

A

B

", "

C

D

"); 40 | }); 41 | 42 | it("morphs white space", function () { 43 | testFidelity( 44 | "

A

B

", 45 | "

C

D

", 46 | ); 47 | }); 48 | 49 | it("drops content", function () { 50 | testFidelity("

A

B

", "
"); 51 | }); 52 | 53 | it("adds content", function () { 54 | testFidelity("
", "

A

B

"); 55 | }); 56 | 57 | it("should morph a node", function () { 58 | testFidelity("

hello world

", "

hello you

"); 59 | }); 60 | 61 | it("should stay same if same", function () { 62 | testFidelity("

hello world

", "

hello world

"); 63 | }); 64 | 65 | it("should replace a node", function () { 66 | testFidelity( 67 | "

hello world

", 68 | "
hello you
", 69 | ); 70 | }); 71 | 72 | it("should wrap an IDed node", function () { 73 | testFidelity(`
`, `

`); 74 | }); 75 | 76 | it("should wrap an anonymous node", function () { 77 | testFidelity(`
`, `

`); 78 | }); 79 | 80 | it("should append a node", function () { 81 | testFidelity("
", "

hello you

"); 82 | }); 83 | 84 | it("moves a node from the future", function () { 85 | testFidelity( 86 | `
`, 87 | `
`, 88 | ); 89 | }); 90 | 91 | it("move id node into div does not break insertion point", function () { 92 | // bug: https://github.com/bigskysoftware/idiomorph/pull/99 93 | // when moving an IDed element into an inner div, moveBeforeById can 94 | // move the parent's insertionPoint node, causing an incorrect morph result 95 | testFidelity( 96 | `
`, 97 | `
`, 98 | ); 99 | }); 100 | 101 | it("move id node into div that has been restored from pantry does not break insertion point", function () { 102 | // bug: https://github.com/bigskysoftware/idiomorph/pull/99 103 | // after restoring a node from the pantry, if its next sibling gets moved into it 104 | // via moveBeforeById, thats the current insertionPoint, thus an incorrect morph 105 | testFidelity( 106 | `

`, 107 | `

`, 108 | ); 109 | }); 110 | 111 | it("issue https://github.com/bigskysoftware/idiomorph/issues/11", function () { 112 | let el1 = make('
'); 113 | 114 | el1.classList.add("foo"); 115 | el1.disabled = true; 116 | 117 | // Also fails (reorder setting class and disabling) 118 | // el1.disabled = true; 119 | // el1.classList.add('foo'); 120 | 121 | // Also fails (add and remove class) 122 | // el1.classList.add('foo'); 123 | // el1.classList.remove('foo'); 124 | // el1.disabled = true; 125 | 126 | let el2 = make('
hello
'); 127 | 128 | // Act 129 | Idiomorph.morph(el1, el2); 130 | 131 | // Assert 132 | should.equal("hello", el1.innerHTML); 133 | should.equal(0, el1.classList.length); 134 | should.equal(false, el1.disabled); 135 | }); 136 | 137 | it("issue https://github.com/bigskysoftware/idiomorph/issues/41", function () { 138 | window.customElements.define( 139 | "fake-turbo-frame", 140 | class extends HTMLElement { 141 | static observedAttributes = ["src"]; 142 | attributeChangedCallback(name, oldValue, newValue) { 143 | if (name === "src" && oldValue && oldValue !== newValue) { 144 | this.removeAttribute("complete"); 145 | } 146 | } 147 | }, 148 | ); 149 | 150 | let element = make( 151 | '', 152 | ); 153 | let finalSrc = ''; 154 | 155 | Idiomorph.morph(element, finalSrc); 156 | 157 | element.outerHTML.should.equal(finalSrc); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /test/head.js: -------------------------------------------------------------------------------- 1 | describe("Tests to ensure that the head tag merging works correctly", function () { 2 | setup(); 3 | 4 | it("adds a new element correctly", function () { 5 | let parser = new DOMParser(); 6 | let document = parser.parseFromString( 7 | "Foo", 8 | "text/html", 9 | ); 10 | let originalHead = document.head; 11 | Idiomorph.morph( 12 | document, 13 | "Foo", 14 | ); 15 | 16 | originalHead.should.equal(document.head); 17 | originalHead.childNodes.length.should.equal(2); 18 | originalHead.childNodes[0].outerHTML.should.equal("Foo"); 19 | originalHead.childNodes[1].outerHTML.should.equal( 20 | '', 21 | ); 22 | }); 23 | 24 | it("removes a new element correctly", function () { 25 | let parser = new DOMParser(); 26 | let document = parser.parseFromString( 27 | "Foo", 28 | "text/html", 29 | ); 30 | let originalHead = document.head; 31 | Idiomorph.morph(document, "Foo"); 32 | originalHead.should.equal(document.head); 33 | originalHead.childNodes.length.should.equal(1); 34 | originalHead.childNodes[0].outerHTML.should.equal("Foo"); 35 | }); 36 | 37 | it("preserves an element correctly", function () { 38 | let parser = new DOMParser(); 39 | let document = parser.parseFromString( 40 | "Foo", 41 | "text/html", 42 | ); 43 | let originalHead = document.head; 44 | Idiomorph.morph(document, "Foo"); 45 | 46 | originalHead.should.equal(document.head); 47 | originalHead.childNodes.length.should.equal(1); 48 | originalHead.childNodes[0].outerHTML.should.equal("Foo"); 49 | }); 50 | 51 | it("head elements are preserved in order", function () { 52 | let parser = new DOMParser(); 53 | let document = parser.parseFromString( 54 | "Foo", 55 | "text/html", 56 | ); 57 | let originalHead = document.head; 58 | Idiomorph.morph( 59 | document, 60 | "Foo", 61 | ); 62 | 63 | originalHead.should.equal(document.head); 64 | originalHead.childNodes.length.should.equal(2); 65 | originalHead.childNodes[0].outerHTML.should.equal("Foo"); 66 | originalHead.childNodes[1].outerHTML.should.equal( 67 | '', 68 | ); 69 | }); 70 | 71 | it("morph style reorders head", function () { 72 | let parser = new DOMParser(); 73 | let document = parser.parseFromString( 74 | "Foo", 75 | "text/html", 76 | ); 77 | let originalHead = document.head; 78 | Idiomorph.morph( 79 | document, 80 | "Foo", 81 | { head: { style: "morph" } }, 82 | ); 83 | 84 | originalHead.should.equal(document.head); 85 | originalHead.childNodes.length.should.equal(2); 86 | originalHead.childNodes[0].outerHTML.should.equal( 87 | '', 88 | ); 89 | originalHead.childNodes[1].outerHTML.should.equal("Foo"); 90 | }); 91 | 92 | it("append style appends to head", function () { 93 | let parser = new DOMParser(); 94 | let document = parser.parseFromString( 95 | "Foo", 96 | "text/html", 97 | ); 98 | let originalHead = document.head; 99 | Idiomorph.morph( 100 | document, 101 | "", 102 | { head: { style: "append" } }, 103 | ); 104 | 105 | originalHead.should.equal(document.head); 106 | originalHead.childNodes.length.should.equal(2); 107 | originalHead.childNodes[0].outerHTML.should.equal("Foo"); 108 | originalHead.childNodes[1].outerHTML.should.equal( 109 | '', 110 | ); 111 | }); 112 | 113 | it("ignore style ignores head", function () { 114 | let parser = new DOMParser(); 115 | let document = parser.parseFromString( 116 | "Foo", 117 | "text/html", 118 | ); 119 | let originalHead = document.head; 120 | Idiomorph.morph( 121 | document, 122 | "", 123 | { head: { ignore: true } }, 124 | ); 125 | 126 | originalHead.outerHTML.should.equal("Foo"); 127 | }); 128 | 129 | it("im-preserve preserves", function () { 130 | let parser = new DOMParser(); 131 | let document = parser.parseFromString( 132 | "Foo", 133 | "text/html", 134 | ); 135 | let originalHead = document.head; 136 | Idiomorph.morph( 137 | document, 138 | "", 139 | ); 140 | 141 | originalHead.should.equal(document.head); 142 | originalHead.childNodes.length.should.equal(2); 143 | originalHead.childNodes[0].outerHTML.should.equal( 144 | 'Foo', 145 | ); 146 | originalHead.childNodes[1].outerHTML.should.equal( 147 | '', 148 | ); 149 | }); 150 | 151 | it("im-re-append re-appends", function () { 152 | let parser = new DOMParser(); 153 | let document = parser.parseFromString( 154 | "Foo", 155 | "text/html", 156 | ); 157 | let originalHead = document.head; 158 | let originalTitle = originalHead.children[0]; 159 | Idiomorph.morph( 160 | document, 161 | "Foo", 162 | ); 163 | 164 | originalHead.should.equal(document.head); 165 | originalHead.childNodes.length.should.equal(2); 166 | originalHead.childNodes[0].outerHTML.should.equal( 167 | 'Foo', 168 | ); 169 | originalHead.childNodes[0].should.not.equal(originalTitle); // original title should have been removed in place of a new, reappended title 170 | originalHead.childNodes[1].outerHTML.should.equal( 171 | '', 172 | ); 173 | }); 174 | 175 | it("im-re-append re-appends with append style", function () { 176 | let parser = new DOMParser(); 177 | let document = parser.parseFromString( 178 | "", 179 | "text/html", 180 | ); 181 | let originalHead = document.head; 182 | let originalTitle = originalHead.children[0]; 183 | Idiomorph.morph( 184 | document, 185 | "", 186 | { head: { style: "append" } }, 187 | ); 188 | 189 | originalHead.should.equal(document.head); 190 | originalHead.childNodes.length.should.equal(2); 191 | originalHead.innerHTML.should.equal( 192 | '', 193 | ); 194 | }); 195 | 196 | it("can handle scripts with block mode with innerHTML morph", async function () { 197 | Idiomorph.morph( 198 | window.document, 199 | `${window.document.body.outerHTML}`, 200 | { morphStyle: "innerHTML", head: { block: true, style: "append" } }, 201 | ); 202 | await waitFor(() => window.hasOwnProperty("fixture")); 203 | window.fixture.should.equal("FIXTURE"); 204 | delete window.fixture; 205 | window.document.head 206 | .querySelector('script[src$="lib/fixture.js"]') 207 | .remove(); 208 | }); 209 | 210 | it("can handle scripts with block mode with outerHTML morph", async function () { 211 | Idiomorph.morph( 212 | window.document, 213 | `${window.document.body.outerHTML}`, 214 | { morphStyle: "outerHTML", head: { block: true, style: "append" } }, 215 | ); 216 | await waitFor(() => window.hasOwnProperty("fixture")); 217 | window.fixture.should.equal("FIXTURE"); 218 | delete window.fixture; 219 | window.document.head 220 | .querySelector('script[src$="lib/fixture.js"]') 221 | .remove(); 222 | }); 223 | }); 224 | -------------------------------------------------------------------------------- /test/hooks.js: -------------------------------------------------------------------------------- 1 | describe("lifecycle hooks", function () { 2 | beforeEach(function () { 3 | clearWorkArea(); 4 | }); 5 | 6 | it("calls beforeNodeAdded before a new node is added to the DOM", function () { 7 | let calls = []; 8 | let initial = make(""); 9 | Idiomorph.morph(initial, "", { 10 | callbacks: { 11 | beforeNodeAdded: (node) => { 12 | calls.push(node.outerHTML); 13 | }, 14 | }, 15 | }); 16 | initial.outerHTML.should.equal(""); 17 | calls.should.eql(["
  • B
  • "]); 18 | }); 19 | 20 | it("returning false to beforeNodeAdded prevents adding the node", function () { 21 | let initial = make(""); 22 | Idiomorph.morph(initial, "", { 23 | callbacks: { 24 | beforeNodeAdded: (node) => false, 25 | }, 26 | }); 27 | initial.outerHTML.should.equal(""); 28 | }); 29 | 30 | it("calls afterNodeAdded after a new node is added to the DOM", function () { 31 | let calls = []; 32 | let initial = make(""); 33 | Idiomorph.morph(initial, "", { 34 | callbacks: { 35 | afterNodeAdded: (node) => { 36 | calls.push(node.outerHTML); 37 | }, 38 | }, 39 | }); 40 | initial.outerHTML.should.equal(""); 41 | calls.should.eql(["
  • B
  • "]); 42 | }); 43 | 44 | it("calls beforeNodeMorphed before a node is morphed", function () { 45 | let calls = []; 46 | let initial = make(``); 47 | Idiomorph.morph(initial, ``, { 48 | callbacks: { 49 | beforeNodeMorphed: (oldNode, newNode) => { 50 | calls.push([ 51 | oldNode.outerHTML || oldNode.textContent, 52 | newNode.outerHTML || newNode.textContent, 53 | ]); 54 | }, 55 | }, 56 | }); 57 | initial.outerHTML.should.equal(``); 58 | calls.should.eql([ 59 | [``, ``], 60 | [`
  • A
  • `, `
  • B
  • `], 61 | [`A`, `B`], 62 | ]); 63 | }); 64 | 65 | it("returning false to beforeNodeMorphed prevents morphing the node", function () { 66 | let initial = make(``); 67 | Idiomorph.morph(initial, ``, { 68 | callbacks: { 69 | beforeNodeMorphed: (node) => { 70 | if (node.nodeType === Node.TEXT_NODE) return false; 71 | }, 72 | }, 73 | }); 74 | initial.outerHTML.should.equal( 75 | ``, 76 | ); 77 | }); 78 | 79 | it("calls afterNodeMorphed before a node is morphed", function () { 80 | let calls = []; 81 | let initial = make(``); 82 | Idiomorph.morph(initial, ``, { 83 | callbacks: { 84 | afterNodeMorphed: (oldNode, newNode) => { 85 | calls.push([ 86 | oldNode.outerHTML || oldNode.textContent, 87 | newNode.outerHTML || newNode.textContent, 88 | ]); 89 | }, 90 | }, 91 | }); 92 | initial.outerHTML.should.equal(``); 93 | calls.should.eql([ 94 | [`B`, `B`], 95 | [`
  • B
  • `, `
  • B
  • `], 96 | [``, ``], 97 | ]); 98 | }); 99 | 100 | it("calls beforeNodeRemoved before a node is removed from the DOM", function () { 101 | let calls = []; 102 | let initial = make(""); 103 | Idiomorph.morph(initial, "", { 104 | callbacks: { 105 | beforeNodeRemoved: (node) => { 106 | calls.push(node.outerHTML); 107 | }, 108 | }, 109 | }); 110 | initial.outerHTML.should.equal(""); 111 | calls.should.eql(["
  • B
  • "]); 112 | }); 113 | 114 | it("returning false to beforeNodeRemoved prevents removing the node", function () { 115 | let initial = make(""); 116 | Idiomorph.morph(initial, "", { 117 | callbacks: { 118 | beforeNodeRemoved: (node) => false, 119 | }, 120 | }); 121 | initial.outerHTML.should.equal(""); 122 | }); 123 | 124 | it("returning false to beforeNodeRemoved prevents removing the node when an IDed child is moved elsewhere", function () { 125 | let initial = make( 126 | ``, 127 | ); 128 | Idiomorph.morph( 129 | initial, 130 | ``, 131 | { 132 | callbacks: { 133 | beforeNodeRemoved: (node) => false, 134 | }, 135 | }, 136 | ); 137 | initial.outerHTML.should.equal( 138 | ``, 139 | ); 140 | }); 141 | 142 | it("returning false to beforeNodeRemoved prevents removing the node with different tag types", function () { 143 | let initial = make("
    ABC
    "); 144 | Idiomorph.morph(initial, "
    B
    ", { 145 | callbacks: { 146 | beforeNodeRemoved: (node) => false, 147 | }, 148 | }); 149 | initial.outerHTML.should.equal("
    ABC
    "); 150 | }); 151 | 152 | it("calls afterNodeRemoved after a node is removed from the DOM", function () { 153 | let calls = []; 154 | let initial = make(""); 155 | Idiomorph.morph(initial, "", { 156 | callbacks: { 157 | afterNodeRemoved: (node) => { 158 | calls.push(node.outerHTML); 159 | }, 160 | }, 161 | }); 162 | initial.outerHTML.should.equal(""); 163 | calls.should.eql(["
  • B
  • "]); 164 | }); 165 | 166 | it("calls beforeAttributeUpdated when an attribute is added", function () { 167 | let calls = []; 168 | let initial = make(""); 169 | Idiomorph.morph(initial, ``, { 170 | callbacks: { 171 | beforeAttributeUpdated: (attributeName, node, mutationType) => { 172 | calls.push([attributeName, node.outerHTML, mutationType]); 173 | }, 174 | }, 175 | }); 176 | initial.outerHTML.should.equal(``); 177 | calls.should.eql([["href", ``, "update"]]); 178 | }); 179 | 180 | it("calls beforeAttributeUpdated when an attribute is updated", function () { 181 | let calls = []; 182 | let initial = make(``); 183 | Idiomorph.morph(initial, ``, { 184 | callbacks: { 185 | beforeAttributeUpdated: (attributeName, node, mutationType) => { 186 | calls.push([attributeName, node.outerHTML, mutationType]); 187 | }, 188 | }, 189 | }); 190 | initial.outerHTML.should.equal(``); 191 | calls.should.eql([["href", ``, "update"]]); 192 | }); 193 | 194 | it("calls beforeAttributeUpdated when an attribute is removed", function () { 195 | let calls = []; 196 | let initial = make(``); 197 | Idiomorph.morph(initial, ``, { 198 | callbacks: { 199 | beforeAttributeUpdated: (attributeName, node, mutationType) => { 200 | calls.push([attributeName, node.outerHTML, mutationType]); 201 | }, 202 | }, 203 | }); 204 | initial.outerHTML.should.equal(``); 205 | calls.should.eql([["href", ``, "remove"]]); 206 | }); 207 | 208 | it("returning false to beforeAttributeUpdated prevents the attribute addition", function () { 209 | let initial = make(""); 210 | Idiomorph.morph(initial, ``, { 211 | callbacks: { 212 | beforeAttributeUpdated: () => false, 213 | }, 214 | }); 215 | initial.outerHTML.should.equal(``); 216 | }); 217 | 218 | it("returning false to beforeAttributeUpdated prevents the attribute update", function () { 219 | let initial = make(``); 220 | Idiomorph.morph(initial, ``, { 221 | callbacks: { 222 | beforeAttributeUpdated: () => false, 223 | }, 224 | }); 225 | initial.outerHTML.should.equal(``); 226 | }); 227 | 228 | it("returning false to beforeAttributeUpdated prevents the attribute removal", function () { 229 | let initial = make(``); 230 | Idiomorph.morph(initial, ``, { 231 | callbacks: { 232 | beforeAttributeUpdated: () => false, 233 | }, 234 | }); 235 | initial.outerHTML.should.equal(``); 236 | }); 237 | 238 | it("hooks work as expected", function () { 239 | let beginSrc = ` 240 |
    241 | 242 | 243 |
    244 | `.trim(); 245 | getWorkArea().append(make(beginSrc)); 246 | 247 | let finalSrc = ` 248 |
    249 | 250 | 251 |
    252 | `.trim(); 253 | 254 | let wrongHookCalls = []; 255 | let wrongHookHandler = (name) => { 256 | return (node) => { 257 | if (node.nodeType !== Node.ELEMENT_NODE) return; 258 | wrongHookCalls.push([name, node.outerHTML]); 259 | }; 260 | }; 261 | 262 | let calls = []; 263 | 264 | Idiomorph.morph(getWorkArea(), finalSrc, { 265 | morphStyle: "innerHTML", 266 | callbacks: { 267 | beforeNodeAdded: wrongHookHandler("beforeNodeAdded"), 268 | afterNodeAdded: wrongHookHandler("afterNodeAdded"), 269 | beforeNodeRemoved: wrongHookHandler("beforeNodeRemoved"), 270 | afterNodeRemoved: wrongHookHandler("afterNodeRemoved"), 271 | beforeNodeMorphed: (oldNode, newNode) => { 272 | if (oldNode.nodeType !== Node.ELEMENT_NODE) return; 273 | calls.push(["before", oldNode.outerHTML, newNode.outerHTML]); 274 | }, 275 | afterNodeMorphed: (oldNode, newNode) => { 276 | if (oldNode.nodeType !== Node.ELEMENT_NODE) return; 277 | calls.push(["after", oldNode.outerHTML, newNode.outerHTML]); 278 | }, 279 | }, 280 | }); 281 | 282 | getWorkArea().innerHTML.should.equal(finalSrc); 283 | 284 | wrongHookCalls.should.eql([]); 285 | calls.should.eql([ 286 | ["before", beginSrc, finalSrc], 287 | [ 288 | "before", 289 | ``, 290 | ``, 291 | ], 292 | [ 293 | "after", 294 | ``, 295 | ``, 296 | ], 297 | [ 298 | "before", 299 | ``, 300 | ``, 301 | ], 302 | [ 303 | "after", 304 | ``, 305 | ``, 306 | ], 307 | [ 308 | "after", 309 | '
    \n \n \n
    ', 310 | '
    \n \n \n
    ', 311 | ], 312 | ]); 313 | }); 314 | 315 | it("beforeNodeMorphed hook also applies to nodes restored from the pantry", function () { 316 | getWorkArea().append( 317 | make(` 318 |
    319 |

    First paragraph

    320 |

    Second paragraph

    321 |
    322 | `), 323 | ); 324 | document.getElementById("first").innerHTML = "First paragraph EDITED"; 325 | document.getElementById("second").innerHTML = "Second paragraph EDITED"; 326 | 327 | let finalSrc = ` 328 |
    329 |

    Second paragraph

    330 |

    First paragraph

    331 |
    332 | `; 333 | 334 | Idiomorph.morph(getWorkArea(), finalSrc, { 335 | morphStyle: "innerHTML", 336 | callbacks: { 337 | // basic implementation of a preserve-me attr 338 | beforeNodePantried(node) { 339 | if (node.parentNode?.dataset?.preserveMe) return false; 340 | }, 341 | beforeNodeMorphed(oldNode, newContent) { 342 | if (oldNode.dataset?.preserveMe) return false; 343 | }, 344 | }, 345 | }); 346 | 347 | getWorkArea().innerHTML.should.equal(` 348 |
    349 |

    Second paragraph EDITED

    350 |

    First paragraph EDITED

    351 |
    352 | `); 353 | }); 354 | }); 355 | -------------------------------------------------------------------------------- /test/htmx-integration.js: -------------------------------------------------------------------------------- 1 | describe("Tests for the htmx integration", function () { 2 | function makeServer() { 3 | var server = sinon.fakeServer.create(); 4 | htmx.config.defaultSettleDelay = 0; 5 | server.fakeHTTPMethods = true; 6 | return server; 7 | } 8 | 9 | beforeEach(function () { 10 | this.server = makeServer(); 11 | clearWorkArea(); 12 | }); 13 | 14 | afterEach(function () { 15 | this.server.restore(); 16 | }); 17 | 18 | function makeForHtmxTest(htmlStr) { 19 | let elt = make(htmlStr); 20 | getWorkArea().appendChild(elt); 21 | htmx.process(elt); 22 | return elt; 23 | } 24 | 25 | it("keeps the element stable in an outer morph", function () { 26 | this.server.respondWith( 27 | "GET", 28 | "/test", 29 | "", 30 | ); 31 | let initialBtn = makeForHtmxTest( 32 | "", 33 | ); 34 | initialBtn.click(); 35 | this.server.respond(); 36 | let newBtn = document.getElementById("b1"); 37 | initialBtn.should.equal(newBtn); 38 | initialBtn.classList.contains("bar").should.equal(true); 39 | }); 40 | 41 | it("keeps the element live in an outer morph", function () { 42 | this.server.respondWith( 43 | "GET", 44 | "/test", 45 | "", 46 | ); 47 | this.server.respondWith( 48 | "GET", 49 | "/test2", 50 | "", 51 | ); 52 | let initialBtn = makeForHtmxTest( 53 | "", 54 | ); 55 | 56 | initialBtn.click(); 57 | this.server.respond(); 58 | let newBtn = document.getElementById("b1"); 59 | initialBtn.should.equal(newBtn); 60 | initialBtn.classList.contains("bar").should.equal(true); 61 | initialBtn.classList.contains("doh").should.equal(false); 62 | 63 | initialBtn.click(); 64 | this.server.respond(); 65 | newBtn = document.getElementById("b1"); 66 | initialBtn.should.equal(newBtn); 67 | initialBtn.classList.contains("bar").should.equal(false); 68 | initialBtn.classList.contains("doh").should.equal(true); 69 | }); 70 | 71 | it("keeps the element stable in an outer morph w/ explicit syntax", function () { 72 | this.server.respondWith( 73 | "GET", 74 | "/test", 75 | "", 76 | ); 77 | let initialBtn = makeForHtmxTest( 78 | "", 79 | ); 80 | initialBtn.click(); 81 | this.server.respond(); 82 | let newBtn = document.getElementById("b1"); 83 | initialBtn.should.equal(newBtn); 84 | initialBtn.classList.contains("bar").should.equal(true); 85 | }); 86 | 87 | it("keeps the element live in an outer morph w/explicit syntax", function () { 88 | this.server.respondWith( 89 | "GET", 90 | "/test", 91 | "", 92 | ); 93 | this.server.respondWith( 94 | "GET", 95 | "/test2", 96 | "", 97 | ); 98 | let initialBtn = makeForHtmxTest( 99 | "", 100 | ); 101 | 102 | initialBtn.click(); 103 | this.server.respond(); 104 | let newBtn = document.getElementById("b1"); 105 | initialBtn.should.equal(newBtn); 106 | initialBtn.classList.contains("bar").should.equal(true); 107 | initialBtn.classList.contains("doh").should.equal(false); 108 | 109 | initialBtn.click(); 110 | this.server.respond(); 111 | newBtn = document.getElementById("b1"); 112 | initialBtn.should.equal(newBtn); 113 | initialBtn.classList.contains("bar").should.equal(false); 114 | initialBtn.classList.contains("doh").should.equal(true); 115 | }); 116 | 117 | it("keeps elements stable in an inner morph", function () { 118 | this.server.respondWith( 119 | "GET", 120 | "/test", 121 | "", 122 | ); 123 | let div = makeForHtmxTest( 124 | "
    ", 125 | ); 126 | let initialBtn = document.getElementById("b1"); 127 | div.click(); 128 | this.server.respond(); 129 | let newBtn = document.getElementById("b1"); 130 | initialBtn.should.equal(newBtn); 131 | initialBtn.classList.contains("bar").should.equal(true); 132 | }); 133 | 134 | it("keeps elements stable in an inner morph w/ long syntax", function () { 135 | this.server.respondWith( 136 | "GET", 137 | "/test", 138 | "", 139 | ); 140 | let div = makeForHtmxTest( 141 | "
    ", 142 | ); 143 | let initialBtn = document.getElementById("b1"); 144 | div.click(); 145 | this.server.respond(); 146 | let newBtn = document.getElementById("b1"); 147 | initialBtn.should.equal(newBtn); 148 | initialBtn.classList.contains("bar").should.equal(true); 149 | }); 150 | 151 | it("keeps the element stable in an outer morph with oob-swap", function () { 152 | this.server.respondWith( 153 | "GET", 154 | "/test", 155 | "", 156 | ); 157 | let div = makeForHtmxTest( 158 | "
    ", 159 | ); 160 | let initialBtn = document.getElementById("b1"); 161 | div.click(); 162 | this.server.respond(); 163 | let newBtn = document.getElementById("b1"); 164 | initialBtn.should.equal(newBtn); 165 | initialBtn.innerHTML.should.equal("Bar"); 166 | }); 167 | 168 | /* Currently unable to test innerHTML style oob swaps because oob-swap syntax uses a : which conflicts with morph:innerHTML 169 | it("keeps the element stable in an inner morph with oob-swap", function () { 170 | this.server.respondWith( 171 | "GET", 172 | "/test", 173 | "
    ", 174 | ); 175 | let div = makeForHtmxTest( 176 | "
    ", 177 | ); 178 | let initialBtn = document.getElementById("b1"); 179 | div.click(); 180 | this.server.respond(); 181 | let newBtn = document.getElementById("b1"); 182 | initialBtn.should.equal(newBtn); 183 | initialBtn.innerHTML.should.equal("Bar"); 184 | }); 185 | */ 186 | 187 | it("keeps the element live in an outer morph when node type changes", function () { 188 | this.server.respondWith( 189 | "GET", 190 | "/test", 191 | "
    Foo
    ", 192 | ); 193 | this.server.respondWith( 194 | "GET", 195 | "/test2", 196 | "", 197 | ); 198 | let initialBtn = makeForHtmxTest( 199 | "", 200 | ); 201 | 202 | initialBtn.click(); 203 | this.server.respond(); 204 | let newDiv = document.getElementById("b1"); 205 | 206 | newDiv.classList.contains("bar").should.equal(true); 207 | newDiv.classList.contains("doh").should.equal(false); 208 | 209 | newDiv.click(); 210 | this.server.respond(); 211 | let newBtn = document.getElementById("b1"); 212 | newBtn.classList.contains("bar").should.equal(false); 213 | newBtn.classList.contains("doh").should.equal(true); 214 | }); 215 | }); 216 | -------------------------------------------------------------------------------- /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 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |

    idiomorph.js test suite

    18 | 19 |

    Mocha Test Suite

    20 | [ALL] 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 |
    53 | 54 | Work Area 55 |
    56 |
    57 |       Output Here...
    58 |     
    59 | 60 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /test/lib/ensure-full-coverage.js: -------------------------------------------------------------------------------- 1 | const lcovParse = require("lcov-parse"); 2 | 3 | lcovParse("coverage/lcov.info", (err, data) => { 4 | if (err) { 5 | console.error("Error parsing lcov file:", err); 6 | process.exit(1); 7 | } 8 | 9 | data.forEach((record) => { 10 | ["lines", "functions", "branches"].forEach((type) => { 11 | if(record[type].hit !== record[type].found) { 12 | process.exit(1); 13 | } 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /test/lib/fail-only.mjs: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import path from 'path'; 3 | 4 | export default { 5 | name: 'fail-only', 6 | async transform(context) { 7 | const filePath = context.path; 8 | const failOnlyEnabled = process.argv.includes('--fail-only'); 9 | if (failOnlyEnabled && filePath.match(/^\/test\/[^/]+\.js$/)) { 10 | const fileContent = await fs.readFile(`.${filePath}`, 'utf-8'); 11 | if (/\bit\.only\b/.test(fileContent)) { 12 | abort(`--fail-only:\nFound 'it.only' in ${filePath}. Remove it to proceed.`); 13 | } 14 | } 15 | }, 16 | }; 17 | 18 | function abort(message) { 19 | const RED = '\x1b[31m'; // Red color code 20 | const RESET = '\x1b[0m'; // Reset color code 21 | process.stderr.write(`${RED}${message}\n${RESET}`, () => { 22 | process.exit(1); 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /test/lib/fixture.js: -------------------------------------------------------------------------------- 1 | window.fixture = "FIXTURE"; 2 | -------------------------------------------------------------------------------- /test/lib/utilities.js: -------------------------------------------------------------------------------- 1 | /* Test Utilities */ 2 | 3 | function setup() { 4 | beforeEach(() => { 5 | clearWorkArea(); 6 | }); 7 | } 8 | 9 | function hasMoveBefore() { 10 | return !!document.body.moveBefore; 11 | } 12 | 13 | function make(htmlStr) { 14 | let range = document.createRange(); 15 | let fragment = range.createContextualFragment(htmlStr); 16 | 17 | let element = fragment.children[0]; 18 | element.mutations = {elt: element, attribute:0, childrenAdded:0, childrenRemoved:0, characterData:0}; 19 | 20 | let observer = new MutationObserver((mutationList, observer) => { 21 | for (const mutation of mutationList) { 22 | if (mutation.type === 'childList') { 23 | element.mutations.childrenAdded += mutation.addedNodes.length; 24 | element.mutations.childrenRemoved += mutation.removedNodes.length; 25 | } else if (mutation.type === 'attributes') { 26 | element.mutations.attribute++; 27 | } else if (mutation.type === 'characterData') { 28 | element.mutations.characterData++; 29 | } 30 | } 31 | }); 32 | observer.observe(fragment, {attributes: true, childList: true, subtree: true}); 33 | 34 | return element; 35 | } 36 | 37 | function makeElements(htmlStr) { 38 | let range = document.createRange(); 39 | let fragment = range.createContextualFragment(htmlStr); 40 | return fragment.children; 41 | } 42 | 43 | function parseHTML(src) { 44 | let parser = new DOMParser(); 45 | return parser.parseFromString(src, "text/html"); 46 | } 47 | 48 | function getWorkArea() { 49 | return document.getElementById("work-area"); 50 | } 51 | 52 | function clearWorkArea() { 53 | getWorkArea().innerHTML = ""; 54 | } 55 | 56 | function print(elt) { 57 | let text = document.createTextNode( elt.outerHTML + "\n\n" ); 58 | getWorkArea().appendChild(text); 59 | return elt; 60 | } 61 | 62 | function setFocusAndSelection(elementId, selectedText) { 63 | const element = document.getElementById(elementId); 64 | const value = element.value 65 | const index = value.indexOf(selectedText); 66 | if(index === -1) throw `"${value}" does not contain "${selectedText}"`; 67 | element.focus(); 68 | element.setSelectionRange(index, index + selectedText.length); 69 | } 70 | 71 | function setFocus(elementId) { 72 | document.getElementById(elementId).focus(); 73 | } 74 | 75 | function assertFocusAndSelection(elementId, selectedText) { 76 | assertFocus(elementId); 77 | const activeElement = document.activeElement; 78 | activeElement.id.should.eql(elementId); 79 | activeElement.value.substring(activeElement.selectionStart, activeElement.selectionEnd).should.eql(selectedText); 80 | } 81 | 82 | function assertFocus(elementId) { 83 | document.activeElement.id.should.eql(elementId); 84 | } 85 | 86 | function assertNoFocus() { 87 | document.activeElement.tagName.should.eql("BODY"); 88 | } 89 | 90 | function assertOps(before, after, expectedOps) { 91 | let ops = []; 92 | let initial = make(before); 93 | let final = make(after); 94 | let finalCopy = document.importNode(final, true); 95 | Idiomorph.morph(initial, final, { 96 | callbacks: { 97 | beforeNodeMorphed: (oldNode, newNode) => { 98 | // Text node morphs are mostly noise 99 | if (oldNode.nodeType === Node.TEXT_NODE) return; 100 | 101 | ops.push([ 102 | "Morphed", 103 | oldNode.outerHTML || oldNode.textContent, 104 | newNode.outerHTML || newNode.textContent, 105 | ]); 106 | }, 107 | beforeNodeRemoved: (node) => { 108 | ops.push(["Removed", node.outerHTML || node.textContent]); 109 | }, 110 | beforeNodeAdded: (node) => { 111 | ops.push(["Added", node.outerHTML || node.textContent]); 112 | }, 113 | }, 114 | }); 115 | if (JSON.stringify(ops) != JSON.stringify(expectedOps)) { 116 | console.log('test expected Operations is:'); 117 | console.log(expectedOps); 118 | console.log('test failing Operations is:'); 119 | console.log(ops); 120 | } 121 | initial.outerHTML.should.equal(finalCopy.outerHTML); 122 | ops.should.eql(expectedOps); 123 | } 124 | 125 | -------------------------------------------------------------------------------- /test/lib/wait-for.js: -------------------------------------------------------------------------------- 1 | async function waitFor(condition) { 2 | return new Promise((resolve, reject) => { 3 | const check = async () => { 4 | try { 5 | if (condition() === true) { 6 | resolve(); 7 | } else { 8 | setTimeout(check, 20); 9 | } 10 | } catch (error) { 11 | reject(error); 12 | } 13 | }; 14 | check(); 15 | }); 16 | } 17 | 18 | -------------------------------------------------------------------------------- /test/ops.js: -------------------------------------------------------------------------------- 1 | // This file describes the ideal operations for many common morphs. 2 | // Skipped tests could be viewed as a TODO list for future improvements. 3 | 4 | describe("morphing operations", function () { 5 | setup(); 6 | 7 | it("removing anonymous siblings", function () { 8 | assertOps("
    ABC
    ", "
    B
    ", [ 9 | ["Morphed", "
    ABC
    ", "
    B
    "], 10 | ["Removed", "A"], 11 | ["Morphed", "B", "B"], 12 | ["Removed", "C"], 13 | ]); 14 | }); 15 | 16 | it("removing IDed siblings", function () { 17 | assertOps( 18 | `
    ABC
    `, 19 | `
    B
    `, 20 | [ 21 | [ 22 | "Morphed", 23 | `
    ABC
    `, 24 | `
    B
    `, 25 | ], 26 | ["Removed", `A`], 27 | ["Morphed", `B`, `B`], 28 | ["Removed", `C`], 29 | ], 30 | ); 31 | }); 32 | 33 | it.skip("reordering anonymous siblings", function () { 34 | assertOps( 35 | "
    ABC
    ", 36 | "
    CBA
    ", 37 | [ 38 | [ 39 | "Morphed", 40 | "
    ABC
    ", 41 | "
    CBA
    ", 42 | ], 43 | ["Morphed", "C", "C"], 44 | ["Morphed", "B", "B"], 45 | ["Morphed", "A", "A"], 46 | ], 47 | ); 48 | }); 49 | 50 | it("reordering IDed siblings", function () { 51 | assertOps( 52 | `
    ABC
    `, 53 | `
    CBA
    `, 54 | [ 55 | [ 56 | "Morphed", 57 | `
    ABC
    `, 58 | `
    CBA
    `, 59 | ], 60 | ["Morphed", `C`, `C`], 61 | ["Morphed", `B`, `B`], 62 | ["Morphed", `A`, `A`], 63 | ], 64 | ); 65 | }); 66 | 67 | it.skip("prepending a new softmatchable node onto the beginning", function () { 68 | assertOps( 69 | "
    AB
    ", 70 | "
    NewAB
    ", 71 | [ 72 | [ 73 | "Morphed", 74 | "
    AB
    ", 75 | "
    NewAB
    ", 76 | ], 77 | ["Added", "New"], 78 | ["Morphed", "A", "A"], 79 | ["Morphed", "B", "B"], 80 | ], 81 | ); 82 | }); 83 | 84 | it.skip("inserting a new softmatchable node into the middle", function () { 85 | assertOps( 86 | "
    ABCD
    ", 87 | "
    ABNewCD
    ", 88 | [ 89 | [ 90 | "Morphed", 91 | "
    ABCD
    ", 92 | "
    ABNewCD
    ", 93 | ], 94 | ["Morphed", "A", "A"], 95 | ["Morphed", "B", "B"], 96 | ["Added", "New"], 97 | ["Morphed", "C", "C"], 98 | ["Morphed", "D", "D"], 99 | ], 100 | ); 101 | }); 102 | 103 | it("appending a new softmatchable node onto the end", function () { 104 | assertOps( 105 | "
    AB
    ", 106 | "
    ABNew
    ", 107 | [ 108 | [ 109 | "Morphed", 110 | "
    AB
    ", 111 | "
    ABNew
    ", 112 | ], 113 | ["Morphed", "A", "A"], 114 | ["Morphed", "B", "B"], 115 | ["Added", "New"], 116 | ], 117 | ); 118 | }); 119 | 120 | it.skip("removing a softmatchable node from the front", function () { 121 | assertOps( 122 | "
    ABC
    ", 123 | "
    BC
    ", 124 | [ 125 | [ 126 | "Morphed", 127 | "
    ABC
    ", 128 | "
    BC
    ", 129 | ], 130 | ["Removed", "A"], 131 | ["Morphed", "B", "B"], 132 | ["Morphed", "C", "C"], 133 | ], 134 | ); 135 | }); 136 | 137 | it.skip("removing a softmatchable node from the middle", function () { 138 | assertOps( 139 | "
    ABC
    ", 140 | "
    AC
    ", 141 | [ 142 | [ 143 | "Morphed", 144 | "
    ABC
    ", 145 | "
    AC
    ", 146 | ], 147 | ["Morphed", "A", "A"], 148 | ["Removed", "B"], 149 | ["Morphed", "C", "C"], 150 | ], 151 | ); 152 | }); 153 | 154 | it("removing a softmatchable node from the end", function () { 155 | assertOps( 156 | "
    ABC
    ", 157 | "
    AB
    ", 158 | [ 159 | [ 160 | "Morphed", 161 | "
    ABC
    ", 162 | "
    AB
    ", 163 | ], 164 | ["Morphed", "A", "A"], 165 | ["Morphed", "B", "B"], 166 | ["Removed", "C"], 167 | ], 168 | ); 169 | }); 170 | 171 | it("show softMatch aborting on two future soft matches", function () { 172 | // when nodes can't be softMatched because they have different types it will scan ahead 173 | // but it aborts the scan ahead if it finds two nodes ahead in both the new and old content 174 | // that softmatch so it can just insert the mis matched node it is on and get to the matching. 175 | assertOps( 176 | "

    ", 177 | "
    Alert

    ", 178 | [ 179 | [ 180 | "Morphed", 181 | "

    ", 182 | "
    Alert

    ", 183 | ], 184 | ["Added", "
    Alert
    "], 185 | ["Morphed", "

    ", "

    "], 186 | ["Morphed", "

    ", "

    "], 187 | ["Morphed", "
    ", "
    "], 188 | ], 189 | ); 190 | }); 191 | }); 192 | -------------------------------------------------------------------------------- /test/preserve-focus.js: -------------------------------------------------------------------------------- 1 | describe("Preserves focus algorithmically where possible", function () { 2 | setup(); 3 | 4 | function assertFocusPreservation( 5 | before, 6 | after, 7 | focusId, 8 | selection, 9 | shouldAssertFocusAndSelection = true, 10 | ) { 11 | getWorkArea().innerHTML = before; 12 | setFocusAndSelection(focusId, selection); 13 | Idiomorph.morph(getWorkArea(), after, { 14 | morphStyle: "innerHTML", 15 | restoreFocus: false, 16 | }); 17 | getWorkArea().innerHTML.should.equal(after); 18 | // for when we fall short of the ideal 19 | // these should be considered TODOs for future improvement 20 | if (shouldAssertFocusAndSelection) { 21 | assertFocusAndSelection(focusId, selection); 22 | } 23 | } 24 | 25 | it("preserves focus state when elements are swapped", function () { 26 | assertFocusPreservation( 27 | ` 28 |
    29 | 30 | 31 |
    `, 32 | ` 33 |
    34 | 35 | 36 |
    `, 37 | "focused", 38 | "b", 39 | ); 40 | }); 41 | 42 | it("preserves focus state when elements are swapped the other way", function () { 43 | assertFocusPreservation( 44 | ` 45 |
    46 | 47 | 48 |
    `, 49 | ` 50 |
    51 | 52 | 53 |
    `, 54 | "focused", 55 | "b", 56 | ); 57 | }); 58 | 59 | it("preserves focus state when previous element is replaced", function () { 60 | assertFocusPreservation( 61 | ` 62 |
    63 | 64 | 65 |
    `, 66 | ` 67 |
    68 | 69 | 70 |
    `, 71 | "focused", 72 | "b", 73 | ); 74 | }); 75 | 76 | it("preserves focus state when elements are moved to different levels of the DOM", function () { 77 | assertFocusPreservation( 78 | ` 79 |
    80 | 81 |
    82 | 83 |
    84 |
    `, 85 | ` 86 |
    87 | 88 | 89 |
    `, 90 | "focused", 91 | "b", 92 | false, // skip assertion 93 | ); 94 | if (hasMoveBefore()) { 95 | assertFocus("focused"); 96 | // TODO moveBefore loses selection on Chrome 133 97 | // expect will be fixed in future release 98 | // assertFocusAndSelection("focused", "b"); 99 | } else { 100 | assertNoFocus(); 101 | } 102 | }); 103 | 104 | it("preserves focus state when focused element is moved between anonymous containers", function () { 105 | assertFocusPreservation( 106 | ` 107 |
    108 | 109 |
    110 |
    111 | 112 |
    `, 113 | ` 114 |
    115 | 116 | 117 |
    `, 118 | "focused", 119 | "b", 120 | false, // skip assertion 121 | ); 122 | if (hasMoveBefore()) { 123 | assertFocus("focused"); 124 | // TODO moveBefore loses selection on Chrome 133 125 | // expect will be fixed in future release 126 | // assertFocusAndSelection("focused", "b"); 127 | } else { 128 | assertNoFocus(); 129 | } 130 | }); 131 | 132 | it("preserves focus state when elements are moved between IDed containers", function () { 133 | assertFocusPreservation( 134 | ` 135 |
    136 |
    137 | 138 |
    139 | 142 |
    `, 143 | ` 144 |
    145 |
    146 | 147 |
    148 | 151 |
    `, 152 | "focused", 153 | "b", 154 | false, // skip assertion 155 | ); 156 | if (hasMoveBefore()) { 157 | assertFocus("focused"); 158 | // TODO moveBefore loses selection on Chrome 133 159 | // expect will be fixed in future release 160 | // assertFocusAndSelection("focused", "b"); 161 | } else { 162 | assertNoFocus(); 163 | } 164 | }); 165 | 166 | it("preserves focus state when focus parent is moved down", function () { 167 | assertFocusPreservation( 168 | ` 169 |
    170 |
    171 | 172 |
    173 |
    174 | 175 |
    176 |
    `, 177 | ` 178 |
    179 |
    180 | 181 |
    182 |
    183 | 184 |
    185 |
    `, 186 | "focused", 187 | "b", 188 | ); 189 | }); 190 | 191 | it("preserves focus state when focus parent is moved up", function () { 192 | assertFocusPreservation( 193 | ` 194 |
    195 |
    196 | 197 |
    198 |
    199 | 200 |
    201 |
    `, 202 | ` 203 |
    204 |
    205 | 206 |
    207 |
    208 | 209 |
    210 |
    `, 211 | "focused", 212 | "b", 213 | ); 214 | }); 215 | 216 | it("preserves focus state when matching anonymous element is inserted", function () { 217 | assertFocusPreservation( 218 | ` 219 |
    220 |
    221 | 222 |
    223 |
    `, 224 | ` 225 |
    226 |
    227 |
    228 | 229 |
    230 |
    `, 231 | "focused", 232 | "b", 233 | ); 234 | }); 235 | }); 236 | -------------------------------------------------------------------------------- /test/retain-hidden-state.js: -------------------------------------------------------------------------------- 1 | describe("Hidden state preservation tests", function () { 2 | setup(); 3 | 4 | it("preserves all non-attribute element state", function () { 5 | getWorkArea().append( 6 | make(` 7 |
    8 | 9 | 10 |
    11 | `), 12 | ); 13 | document.getElementById("first").indeterminate = true; 14 | document.getElementById("second").indeterminate = true; 15 | 16 | let finalSrc = ` 17 |
    18 | 19 | 20 |
    21 | `; 22 | Idiomorph.morph(getWorkArea(), finalSrc, { 23 | morphStyle: "innerHTML", 24 | }); 25 | 26 | getWorkArea().innerHTML.should.equal(finalSrc); 27 | const states = Array.from(getWorkArea().querySelectorAll("input")).map( 28 | (e) => e.indeterminate, 29 | ); 30 | states.should.eql([true, true]); 31 | }); 32 | 33 | it("preserves all non-attribute element state and outerHTML morphStyle", function () { 34 | const div = make(` 35 |
    36 | 37 | 38 |
    39 | `); 40 | getWorkArea().append(div); 41 | document.getElementById("first").indeterminate = true; 42 | document.getElementById("second").indeterminate = true; 43 | 44 | let finalSrc = ` 45 |
    46 | 47 | 48 |
    49 | `; 50 | Idiomorph.morph(div, finalSrc, { morphStyle: "outerHTML" }); 51 | 52 | getWorkArea().innerHTML.should.equal(finalSrc); 53 | const states = Array.from(getWorkArea().querySelectorAll("input")).map( 54 | (e) => e.indeterminate, 55 | ); 56 | states.should.eql([true, true]); 57 | }); 58 | 59 | it("preserves non-attribute state when elements are moved to different levels of the DOM", function () { 60 | getWorkArea().append( 61 | make(` 62 |
    63 | 64 |
    65 | 66 |
    67 |
    68 | `), 69 | ); 70 | document.getElementById("first").indeterminate = true; 71 | document.getElementById("second").indeterminate = true; 72 | 73 | let finalSrc = ` 74 |
    75 | 76 | 77 |
    78 | `; 79 | Idiomorph.morph(getWorkArea(), finalSrc, { 80 | morphStyle: "innerHTML", 81 | }); 82 | 83 | getWorkArea().innerHTML.should.equal(finalSrc); 84 | const states = Array.from(getWorkArea().querySelectorAll("input")).map( 85 | (e) => e.indeterminate, 86 | ); 87 | states.should.eql([true, true]); 88 | }); 89 | 90 | it("preserves non-attribute state when elements are moved between different containers", function () { 91 | getWorkArea().append( 92 | make(` 93 |
    94 |
    95 | 96 |
    97 | 100 |
    101 | `), 102 | ); 103 | document.getElementById("first").indeterminate = true; 104 | document.getElementById("second").indeterminate = true; 105 | 106 | let finalSrc = ` 107 |
    108 |
    109 | 110 |
    111 | 114 |
    115 | `; 116 | Idiomorph.morph(getWorkArea(), finalSrc, { 117 | morphStyle: "innerHTML", 118 | }); 119 | 120 | getWorkArea().innerHTML.should.equal(finalSrc); 121 | const states = Array.from(getWorkArea().querySelectorAll("input")).map( 122 | (e) => e.indeterminate, 123 | ); 124 | states.should.eql([true, true]); 125 | }); 126 | 127 | it("preserves non-attribute state when parents are reorderd", function () { 128 | getWorkArea().append( 129 | make(` 130 |
    131 |
    132 | 133 |
    134 | 137 |
    138 | `), 139 | ); 140 | document.getElementById("first").indeterminate = true; 141 | document.getElementById("second").indeterminate = true; 142 | 143 | let finalSrc = ` 144 |
    145 | 148 |
    149 | 150 |
    151 |
    152 | `; 153 | Idiomorph.morph(getWorkArea(), finalSrc, { 154 | morphStyle: "innerHTML", 155 | }); 156 | 157 | getWorkArea().innerHTML.should.equal(finalSrc); 158 | const states = Array.from(getWorkArea().querySelectorAll("input")).map( 159 | (e) => e.indeterminate, 160 | ); 161 | states.should.eql([true, true]); 162 | }); 163 | 164 | it("duplicate ids on elements aborts matching to avoid invalid morph state", function () { 165 | // we try to reuse existing ids where possible and has to exclude matching on duplicate ids 166 | // to avoid losing content 167 | getWorkArea().append( 168 | make(` 169 |
    170 |
    171 | 172 | 173 |
    174 | 178 |
    179 | `), 180 | ); 181 | document.getElementById("first").focus(); 182 | 183 | let finalSrc = ` 184 |
    185 |
    186 | 187 | 188 |
    189 | 193 |
    194 | `; 195 | Idiomorph.morph(getWorkArea(), finalSrc, { 196 | morphStyle: "innerHTML", 197 | restoreFocus: false, // to capture current reality of focus loss 198 | }); 199 | 200 | getWorkArea().innerHTML.should.equal(finalSrc); 201 | // should have lost active element focus because duplicate ids can not be processed properly 202 | document.activeElement.outerHTML.should.equal(document.body.outerHTML); 203 | }); 204 | 205 | it("duplicate destination ids on elements aborts matching to avoid invalid morph state", function () { 206 | // exclude matching on duplicate ids to avoid losing content 207 | getWorkArea().append( 208 | make(` 209 |
    210 |
    211 | 212 |
    213 | 216 |
    217 | `), 218 | ); 219 | document.getElementById("first").focus(); 220 | 221 | let finalSrc = ` 222 |
    223 |
    224 | 225 | 226 |
    227 | 231 |
    232 | `; 233 | Idiomorph.morph(getWorkArea(), finalSrc, { 234 | morphStyle: "innerHTML", 235 | restoreFocus: false, // to capture current reality of focus loss 236 | }); 237 | 238 | getWorkArea().innerHTML.should.equal(finalSrc); 239 | // should have lost active element focus because duplicate ids can not be processed properly 240 | document.activeElement.outerHTML.should.equal(document.body.outerHTML); 241 | }); 242 | 243 | it("preserves all non-attribute element state and outerHTML morphStyle when morphing to two top level nodes", function () { 244 | // when using outerHTML you can replace one node with two nodes with the state preserving items split and it will just 245 | // pick one best node to morph and just insert the other nodes so need to check these also retain state 246 | const div = make(` 247 |
    248 | 249 | 250 |
    251 | `); 252 | getWorkArea().append(div); 253 | document.getElementById("first").indeterminate = true; 254 | document.getElementById("second").indeterminate = true; 255 | 256 | let finalSrc = ` 257 |
    258 | 259 |
    260 | 261 | `; 262 | Idiomorph.morph(div, finalSrc, { morphStyle: "outerHTML" }); 263 | 264 | getWorkArea().innerHTML.should.equal(finalSrc); 265 | const states = Array.from(getWorkArea().querySelectorAll("input")).map( 266 | (e) => e.indeterminate, 267 | ); 268 | states.should.eql([true, true]); 269 | }); 270 | 271 | it("preserves all non-attribute element state and outerHTML morphStyle when morphing to two top level nodes with nesting", function () { 272 | // when using outerHTML you can replace one node with two nodes with the state preserving items split and it will just 273 | // pick one best node to morph and just insert the other nodes so need to check these also retain state 274 | const div = make(` 275 |
    276 | 277 | 278 |
    279 | `); 280 | getWorkArea().append(div); 281 | document.getElementById("first").indeterminate = true; 282 | document.getElementById("second").indeterminate = true; 283 | 284 | let finalSrc = ` 285 |
    286 | 287 |
    288 |
    289 | 290 |
    291 | `; 292 | Idiomorph.morph(div, finalSrc, { morphStyle: "outerHTML" }); 293 | 294 | getWorkArea().innerHTML.should.equal(finalSrc); 295 | const states = Array.from(getWorkArea().querySelectorAll("input")).map( 296 | (e) => e.indeterminate, 297 | ); 298 | states.should.eql([true, true]); 299 | }); 300 | 301 | it("preserves all non-attribute element state and innerHTML morphStyle when morphing to two top level nodes with nesting", function () { 302 | getWorkArea().innerHTML = ` 303 |
    304 | 305 |
    306 |
    307 |
    308 | 309 |
    310 | `; 311 | document.getElementById("first").indeterminate = true; 312 | document.getElementById("second").indeterminate = true; 313 | 314 | let finalSrc = ` 315 |
    316 | 317 |
    318 |
    319 | 320 |
    321 | `; 322 | Idiomorph.morph(getWorkArea(), finalSrc, { morphStyle: "innerHTML" }); 323 | 324 | getWorkArea().innerHTML.should.equal(finalSrc); 325 | const states = Array.from(getWorkArea().querySelectorAll("input")).map( 326 | (e) => e.indeterminate, 327 | ); 328 | states.should.eql([true, true]); 329 | }); 330 | 331 | it("preserves all non-attribute element state when wrapping element changes tag", function () { 332 | // just changing the type from div to span of the wrapper causes softmatch to fail so it abandons all hope 333 | // of morphing and just inserts the node so we need to check this still handles preserving state here. 334 | const div = make(` 335 |
    336 |
    337 |
    338 | `); 339 | getWorkArea().append(div); 340 | document.getElementById("first").indeterminate = true; 341 | 342 | let finalSrc = ` 343 |
    344 | 345 |
    346 | `; 347 | Idiomorph.morph(div, finalSrc, { morphStyle: "outerHTML" }); 348 | 349 | getWorkArea().innerHTML.should.equal(finalSrc); 350 | const states = Array.from(getWorkArea().querySelectorAll("input")).map( 351 | (e) => e.indeterminate, 352 | ); 353 | states.should.eql([true]); 354 | }); 355 | 356 | it("preserves all non-attribute element state when wrapping element changes tag at top level", function () { 357 | // just changing the type from div to span of the top wrapping item with outerHTML morph will cause morphOldNodeTo function 358 | // to morph the two nodes that don't softmatch that also needs to handle preserving state 359 | const div = make(`
    `); 360 | getWorkArea().append(div); 361 | document.getElementById("first").indeterminate = true; 362 | 363 | let finalSrc = ``; 364 | Idiomorph.morph(div, finalSrc, { morphStyle: "outerHTML" }); 365 | 366 | getWorkArea().innerHTML.should.equal(finalSrc); 367 | const states = Array.from(getWorkArea().querySelectorAll("input")).map( 368 | (e) => e.indeterminate, 369 | ); 370 | states.should.eql([true]); 371 | }); 372 | 373 | it("moveBefore function fails back to insertBefore if moveBefore fails", function () { 374 | getWorkArea().append( 375 | make(` 376 |
    377 | 378 | 379 |
    380 | `), 381 | ); 382 | // replace moveBefore function with a boolean which will fail the try catch 383 | document.getElementById("first").parentNode.moveBefore = true; 384 | 385 | let finalSrc = ` 386 |
    387 | 388 | 389 |
    390 | `; 391 | Idiomorph.morph(getWorkArea(), finalSrc, { 392 | morphStyle: "innerHTML", 393 | }); 394 | 395 | getWorkArea().innerHTML.should.equal(finalSrc); 396 | }); 397 | 398 | it("moveBefore function falls back to insertBefore if moveBefore is missing", function () { 399 | getWorkArea().append( 400 | make(` 401 |
    402 | 403 | 404 |
    405 | `), 406 | ); 407 | // disable moveBefore function to force it to use insertBefore 408 | document.getElementById("first").parentNode.moveBefore = undefined; 409 | 410 | let finalSrc = ` 411 |
    412 | 413 | 414 |
    415 | `; 416 | Idiomorph.morph(getWorkArea(), finalSrc, { 417 | morphStyle: "innerHTML", 418 | }); 419 | 420 | getWorkArea().innerHTML.should.equal(finalSrc); 421 | }); 422 | 423 | it("moveBefore is used if it exists", function () { 424 | const div = make(` 425 |
    426 | 427 | 428 |
    429 | `); 430 | getWorkArea().append(div); 431 | 432 | let called = false; 433 | div.moveBefore = function (element, after) { 434 | called = true; 435 | return div.insertBefore(element, after); 436 | }; 437 | 438 | let finalSrc = ` 439 |
    440 | 441 | 442 |
    443 | `; 444 | Idiomorph.morph(getWorkArea(), finalSrc, { 445 | morphStyle: "innerHTML", 446 | }); 447 | 448 | getWorkArea().innerHTML.should.equal(finalSrc); 449 | called.should.be.true; 450 | }); 451 | }); 452 | -------------------------------------------------------------------------------- /tmp/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bigskysoftware/idiomorph/a6ada993e8c8d7cb30f417983d3b003982d2e9d0/tmp/.gitkeep -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | // "module": "commonjs", /* Specify what module code is generated. */ 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 39 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 40 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 41 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 42 | // "resolveJsonModule": true, /* Enable importing .json files. */ 43 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 44 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 45 | 46 | /* JavaScript Support */ 47 | "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 48 | "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 49 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 50 | 51 | /* Emit */ 52 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 53 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 54 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 55 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 56 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 57 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 58 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 59 | // "removeComments": true, /* Disable emitting comments. */ 60 | "noEmit": true, /* Disable emitting files from a compilation. */ 61 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 62 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 63 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 64 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 65 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 66 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 67 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 68 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 69 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 70 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 71 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 72 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 73 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 74 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 75 | 76 | /* Interop Constraints */ 77 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 78 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 79 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 80 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 81 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 82 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 83 | 84 | /* Type Checking */ 85 | "strict": true, /* Enable all strict type-checking options. */ 86 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 87 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 88 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 89 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 90 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 91 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 92 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 93 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 94 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 95 | "noUnusedParameters": false, /* Raise an error when a function parameter isn't read. */ 96 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 97 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 98 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 99 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 100 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 101 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 102 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 103 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 104 | 105 | /* Completeness */ 106 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 107 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 108 | }, 109 | "include": ["src/idiomorph.js"] 110 | } 111 | -------------------------------------------------------------------------------- /web-test-runner.config.mjs: -------------------------------------------------------------------------------- 1 | import { 2 | chromeLauncher, 3 | summaryReporter, 4 | defaultReporter, 5 | } from "@web/test-runner"; 6 | import { exec } from "child_process"; 7 | import failOnly from "./test/lib/fail-only.mjs"; 8 | 9 | let config = { 10 | testRunnerHtml: (testFramework) => ` 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | Work Area 30 |
    31 |
    32 |         Output Here...
    33 |       
    34 | 35 | 36 | `, 37 | 38 | nodeResolve: true, 39 | coverage: true, 40 | coverageConfig: { 41 | include: ["src/**/*"], 42 | }, 43 | files: "test/*.js", 44 | plugins: [failOnly], 45 | reporters: [summaryReporter(), defaultReporter()], 46 | }; 47 | 48 | export default config; 49 | --------------------------------------------------------------------------------