Skip to content

Commit

Permalink
Merge e8e65ae into 42b469f
Browse files Browse the repository at this point in the history
  • Loading branch information
stwa committed May 23, 2019
2 parents 42b469f + e8e65ae commit f9d3fdb
Show file tree
Hide file tree
Showing 4 changed files with 245 additions and 0 deletions.
85 changes: 85 additions & 0 deletions src/dom.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { signalFn } from "./signal";
import { queue } from "./internal/queue";

/**
* The dom module helps to connect view functions with signal circuits. A view
* can be mounted by passing a function that returns a views representation.
* mount/unmount will then take care of the signal creation and lifetime
* management for you. Also, mount/unmount helps in doing the necessary
* side-effects to the DOM by calling the provided `patchFn` (mount) and
* `cleanupFn` (unmount). The goal is to provide a simple interface consisting
* of these two functions but perform optimized rendering based on signals.
*
* @module dom
*/

export const renderQueue = queue();

/**
* Mounts the result of `viewFn` as a replacement of `root`.
*
* The view function is wrapped inside a signal, therefore the view re-computes
* whenever a state change in any referenced input signal gets detected.
*
* As `mount` only tries to make minimal assumptions on how the DOM gets patched
* a `patchFn` and `cleanupFn` must be provided. What a view function returns
* is up to the user but must be understood by the patch function. Whenever the
* result of `viewFn` changes, `patchFn` gets called with the last result from
* `patchFn` as the first argument (`root` on first call) and the result from
* `viewFn` as the second argument. The patch function must ensure that the DOM
* gets updated accordingly. On unmount, the `cleanupFn` gets called with the
* last result from `patchFn` and must ensure the previously mounted DOM node
* gets removed from the DOM.
*
* @param {DOMNode} root The root DOM node.
* @param {function} viewFn The view function.
* @param {function} patchFn The patch function.
* @param {function} [cleanupFn] The cleanup function.
* @returns {function} A function to unmount the mounted view from the DOM.
*
* @example
*
* function htmlToElement(html) {
* let template = document.createElement('template');
* template.innerHTML = html;
* return template.content.firstChild;
* }
*
* // Mount with a really simple patch function that creates new elements from
* // text. Note: This solution is _very_ limited and is only used for
* // demonstration purposes.
* mount(document.querySelector("#my-view"),
* () => "<h1>Hello World!</h1>",
* (prev, next) => {
* let el = htmlToElement(next);
* prev.parentNode.replaceChild(el, prev);
* return el;
* },
* prev => {
* return prev.parentNode.removeChild(el);
* }
* );
*/
export function mount(root, viewFn, patchFn, cleanupFn = () => {}) {
let s = signalFn(viewFn);
let dirty = true;

let render = () => {
if (dirty) {
root = patchFn(root, s.value());
dirty = false;
}
};

render(s.value());

let disconnect = s.connect(() => {
dirty = true;
renderQueue.enqueue(render);
});

return () => {
disconnect();
return cleanupFn(root);
};
}
131 changes: 131 additions & 0 deletions src/dom.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { mount } from "./dom";
import { signal } from "./signal";
import { renderQueue } from "./dom";

/* global global */

let ticker = (function() {
let fns = [];
return {
dispatch(fn) {
fns.push(fn);
},
advance() {
let fn = fns.pop();
if (fn) fn();
},
size() {
return fns.length;
},
};
})();

beforeEach(() => {
global.console.warn = jest.fn();
document.body.innerHTML = '<div id="my-view"></div>';
renderQueue.tickFn(ticker.dispatch);
});

describe("mount", () => {
it("calls the patch function on mount", () => {
let patches = [];
let patchFn = (prev, next) => {
patches = [...patches, [prev, next]];
return next;
};
let viewFn = () => "foo";

let root = document.querySelector("#my-view");
mount(root, viewFn, patchFn);

expect(patches).toHaveLength(1);
let [prev, next] = patches[0];
expect(prev).toBe(root);
expect(next).toBe("foo");
});
it("calls the patch function on view changes", () => {
let patches = [];
let patchFn = (prev, next) => {
patches = [...patches, [prev, next]];
return next;
};
let s = signal("foo");
let viewFn = () => s.value();

mount(document.querySelector("#my-view"), viewFn, patchFn);

s.reset("bar");
ticker.advance();

expect(patches).toHaveLength(2);
let [prev, next] = patches[1];
expect(prev).toBe("foo");
expect(next).toBe("bar");
});
it("renders only when view is dirty", () => {
let patches = [];
let patchFn = (prev, next) => {
patches = [...patches, [prev, next]];
return next;
};
let s = signal("foo");
let viewFn = () => s.value();

mount(document.querySelector("#my-view"), viewFn, patchFn);

s.reset("bar");
s.reset("baz");

expect(ticker.size()).toBe(1);
ticker.advance();

expect(patches).toHaveLength(2);
let [prev, next] = patches[1];
expect(prev).toBe("foo");
expect(next).toBe("baz");
});
});

describe("unmount", () => {
it("disconnects from view signal", () => {
let s = signal("foo");
let patched = 0;

let unmount = mount(
document.querySelector("#my-view"),
() => s.value(),
() => ++patched,
() => {},
);
expect(patched).toBe(1);

s.reset("bar");
ticker.advance();
expect(patched).toBe(2);

unmount();

s.reset("baz");
ticker.advance();
expect(patched).toBe(2);
});
it("calls the cleanup function and returns its result", () => {
let root = document.querySelector("#my-view");
let cleanedup = false;

let unmount = mount(
root,
() => {},
() => "foo",
node => {
expect(node).toBe("foo");
cleanedup = true;
return "some-value";
},
);

let unmounted = unmount();
expect(unmounted).toBe("some-value");
expect(cleanedup).toBeTruthy();
});
});
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export {
rawConnector,
withInputSignals,
} from "./connector";
export { mount } from "./dom";
export { signal, signalFn } from "./signal";
28 changes: 28 additions & 0 deletions src/internal/queue.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export function queue(nextTick = window.requestAnimationFrame) {
let queue = [];
let scheduled = false;

return {
enqueue(fn) {
queue.push(fn);
this.schedule();
},
flush() {
scheduled = false;

let fns = [...queue];
queue = [];

fns.forEach(fn => fn());
},
schedule() {
if (!scheduled) {
scheduled = true;
nextTick(this.flush);
}
},
tickFn(fn) {
nextTick = fn;
},
};
}

0 comments on commit f9d3fdb

Please sign in to comment.