UNPKG

@cycle/dom

Version:

The standard DOM Driver for Cycle.js, based on Snabbdom

176 lines (155 loc) 5.41 kB
import {Driver, FantasyObservable} from '@cycle/run'; import {init, Module, Options as SnabbdomOptions, VNode, toVNode} from 'snabbdom'; import xs, {Stream, Listener} from 'xstream'; import concat from 'xstream/extra/concat'; import sampleCombine from 'xstream/extra/sampleCombine'; import {DOMSource} from './DOMSource'; import {MainDOMSource} from './MainDOMSource'; import {VNodeWrapper} from './VNodeWrapper'; import {getValidNode, checkValidContainer} from './utils'; import defaultModules from './modules'; import {IsolateModule} from './IsolateModule'; import {EventDelegator} from './EventDelegator'; function makeDOMDriverInputGuard(modules: any) { if (!Array.isArray(modules)) { throw new Error( `Optional modules option must be an array for snabbdom modules` ); } } function domDriverInputGuard(view$: Stream<VNode>): void { if ( !view$ || typeof view$.addListener !== `function` || typeof view$.fold !== `function` ) { throw new Error( `The DOM driver function expects as input a Stream of ` + `virtual DOM elements` ); } } export interface DOMDriverOptions { modules?: Array<Partial<Module>>; reportSnabbdomError?(err: any): void; snabbdomOptions?: SnabbdomOptions; } function dropCompletion<T>(input: Stream<T>): Stream<T> { return xs.merge(input, xs.never()); } function unwrapElementFromVNode(vnode: VNode): Element { return vnode.elm as Element; } function defaultReportSnabbdomError(err: any): void { (console.error || console.log)(err); } function makeDOMReady$(): Stream<null> { return xs.create<null>({ start(lis: Listener<null>) { if (document.readyState === 'loading') { document.addEventListener('readystatechange', () => { const state = document.readyState; if (state === 'interactive' || state === 'complete') { lis.next(null); lis.complete(); } }); } else { lis.next(null); lis.complete(); } }, stop() {}, }); } function addRootScope(vnode: VNode): VNode { vnode.data = vnode.data || {}; vnode.data.isolate = []; return vnode; } function makeDOMDriver( container: string | Element | DocumentFragment, options: DOMDriverOptions = {} ): Driver<Stream<VNode>, MainDOMSource> { checkValidContainer(container); const modules = options.modules || defaultModules; makeDOMDriverInputGuard(modules); const isolateModule = new IsolateModule(); const snabbdomOptions = options && options.snabbdomOptions || undefined; const patch = init([isolateModule.createModule() as Partial<Module>].concat(modules), undefined, snabbdomOptions); const domReady$ = makeDOMReady$(); let vnodeWrapper: VNodeWrapper; let mutationObserver: MutationObserver; const mutationConfirmed$ = xs.create<null>({ start(listener) { mutationObserver = new MutationObserver(() => listener.next(null)); }, stop() { mutationObserver.disconnect(); }, }); function DOMDriver(vnode$: Stream<VNode>, name = 'DOM'): MainDOMSource { domDriverInputGuard(vnode$); const sanitation$ = xs.create<null>(); const firstRoot$ = domReady$.map(() => { const firstRoot = getValidNode(container) || document.body; vnodeWrapper = new VNodeWrapper(firstRoot); return firstRoot; }); // We need to subscribe to the sink (i.e. vnode$) synchronously inside this // driver, and not later in the map().flatten() because this sink is in // reality a SinkProxy from @cycle/run, and we don't want to miss the first // emission when the main() is connected to the drivers. // Read more in issue #739. const rememberedVNode$ = vnode$.remember(); rememberedVNode$.addListener({}); // The mutation observer internal to mutationConfirmed$ should // exist before elementAfterPatch$ calls mutationObserver.observe() mutationConfirmed$.addListener({}); const elementAfterPatch$ = firstRoot$ .map( firstRoot => xs .merge(rememberedVNode$.endWhen(sanitation$), sanitation$) .map(vnode => vnodeWrapper.call(vnode)) .startWith(addRootScope(toVNode(firstRoot))) .fold(patch, toVNode(firstRoot)) .drop(1) .map(unwrapElementFromVNode) .startWith(firstRoot as any) .map(el => { mutationObserver.observe(el, { childList: true, attributes: true, characterData: true, subtree: true, attributeOldValue: true, characterDataOldValue: true, }); return el; }) .compose(dropCompletion) // don't complete this stream ) .flatten(); const rootElement$ = concat(domReady$, mutationConfirmed$) .endWhen(sanitation$) .compose(sampleCombine(elementAfterPatch$)) .map(arr => arr[1]) .remember(); // Start the snabbdom patching, over time rootElement$.addListener({ error: options.reportSnabbdomError || defaultReportSnabbdomError, }); const delegator = new EventDelegator(rootElement$, isolateModule); return new MainDOMSource( rootElement$, sanitation$, [], isolateModule, delegator, name ); } return DOMDriver as any; } export {makeDOMDriver};