cyclejs/cycle-core

View on GitHub
dom/src/makeDOMDriver.ts

Summary

Maintainability
B
4 hrs
Test Coverage
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};