Created
February 26, 2023 23:47
-
-
Save alexbezhan/59e86e77315baff24fd161ff1a588947 to your computer and use it in GitHub Desktop.
Laser.js - fast and small HTMX-like library
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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