From e9f235505f05c928b14a199fb8513b1ca09b3d1b Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 6 Aug 2026 16:46:48 -0400 Subject: [PATCH] fix: inspector reload hoepfully? --- public/js/bliss_inspector.js | 96 ++++++++++++++++++++++++++++++++++-- 1 file changed, 93 insertions(+), 3 deletions(-) diff --git a/public/js/bliss_inspector.js b/public/js/bliss_inspector.js index 2ce5a76..7f68b6c 100644 --- a/public/js/bliss_inspector.js +++ b/public/js/bliss_inspector.js @@ -97,9 +97,99 @@ } } - async function replaceBody(html) { - const ui = [...document.body.querySelectorAll(":scope > [data-bliss-ui]")]; + function nodeKey(node) { + if (node.nodeType !== Node.ELEMENT_NODE) return null; + if (node.id) return `id:${node.id}`; + if (node.dataset.messageId) return `message:${node.dataset.messageId}`; + if (node.dataset.blissRouteId) { + return `route:${node.dataset.blissRouteId}:${node.dataset.blissRequestUrl || ""}`; + } + return null; + } + + function sameNodeType(left, right) { + return ( + left.nodeType === right.nodeType && + (left.nodeType !== Node.ELEMENT_NODE || left.tagName === right.tagName) + ); + } + + function syncAttributes(element, next) { + for (const attribute of [...element.attributes]) { + if (!next.hasAttribute(attribute.name)) element.removeAttribute(attribute.name); + } + for (const attribute of [...next.attributes]) { + if (element.getAttribute(attribute.name) !== attribute.value) { + element.setAttribute(attribute.name, attribute.value); + } + } + } + + // Live regions can contain data that arrived after the template was first + // rendered (a WebSocket chat message is the important case). Those nodes + // are not present in the saved render context, so retain them during a + // template patch instead of deleting them with the old tree. + function keepLiveNode(node) { + return ( + node.nodeType === Node.ELEMENT_NODE && + (node.matches("[data-bliss-ui]") || + node.hasAttribute("data-message-id") || + node.hasAttribute("data-bliss-route-id")) + ); + } + + function morphNode(current, next) { + if (!sameNodeType(current, next)) { + current.replaceWith(next.cloneNode(true)); + return; + } + if (current.nodeType !== Node.ELEMENT_NODE) { + if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue; + return; + } + + syncAttributes(current, next); + const currentChildren = [...current.childNodes]; + const keyed = new Map(); + for (const child of currentChildren) { + const key = nodeKey(child); + if (key && !keyed.has(key)) keyed.set(key, child); + } + + const used = new Set(); + let cursor = current.firstChild; + for (const nextChild of [...next.childNodes]) { + const key = nodeKey(nextChild); + let child = key ? keyed.get(key) : null; + if (child && used.has(child)) child = null; + if (!child) { + child = currentChildren.find( + (candidate) => + !used.has(candidate) && + !nodeKey(candidate) && + sameNodeType(candidate, nextChild), + ); + } + if (!child) child = nextChild.cloneNode(false); + if (child !== cursor) current.insertBefore(child, cursor); + used.add(child); + morphNode(child, nextChild); + cursor = child.nextSibling; + } + + for (const child of currentChildren) { + if (!used.has(child) && child.isConnected && !keepLiveNode(child)) child.remove(); + } + } + + async function replaceBody(html, { morph = false } = {}) { const next = new DOMParser().parseFromString(html, "text/html"); + if (morph) { + morphNode(document.body, next.body); + htmx.process(document.body); + return; + } + const ui = [...document.body.querySelectorAll(":scope > [data-bliss-ui]")]; for (const attribute of [...document.body.attributes]) { document.body.removeAttribute(attribute.name); } @@ -154,7 +244,7 @@ throw new Error(`Template ${templateId} returned ${response.status}`); } const html = await response.text(); - if (element === document.body) return replaceBody(html); + if (element === document.body) return replaceBody(html, { morph: true }); const fragment = document.createElement("template"); fragment.innerHTML = html; // A template may legally have several top-level nodes. They share one