Skip to content

Instantly share code, notes, and snippets.

@alexbezhan
Created February 26, 2023 23:47
Show Gist options
  • Save alexbezhan/59e86e77315baff24fd161ff1a588947 to your computer and use it in GitHub Desktop.
Save alexbezhan/59e86e77315baff24fd161ff1a588947 to your computer and use it in GitHub Desktop.
Laser.js - fast and small HTMX-like library
/**
* Principles:
* 1. Performance
* 2. Simplicity and debuggability.
*
* It's not trying to be:
* 1. Flexible.
*
* This results into the following:
* 1. Explicit attributes only
* 2. No tree walking
* 3. No extensions
*/
const attr = 'lr-post';
const httpMethod = 'POST';
const internalDataKey = 'lr-internal-data';
function onContentLoad(content) {
const controlElts = Array.from(content.querySelectorAll(`[${attr}]`));
// content node itself may have action attributes
controlElts.push(content);
for (const controlElt of controlElts) {
const link = controlElt.getAttribute && controlElt.getAttribute(attr);
if (link === null || link === undefined) {
continue
}
const trigger = 'click'
const listener = async function controlEltListener() {
async function issueRequestAndSwap() {
// get request indicator
let requestIndicatorElt;
{
let selector = controlElt.getAttribute && controlElt.getAttribute('hx-indicator');
if (selector) {
selector = selector.trim();
if (selector.at(0) === '#') {
requestIndicatorElt = document.getElementById(selector.slice(1));
} else if (selector === 'this') {
requestIndicatorElt = controlElt;
} else {
console.error(`Unsupported selector: ${selector}`);
return;
}
}
}
// build request params
let paramsValue = controlElt.getAttribute && controlElt.getAttribute('lr-params');
if (paramsValue === null || paramsValue === undefined) {
paramsValue = '';
}
const paramsValueArr = paramsValue.split(',');
const params = new URLSearchParams();
for (const name of paramsValueArr) {
const nameClean = name.trim();
const paramElts = document.getElementsByName(nameClean);
for (const paramElt of paramElts) {
let shouldInclude = false;
const tpe = paramElt.type;
if (tpe === 'checkbox' || tpe === 'radio') {
shouldInclude = paramElt.checked;
} else if (tpe === 'hidden') {
shouldInclude = true;
}
if (shouldInclude) {
params.append(name, paramElt.value);
}
}
}
// send request
const request = new Request(link, {
method: httpMethod,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
});
if (requestIndicatorElt) {
requestIndicatorElt.classList.add(htmx.config.requestClass);
}
const response = await fetch(request);
// process response
const responseText = await response.text();
if (!response.ok) {
throw new Error(responseText);
}
const triggerEventBeforeSwapAttr = response.headers.get('HX-Trigger')
const triggerEventAfterSwapAttr = response.headers.get('HX-Trigger-After-Swap')
let parser = new DOMParser();
let responseDoc = parser.parseFromString(`<body><template>${responseText}</template></body>`, 'text/html');
let responseFragment = responseDoc.body.firstChild.content;
if (triggerEventBeforeSwapAttr) {
const event = new CustomEvent(triggerEventBeforeSwapAttr, { bubbles: true, cancelable: true });
controlElt.dispatchEvent(event);
}
// swap
for (let i = 0; i < responseFragment.children.length; i++) {
const responseElt = responseFragment.children[i];
const swapOobAttr = responseElt.getAttribute && responseElt.getAttribute('hx-swap-oob');
if (swapOobAttr === null || swapOobAttr === undefined || swapOobAttr.length === 0) {
continue;
}
const [swapStrategy, destSelector] = swapOobAttr.split(':');
let existingElt
if (destSelector === null || destSelector === undefined) {
const id = responseElt.id;
existingElt = document.getElementById(id);
} else if (destSelector.at(0) === '#') {
existingElt = document.getElementById(destSelector.slice(1));
} else {
console.error(`Unsupported selector: ${destSelector}`);
continue
}
if (existingElt === null || existingElt === undefined) {
continue;
}
const newEltsToRegister = {};
function maybeAddElementToRegister(newElt, initDelay) {
if (newElt.nodeType !== Node.TEXT_NODE && newElt.nodeType !== Node.COMMENT_NODE) {
const arr = newEltsToRegister[initDelay];
if (arr === undefined) {
newEltsToRegister[initDelay] = [newElt];
} else {
arr.push(newElt);
}
}
}
const initDelay = responseElt.getAttribute('lr-init-delay') | 0;
const noHtmxInitialization = responseElt.getAttribute('lr-no-htmx') === 'true';
// if (responseElt.tagName === 'TR') {
// console.log(responseElt, 'swapOobAttr', swapOobAttr, 'existingElt', existingElt, 'swapStrategy', swapStrategy);
// }
// swap
if (swapStrategy === 'outerHTML' || swapStrategy === 'true') {
Idiomorph.morph(existingElt, responseElt.cloneNode(true), {
callbacks: {
afterNodeMorphed: function afterNodeMorphedCb(existingElt) {
if (existingElt.nodeType !== Node.TEXT_NODE && existingElt.nodeType !== Node.COMMENT_NODE) {
const internalData = existingElt[internalDataKey];
// if (existingElt.tagName === 'svg') {
// console.log(existingElt, 'internalData', internalData);
// }
// maybe unregister old stuff
if (!internalData) {
return
}
const listener = internalData.listener;
if (!listener) {
return
}
const trigger = internalData.trigger;
if (!trigger) {
return
}
existingElt.removeEventListener(trigger, listener);
}
}
}
});
// register only once
// we don't wanna register morhped children, since we scan every registered node top-down by query selectors and get all the children anyway
maybeAddElementToRegister(existingElt, initDelay);
} else if (swapStrategy === 'beforebegin') {
const parentElt = existingElt.parentNode;
const newElt = parentElt.insertBefore(responseElt.cloneNode(true), existingElt);
maybeAddElementToRegister(newElt, initDelay);
} else if (swapStrategy === 'beforeend') {
const parentElt = existingElt;
const newElt = parentElt.insertBefore(responseElt.cloneNode(true), null);
maybeAddElementToRegister(newElt, initDelay);
} else if (swapStrategy === 'delete') {
const parentElt = existingElt.parentNode;
parentElt.removeChild(existingElt);
}
// init new elements
for (const initDelayStr in newEltsToRegister) {
const initDelayNum = initDelayStr | 0;
if (initDelayNum === 0) {
const newElts = newEltsToRegister[0];
// console.log(`init 0`, newElts);
for (const newElt of newElts) {
if (!noHtmxInitialization) {
htmx.process(newElt);
}
_hyperscript.processNode(newElt);
onContentLoad(newElt);
}
} else {
// unload processing new elements to new event loop
const newElts = newEltsToRegister[initDelayNum];
setTimeout(function initNewEltsWithDelay() {
// console.log(`init ${initDelayNum}`, newElts);
for (const newElt of newElts) {
if (!noHtmxInitialization) {
htmx.process(newElt);
}
_hyperscript.processNode(newElt);
onContentLoad(newElt);
}
}, initDelayNum);
}
}
}
if (triggerEventAfterSwapAttr) {
const event = new Event(triggerEventAfterSwapAttr);
controlElt.dispatchEvent(event);
}
if (requestIndicatorElt) {
requestIndicatorElt.classList.remove(htmx.config.requestClass);
}
for(const l of onResponseListeners) {
l();
}
}
let internalData = controlElt[internalDataKey];
if (internalData.requestInProgress === true) {
// we don't wanna fire several parallel requests for the same control, it doesn't make sense
return;
}
try {
internalData.requestInProgress = true;
await issueRequestAndSwap()
} finally {
// allow new requests to come in only after swap been done completely
internalData.requestInProgress = false;
}
}
// upsert internal data
let internalData = controlElt[internalDataKey];
if (!internalData) {
controlElt[internalDataKey] = {
trigger,
listener,
link,
requestInProgress: false,
}
} else {
internalData.trigger = trigger;
internalData.listener = listener;
internalData.link = link;
internalData.requestInProgress = false;
}
// init listener after the internal data been assigned to element
// because the listener inside relies on internal data to be always present
controlElt.addEventListener(trigger, listener);
}
for (const l of onLoadListeners) {
l(content);
}
}
const onLoadListeners = []
const onResponseListeners = []
let laser = {
ready: function ready(fn) {
if (document.readyState !== 'loading') {
fn();
} else {
document.addEventListener('DOMContentLoaded', fn);
}
},
onLoad: function onLoad(callback) {
onLoadListeners.push(callback);
},
onResponse: function onResponse(callback) {
onResponseListeners.push(callback);
}
};
window.laser = laser;
laser.ready(function() {
onContentLoad(document.body);
for (const l of onLoadListeners) {
l(document.body);
}
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment