feat: add comparator support to thunk and improve typing and encapsulation

Make it possible to compare arguments to thunks with a comparator
function for custom notions of equality

ISSUES CLOSED: #143
BREAKING CHANGE:
The signature of thunk is changed. The second argument is now an
options object that can contain a key and a comparator function. The
type of thunk is much stronger and stricter. The vnode properties used
by thunks are now private and not part of the public types. Changing the
function to thunk is no longer supported and does not cause the thunk to
be rebuild.
pull/1097/head
Simon Friis Vindum 1 year ago
parent 710b319622
commit d3cf752640

@ -6,7 +6,6 @@ export { vnode } from "./vnode";
export type { DOMAPI } from "./htmldomapi";
export type { Options } from "./init";
export type { ThunkData, Thunk, ThunkFn } from "./thunk";
export type { Key, VNode, VNodeData } from "./vnode";
// helpers

@ -1,71 +1,69 @@
import { VNode, VNodeData } from "./vnode";
import { Key, VNode, VNodeData } from "./vnode";
import { h, addNS } from "./h";
export interface ThunkData extends VNodeData {
fn: () => VNode;
args: any[];
}
export interface Thunk extends VNode {
data: ThunkData;
}
export interface ThunkFn {
(sel: string, fn: (...args: any[]) => any, args: any[]): Thunk;
(sel: string, key: any, fn: (...args: any[]) => any, args: any[]): Thunk;
interface ThunkData<T extends readonly unknown[]> extends VNodeData {
_args: [...T];
_fn: (...args: T) => VNode;
_comparator: (prev: T, cur: T) => boolean | undefined;
}
function copyToThunk(vnode: VNode, thunk: VNode): void {
const ns = thunk.data?.ns;
(vnode.data as VNodeData).fn = (thunk.data as VNodeData).fn;
(vnode.data as VNodeData).args = (thunk.data as VNodeData).args;
vnode.data!._args = thunk.data!._args;
thunk.data = vnode.data;
thunk.children = vnode.children;
thunk.text = vnode.text;
thunk.elm = vnode.elm;
if (ns) addNS(thunk.data, thunk.children, thunk.sel);
if (ns !== undefined) {
addNS(thunk.data, thunk.children, thunk.sel);
}
}
function init(thunk: VNode): void {
const cur = thunk.data as VNodeData;
const vnode = (cur.fn as any)(...cur.args!);
function init<T extends readonly unknown[]>(thunk: VNode): void {
const cur = thunk.data as ThunkData<T>;
const vnode = cur._fn(...cur._args);
copyToThunk(vnode, thunk);
}
function prepatch(oldVnode: VNode, thunk: VNode): void {
let i: number;
const old = oldVnode.data as VNodeData;
const cur = thunk.data as VNodeData;
const oldArgs = old.args;
const args = cur.args;
if (old.fn !== cur.fn || (oldArgs as any).length !== (args as any).length) {
copyToThunk((cur.fn as any)(...args!), thunk);
function prepatch<T extends readonly unknown[]>(
oldVnode: VNode,
thunk: VNode
): void {
const cur = thunk.data! as ThunkData<T>;
const args = cur._args;
const oldArgs = oldVnode.data!._args as T;
if (oldArgs.length !== args.length) {
copyToThunk(cur._fn(...args), thunk);
return;
}
for (i = 0; i < (args as any).length; ++i) {
if ((oldArgs as any)[i] !== (args as any)[i]) {
copyToThunk((cur.fn as any)(...args!), thunk);
const comparator = cur._comparator;
if (comparator === undefined) {
for (let i = 0; i < args.length; ++i) {
if (oldArgs[i] !== args[i]) {
copyToThunk(cur._fn(...args), thunk);
return;
}
}
copyToThunk(oldVnode, thunk);
} else {
const vnode = comparator(oldArgs, args) ? oldVnode : cur._fn(...args);
copyToThunk(vnode, thunk);
}
}
export const thunk = function thunk(
const thunkHooks = { init, prepatch };
export function thunk<T extends readonly unknown[]>(
sel: string,
key?: any,
fn?: any,
args?: any
opts: { key?: Key; comparator?: (prev: T, cur: T) => boolean },
fn: (...args: T) => VNode,
args: [...T]
): VNode {
if (args === undefined) {
args = fn;
fn = key;
key = undefined;
}
return h(sel, {
key: key,
hook: { init, prepatch },
fn: fn,
args: args
key: opts.key,
hook: thunkHooks,
_fn: fn,
_args: args,
_comparator: opts.comparator
});
} as ThunkFn;
}

@ -29,8 +29,6 @@ export interface VNodeData<VNodeProps = Props> {
hook?: Hooks;
key?: Key;
ns?: string; // for SVGs
fn?: () => VNode; // for thunks
args?: any[]; // for thunks
is?: string; // for custom elements v1
[key: string]: any; // for any other 3rd party module
}

@ -9,14 +9,13 @@ describe("thunk", function () {
beforeEach(function () {
elm = vnode0 = document.createElement("div");
});
it("returns vnode with data and render function", function () {
it("returns vnode with selector and key function", function () {
function numberInSpan(n: number) {
return h("span", `Number is ${n}`);
}
const vnode = thunk("span", "num", numberInSpan, [22]);
const vnode = thunk("span", { key: "num" }, numberInSpan, [22]);
assert.deepEqual(vnode.sel, "span");
assert.deepEqual(vnode.data.key, "num");
assert.deepEqual(vnode.data.args, [22]);
assert.deepEqual(vnode.data?.key, "num");
});
it("calls render function once on data change", function () {
let called = 0;
@ -24,21 +23,21 @@ describe("thunk", function () {
called++;
return h("span", { key: "num" }, `Number is ${n}`);
}
const vnode1 = h("div", [thunk("span", "num", numberInSpan, [1])]);
const vnode2 = h("div", [thunk("span", "num", numberInSpan, [2])]);
const vnode1 = h("div", [thunk("span", { key: "num" }, numberInSpan, [1])]);
const vnode2 = h("div", [thunk("span", { key: "num" }, numberInSpan, [2])]);
patch(vnode0, vnode1);
assert.strictEqual(called, 1);
patch(vnode1, vnode2);
assert.strictEqual(called, 2);
});
it("does not call render function on data unchanged", function () {
it("does not call render function when data is unchanged", function () {
let called = 0;
function numberInSpan(n: number) {
called++;
return h("span", { key: "num" }, `Number is ${n}`);
}
const vnode1 = h("div", [thunk("span", "num", numberInSpan, [1])]);
const vnode2 = h("div", [thunk("span", "num", numberInSpan, [1])]);
const vnode1 = h("div", [thunk("span", { key: "num" }, numberInSpan, [1])]);
const vnode2 = h("div", [thunk("span", { key: "num" }, numberInSpan, [1])]);
patch(vnode0, vnode1);
assert.strictEqual(called, 1);
patch(vnode1, vnode2);
@ -46,18 +45,20 @@ describe("thunk", function () {
});
it("calls render function once on data-length change", function () {
let called = 0;
function numberInSpan(n: number) {
function numberInSpan(...ns: number[]) {
called++;
return h("span", { key: "num" }, `Number is ${n}`);
return h("span", { key: "num" }, `Length is ${ns.length}`);
}
const vnode1 = h("div", [thunk("span", "num", numberInSpan, [1])]);
const vnode2 = h("div", [thunk("span", "num", numberInSpan, [1, 2])]);
const vnode1 = h("div", [thunk("span", { key: "num" }, numberInSpan, [1])]);
const vnode2 = h("div", [
thunk("span", { key: "num" }, numberInSpan, [1, 2])
]);
patch(vnode0, vnode1);
assert.strictEqual(called, 1);
patch(vnode1, vnode2);
assert.strictEqual(called, 2);
});
it("calls render function once on function change", function () {
it("does not call render function on function change", function () {
let called = 0;
function numberInSpan(n: number) {
called++;
@ -67,38 +68,40 @@ describe("thunk", function () {
called++;
return h("span", { key: "num" }, `Number really is ${n}`);
}
const vnode1 = h("div", [thunk("span", "num", numberInSpan, [1])]);
const vnode2 = h("div", [thunk("span", "num", numberInSpan2, [1])]);
const vnode1 = h("div", [thunk("span", { key: "num" }, numberInSpan, [1])]);
const vnode2 = h("div", [
thunk("span", { key: "num" }, numberInSpan2, [1])
]);
patch(vnode0, vnode1);
assert.strictEqual(called, 1);
patch(vnode1, vnode2);
assert.strictEqual(called, 2);
assert.strictEqual(called, 1);
});
it("renders correctly", function () {
let called = 0;
function numberInSpan(n: number) {
function sumInSpan(n: number, m: number) {
called++;
return h("span", { key: "num" }, `Number is ${n}`);
return h("span", { key: "num" }, `Sum is ${n + m}`);
}
const vnode1 = h("div", [thunk("span", "num", numberInSpan, [1])]);
const vnode2 = h("div", [thunk("span", "num", numberInSpan, [1])]);
const vnode3 = h("div", [thunk("span", "num", numberInSpan, [2])]);
const vnode1 = h("div", [thunk("span", { key: "num" }, sumInSpan, [1, 2])]);
const vnode2 = h("div", [thunk("span", { key: "num" }, sumInSpan, [1, 2])]);
const vnode3 = h("div", [thunk("span", { key: "num" }, sumInSpan, [2, 3])]);
elm = patch(vnode0, vnode1).elm;
assert.strictEqual(elm.firstChild.tagName.toLowerCase(), "span");
assert.strictEqual(elm.firstChild.innerHTML, "Number is 1");
assert.strictEqual(elm.firstChild.innerHTML, "Sum is 3");
elm = patch(vnode1, vnode2).elm;
assert.strictEqual(elm.firstChild.tagName.toLowerCase(), "span");
assert.strictEqual(elm.firstChild.innerHTML, "Number is 1");
assert.strictEqual(elm.firstChild.innerHTML, "Sum is 3");
elm = patch(vnode2, vnode3).elm;
assert.strictEqual(elm.firstChild.tagName.toLowerCase(), "span");
assert.strictEqual(elm.firstChild.innerHTML, "Number is 2");
assert.strictEqual(elm.firstChild.innerHTML, "Sum is 5");
assert.strictEqual(called, 2);
});
it("supports leaving out the `key` argument", function () {
function vnodeFn(s: string) {
return h("span.number", "Hello " + s);
}
const vnode1 = thunk("span.number", vnodeFn, ["World!"]);
const vnode1 = thunk("span.number", {}, vnodeFn, ["World!"]);
elm = patch(vnode0, vnode1).elm;
assert.strictEqual(elm.innerText, "Hello World!");
});
@ -108,9 +111,9 @@ describe("thunk", function () {
called++;
return h("span", { key: "num" }, `Number is ${n}`);
}
const vnode1 = thunk("span", "num", numberInSpan, [1]);
const vnode2 = thunk("span", "num", numberInSpan, [1]);
const vnode3 = thunk("span", "num", numberInSpan, [2]);
const vnode1 = thunk("span", { key: "num" }, numberInSpan, [1]);
const vnode2 = thunk("span", { key: "num" }, numberInSpan, [1]);
const vnode3 = thunk("span", { key: "num" }, numberInSpan, [2]);
elm = patch(vnode0, vnode1).elm;
assert.strictEqual(elm.tagName.toLowerCase(), "span");
@ -133,8 +136,8 @@ describe("thunk", function () {
const prefix = n % 2 === 0 ? "Even" : "Odd";
return h("div", { key: oddEven as any }, `${prefix}: ${n}`);
}
const vnode1 = h("div", [thunk("span", "num", numberInSpan, [1])]);
const vnode2 = h("div", [thunk("div", "oddEven", oddEven, [4])]);
const vnode1 = h("div", [thunk("span", { key: "num" }, numberInSpan, [1])]);
const vnode2 = h("div", [thunk("div", { key: "oddEven" }, oddEven, [4])]);
elm = patch(vnode0, vnode1).elm;
assert.strictEqual(elm.firstChild.tagName.toLowerCase(), "span");
@ -152,8 +155,8 @@ describe("thunk", function () {
const prefix = n % 2 === 0 ? "Even" : "Odd";
return h("div", { key: oddEven as any }, `${prefix}: ${n}`);
}
const vnode1 = thunk("span", "num", numberInSpan, [1]);
const vnode2 = thunk("div", "oddEven", oddEven, [4]);
const vnode1 = thunk("span", { key: "num" }, numberInSpan, [1]);
const vnode2 = thunk("div", { key: "oddEven" }, oddEven, [4]);
elm = patch(vnode0, vnode1).elm;
assert.strictEqual(elm.tagName.toLowerCase(), "span");
@ -177,7 +180,7 @@ describe("thunk", function () {
}
const vnode1 = h("div", [
h("div", "Foo"),
thunk("span", "num", numberInSpan, [1]),
thunk("span", { key: "num" }, numberInSpan, [1]),
h("div", "Foo")
]);
const vnode2 = h("div", [h("div", "Foo"), h("div", "Foo")]);
@ -199,7 +202,7 @@ describe("thunk", function () {
}
const vnode1 = h("div", [
h("div", "Foo"),
thunk("span", "num", numberInSpan, [1]),
thunk("span", { key: "num" }, numberInSpan, [1]),
h("div", "Foo")
]);
const vnode2 = h("div", [h("div", "Foo"), h("div", "Foo")]);
@ -207,4 +210,86 @@ describe("thunk", function () {
patch(vnode1, vnode2);
assert.strictEqual(called, 1);
});
it("causes type-error when argument list does not match function", function () {
function view(name: string, year: number) {
return h("span", { key: "num" }, `${name} was created in ${year}`);
}
thunk("div", {}, view, ["JavaScript", 1995]);
// @ts-expect-error Too few arguments
thunk("div", {}, view, ["JavaScript"]);
// @ts-expect-error Too many arguments
thunk("div", {}, view, ["JavaScript", 1995, 1996]);
// @ts-expect-error Wrong type of arguments
thunk("div", {}, view, ["JavaScript", "1995"]);
});
describe("custom equality comparison", function () {
it("only calls render function when comparator indicates change", function () {
let called = 0;
function view(n: number, s: string) {
called++;
return h("span", {}, `number: ${Math.round(n)}, char: ${s[0]}`);
}
function comparator(pre: [number, string], cur: [number, string]) {
const [n1, s1] = pre;
const [n2, s2] = cur;
return Math.round(n1) == Math.round(n2) && s1[0] === s2[0];
}
const vnode1 = h("div", [
thunk("span", { comparator }, view, [1.1, "hello"])
]);
const vnode2 = h("div", [
thunk("span", { comparator }, view, [1.4, "hi"])
]);
const vnode3 = h("div", [
thunk("span", { comparator }, view, [1.6, "hi"])
]);
const vnode4 = h("div", [
thunk("span", { comparator }, view, [1.7, "hey"])
]);
patch(vnode0, vnode1);
assert.strictEqual(called, 1);
patch(vnode1, vnode2);
assert.strictEqual(called, 1);
patch(vnode2, vnode3);
assert.strictEqual(called, 2);
patch(vnode3, vnode4);
assert.strictEqual(called, 2);
});
it("does not invoke comparator when the number of arguments change", function () {
let viewCalled = 0;
function viewNumbers(...ns: number[]) {
viewCalled++;
return h("span", {}, `Numbers: ${ns.map((n) => Math.round(n)).join()}`);
}
let comparatorCalled = 0;
function comparator(pre: number[], cur: number[]) {
comparatorCalled++;
return pre.every((n, i) => Math.round(n) === Math.round(cur[i]));
}
const vnode1 = h("div", [
thunk("span", { comparator }, viewNumbers, [1.1, 1.2])
]);
const vnode2 = h("div", [
thunk("span", { comparator }, viewNumbers, [1.3, 1.4])
]);
const vnode3 = h("div", [
thunk("span", { comparator }, viewNumbers, [1.3, 1.4, 2])
]);
const vnode4 = h("div", [
thunk("span", { comparator }, viewNumbers, [1.3, 1.4, 2.6])
]);
patch(vnode0, vnode1);
assert.strictEqual(viewCalled, 1);
assert.strictEqual(comparatorCalled, 0);
patch(vnode1, vnode2);
assert.strictEqual(viewCalled, 1);
assert.strictEqual(comparatorCalled, 1);
patch(vnode2, vnode3);
assert.strictEqual(viewCalled, 2);
assert.strictEqual(comparatorCalled, 1);
patch(vnode3, vnode4);
assert.strictEqual(viewCalled, 3);
assert.strictEqual(comparatorCalled, 2);
});
});
});

Loading…
Cancel
Save