[READ-ONLY] Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
6

Configure Feed

Select the types of activity you want to include in your feed.

Add vdom diffing for plugin rendering

Grace Kind (May 19, 2026, 3:54 PM -0500) e0b98212 c75837ba

+381 -63
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.14.64", 3 + "version": "0.14.65", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf build && NODE_ENV=development eleventy --serve",
+3 -3
src/js/modals.js
··· 318 318 319 319 modal.contentEl.replaceChildren(); 320 320 if (!pluginRenderer.isEmptyNode(title)) { 321 - const titleEl = pluginRenderer.renderNode(title, pluginId); 321 + const titleEl = pluginRenderer.createRoot().render(title); 322 322 titleEl.classList.add("modal-dialog-title"); 323 323 modal.contentEl.appendChild(titleEl); 324 324 } 325 325 if (content?.children?.length) { 326 326 for (const childNode of content.children) { 327 327 modal.contentEl.appendChild( 328 - pluginRenderer.renderNode(childNode, pluginId), 328 + pluginRenderer.createRoot().render(childNode), 329 329 ); 330 330 } 331 331 } else if (!pluginRenderer.isEmptyNode(content)) { 332 - modal.contentEl.appendChild(pluginRenderer.renderNode(content, pluginId)); 332 + modal.contentEl.appendChild(pluginRenderer.createRoot().render(content)); 333 333 } 334 334 modal.isOpen = true; 335 335 modal.dialog.showModal();
+133 -34
src/js/plugins/pluginRendering.js
··· 76 76 }; 77 77 } 78 78 79 + const HANDLER_MAP = Symbol("pluginHandlerMap"); 80 + 81 + function resolveTag(node, pluginId) { 82 + let tag = typeof node.tag === "string" ? node.tag.toLowerCase() : "div"; 83 + if (!isAllowedTag(tag)) { 84 + if (pluginId !== undefined) { 85 + console.warn( 86 + `[plugins] "${pluginId}" tried to render disallowed tag <${tag}>`, 87 + ); 88 + } 89 + tag = "span"; 90 + } 91 + if (tag === "input" && node.attrs?.type === "checkbox") tag = "toggle-switch"; 92 + return tag; 93 + } 94 + 79 95 // Render a serialized VirtualEl node ({ tag, attrs, text, children }) into a 80 96 // real element. Text and children are mutually exclusive on 81 97 // the worker side (setText() clears children). 82 98 export class PluginRenderer { 83 - constructor(pluginBridge) { 99 + constructor(pluginBridge, pluginId) { 84 100 this.pluginBridge = pluginBridge; 101 + this.pluginId = pluginId; 85 102 } 86 - renderNode(node, pluginId) { 87 - let tag = typeof node.tag === "string" ? node.tag.toLowerCase() : "div"; 88 - if (!isAllowedTag(tag)) { 89 - console.warn( 90 - `[plugins] "${pluginId}" tried to render disallowed tag <${tag}>`, 91 - ); 92 - tag = "span"; 93 - } 94 - const aliasedAsToggle = tag === "input" && node.attrs?.type === "checkbox"; 95 - if (aliasedAsToggle) { 96 - tag = "toggle-switch"; 97 - } 103 + 104 + createRoot() { 105 + const renderer = this; 106 + const pluginId = this.pluginId; 107 + return { 108 + tree: null, 109 + el: null, 110 + render(node) { 111 + if (this.el && renderer._sameType(this.tree, node)) { 112 + renderer._patch(this.el, this.tree, node, pluginId); 113 + } else { 114 + this.el = renderer._create(node, pluginId); 115 + } 116 + this.tree = node; 117 + return this.el; 118 + }, 119 + }; 120 + } 121 + 122 + _sameType(oldNode, newNode) { 123 + if (!oldNode || !newNode) return false; 124 + return resolveTag(oldNode) === resolveTag(newNode); 125 + } 126 + 127 + _create(node, pluginId) { 128 + const tag = resolveTag(node, pluginId); 98 129 const element = document.createElement(tag); 99 - if (aliasedAsToggle) { 130 + if (tag === "toggle-switch") { 100 131 // toggle-switch is controlled — flip its state here since the plugin 101 132 // worker can't observe events synchronously to re-render. 102 - element.addEventListener("change", (e) => { 103 - element.checked = e.detail?.checked ?? !element.checked; 133 + element.addEventListener("change", (event) => { 134 + element.checked = event.detail?.checked ?? !element.checked; 104 135 }); 105 136 } 106 137 if (node.attrs) { ··· 114 145 element.setAttribute(name, String(value)); 115 146 } 116 147 } 117 - if (node.events && typeof node.events === "object") { 118 - for (const [name, handlerId] of Object.entries(node.events)) { 119 - if (!ALLOWED_EVENTS.includes(name)) { 120 - console.warn( 121 - `[plugins] "${pluginId}" tried to bind disallowed event "${name}"`, 122 - ); 123 - continue; 124 - } 125 - element.addEventListener(name, (e) => { 126 - this.pluginBridge.handleNodeEvent( 127 - pluginId, 128 - handlerId, 129 - createVirtualEvent(e), 130 - ); 131 - }); 132 - } 133 - } 148 + this._patchEvents(element, null, node.events, pluginId); 134 149 if (node.text != null) { 135 150 element.textContent = node.text; 136 151 } else if (Array.isArray(node.children)) { 137 152 for (const child of node.children) { 138 - element.appendChild(this.renderNode(child, pluginId)); 153 + element.appendChild(this._create(child, pluginId)); 139 154 } 140 155 } 141 156 return element; 157 + } 158 + 159 + _patch(element, oldNode, newNode, pluginId) { 160 + const oldAttrs = oldNode.attrs ?? {}; 161 + const newAttrs = newNode.attrs ?? {}; 162 + const isFocused = document.activeElement === element; 163 + 164 + for (const name of Object.keys(oldAttrs)) { 165 + if (!(name in newAttrs) && isAllowedAttr(name)) { 166 + element.removeAttribute(name); 167 + } 168 + } 169 + for (const [name, value] of Object.entries(newAttrs)) { 170 + if (!isAllowedAttr(name)) { 171 + console.warn( 172 + `[plugins] "${pluginId}" tried to set disallowed attribute "${name}"`, 173 + ); 174 + continue; 175 + } 176 + // Don't clobber what the user is currently editing. 177 + if (isFocused && (name === "value" || name === "checked")) continue; 178 + if (oldAttrs[name] !== value) element.setAttribute(name, String(value)); 179 + } 180 + 181 + this._patchEvents(element, oldNode.events, newNode.events, pluginId); 182 + 183 + if (newNode.text != null) { 184 + if (newNode.text !== oldNode.text) element.textContent = newNode.text; 185 + return; 186 + } 187 + 188 + const oldChildren = Array.isArray(oldNode.children) ? oldNode.children : []; 189 + const newChildren = Array.isArray(newNode.children) ? newNode.children : []; 190 + // If old node had text, clear it before reconciling children. 191 + if (oldNode.text != null) { 192 + element.textContent = ""; 193 + } 194 + const domChildren = Array.from(element.childNodes); 195 + const max = Math.max(oldChildren.length, newChildren.length); 196 + for (let index = 0; index < max; index++) { 197 + const oldChild = oldChildren[index]; 198 + const newChild = newChildren[index]; 199 + const domChild = domChildren[index]; 200 + if (!oldChild && newChild) { 201 + element.appendChild(this._create(newChild, pluginId)); 202 + } else if (oldChild && !newChild) { 203 + if (domChild) element.removeChild(domChild); 204 + } else if (this._sameType(oldChild, newChild)) { 205 + this._patch(domChild, oldChild, newChild, pluginId); 206 + } else { 207 + element.replaceChild(this._create(newChild, pluginId), domChild); 208 + } 209 + } 210 + } 211 + 212 + _patchEvents(element, oldEvents, newEvents, pluginId) { 213 + const map = (element[HANDLER_MAP] ??= {}); 214 + const next = newEvents && typeof newEvents === "object" ? newEvents : {}; 215 + if (oldEvents) { 216 + for (const name of Object.keys(oldEvents)) { 217 + if (!(name in next)) delete map[name]; 218 + } 219 + } 220 + for (const [name, handlerId] of Object.entries(next)) { 221 + if (!ALLOWED_EVENTS.includes(name)) { 222 + console.warn( 223 + `[plugins] "${pluginId}" tried to bind disallowed event "${name}"`, 224 + ); 225 + continue; 226 + } 227 + const isNew = !(name in map); 228 + map[name] = handlerId; 229 + if (isNew) { 230 + element.addEventListener(name, (event) => { 231 + const currentId = element[HANDLER_MAP]?.[name]; 232 + if (currentId == null) return; 233 + this.pluginBridge.handleNodeEvent( 234 + pluginId, 235 + currentId, 236 + createVirtualEvent(event), 237 + ); 238 + }); 239 + } 240 + } 142 241 } 143 242 144 243 isEmptyNode(node) {
+14 -3
src/js/plugins/pluginService.js
··· 68 68 this.sourceProvider, 69 69 this.pluginStylesLoader, 70 70 ); 71 - this.pluginRenderer = new PluginRenderer(this.pluginBridge); 71 + this._pluginRenderers = new Map(); 72 72 this.prefManager = new PluginPreferencesManager(preferencesProvider); 73 73 this.session = session; 74 74 this._setupRegistries(); 75 75 this._setupHostMethods(); 76 76 } 77 77 78 + getRenderer(pluginId) { 79 + let renderer = this._pluginRenderers.get(pluginId); 80 + if (!renderer) { 81 + renderer = new PluginRenderer(this.pluginBridge, pluginId); 82 + this._pluginRenderers.set(pluginId, renderer); 83 + } 84 + return renderer; 85 + } 86 + 78 87 _setupRegistries() { 79 88 this.pluginBridge.addRegistrationTarget( 80 89 "sidebarItem", ··· 132 141 "openModal", 133 142 (plugin, { modalId, title, content }) => { 134 143 showPluginModal({ 135 - pluginRenderer: this.pluginRenderer, 144 + pluginRenderer: this.getRenderer(plugin.pluginId), 136 145 pluginId: plugin.pluginId, 137 146 modalId, 138 147 title, ··· 191 200 "showToast", 192 201 (plugin, { toastId, element, timeout }) => { 193 202 showPluginToast({ 194 - pluginRenderer: this.pluginRenderer, 203 + pluginRenderer: this.getRenderer(plugin.pluginId), 195 204 pluginId: plugin.pluginId, 196 205 toastId, 197 206 element, ··· 417 426 418 427 async uninstallPlugin(pluginId) { 419 428 this.pluginBridge.unloadPlugin(pluginId); 429 + this._pluginRenderers.delete(pluginId); 420 430 await this.prefManager.removeInstalledPlugin(pluginId); 421 431 await this.prefManager.clearSettingsForPlugin(pluginId); 422 432 await this._reconcileCache(this.prefManager.getInstalledPlugins()); ··· 439 449 440 450 async disablePlugin(pluginId) { 441 451 this.pluginBridge.unloadPlugin(pluginId); 452 + this._pluginRenderers.delete(pluginId); 442 453 await this.prefManager.setPluginDisabled(pluginId); 443 454 } 444 455
+1 -1
src/js/toasts.js
··· 102 102 }) { 103 103 const key = `${pluginId}:${toastId}`; 104 104 if (pluginToasts.has(key)) return; 105 - const toast = pluginRenderer.renderNode(element, pluginId); 105 + const toast = pluginRenderer.createRoot().render(element); 106 106 const handle = mountToast(toast, { 107 107 timeout, 108 108 onDismiss: () => pluginToasts.delete(key),
+3 -1
src/js/views/settings/pluginDetail.view.js
··· 60 60 } 61 61 }); 62 62 63 + const tabRoot = pluginService.getRenderer(pluginId).createRoot(); 64 + 63 65 function renderTabContent(containerNode) { 64 66 if (!containerNode) return null; 65 - return pluginService.pluginRenderer.renderNode(containerNode, pluginId); 67 + return tabRoot.render(containerNode); 66 68 } 67 69 68 70 function renderPage() {
+1 -1
tests/unit/specs/modals.test.js
··· 10 10 } from "/js/modals.js"; 11 11 import { PluginRenderer } from "/js/plugins/pluginRendering.js"; 12 12 13 - const pluginRenderer = new PluginRenderer(null); 14 13 function showPluginModal(opts) { 14 + const pluginRenderer = new PluginRenderer(null, opts.pluginId); 15 15 return _showPluginModal({ pluginRenderer, ...opts }); 16 16 } 17 17
+195
tests/unit/specs/plugins/pluginRendering.test.js
··· 1 + import { TestSuite } from "../../testSuite.js"; 2 + import { assert, assertEquals } from "../../testHelpers.js"; 3 + import { PluginRenderer } from "/js/plugins/pluginRendering.js"; 4 + 5 + function makeBridge() { 6 + const calls = []; 7 + const bridge = { 8 + handleNodeEvent(pluginId, handlerId, event) { 9 + calls.push({ pluginId, handlerId, event }); 10 + }, 11 + }; 12 + return { bridge, calls }; 13 + } 14 + 15 + const t = new TestSuite("pluginRendering"); 16 + 17 + t.describe("PluginRenderer:render with fresh roots", (it) => { 18 + it("creates a fresh element when given a fresh root each call", () => { 19 + const { bridge } = makeBridge(); 20 + const renderer = new PluginRenderer(bridge, "demo"); 21 + const node = { tag: "div", attrs: { class: "x" }, text: "hi" }; 22 + const first = renderer.createRoot().render(node); 23 + const second = renderer.createRoot().render(node); 24 + assert(first !== second); 25 + assertEquals(first.textContent, "hi"); 26 + assertEquals(first.getAttribute("class"), "x"); 27 + }); 28 + 29 + it("rewrites <input type=checkbox> as <toggle-switch>", () => { 30 + const { bridge } = makeBridge(); 31 + const renderer = new PluginRenderer(bridge, "demo"); 32 + const element = renderer 33 + .createRoot() 34 + .render({ tag: "input", attrs: { type: "checkbox" } }); 35 + assertEquals(element.tagName.toLowerCase(), "toggle-switch"); 36 + }); 37 + }); 38 + 39 + t.describe("PluginRenderer:root reconciliation", (it) => { 40 + it("returns the same element across renders when the tag matches", () => { 41 + const { bridge } = makeBridge(); 42 + const renderer = new PluginRenderer(bridge, "demo"); 43 + const root = renderer.createRoot(); 44 + const first = root.render({ tag: "div", text: "a" }); 45 + const second = root.render({ tag: "div", text: "b" }); 46 + assert(first === second); 47 + assertEquals(second.textContent, "b"); 48 + }); 49 + 50 + it("replaces the element when the tag changes", () => { 51 + const { bridge } = makeBridge(); 52 + const renderer = new PluginRenderer(bridge, "demo"); 53 + const root = renderer.createRoot(); 54 + const first = root.render({ tag: "div" }); 55 + const second = root.render({ tag: "span" }); 56 + assert(first !== second); 57 + assertEquals(second.tagName.toLowerCase(), "span"); 58 + }); 59 + 60 + it("patches attributes in place", () => { 61 + const { bridge } = makeBridge(); 62 + const renderer = new PluginRenderer(bridge, "demo"); 63 + const root = renderer.createRoot(); 64 + const element = root.render({ 65 + tag: "input", 66 + attrs: { type: "text", value: "one", placeholder: "old" }, 67 + }); 68 + root.render({ 69 + tag: "input", 70 + attrs: { type: "text", value: "two" }, 71 + }); 72 + assertEquals(element.getAttribute("value"), "two"); 73 + assert(!element.hasAttribute("placeholder")); 74 + }); 75 + 76 + it("preserves the value of a focused input across re-render", () => { 77 + document.body.innerHTML = ""; 78 + const { bridge } = makeBridge(); 79 + const renderer = new PluginRenderer(bridge, "demo"); 80 + const root = renderer.createRoot(); 81 + const input = root.render({ 82 + tag: "input", 83 + attrs: { type: "text", value: "initial" }, 84 + }); 85 + document.body.appendChild(input); 86 + input.focus(); 87 + input.value = "user-typed"; 88 + root.render({ 89 + tag: "input", 90 + attrs: { type: "text", value: "stale-from-worker" }, 91 + }); 92 + assertEquals(input.value, "user-typed"); 93 + assert(document.activeElement === input); 94 + }); 95 + 96 + it("reuses matching children and patches their text in place", () => { 97 + const { bridge } = makeBridge(); 98 + const renderer = new PluginRenderer(bridge, "demo"); 99 + const root = renderer.createRoot(); 100 + const element = root.render({ 101 + tag: "div", 102 + children: [ 103 + { tag: "span", text: "one" }, 104 + { tag: "span", text: "two" }, 105 + ], 106 + }); 107 + const firstChild = element.children[0]; 108 + const secondChild = element.children[1]; 109 + root.render({ 110 + tag: "div", 111 + children: [ 112 + { tag: "span", text: "ONE" }, 113 + { tag: "span", text: "two" }, 114 + ], 115 + }); 116 + assert(element.children[0] === firstChild); 117 + assert(element.children[1] === secondChild); 118 + assertEquals(firstChild.textContent, "ONE"); 119 + }); 120 + 121 + it("appends new children and removes dropped ones", () => { 122 + const { bridge } = makeBridge(); 123 + const renderer = new PluginRenderer(bridge, "demo"); 124 + const root = renderer.createRoot(); 125 + const element = root.render({ 126 + tag: "div", 127 + children: [{ tag: "span", text: "a" }], 128 + }); 129 + root.render({ 130 + tag: "div", 131 + children: [ 132 + { tag: "span", text: "a" }, 133 + { tag: "span", text: "b" }, 134 + ], 135 + }); 136 + assertEquals(element.children.length, 2); 137 + root.render({ tag: "div", children: [] }); 138 + assertEquals(element.children.length, 0); 139 + }); 140 + 141 + it("dispatches the updated handlerId after a re-render without leaking listeners", () => { 142 + const { bridge, calls } = makeBridge(); 143 + const renderer = new PluginRenderer(bridge, "demo"); 144 + const root = renderer.createRoot(); 145 + const button = root.render({ 146 + tag: "button", 147 + events: { click: "h1" }, 148 + }); 149 + root.render({ tag: "button", events: { click: "h2" } }); 150 + button.dispatchEvent(new Event("click")); 151 + assertEquals(calls.length, 1); 152 + assertEquals(calls[0].handlerId, "h2"); 153 + }); 154 + 155 + it("stops dispatching when an event handler is removed", () => { 156 + const { bridge, calls } = makeBridge(); 157 + const renderer = new PluginRenderer(bridge, "demo"); 158 + const root = renderer.createRoot(); 159 + const button = root.render({ 160 + tag: "button", 161 + events: { click: "h1" }, 162 + }); 163 + root.render({ tag: "button" }); 164 + button.dispatchEvent(new Event("click")); 165 + assertEquals(calls.length, 0); 166 + }); 167 + 168 + it("clears stale text when the new node has neither text nor children", () => { 169 + const { bridge } = makeBridge(); 170 + const renderer = new PluginRenderer(bridge, "demo"); 171 + const root = renderer.createRoot(); 172 + const element = root.render({ tag: "div", text: "hi" }); 173 + root.render({ tag: "div" }); 174 + assertEquals(element.textContent, ""); 175 + }); 176 + 177 + it("replaces a child whose tag no longer matches", () => { 178 + const { bridge } = makeBridge(); 179 + const renderer = new PluginRenderer(bridge, "demo"); 180 + const root = renderer.createRoot(); 181 + const element = root.render({ 182 + tag: "div", 183 + children: [{ tag: "span", text: "x" }], 184 + }); 185 + const oldChild = element.children[0]; 186 + root.render({ 187 + tag: "div", 188 + children: [{ tag: "button", text: "x" }], 189 + }); 190 + assert(element.children[0] !== oldChild); 191 + assertEquals(element.children[0].tagName.toLowerCase(), "button"); 192 + }); 193 + }); 194 + 195 + await t.run();
+30 -19
tests/unit/specs/toasts.test.js
··· 98 98 }); 99 99 }); 100 100 101 - function makePluginRenderer() { 101 + function makePluginRenderer(pluginId = "test-plugin") { 102 102 const calls = []; 103 + function renderNodeImpl(node) { 104 + calls.push({ node, pluginId }); 105 + const element = document.createElement(node.tag ?? "div"); 106 + const className = node.attrs?.class; 107 + if (className) element.className = className; 108 + if (node.text != null) element.textContent = node.text; 109 + if (Array.isArray(node.children)) { 110 + for (const child of node.children) { 111 + element.appendChild(renderNodeImpl(child)); 112 + } 113 + } 114 + return element; 115 + } 103 116 const renderer = { 104 - renderNode(node, pluginId) { 105 - calls.push({ node, pluginId }); 106 - const element = document.createElement(node.tag ?? "div"); 107 - const className = node.attrs?.class; 108 - if (className) element.className = className; 109 - if (node.text != null) element.textContent = node.text; 110 - if (Array.isArray(node.children)) { 111 - for (const child of node.children) { 112 - element.appendChild(this.renderNode(child, pluginId)); 113 - } 114 - } 115 - return element; 117 + createRoot() { 118 + return { 119 + tree: null, 120 + el: null, 121 + render(node) { 122 + this.el = renderNodeImpl(node); 123 + this.tree = node; 124 + return this.el; 125 + }, 126 + }; 116 127 }, 117 128 isEmptyNode(node) { 118 129 if (!node) return true; ··· 144 155 t.describe("showPluginToast", (it) => { 145 156 it("should render the element via the pluginRenderer and mount it", () => { 146 157 clearDOM(); 147 - const { renderer, calls } = makePluginRenderer(); 148 158 const { pluginId, toastId } = uniqueIds(); 159 + const { renderer, calls } = makePluginRenderer(pluginId); 149 160 showPluginToast({ 150 161 pluginRenderer: renderer, 151 162 pluginId, ··· 162 173 163 174 it("should set the popover attribute on the toast element", () => { 164 175 clearDOM(); 165 - const { renderer } = makePluginRenderer(); 166 176 const { pluginId, toastId } = uniqueIds(); 177 + const { renderer } = makePluginRenderer(pluginId); 167 178 showPluginToast({ 168 179 pluginRenderer: renderer, 169 180 pluginId, ··· 177 188 178 189 it("should preserve plugin-supplied classes on the toast", () => { 179 190 clearDOM(); 180 - const { renderer } = makePluginRenderer(); 181 191 const { pluginId, toastId } = uniqueIds(); 192 + const { renderer } = makePluginRenderer(pluginId); 182 193 showPluginToast({ 183 194 pluginRenderer: renderer, 184 195 pluginId, ··· 193 204 194 205 it("should ignore a second call with the same plugin+toast id", () => { 195 206 clearDOM(); 196 - const { renderer, calls } = makePluginRenderer(); 197 207 const { pluginId, toastId } = uniqueIds(); 208 + const { renderer, calls } = makePluginRenderer(pluginId); 198 209 const args = { 199 210 pluginRenderer: renderer, 200 211 pluginId, ··· 235 246 t.describe("hidePluginToast", (it) => { 236 247 it("should dismiss the matching toast", () => { 237 248 clearDOM(); 238 - const { renderer } = makePluginRenderer(); 239 249 const { pluginId, toastId } = uniqueIds(); 250 + const { renderer } = makePluginRenderer(pluginId); 240 251 showPluginToast({ 241 252 pluginRenderer: renderer, 242 253 pluginId, ··· 289 300 290 301 it("should allow re-showing a toast with the same id after hide", () => { 291 302 clearDOM(); 292 - const { renderer } = makePluginRenderer(); 293 303 const { pluginId, toastId } = uniqueIds(); 304 + const { renderer } = makePluginRenderer(pluginId); 294 305 showPluginToast({ 295 306 pluginRenderer: renderer, 296 307 pluginId,