-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
4 changed files
with
245 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}, | ||
}; | ||
} |