diff --git a/gems.rb b/gems.rb index cde0b2d..47b7c12 100644 --- a/gems.rb +++ b/gems.rb @@ -20,4 +20,7 @@ gem "bake-test" gem "bake-test-external" + + gem "sus-fixtures-async-http" + gem "sus-fixtures-async-webdriver" end diff --git a/guides/getting-started/README.md b/guides/getting-started/readme.md similarity index 100% rename from guides/getting-started/README.md rename to guides/getting-started/readme.md diff --git a/lib/live/element.rb b/lib/live/element.rb index 6f6060e..627a8ea 100644 --- a/lib/live/element.rb +++ b/lib/live/element.rb @@ -23,11 +23,19 @@ def initialize(id, **data) # Generate a JavaScript string which forwards the specified event to the server. # @parameter details [Hash] The details associated with the forwarded event. - def forward(details = nil) + def forward_event(details = nil) if details - "live.forward(#{JSON.dump(@id)}, event, #{JSON.dump(details)})" + "live.forwardEvent(#{JSON.dump(@id)}, event, #{JSON.dump(details)})" else - "live.forward(#{JSON.dump(@id)}, event)" + "live.forwardEvent(#{JSON.dump(@id)}, event)" + end + end + + def forward_form_event(details = nil) + if details + "live.forwardFormEvent(#{JSON.dump(@id)}, event, #{JSON.dump(details)})" + else + "live.forwardFormEvent(#{JSON.dump(@id)}, event)" end end @@ -49,9 +57,9 @@ def handle(event) # Enqueue a remote procedure call to the currently bound page. # @parameter method [Symbol] The name of the remote functio to invoke. # @parameter arguments [Array] - def rpc(method, arguments) + def rpc(*arguments) # This update might not be sent right away. Therefore, mutable arguments may be serialized to JSON at a later time (or never). This could be a race condition: - @page.updates.enqueue([method, arguments]) + @page.updates.enqueue(arguments) end end end diff --git a/lib/live/page.rb b/lib/live/page.rb index b033915..ab656cc 100644 --- a/lib/live/page.rb +++ b/lib/live/page.rb @@ -50,7 +50,7 @@ def handle(id, event) if element = @elements[id] return element.handle(event) else - Console.logger.warn(self, "Could not handle event:", event, details) + Console.warn(self, "Could not handle event:", event, details) end return nil @@ -68,12 +68,12 @@ def process_message(message) if element = self.resolve(id, data) self.bind(element) else - Console.logger.warn(self, "Could not resolve element:", message) + Console.warn(self, "Could not resolve element:", message) end elsif id = message[:id] self.handle(id, message[:event]) else - Console.logger.warn(self, "Unhandled message:", message) + Console.warn(self, "Unhandled message:", message) end end @@ -82,7 +82,7 @@ def process_message(message) def run(connection) queue_task = Async do while update = @updates.dequeue - Console.logger.debug(self, "Sending update:", update) + Console.debug(self, "Sending update:", update) connection.write(::Protocol::WebSocket::JSONMessage.generate(update)) connection.flush if @updates.empty? @@ -90,12 +90,12 @@ def run(connection) end while message = connection.read - Console.logger.debug(self, "Reading message:", message) + Console.debug(self, "Reading message:", message) if json_message = ::Protocol::WebSocket::JSONMessage.wrap(message) process_message(json_message.parse) else - Console.logger.warn(self, "Unhandled message:", message) + Console.warn(self, "Unhandled message:", message) end end ensure diff --git a/lib/live/view.rb b/lib/live/view.rb index b4cbc8f..43052bd 100644 --- a/lib/live/view.rb +++ b/lib/live/view.rb @@ -9,21 +9,40 @@ module Live # Represents a single division of content on the page an provides helpers for rendering the content. class View < Element + # Update the content of the client-side element by rendering this view. + def update!(**options) + rpc(:update, @id, self.to_html, options) + end + # Replace the content of the client-side element by rendering this view. - def replace! - rpc(:replace, [@id, self.to_html]) + # @parameter selector [String] The CSS selector to replace. + # @parameter node [String] The HTML to replace. + def replace(selector, node, **options) + rpc(:replace, selector, node.to_s, options) + end + + # Prepend to the content of the client-side element by appending the specified element. + # @parameter selector [String] The CSS selector to prepend to. + # @parameter node [String] The HTML to prepend. + def prepend(selector, node, **options) + rpc(:prepend, selector, node.to_s, options) end # Append to the content of the client-side element by appending the specified element. - # @parameter node [Live::Element] The element to append. - def append!(element) - rpc(:append, [@id, element.to_html]) + # @parameter selector [String] The CSS selector to append to. + # @parameter node [String] The HTML to prepend. + def append(selector, node, **options) + rpc(:append, selector, node.to_s, options) end - # Prepend to the content of the client-side element by appending the specified element. - # @parameter node [Live::Element] The element to prepend. - def prepend!(element) - rpc(:prepend, [@id, element.to_html]) + # Remove the specified element from the client-side element. + # @parameter selector [String] The CSS selector to remove. + def remove(selector, **options) + rpc(:remove, selector, options) + end + + def dispatch_event(selector, type, **options) + rpc(:dispatch_event, selector, event, options) end # Render the element. diff --git a/test/live/.website/index.html b/test/live/.website/index.html new file mode 100644 index 0000000..228d3c1 --- /dev/null +++ b/test/live/.website/index.html @@ -0,0 +1,22 @@ + + + + Live Test + + + +
+ + + \ No newline at end of file diff --git a/test/live/.website/node_modules/.package-lock.json b/test/live/.website/node_modules/.package-lock.json new file mode 100644 index 0000000..5e58f79 --- /dev/null +++ b/test/live/.website/node_modules/.package-lock.json @@ -0,0 +1,20 @@ +{ + "name": ".website", + "lockfileVersion": 3, + "requires": true, + "packages": { + "node_modules/@socketry/live": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@socketry/live/-/live-0.7.0.tgz", + "integrity": "sha512-dyo0ou5hc4C4hsBY04IPYzagVQ56ew0MuIE98eG7SySvialc/Z0qhXyeM7NWqm9yNlkXdGgZiwy8t2qi9Rzeyg==", + "dependencies": { + "morphdom": "^2.7" + } + }, + "node_modules/morphdom": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.2.tgz", + "integrity": "sha512-Dqb/lHFyTi7SZpY0a5R4I/0Edo+iPMbaUexsHHsLAByyixCDiLHPHyVoKVmrpL0THcT7V9Cgev9y21TQYq6wQg==" + } + } +} diff --git a/test/live/.website/node_modules/@socketry/live/Live.js b/test/live/.website/node_modules/@socketry/live/Live.js new file mode 100644 index 0000000..d92eb9e --- /dev/null +++ b/test/live/.website/node_modules/@socketry/live/Live.js @@ -0,0 +1,214 @@ + +import morphdom from 'morphdom'; + +export class Live { + static start(options = {}) { + let window = options.window || globalThis; + let path = options.path || 'live' + let base = options.base || window.location.href; + + let url = new URL(path, base); + url.protocol = url.protocol.replace('http', 'ws'); + + return new this(window, url); + } + + constructor(window, url) { + this.window = window; + this.document = window.document; + + this.url = url; + this.events = []; + + this.failures = 0; + + // Track visibility state and connect if required: + this.document.addEventListener("visibilitychange", () => this.handleVisibilityChange()); + this.handleVisibilityChange(); + } + + // -- Connection Handling -- + + connect() { + if (this.server) return this.server; + + let server = this.server = new this.window.WebSocket(this.url); + + server.onopen = () => { + this.failures = 0; + this.attach(); + }; + + server.onmessage = (message) => { + const [name, ..._arguments] = JSON.parse(message.data); + + this[name](..._arguments); + }; + + // The remote end has disconnected: + server.addEventListener('error', () => { + this.failures += 1; + }); + + server.addEventListener('close', () => { + // Explicit disconnect will clear `this.server`: + if (this.server) { + // We need a minimum delay otherwise this can end up immediately invoking the callback: + const delay = Math.max(100 * (this.failures + 1) ** 2, 60000); + setTimeout(() => this.connect(), delay); + } + + this.server = null; + }); + + return server; + } + + disconnect() { + if (this.server) { + const server = this.server; + this.server = null; + server.close(); + } + } + + send(message) { + try { + this.server.send(message); + } catch (error) { + this.events.push(message); + } + } + + flush() { + if (this.events.length === 0) return; + + let events = this.events; + this.events = []; + + for (var event of events) { + this.send(event); + } + } + + bind(elements) { + for (var element of elements) { + this.send(JSON.stringify({bind: element.id, data: element.dataset})); + } + } + + bindElementsByClassName(selector = 'live') { + this.bind( + this.document.getElementsByClassName(selector) + ); + + this.flush(); + } + + handleVisibilityChange() { + if (this.document.hidden) { + this.disconnect(); + } else { + this.connect(); + } + } + + attach() { + if (this.document.readyState === 'loading') { + this.document.addEventListener('DOMContentLoaded', () => this.bindElementsByClassName()); + } else { + this.bindElementsByClassName(); + } + } + + createDocumentFragment(html) { + return this.document.createRange().createContextualFragment(html); + } + + reply(options) { + if (options && options.reply) { + this.send(JSON.stringify({reply: options.reply})); + } + } + + // -- RPC Methods -- + + update(id, html, options) { + let element = this.document.getElementById(id); + let fragment = this.createDocumentFragment(html); + + morphdom(element, fragment); + + this.reply(options); + } + + replace(selector, html, options) { + let elements = this.document.querySelectorAll(selector); + let fragment = this.createDocumentFragment(html); + + elements.forEach(element => morphdom(element, fragment.cloneNode(true))); + + this.reply(options); + } + + prepend(selector, html, options) { + let elements = this.document.querySelectorAll(selector); + let fragment = this.createDocumentFragment(html); + + elements.forEach(element => element.prepend(fragment.cloneNode(true))); + + this.reply(options); + } + + append(selector, html, options) { + let elements = this.document.querySelectorAll(selector); + let fragment = this.createDocumentFragment(html); + + elements.forEach(element => element.append(fragment.cloneNode(true))); + + this.reply(options); + } + + remove(selector, options) { + let elements = this.document.querySelectorAll(selector); + + elements.forEach(element => element.remove()); + + this.reply(options); + } + + dispatchEvent(selector, type, options) { + let elements = this.document.querySelectorAll(selector); + + elements.forEach(element => element.dispatchEvent( + new this.window.CustomEvent(type, options) + )); + + this.reply(options); + } + + // -- Event Handling -- + + forward(id, event) { + this.connect(); + + this.send( + JSON.stringify({id: id, event: event}) + ); + } + + forwardEvent(id, event, detail) { + event.preventDefault(); + + this.forward(id, {type: event.type, detail: detail}); + } + + forwardFormEvent(id, event, detail) { + event.preventDefault(); + + let form = event.form; + let formData = new FormData(form); + + this.forward(id, {type: event.type, detail: detail, formData: [...formData]}); + } +} diff --git a/test/live/.website/node_modules/@socketry/live/package.json b/test/live/.website/node_modules/@socketry/live/package.json new file mode 100644 index 0000000..618af4e --- /dev/null +++ b/test/live/.website/node_modules/@socketry/live/package.json @@ -0,0 +1,33 @@ +{ + "name": "@socketry/live", + "type": "module", + "version": "0.7.0", + "description": "Live HTML tags for Ruby.", + "main": "Live.js", + "repository": { + "type": "git", + "url": "git+https://github.com/socketry/live-js.git" + }, + "scripts": { + "test": "node --test" + }, + "devDependencies": { + "jsdom": "^24.0", + "ws": "^8.17" + }, + "dependencies": { + "morphdom": "^2.7" + }, + "keywords": [ + "live", + "dynamic", + "html", + "ruby" + ], + "author": "Samuel Williams (http://www.codeotaku.com/)", + "license": "MIT", + "bugs": { + "url": "https://github.com/socketry/live-js/issues" + }, + "homepage": "https://github.com/socketry/live-js#readme" +} diff --git a/test/live/.website/node_modules/@socketry/live/readme.md b/test/live/.website/node_modules/@socketry/live/readme.md new file mode 100644 index 0000000..7d68d1c --- /dev/null +++ b/test/live/.website/node_modules/@socketry/live/readme.md @@ -0,0 +1,58 @@ +# Live (JavaScript) + +This is the JavaScript library for implementing the Ruby gem of the same name. + +## Document Manipulation + +### `live.update(id, html, options)` + +Updates the content of the element with the given `id` with the given `html`. The `options` parameter is optional and can be used to pass additional options to the update method. + +- `options.reply` - If truthy, the server will reply with `{reply: options.reply}`. + +### `live.replace(selector, html, options)` + +Replaces the element(s) selected by the given `selector` with the given `html`. The `options` parameter is optional and can be used to pass additional options to the replace method. + +- `options.reply` - If truthy, the server will reply with `{reply: options.reply}`. + +### `live.prepend(selector, html, options)` + +Prepends the given `html` to the element(s) selected by the given `selector`. The `options` parameter is optional and can be used to pass additional options to the prepend method. + +- `options.reply` - If truthy, the server will reply with `{reply: options.reply}`. + +### `live.append(selector, html, options)` + +Appends the given `html` to the element(s) selected by the given `selector`. The `options` parameter is optional and can be used to pass additional options to the append method. + +- `options.reply` - If truthy, the server will reply with `{reply: options.reply}`. + +### `live.remove(selector, options)` + +Removes the element(s) selected by the given `selector`. The `options` parameter is optional and can be used to pass additional options to the remove method. + +- `options.reply` - If truthy, the server will reply with `{reply: options.reply}`. + +### `live.dispatchEvent(selector, type, options)` + +Dispatches an event of the given `type` on the element(s) selected by the given `selector`. The `options` parameter is optional and can be used to pass additional options to the dispatchEvent method. + +- `options.detail` - The detail object to pass to the event. +- `options.bubbles` - A boolean indicating whether the event should bubble up through the DOM. +- `options.cancelable` - A boolean indicating whether the event can be canceled. +- `options.composed` - A boolean indicating whether the event will trigger listeners outside of a shadow root. + +## Event Handling + +### `live.forward(id, event)` + +Connect and forward an event on the element with the given `id`. If the connection can't be established, the event will be buffered. + +### `live.forwardEvent(id, event, details)` + +Forward a HTML DOM event to the server. The `details` parameter is optional and can be used to pass additional details to the server. + +### `live.forwardFormEvent(id, event, details)` + +Forward an event which has form data to the server. The `details` parameter is optional and can be used to pass additional details to the server. diff --git a/test/live/.website/node_modules/@socketry/live/test/Live.js b/test/live/.website/node_modules/@socketry/live/test/Live.js new file mode 100644 index 0000000..70ac380 --- /dev/null +++ b/test/live/.website/node_modules/@socketry/live/test/Live.js @@ -0,0 +1,285 @@ +import {describe, before, after, it} from 'node:test'; +import {ok, strict, strictEqual} from 'node:assert'; + +import {WebSocket} from 'ws'; +import {JSDOM} from 'jsdom'; +import {Live} from '../Live.js'; + +describe('Live', function () { + let dom; + let webSocketServer; + + const webSocketServerConfig = {port: 3000}; + const webSocketServerURL = `ws://localhost:${webSocketServerConfig.port}/live`; + + before(async function () { + const listening = new Promise(resolve => { + webSocketServer = new WebSocket.Server(webSocketServerConfig, resolve); + }); + + dom = new JSDOM('

Hello World

'); + // Ensure the WebSocket class is available: + dom.window.WebSocket = WebSocket; + + await new Promise(resolve => dom.window.addEventListener('load', resolve)); + + await listening; + }); + + after(function () { + webSocketServer.close(); + }); + + it('should start the live connection', function () { + const live = Live.start({window: dom.window, base: 'http://localhost/'}); + ok(live); + + strictEqual(live.window, dom.window); + strictEqual(live.document, dom.window.document); + strictEqual(live.url.href, 'ws://localhost/live'); + }); + + it('should connect to the WebSocket server', function () { + const live = new Live(dom.window, webSocketServerURL); + + const server = live.connect(); + ok(server); + + live.disconnect(); + }); + + it('should handle visibility changes', function () { + const live = new Live(dom.window, webSocketServerURL); + + var hidden = false; + Object.defineProperty(dom.window.document, "hidden", { + get() {return hidden}, + }); + + live.handleVisibilityChange(); + + ok(live.server); + + hidden = true; + + live.handleVisibilityChange(); + + ok(!live.server); + }); + + it('should handle updates', async function () { + const live = new Live(dom.window, webSocketServerURL); + + live.connect(); + + const connected = new Promise(resolve => { + webSocketServer.on('connection', resolve); + }); + + let socket = await connected; + + const reply = new Promise((resolve, reject) => { + socket.on('message', message => { + let payload = JSON.parse(message); + if (payload.reply) resolve(payload); + }); + }); + + socket.send( + JSON.stringify(['update', 'my', '

Goodbye World!

', {reply: true}]) + ); + + await reply; + + strictEqual(dom.window.document.getElementById('my').innerHTML, '

Goodbye World!

'); + + live.disconnect(); + }); + + it('should handle replacements', async function () { + const live = new Live(dom.window, webSocketServerURL); + + live.connect(); + + const connected = new Promise(resolve => { + webSocketServer.on('connection', resolve); + }); + + let socket = await connected; + + const reply = new Promise((resolve, reject) => { + socket.on('message', message => { + let payload = JSON.parse(message); + if (payload.reply) resolve(payload); + }); + }); + + socket.send( + JSON.stringify(['replace', '#my p', '

Replaced!

', {reply: true}]) + ); + + await reply; + + strictEqual(dom.window.document.getElementById('my').innerHTML, '

Replaced!

'); + + live.disconnect(); + }); + + it('should handle prepends', async function () { + const live = new Live(dom.window, webSocketServerURL); + + live.connect(); + + const connected = new Promise(resolve => { + webSocketServer.on('connection', resolve); + }); + + let socket = await connected; + + socket.send( + JSON.stringify(['update', 'my', '

Middle

']) + ); + + const reply = new Promise((resolve, reject) => { + socket.on('message', message => { + let payload = JSON.parse(message); + if (payload.reply) resolve(payload); + }); + }); + + socket.send( + JSON.stringify(['prepend', '#my', '

Prepended!

', {reply: true}]) + ); + + await reply; + + strictEqual(dom.window.document.getElementById('my').innerHTML, '

Prepended!

Middle

'); + + live.disconnect(); + }); + + it('should handle appends', async function () { + const live = new Live(dom.window, webSocketServerURL); + + live.connect(); + + const connected = new Promise(resolve => { + webSocketServer.on('connection', resolve); + }); + + let socket = await connected; + + socket.send( + JSON.stringify(['update', 'my', '

Middle

']) + ); + + const reply = new Promise((resolve, reject) => { + socket.on('message', message => { + let payload = JSON.parse(message); + if (payload.reply) resolve(payload); + }); + }); + + socket.send( + JSON.stringify(['append', '#my', '

Appended!

', {reply: true}]) + ); + + await reply; + + strictEqual(dom.window.document.getElementById('my').innerHTML, '

Middle

Appended!

'); + + live.disconnect(); + }); + + it ('should handle removals', async function () { + const live = new Live(dom.window, webSocketServerURL); + + live.connect(); + + const connected = new Promise(resolve => { + webSocketServer.on('connection', resolve); + }); + + let socket = await connected; + + socket.send( + JSON.stringify(['update', 'my', '

Middle

']) + ); + + const reply = new Promise((resolve, reject) => { + socket.on('message', message => { + let payload = JSON.parse(message); + if (payload.reply) resolve(payload); + }); + }); + + socket.send( + JSON.stringify(['remove', '#my p', {reply: true}]) + ); + + await reply; + + strictEqual(dom.window.document.getElementById('my').innerHTML, ''); + + live.disconnect(); + }); + + it ('can dispatch custom events', async function () { + const live = new Live(dom.window, webSocketServerURL); + + live.connect(); + + const connected = new Promise(resolve => { + webSocketServer.on('connection', resolve); + }); + + let socket = await connected; + + const reply = new Promise((resolve, reject) => { + socket.on('message', message => { + let payload = JSON.parse(message); + if (payload.reply) resolve(payload); + }); + }); + + socket.send( + JSON.stringify(['dispatchEvent', '#my', 'click', {reply: true}]) + ); + + await reply; + + live.disconnect(); + }); + + it ('can forward events', async function () { + const live = new Live(dom.window, webSocketServerURL); + + live.connect(); + + const connected = new Promise(resolve => { + webSocketServer.on('connection', resolve); + }); + + let socket = await connected; + + const reply = new Promise((resolve, reject) => { + socket.on('message', message => { + let payload = JSON.parse(message); + if (payload.event) resolve(payload); + }); + }); + + dom.window.document.getElementById('my').addEventListener('click', event => { + live.forwardEvent('my', event); + }); + + dom.window.document.getElementById('my').click(); + + let payload = await reply; + + strictEqual(payload.id, 'my'); + strictEqual(payload.event.type, 'click'); + + live.disconnect(); + }); +}); diff --git a/test/live/.website/node_modules/morphdom/CHANGELOG.md b/test/live/.website/node_modules/morphdom/CHANGELOG.md new file mode 100644 index 0000000..61d58e9 --- /dev/null +++ b/test/live/.website/node_modules/morphdom/CHANGELOG.md @@ -0,0 +1,237 @@ +Changelog +========= + +# 2.x + +## 2.7.2 +- Fix morphing duplicate ids of incompatible tags + +## 2.7.1 +- Pass toEl as second argument to `skipFromChildren` callback + +## 2.7.0 + +- Add new `addChild` and `skipFromChildren` callbacks to allow customization of how new children are +added to a parent as well as preserving the from tree when indexing changes for diffing. + +## 2.5.12 + +- Fix merge attrs with multiple properties [PR #175](https://github.com/patrick-steele-idem/morphdom/pull/175) + +## 2.5.11 + +- Multiple forms duplication [PR #174](https://github.com/patrick-steele-idem/morphdom/pull/174) + +## 2.5.10 + +- Pr/167 - Allow document fragment patching [PR #168](https://github.com/patrick-steele-idem/morphdom/pull/168) + +## 2.5.9 + +- Faster attrs merge [PR #165](https://github.com/patrick-steele-idem/morphdom/pull/165) + +## 2.5.8 + +- Minor improvements [PR #164](https://github.com/patrick-steele-idem/morphdom/pull/164) + +## 2.5.7 + +- Chore: Alternate refactor to #155 - Move isSameNode check [PR #156](https://github.com/patrick-steele-idem/morphdom/pull/156) +- Use attribute name with the prefix in XMLNS namespace [PR #133](https://github.com/patrick-steele-idem/morphdom/pull/133) + +## 2.5.6 + +- fixed the string with space trouble [PR #161](https://github.com/patrick-steele-idem/morphdom/pull/161) + +## 2.5.5 + +- Template support for creating element from string [PR #159](https://github.com/patrick-steele-idem/morphdom/pull/159) + +## 2.5.4 + +- Enhancement: Fix id key removal from tree when the element with key is inside a document fragment node (ex: shadow dom) [PR #119](https://github.com/patrick-steele-idem/morphdom/pull/119) +- Minor: small refactor to morphEl to own function [PR #149](small refactor to morphEl to own function) +- selectNode for range b/c documentElement not avail in Safari [commit](https://github.com/patrick-steele-idem/morphdom/commit/6afd2976ab4fac4d8e1575975531644ecc62bc1d) +- clarify getNodeKey docs [PR #151](https://github.com/patrick-steele-idem/morphdom/pull/151) + +## 2.5.3 + +- Minor: update deps [PR #145](https://github.com/patrick-steele-idem/morphdom/pull/145) +- Minor: Minor comments and very very minor refactors [PR #143](https://github.com/patrick-steele-idem/morphdom/pull/143) + +## 2.5.2 + +- New dist for 2.5.1. My bad! + +## 2.5.1 + +- Bugfix: Fix bug where wrong select option would get selected. [PR #117](https://github.com/patrick-steele-idem/morphdom/pull/117) + +## 2.5.0 + +- Enhancement: Publish es6 format as morphdom-esm.js [PR #141](https://github.com/patrick-steele-idem/morphdom/pull/141) +- Enhancement: Start removing old browser support code paths [PR #140](https://github.com/patrick-steele-idem/morphdom/pull/140) + +## 2.4.0 + +- Enhancement: Rollup 1.0 [PR #139](https://github.com/patrick-steele-idem/morphdom/pull/139) +- Enhancement: Add Typescript declaration file [PR #138](https://github.com/patrick-steele-idem/morphdom/pull/138) + +## 2.3.x + +### 2.3.1 + +- Bug: Fixed losing cursor position in Edge ([PR #100](https://github.com/patrick-steele-idem/morphdom/pull/100) by [@zastavnitskiy](https://github.com/zastavnitskiy)) + +### 2.3.0 + +- Changes to improve code maintainability. Single file is now split out into multiple modules and [rollup](https://github.com/rollup/rollup) is used to build the distribution files. + +## 2.2.x + +### 2.2.2 + +- Changes to ensure that `selectedIndex` is updated correctly in all browsers ([PR #94](https://github.com/patrick-steele-idem/morphdom/pull/94) by [@aknuds1](https://github.com/aknuds1)) + +### 2.2.1 + +- IE-specific bug: fix `