UNPKG

rimmel

Version:

A Stream-Oriented UI library for the Rx.Observable Universe

144 lines (141 loc) 7.02 kB
import { INTERACTIVE_NODE_START, RESOLVE_ATTRIBUTE, RML_DEBUG, RESOLVE_SELECTOR, INTERACTIVE_NODE_END } from '../constants.js'; import { waitingElementHandlers, subscriptions } from '../internal-state.js'; import { isSinkBindingConfiguration } from '../types/internal.js'; import { subscribe } from '../lib/drain.js'; import { terminationHandler } from '../sinks/termination-sink.js'; import { addListener } from '../lib/addListener.js'; const AUTOREMOVE_LISTENERS_DELAY = 100; // Cleanup event listeners after this much time const elementNodes = (n) => n.nodeType == 1; const errorHandler = console.error; const Rimmel_Bind_Subtree = (node) => { // Data-to-be-bound text nodes in an element (<div>${thing1} ${thing2}</div>); const intermediateInteractiveNodes = []; const hasInteractiveTextNodes = [...node.childNodes].some(n => { return n.nodeType == 3 && n.nodeValue?.includes(INTERACTIVE_NODE_START); }); if (hasInteractiveTextNodes) { // Bind interactive "text nodes" // TODO: shall we use some ad-hoc container elements, instead? <text-wrapper> const nodes = []; for (const n of node.childNodes) { if (n.nodeType == 3) { const nodeValue = n.nodeValue; const interactiveRE = new RegExp(`[${INTERACTIVE_NODE_START}${INTERACTIVE_NODE_END}]`); const interleaved = nodeValue.split(interactiveRE); const il = interleaved.length; for (var i = 0; i < il; i += 2) { const txt = interleaved[i]; nodes.push(txt); const value = interleaved[i + 1]; if (value != undefined) { const tn = document.createTextNode(value); // or "value"? intermediateInteractiveNodes.push(tn); // do we have an initial value we can add straight away? nodes.push(tn); } } } else { nodes.push(n); } } node.innerHTML = ''; node.append(...nodes); } const bindingRef = node.getAttribute(RESOLVE_ATTRIBUTE); (waitingElementHandlers.get(bindingRef) ?? []).forEach(function Rimmel_Bind_Element(bindingConfiguration) { const debugThisNode = node.hasAttribute(RML_DEBUG); // #IFDEF ENABLE_RML_DEBUGGER if (debugThisNode) { /* Stopped at data binding */ debugger; } // #ENDIF ENABLE_RML_DEBUGGER if (isSinkBindingConfiguration(bindingConfiguration)) { // DATA SINKS // TODO: bindingConfiguration.sinkParams may itself be a promise or an observable, so need to subscribe to it const targetNode = intermediateInteractiveNodes.shift() ?? node; const { sink, t } = bindingConfiguration; const sinkFn = sink(targetNode, bindingConfiguration.params); // A pre-sink step that can show the above sinkFn in a stack trace for debugging const loggingSinkFn = (...data) => { console.groupCollapsed('RML: Sinking', t, data); console.log(bindingConfiguration); console.trace('Stack Trace (from Source to Sink), data=', data); sinkFn(...data); console.groupEnd(); }; // #IFDEF ENABLE_RML_DEBUGGER // This is the actual sink that will be bound to a source const sinkFn2 = debugThisNode ? loggingSinkFn : sinkFn; if (debugThisNode) { console.groupCollapsed('RML: Binding', t, targetNode); console.dir(targetNode); console.debug('Node: %o', targetNode); console.debug('Conf: %o', bindingConfiguration); console.debug('Sink: %o', sinkFn2); console.groupEnd(); } // #ELSE // const sinkFn2 = sinkFn; // #ENDIF ENABLE_RML_DEBUGGER const sourceStream = bindingConfiguration.source; subscribe(targetNode, sourceStream, sinkFn2, bindingConfiguration.error ?? errorHandler, bindingConfiguration.termination ?? terminationHandler, bindingConfiguration.scheduler); } else { // EVENT SOURCES const sourceBindingConfiguration = bindingConfiguration; const { eventName } = sourceBindingConfiguration; addListener(node, eventName, sourceBindingConfiguration.listener, sourceBindingConfiguration.options); } }); node.removeAttribute(RESOLVE_ATTRIBUTE); waitingElementHandlers.delete(bindingRef); }; const removeListeners = (node) => { if (document.contains(node)) { // Don't remove listeners if the node has just been moved across (so it's back in the DOM) return; } [...node.children] .forEach(node => removeListeners(node)); // TODO: add AbortController support for cancellable promises? subscriptions.get(node)?.forEach(l => { // HACK: — destination is not a supported API for Subscription... // l?.destination?.complete(); // do we need this, BTW? // console.debug('Rimmel: Unsubscribing', node, l); // FIXME: DOM Observables don't have unsubscribe => Use an AbortController in addListener.ts // N.B.: unsubscribe is RxJS-specific, but the below still works. l?.unsubscribe?.(); }); subscriptions.delete(node); // #REF49993849837451 Just leaving this around, but there's no need to manually // remove listeners. // DevTools might suggest otherwise, but if you see unreleased instances of // EventListener, then HE is holding on to them (Heisenbug), not Rimmel. // listeners.get(node)?.forEach(ref => node.removeEventListener(...ref)); // listeners.delete(node); }; /** * Main callback triggered when an element is added to the DOM * Here is where we start the data binding process */ const Rimmel_Mount = (mutationsList, observer) => { const childList = mutationsList .filter(m => m.type === 'childList'); // TODO: performance - use document.createTreeWalker const addedNodes = childList .flatMap(m => ([...m.addedNodes])) // .values() for an iterator, according to TS .filter(elementNodes); addedNodes .flatMap(node => [node].concat(...(node.querySelectorAll(RESOLVE_SELECTOR)))) .forEach(Rimmel_Bind_Subtree); // TODO: performance - use document.createTreeWalker const removedNodes = childList .flatMap(m => ([...m.removedNodes])) // .values() for an iterator, according to TS .filter(elementNodes); // TODO: switch when ready // requestIdleCallback(() => removedNodes.forEach(removeListeners)); setTimeout(() => removedNodes.forEach(removeListeners), AUTOREMOVE_LISTENERS_DELAY); }; export { Rimmel_Bind_Subtree, Rimmel_Mount, removeListeners }; //# sourceMappingURL=data-binding.js.map