website-htmx-rustelo-code/site/public/js/htmx-reinit.js

131 lines
4.1 KiB
JavaScript
Raw Permalink Normal View History

2026-07-10 03:44:13 +01:00
/**
* htmx-reinit.js re-initialise non-HTMX scripts after every HTMX swap.
*
* In the htmx-ssr rendering profile, scripts like highlight.js, cytoscape,
* theme-init, and tag-filters run once at page load. When HTMX swaps a
* fragment in, those scripts don't see the new DOM and the content goes
* "dead" (unhighlighted code blocks, unwired filter pills, etc.).
*
* This module fixes that by exposing a registry
* `window.RusteloReinit.register(fn)` and a single listener on
* `htmx:afterSettle` that walks the registry and calls each handler with
* the swapped element as scope.
*
* Handlers MUST be idempotent. We help by tracking processed nodes in a
* WeakSet keyed by handler id, so double-decoration is impossible even if
* a swap re-emits a region that contains an already-processed node.
*
* Usage from another script:
*
* (function () {
* window.RusteloReinit.register('highlight', function (scope) {
* scope.querySelectorAll('pre code:not(.hljs)').forEach(hljs.highlightElement);
* });
* })();
*
* The registered name is used to namespace the processed-nodes WeakSet so
* each handler manages its own idempotency.
*/
(function () {
'use strict';
if (window.RusteloReinit) {
return; // already loaded
}
/** @type {Array<{ name: string, fn: (scope: Element) => void, seen: WeakSet }>} */
const handlers = [];
function findHandler(name) {
for (let i = 0; i < handlers.length; i++) {
if (handlers[i].name === name) {
return handlers[i];
}
}
return null;
}
function runHandler(handler, scope) {
if (handler.seen.has(scope)) {
return;
}
handler.seen.add(scope);
try {
handler.fn(scope);
} catch (err) {
console.error('[RusteloReinit:' + handler.name + '] handler failed', err);
}
}
const api = {
/**
* Register a re-init handler. Calling register twice with the same
* name replaces the previous handler.
*/
register: function (name, fn) {
if (typeof name !== 'string' || typeof fn !== 'function') {
throw new TypeError('RusteloReinit.register(name, fn) — name must be string, fn must be function');
}
const existing = findHandler(name);
if (existing) {
existing.fn = fn;
// Reset the seen set so the replacement gets a fresh chance at the DOM.
existing.seen = new WeakSet();
} else {
handlers.push({ name: name, fn: fn, seen: new WeakSet() });
}
// Run immediately on document.body so the initial page gets processed.
if (document.body) {
runHandler(findHandler(name), document.body);
} else {
document.addEventListener('DOMContentLoaded', function () {
runHandler(findHandler(name), document.body);
}, { once: true });
}
},
/**
* Force-run every handler against the given scope. Used by tests and by
* the htmx:afterSettle listener.
*/
runAll: function (scope) {
for (let i = 0; i < handlers.length; i++) {
runHandler(handlers[i], scope);
}
},
/** Return registered handler names (debug). */
names: function () {
return handlers.map(function (h) { return h.name; });
}
};
window.RusteloReinit = api;
// Hook into htmx lifecycle events. afterSettle fires after the DOM is in
// its final state for the swap; we want this rather than afterSwap to
// avoid running against elements that get replaced moments later.
document.body && document.body.addEventListener('htmx:afterSettle', function (ev) {
const target = ev.detail && ev.detail.target;
if (target instanceof Element) {
api.runAll(target);
} else {
api.runAll(document.body);
}
});
// If body wasn't ready yet, wire on DOMContentLoaded.
if (!document.body) {
document.addEventListener('DOMContentLoaded', function () {
document.body.addEventListener('htmx:afterSettle', function (ev) {
const target = ev.detail && ev.detail.target;
if (target instanceof Element) {
api.runAll(target);
} else {
api.runAll(document.body);
}
});
}, { once: true });
}
})();