fix: restore inspector template hot reload

This commit is contained in:
Your Name 2026-08-06 16:37:42 -04:00
parent cbbb87cd2e
commit ffba038b35
7 changed files with 232 additions and 35 deletions

View file

@ -36,6 +36,14 @@
: [];
}
function contextIdFor(element, templateId) {
try {
const ids = JSON.parse(element.dataset.blissTemplateContextIds || "{}");
if (Object.hasOwn(ids, String(templateId))) return ids[String(templateId)];
} catch (_) {}
return element.dataset.blissTemplateContextId || null;
}
function inventory() {
const items = [];
for (const owner of routeElements()) {
@ -89,44 +97,107 @@
}
}
async function replaceBody(html) {
const ui = [...document.body.querySelectorAll(":scope > [data-bliss-ui]")];
const next = new DOMParser().parseFromString(html, "text/html");
for (const attribute of [...document.body.attributes]) {
document.body.removeAttribute(attribute.name);
}
for (const attribute of [...next.body.attributes]) {
document.body.setAttribute(attribute.name, attribute.value);
}
document.body.innerHTML = next.body.innerHTML;
for (const node of ui) document.body.appendChild(node);
htmx.process(document.body);
}
async function refreshElement(element) {
if (element.dataset.blissMethod !== "GET") return;
const url = element.dataset.blissRequestUrl;
if (!url) return;
if (element === document.body) {
const ui = [...document.body.querySelectorAll(":scope > [data-bliss-ui]")];
const response = await fetch(url, { headers: { "HX-Request": "true" } });
// A page root must be fetched as a document. Asking for an HTMX
// fragment here used to parse an empty <body> and erase the page.
const response = await fetch(url);
if (!response.ok) throw new Error(`GET ${url} returned ${response.status}`);
const next = new DOMParser().parseFromString(await response.text(), "text/html");
for (const attribute of [...document.body.attributes]) {
document.body.removeAttribute(attribute.name);
}
for (const attribute of [...next.body.attributes]) {
document.body.setAttribute(attribute.name, attribute.value);
}
document.body.innerHTML = next.body.innerHTML;
for (const node of ui) document.body.appendChild(node);
htmx.process(document.body);
await replaceBody(await response.text());
return;
}
await htmx.ajax("GET", url, { target: element, swap: "outerHTML" });
}
function outermost(elements, selector) {
const set = new Set(elements);
return elements.filter((element) => {
let parent = element.parentElement;
while (parent) {
if (set.has(parent) && parent.matches(selector)) return false;
parent = parent.parentElement;
}
return true;
});
}
async function refreshTemplateElement(element, templateId, previousRoots = []) {
const response = await fetch("/_bliss/render-template", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
structureId: element.closest(ROUTE_SELECTOR)?.dataset.blissStructureId,
templateId,
contextId: contextIdFor(element, templateId),
}),
});
if (!response.ok) {
throw new Error(`Template ${templateId} returned ${response.status}`);
}
const html = await response.text();
if (element === document.body) return replaceBody(html);
const fragment = document.createElement("template");
fragment.innerHTML = html;
// A template may legally have several top-level nodes. They share one
// captured context id, so replace that one render as a group instead of
// rendering the same template once per root and duplicating its output.
for (const root of previousRoots) {
if (root !== element && root.isConnected) root.remove();
}
element.replaceWith(fragment.content);
htmx.process(document.body);
}
function templateRefreshGroups(templateId) {
const elements = outermost(
templateElements().filter((element) =>
matches(element, { kind: "template", id: templateId }),
),
TEMPLATE_SELECTOR,
);
const groups = new Map();
for (const element of elements) {
const contextId = contextIdFor(element, templateId);
const key = contextId || `element:${Math.random()}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(element);
}
return [...groups.values()];
}
async function refreshSources({ kind, id, method }) {
if (method && method !== "GET") return;
const elements = routeElements().filter((element) => {
if (kind === "template") {
return templateElements().some(
(template) =>
matches(template, { kind: "template", id }) &&
template.closest(ROUTE_SELECTOR) === element,
);
}
return matches(element, { kind, id });
});
const settled = await Promise.allSettled(elements.map(refreshElement));
const elements =
kind === "template"
? templateRefreshGroups(id)
: outermost(
routeElements().filter((element) => matches(element, { kind, id })),
ROUTE_SELECTOR,
);
const refresh =
kind === "template"
? (group) => refreshTemplateElement(group[0], id, group)
: refreshElement;
const settled = await Promise.allSettled(elements.map(refresh));
const failure = settled.find((result) => result.status === "rejected");
if (failure) console.error("Bliss live refresh failed", failure.reason);
publishInventory();