Skip to content

Commit

Permalink
Add mount/unmount to render views to the DOM
Browse files Browse the repository at this point in the history
  • Loading branch information
stwa committed May 21, 2019
1 parent 42b469f commit afb5609
Show file tree
Hide file tree
Showing 3 changed files with 259 additions and 0 deletions.
121 changes: 121 additions & 0 deletions src/dom.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { signalFn } from "./signal";

/**
* 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
*/

/** Holds the root dom nodes with mounted views. */
let roots = new Map();

/** Holds the disconnectors per signal. */
let disconnectors = new WeakMap();

/**
* Mounts the `viewFn` as a child of `root`. Note that there can only be one
* mounted view per 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` must be provided. What a view function returns is up to the user
* but must be understand by the patch function. Whenever the result of `viewFn`
* changes, `patchFn` gets called with the `root` as the first argument, the
* last result from `patchFn` as the second argument and the result from
* `viewFn` as the third argument. The patch function must ensure that the DOM
* gets updated accordingly.
*
* @param {DOMNode} root The root DOM node.
* @param {function} viewFn The view function.
* @param {function} patchFn The patch function.
* @returns {Signal} The view signal.
*
* @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>",
* (root, prev, next) => {
* let el = htmlToElement(next);
* if (prev) {
* root.replaceChild(el, prev);
* } else {
* root.appendChild(el);
* }
* return el;
* }
* );
*/
export function mount(root, viewFn, patchFn) {
if (roots.has(root)) {
console.warn("view already mounted to", root);
return;
}

let render = next => {
let [s, prev] = roots.get(root);
prev = patchFn(root, prev, next);
roots.set(root, [s, prev]);
};

let s = signalFn(viewFn);
let prev = patchFn(root, undefined, s.value());
roots.set(root, [s, prev]);

let disconnect = s.connect((signal, prev, next) => render(next));
disconnectors.set(s, disconnect);

return s;
}

/**
* Unmounts a mounted view from `root`.
*
* As `unmount` only tries to make minimal assumptions on how the DOM gets
* patched a `cleanupFn` must be provided. It gets called with the last result
* from `patchFn` used when mounting the view and must ensure any created DOM
* nodes are removed from `root`.
*
* @param {DOMNode} root The root DOM node.
* @param {function} cleanupFn The cleanup function.
* @return {*} The return value from `cleanupFn`.
*
* @example
*
* // Given the example from `mount`, this is an example how to cleanup the
* // created elements.
* unmount(document.querySelector("#my-view"), (el) => {
* return el.parentNode.removeChild(el);
* });
*/
export function unmount(root, cleanupFn) {
if (!roots.has(root)) {
console.warn("no mounted view found to unmount from", root);
return;
}

let [s, prev] = roots.get(root);
roots.delete(root);

let disconnect = disconnectors.get(s);
disconnectors.delete(s);
disconnect();

return cleanupFn(prev);
}
137 changes: 137 additions & 0 deletions src/dom.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { mount, unmount } from "./dom";
import { signal } from "./signal";

/* global global */

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

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

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

expect(patches).toHaveLength(1);
let [prev, next] = patches[0];
expect(prev).toBeUndefined();
expect(next).toBe("foo");
});
it("calls the patch function on view changes", () => {
let patches = [];
let patchFn = (root, 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");

expect(patches).toHaveLength(2);
let [prev, next] = patches[1];
expect(prev).toBe("foo");
expect(next).toBe("bar");
});
it("mounts only one view per root", () => {
let patches = [];
let patchFn = (root, prev, next) => {
patches = [...patches, [prev, next]];
return next;
};
let root = document.querySelector("#my-view");

mount(root, () => "foo", patchFn);
expect(patches).toHaveLength(1);

mount(root, () => "bar", patchFn);
expect(patches).toHaveLength(1);
expect(global.console.warn).toHaveBeenCalledWith(
"view already mounted to",
root,
);
});
it("can mount a view to the same root after unmount", () => {
let patches = [];
let patchFn = (root, prev, next) => {
patches = [...patches, [prev, next]];
return next;
};
let root = document.querySelector("#my-view");

mount(root, () => "foo", patchFn);
expect(patches).toHaveLength(1);

unmount(root, () => {});

mount(root, () => "bar", patchFn);
expect(patches).toHaveLength(2);

{
let [prev, next] = patches[0];
expect(prev).toBeUndefined();
expect(next).toBe("foo");
}

{
let [prev, next] = patches[1];
expect(prev).toBeUndefined();
expect(next).toBe("bar");
}
});
});

describe("unmount", () => {
it("handles unmounting of unmounted roots", () => {
let root = document.querySelector("#my-view");
unmount(root, () => {});
expect(global.console.warn).toHaveBeenCalledWith(
"no mounted view found to unmount from",
root,
);
});
it("disconnects from view signal", () => {
let s = signal("foo");
let patched = 0;
let freed = false;

let viewSignal = mount(
document.querySelector("#my-view"),
() => s.value(),
() => ++patched,
);
viewSignal.onFree(() => (freed = true));
expect(patched).toBe(1);

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

unmount(document.querySelector("#my-view"), () => {});
expect(freed).toBeTruthy();

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

mount(root, () => {}, () => "foo");
let unmounted = unmount(root, node => {
expect(node).toBe("foo");
cleanedup = true;
return "some-value";
});
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, unmount } from "./dom";
export { signal, signalFn } from "./signal";

0 comments on commit afb5609

Please sign in to comment.