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('');
+ // 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', '', {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', ''])
+ );
+
+ 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', ''])
+ );
+
+ 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', ''])
+ );
+
+ 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 `