diff --git a/src/index.ts b/src/index.ts index cbcb67f..92bd0b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 diff --git a/src/thunk.ts b/src/thunk.ts index 1dad062..ed9f5ca 100644 --- a/src/thunk.ts +++ b/src/thunk.ts @@ -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 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(thunk: VNode): void { + const cur = thunk.data as ThunkData; + 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( + oldVnode: VNode, + thunk: VNode +): void { + const cur = thunk.data! as ThunkData; + 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); - return; + 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); } - copyToThunk(oldVnode, thunk); } -export const thunk = function thunk( +const thunkHooks = { init, prepatch }; + +export function thunk( 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; +} diff --git a/src/vnode.ts b/src/vnode.ts index 3bb7ec3..81860f1 100644 --- a/src/vnode.ts +++ b/src/vnode.ts @@ -29,8 +29,6 @@ export interface VNodeData { 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 } diff --git a/test/unit/thunk.ts b/test/unit/thunk.ts index 9e5be97..1a4e606 100644 --- a/test/unit/thunk.ts +++ b/test/unit/thunk.ts @@ -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); + }); + }); });