[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.

Update plugin rendering to handle text and children

Grace Kind (May 19, 2026, 4:15 PM -0500) a53b21ef e0b98212

+141 -14
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.14.65", 3 + "version": "0.14.66", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf build && NODE_ENV=development eleventy --serve",
+54 -13
src/js/plugins/pluginRendering.js
··· 92 92 return tag; 93 93 } 94 94 95 - // Render a serialized VirtualEl node ({ tag, attrs, text, children }) into a 96 - // real element. Text and children are mutually exclusive on 97 - // the worker side (setText() clears children). 95 + // Render a serialized VirtualEl node ({ tag, attrs, text, children }) into a DOM element. 98 96 export class PluginRenderer { 99 97 constructor(pluginBridge, pluginId) { 100 98 this.pluginBridge = pluginBridge; ··· 146 144 } 147 145 } 148 146 this._patchEvents(element, null, node.events, pluginId); 149 - if (node.text != null) { 147 + const children = Array.isArray(node.children) ? node.children : []; 148 + const hasText = node.text != null && node.text !== ""; 149 + if (hasText && children.length === 0) { 150 150 element.textContent = node.text; 151 - } else if (Array.isArray(node.children)) { 152 - for (const child of node.children) { 151 + } else { 152 + if (hasText) { 153 + element.appendChild(document.createTextNode(node.text)); 154 + } 155 + for (const child of children) { 153 156 element.appendChild(this._create(child, pluginId)); 154 157 } 155 158 } ··· 180 183 181 184 this._patchEvents(element, oldNode.events, newNode.events, pluginId); 182 185 183 - if (newNode.text != null) { 184 - if (newNode.text !== oldNode.text) element.textContent = newNode.text; 186 + const oldChildren = Array.isArray(oldNode.children) ? oldNode.children : []; 187 + const newChildren = Array.isArray(newNode.children) ? newNode.children : []; 188 + const oldHasText = oldNode.text != null && oldNode.text !== ""; 189 + const newHasText = newNode.text != null && newNode.text !== ""; 190 + 191 + // Fast path: text-only on both sides. 192 + if (oldChildren.length === 0 && newChildren.length === 0) { 193 + if (newHasText) { 194 + if (newNode.text !== oldNode.text) element.textContent = newNode.text; 195 + } else if (oldHasText) { 196 + element.textContent = ""; 197 + } 185 198 return; 186 199 } 187 200 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) { 201 + // If the children-vs-text shape changed dramatically, rebuild content. 202 + const oldHadOnlyText = oldChildren.length === 0 && oldHasText; 203 + const newHasOnlyText = newChildren.length === 0 && newHasText; 204 + if (oldHadOnlyText || newHasOnlyText) { 192 205 element.textContent = ""; 206 + if (newHasText) { 207 + element.appendChild(document.createTextNode(newNode.text)); 208 + } 209 + for (const child of newChildren) { 210 + element.appendChild(this._create(child, pluginId)); 211 + } 212 + return; 193 213 } 214 + 215 + // Both sides have element children — manage the optional leading text node. 216 + let textOffset = 0; 217 + const firstIsTextNode = 218 + element.firstChild && element.firstChild.nodeType === Node.TEXT_NODE; 219 + if (newHasText) { 220 + if (firstIsTextNode) { 221 + if (element.firstChild.textContent !== newNode.text) { 222 + element.firstChild.textContent = newNode.text; 223 + } 224 + } else { 225 + element.insertBefore( 226 + document.createTextNode(newNode.text), 227 + element.firstChild, 228 + ); 229 + } 230 + textOffset = 1; 231 + } else if (oldHasText && firstIsTextNode) { 232 + element.removeChild(element.firstChild); 233 + } 234 + 194 235 const domChildren = Array.from(element.childNodes); 195 236 const max = Math.max(oldChildren.length, newChildren.length); 196 237 for (let index = 0; index < max; index++) { 197 238 const oldChild = oldChildren[index]; 198 239 const newChild = newChildren[index]; 199 - const domChild = domChildren[index]; 240 + const domChild = domChildren[index + textOffset]; 200 241 if (!oldChild && newChild) { 201 242 element.appendChild(this._create(newChild, pluginId)); 202 243 } else if (oldChild && !newChild) {
+86
tests/unit/specs/plugins/pluginRendering.test.js
··· 174 174 assertEquals(element.textContent, ""); 175 175 }); 176 176 177 + it("renders both text and children with text as a leading text node", () => { 178 + const { bridge } = makeBridge(); 179 + const renderer = new PluginRenderer(bridge, "demo"); 180 + const root = renderer.createRoot(); 181 + const element = root.render({ 182 + tag: "button", 183 + text: "Applying", 184 + children: [{ tag: "div", attrs: { class: "loading-spinner" } }], 185 + }); 186 + assertEquals(element.childNodes.length, 2); 187 + assertEquals(element.firstChild.nodeType, 3); 188 + assertEquals(element.firstChild.textContent, "Applying"); 189 + assertEquals(element.children.length, 1); 190 + assertEquals(element.children[0].tagName.toLowerCase(), "div"); 191 + assertEquals(element.children[0].getAttribute("class"), "loading-spinner"); 192 + }); 193 + 194 + it("patches from text-only to text-plus-children, preserving spinner child", () => { 195 + const { bridge } = makeBridge(); 196 + const renderer = new PluginRenderer(bridge, "demo"); 197 + const root = renderer.createRoot(); 198 + const element = root.render({ tag: "button", text: "Apply" }); 199 + assertEquals(element.textContent, "Apply"); 200 + root.render({ 201 + tag: "button", 202 + text: "Applying", 203 + children: [{ tag: "div", attrs: { class: "loading-spinner" } }], 204 + }); 205 + assertEquals(element.children.length, 1); 206 + assertEquals(element.firstChild.nodeType, 3); 207 + assertEquals(element.firstChild.textContent, "Applying"); 208 + assertEquals(element.children[0].getAttribute("class"), "loading-spinner"); 209 + }); 210 + 211 + it("patches from text-plus-children back to text-only, removing the child", () => { 212 + const { bridge } = makeBridge(); 213 + const renderer = new PluginRenderer(bridge, "demo"); 214 + const root = renderer.createRoot(); 215 + const element = root.render({ 216 + tag: "button", 217 + text: "Applying", 218 + children: [{ tag: "div", attrs: { class: "loading-spinner" } }], 219 + }); 220 + root.render({ tag: "button", text: "Apply" }); 221 + assertEquals(element.children.length, 0); 222 + assertEquals(element.textContent, "Apply"); 223 + }); 224 + 225 + it("updates the leading text node in place when children stay stable", () => { 226 + const { bridge } = makeBridge(); 227 + const renderer = new PluginRenderer(bridge, "demo"); 228 + const root = renderer.createRoot(); 229 + const element = root.render({ 230 + tag: "button", 231 + text: "Applying", 232 + children: [{ tag: "div", attrs: { class: "loading-spinner" } }], 233 + }); 234 + const originalSpinner = element.children[0]; 235 + root.render({ 236 + tag: "button", 237 + text: "Working", 238 + children: [{ tag: "div", attrs: { class: "loading-spinner" } }], 239 + }); 240 + assertEquals(element.firstChild.textContent, "Working"); 241 + assert(element.children[0] === originalSpinner); 242 + }); 243 + 244 + it("removes the leading text node while keeping element children", () => { 245 + const { bridge } = makeBridge(); 246 + const renderer = new PluginRenderer(bridge, "demo"); 247 + const root = renderer.createRoot(); 248 + const element = root.render({ 249 + tag: "button", 250 + text: "Applying", 251 + children: [{ tag: "div", attrs: { class: "loading-spinner" } }], 252 + }); 253 + const originalSpinner = element.children[0]; 254 + root.render({ 255 + tag: "button", 256 + children: [{ tag: "div", attrs: { class: "loading-spinner" } }], 257 + }); 258 + assertEquals(element.childNodes.length, 1); 259 + assertEquals(element.children.length, 1); 260 + assert(element.children[0] === originalSpinner); 261 + }); 262 + 177 263 it("replaces a child whose tag no longer matches", () => { 178 264 const { bridge } = makeBridge(); 179 265 const renderer = new PluginRenderer(bridge, "demo");