@ryupold/vode 1.8.7 → 1.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -56
- package/dist/vode.cjs.min.js +2 -2
- package/dist/vode.d.ts +6 -5
- package/dist/vode.es5.min.js +7 -7
- package/dist/vode.js +43 -69
- package/dist/vode.min.js +1 -1
- package/dist/vode.min.mjs +1 -1
- package/dist/vode.mjs +43 -69
- package/package.json +1 -1
- package/src/state-context.ts +6 -4
- package/src/vode.ts +53 -76
- package/test/helper.ts +78 -32
- package/test/index.ts +41 -16
- package/test/mocks.ts +132 -38
- package/test/tests-app.ts +117 -1
- package/test/tests-catch.ts +160 -0
- package/test/tests-defuse.ts +22 -1
- package/test/tests-examples.ts +992 -0
- package/test/tests-hydrate.ts +43 -9
- package/test/tests-memo.ts +91 -50
- package/test/tests-patch-advanced.ts +84 -0
- package/test/tests-patch-merge.ts +66 -0
- package/test/tests-state-context.ts +32 -1
package/test/tests-hydrate.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { expect } from "./helper";
|
|
2
2
|
import { hydrate, DIV, SPAN, P } from "../index";
|
|
3
|
-
import {
|
|
3
|
+
import { FakeElement, FakeTextNode } from "./mocks";
|
|
4
4
|
|
|
5
5
|
export default {
|
|
6
6
|
"hydrate(): text node returns its text content": () => {
|
|
7
|
-
const text = new
|
|
7
|
+
const text = new FakeTextNode("hello world");
|
|
8
8
|
|
|
9
9
|
expect(hydrate(text as any))
|
|
10
10
|
.toMatch("hello world");
|
|
11
11
|
},
|
|
12
12
|
|
|
13
13
|
"hydrate(): empty element returns a vode": () => {
|
|
14
|
-
const el = new
|
|
14
|
+
const el = new FakeElement("div");
|
|
15
15
|
const result = hydrate(el as any);
|
|
16
16
|
|
|
17
17
|
expect(result)
|
|
@@ -19,8 +19,8 @@ export default {
|
|
|
19
19
|
},
|
|
20
20
|
|
|
21
21
|
"hydrate(): element with children returns full vode tree": () => {
|
|
22
|
-
const parent = new
|
|
23
|
-
const child = new
|
|
22
|
+
const parent = new FakeElement("div");
|
|
23
|
+
const child = new FakeElement("span");
|
|
24
24
|
parent.appendChild(child);
|
|
25
25
|
|
|
26
26
|
expect(hydrate(parent as any))
|
|
@@ -28,8 +28,8 @@ export default {
|
|
|
28
28
|
},
|
|
29
29
|
|
|
30
30
|
"hydrate(): element with text child": () => {
|
|
31
|
-
const parent = new
|
|
32
|
-
const text = new
|
|
31
|
+
const parent = new FakeElement("p");
|
|
32
|
+
const text = new FakeTextNode("hello");
|
|
33
33
|
parent.appendChild(text);
|
|
34
34
|
|
|
35
35
|
expect(hydrate(parent as any))
|
|
@@ -37,7 +37,7 @@ export default {
|
|
|
37
37
|
},
|
|
38
38
|
|
|
39
39
|
"hydrate(): element with attributes reads them into props": () => {
|
|
40
|
-
const el = new
|
|
40
|
+
const el = new FakeElement("div");
|
|
41
41
|
el.setAttribute("class", "foo");
|
|
42
42
|
el.setAttribute("id", "bar");
|
|
43
43
|
|
|
@@ -53,7 +53,7 @@ export default {
|
|
|
53
53
|
},
|
|
54
54
|
|
|
55
55
|
"hydrate(): empty text node returns undefined": () => {
|
|
56
|
-
const text = new
|
|
56
|
+
const text = new FakeTextNode(" ");
|
|
57
57
|
|
|
58
58
|
expect(hydrate(text as any))
|
|
59
59
|
.toEqual(undefined);
|
|
@@ -65,4 +65,38 @@ export default {
|
|
|
65
65
|
expect(hydrate(comment))
|
|
66
66
|
.toEqual(undefined);
|
|
67
67
|
},
|
|
68
|
+
|
|
69
|
+
"hydrate(): prepareForRender returns text node for text input": () => {
|
|
70
|
+
const text = new FakeTextNode("hello");
|
|
71
|
+
|
|
72
|
+
const result = hydrate(text as any, true);
|
|
73
|
+
|
|
74
|
+
expect(result instanceof FakeTextNode).toEqual(true);
|
|
75
|
+
expect((result as any).nodeValue).toEqual("hello");
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
"hydrate(): prepareForRender attaches .node to element vode": () => {
|
|
79
|
+
const el = new FakeElement("div");
|
|
80
|
+
|
|
81
|
+
const result = hydrate(el as any, true) as any;
|
|
82
|
+
|
|
83
|
+
expect(Array.isArray(result)).toEqual(true);
|
|
84
|
+
expect(result[0]).toEqual("div");
|
|
85
|
+
expect(result.node instanceof FakeElement).toEqual(true);
|
|
86
|
+
expect(result.node.tagName).toEqual("DIV");
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
"hydrate(): prepareForRender removes whitespace text nodes": () => {
|
|
90
|
+
const el = new FakeElement("div");
|
|
91
|
+
el.appendChild(new FakeTextNode(" "));
|
|
92
|
+
el.appendChild(new FakeElement("span"));
|
|
93
|
+
el.appendChild(new FakeTextNode(" "));
|
|
94
|
+
|
|
95
|
+
expect(el.childNodes.length).toEqual(3);
|
|
96
|
+
|
|
97
|
+
const result = hydrate(el as any, true);
|
|
98
|
+
|
|
99
|
+
expect(el.childNodes.length).toEqual(1);
|
|
100
|
+
expect((el.childNodes[0] as any).tagName).toEqual("SPAN");
|
|
101
|
+
},
|
|
68
102
|
};
|
package/test/tests-memo.ts
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
import { expect } from "./helper";
|
|
2
|
-
import { memo, DIV, app, createState, SPAN } from "../index";
|
|
2
|
+
import { memo, DIV, app, createState, SPAN, H1, BR, P, UL, LI, Component } from "../index";
|
|
3
3
|
|
|
4
4
|
export default {
|
|
5
|
-
"memo(): returns the given function": () => {
|
|
6
|
-
const fn = (s: any) => [DIV];
|
|
7
|
-
const result = memo([1, 2], fn);
|
|
8
|
-
expect(result === fn).toEqual(true);
|
|
9
|
-
},
|
|
10
|
-
|
|
11
5
|
"memo(): throws when compare is not an array": () => {
|
|
12
6
|
const err = expect(() => memo(null as any, (s: any) => [DIV]))
|
|
13
7
|
.toFail();
|
|
@@ -19,7 +13,7 @@ export default {
|
|
|
19
13
|
const err = expect(() => memo([1], null as any))
|
|
20
14
|
.toFail();
|
|
21
15
|
expect(err.message)
|
|
22
|
-
.toEqual("second argument to memo() must be a function that returns a vode
|
|
16
|
+
.toEqual("second argument to memo() must be a function that returns a child vode");
|
|
23
17
|
},
|
|
24
18
|
|
|
25
19
|
"memo(): integration with app prevents re-render when deps match": () => {
|
|
@@ -53,67 +47,114 @@ export default {
|
|
|
53
47
|
);
|
|
54
48
|
},
|
|
55
49
|
|
|
56
|
-
"memo():
|
|
57
|
-
const state = createState({ count: 12
|
|
50
|
+
"memo(): can be used with a nested component function": () => {
|
|
51
|
+
const state = createState({ count: 12 });
|
|
58
52
|
const root = document.createElement("div");
|
|
59
53
|
const container = document.createElement("div");
|
|
60
54
|
root.appendChild(container);
|
|
61
55
|
|
|
62
56
|
let callCount = 0;
|
|
63
57
|
app<typeof state>(container, state, (s) => [DIV,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
low: s.count < 10,
|
|
72
|
-
high: s.count >= 10,
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
),
|
|
77
|
-
[SPAN, `${s.prefix}${s.count}`]
|
|
78
|
-
],
|
|
79
|
-
]);
|
|
58
|
+
() => memo(
|
|
59
|
+
[s.count],
|
|
60
|
+
(s) => {
|
|
61
|
+
callCount++;
|
|
62
|
+
return [DIV, [SPAN, `${s.count}`]];
|
|
63
|
+
}
|
|
64
|
+
)]);
|
|
80
65
|
|
|
81
66
|
|
|
82
67
|
expect(callCount).toEqual(1);
|
|
83
|
-
state.patch({ count: 12 });
|
|
84
|
-
expect(callCount).toEqual(1);
|
|
85
|
-
state.patch({ count: 13 });
|
|
86
|
-
expect(callCount).toEqual(2);
|
|
87
|
-
state.patch({ prefix: "count: " });
|
|
88
|
-
expect(callCount).toEqual(3);
|
|
89
|
-
expect(container).toMatch(
|
|
90
|
-
[DIV,
|
|
91
|
-
[DIV, { class: { low: false, high: true } },
|
|
92
|
-
[SPAN, "count: 13"]
|
|
93
|
-
]
|
|
94
|
-
]
|
|
95
|
-
);
|
|
68
|
+
state.patch({ count: 12 }); //same value, should not re-render
|
|
69
|
+
expect(callCount).toEqual(1);
|
|
96
70
|
},
|
|
97
71
|
|
|
98
|
-
"memo(): can be
|
|
99
|
-
const state = createState({
|
|
72
|
+
"memo(): can be used with the same component function": () => {
|
|
73
|
+
const state = createState({ test: "foo" });
|
|
100
74
|
const root = document.createElement("div");
|
|
101
75
|
const container = document.createElement("div");
|
|
102
76
|
root.appendChild(container);
|
|
103
77
|
|
|
104
78
|
let callCount = 0;
|
|
79
|
+
const Comp: Component<typeof state> = (s) => {
|
|
80
|
+
callCount++;
|
|
81
|
+
return [DIV, [SPAN, s.test]];
|
|
82
|
+
};
|
|
105
83
|
app<typeof state>(container, state, (s) => [DIV,
|
|
106
|
-
|
|
107
|
-
[s.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
84
|
+
memo(
|
|
85
|
+
[s.test],
|
|
86
|
+
Comp,
|
|
87
|
+
),
|
|
88
|
+
memo(
|
|
89
|
+
[s.test],
|
|
90
|
+
Comp,
|
|
91
|
+
),
|
|
92
|
+
]);
|
|
113
93
|
|
|
114
94
|
|
|
95
|
+
expect(callCount).toEqual(2);
|
|
96
|
+
state.patch({ test: "foo" });
|
|
97
|
+
expect(callCount).toEqual(2);
|
|
98
|
+
state.patch({ test: "bar" });
|
|
99
|
+
expect(callCount).toEqual(4);
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
"memo(): memo with many item list": () => {
|
|
103
|
+
const root = document.createElement("div");
|
|
104
|
+
const container = document.createElement("div");
|
|
105
|
+
root.appendChild(container);
|
|
106
|
+
|
|
107
|
+
const state = createState({ title: "hello", body: "world" });
|
|
108
|
+
type State = typeof state;
|
|
109
|
+
|
|
110
|
+
const CompMemoList: Component<State> = (s) =>
|
|
111
|
+
[DIV, { class: "container" },
|
|
112
|
+
[H1, "Hello World"],
|
|
113
|
+
[BR],
|
|
114
|
+
[P, "This is a paragraph."],
|
|
115
|
+
memo(
|
|
116
|
+
[s.title, s.body],
|
|
117
|
+
(s) => {
|
|
118
|
+
const list = [UL];
|
|
119
|
+
for (let i = 0; i < 10000; i++) {
|
|
120
|
+
list.push(LI, `Item ${i}`);
|
|
121
|
+
}
|
|
122
|
+
return list;
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
app<State>(container, state, (s) => [DIV,
|
|
128
|
+
CompMemoList,
|
|
129
|
+
]);
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
"memo(): double-wrapping ignores the inner memo dependencies, only the outer memo is checked": () => {
|
|
133
|
+
const state = createState({ outer: 1, inner: 1 });
|
|
134
|
+
const root = document.createElement("div");
|
|
135
|
+
const container = document.createElement("div");
|
|
136
|
+
root.appendChild(container);
|
|
137
|
+
|
|
138
|
+
let callCount = 0;
|
|
139
|
+
const comp = (s: typeof state) => {
|
|
140
|
+
callCount++;
|
|
141
|
+
return [DIV, `${s.outer}`];
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const memoed = (s: typeof state) => memo([s.inner], comp);
|
|
145
|
+
const doubleMemoed = (s: typeof state) => memo([s.outer], memoed);
|
|
146
|
+
|
|
147
|
+
expect(() => app(container, state, () => [DIV, doubleMemoed]))
|
|
148
|
+
.toSucceed();
|
|
149
|
+
|
|
115
150
|
expect(callCount).toEqual(1);
|
|
116
|
-
|
|
117
|
-
|
|
151
|
+
expect(container).toMatch([DIV, [DIV, "1"]]);
|
|
152
|
+
|
|
153
|
+
state.patch({ outer: 2 });
|
|
154
|
+
expect(callCount).toEqual(2);
|
|
155
|
+
state.patch({ inner: 2 });
|
|
156
|
+
expect(callCount).toEqual(2);
|
|
157
|
+
state.patch({ outer: 3 });
|
|
158
|
+
expect(callCount).toEqual(3);
|
|
118
159
|
},
|
|
119
160
|
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { expect } from "./helper";
|
|
2
|
+
import { app, createState, DIV } from "../index";
|
|
3
|
+
|
|
4
|
+
function setup() {
|
|
5
|
+
const root = document.createElement("div");
|
|
6
|
+
const container = document.createElement("div");
|
|
7
|
+
root.appendChild(container);
|
|
8
|
+
return container;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
"patch(): generator function yields multiple state updates": async () => {
|
|
13
|
+
const container = setup();
|
|
14
|
+
const state: any = createState({ count: 0 });
|
|
15
|
+
app(container, state, (s: any) => [DIV, String(s.count)]);
|
|
16
|
+
|
|
17
|
+
expect(state.count).toEqual(0);
|
|
18
|
+
|
|
19
|
+
state.patch(function* () {
|
|
20
|
+
yield { count: 1 };
|
|
21
|
+
yield { count: 2 };
|
|
22
|
+
return { count: 3 };
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
await new Promise(r => setTimeout(r, 0));
|
|
26
|
+
|
|
27
|
+
expect(state.count).toEqual(3);
|
|
28
|
+
expect(container).toMatch([DIV, "3"]);
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
"patch(): async generator yields over time": async () => {
|
|
32
|
+
const container = setup();
|
|
33
|
+
const state: any = createState({ phase: "start", value: 0 });
|
|
34
|
+
app(container, state, (s: any) => [DIV, s.phase, String(s.value)]);
|
|
35
|
+
|
|
36
|
+
expect(state.phase).toEqual("start");
|
|
37
|
+
|
|
38
|
+
state.patch(async function* () {
|
|
39
|
+
yield { phase: "working", value: 10 };
|
|
40
|
+
yield { phase: "almost", value: 20 };
|
|
41
|
+
return { phase: "done", value: 30 };
|
|
42
|
+
}());
|
|
43
|
+
|
|
44
|
+
await new Promise(r => setTimeout(r, 0));
|
|
45
|
+
|
|
46
|
+
expect(state.phase).toEqual("done");
|
|
47
|
+
expect(state.value).toEqual(30);
|
|
48
|
+
expect(container).toMatch([DIV, "done", "30"]);
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
"patch(): Promise resolves and applies patch": async () => {
|
|
52
|
+
const container = setup();
|
|
53
|
+
const state: any = createState({ msg: "before" });
|
|
54
|
+
app(container, state, (s: any) => [DIV, s.msg]);
|
|
55
|
+
|
|
56
|
+
state.patch(Promise.resolve({ msg: "after" }));
|
|
57
|
+
|
|
58
|
+
await new Promise(r => setTimeout(r, 0));
|
|
59
|
+
|
|
60
|
+
expect(state.msg).toEqual("after");
|
|
61
|
+
expect(container).toMatch([DIV, "after"]);
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
"patch(): array with empty patches applies nothing": () => {
|
|
65
|
+
const container = setup();
|
|
66
|
+
const state: any = createState({ x: 1, y: 2 });
|
|
67
|
+
app(container, state, (s: any) => [DIV]);
|
|
68
|
+
|
|
69
|
+
state.patch([{}, {}]);
|
|
70
|
+
expect(state.x).toEqual(1);
|
|
71
|
+
expect(state.y).toEqual(2);
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
"patch(): array with null/undefined items skips them": () => {
|
|
75
|
+
const container = setup();
|
|
76
|
+
const state: any = createState({ x: 0, y: 0 });
|
|
77
|
+
app(container, state, (s: any) => [DIV, String(s.x), String(s.y)]);
|
|
78
|
+
|
|
79
|
+
state.patch([null, { x: 10 }, undefined, { y: 20 }]);
|
|
80
|
+
|
|
81
|
+
expect(state.x).toEqual(10);
|
|
82
|
+
expect(state.y).toEqual(20);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { expect } from "./helper";
|
|
2
|
+
import { app, createState, DIV } from "../index";
|
|
3
|
+
|
|
4
|
+
function setup() {
|
|
5
|
+
const root = document.createElement("div");
|
|
6
|
+
const container = document.createElement("div");
|
|
7
|
+
root.appendChild(container);
|
|
8
|
+
return container;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
"patch-merge: array property replaces existing array": () => {
|
|
13
|
+
const container = setup();
|
|
14
|
+
const state: any = createState({ items: [1, 2, 3] });
|
|
15
|
+
app(container, state, () => [DIV]);
|
|
16
|
+
|
|
17
|
+
state.patch({ items: [4, 5, 6] });
|
|
18
|
+
|
|
19
|
+
expect(state.items).toEqual([4, 5, 6]);
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
"patch-merge: Date property stores correctly": () => {
|
|
23
|
+
const container = setup();
|
|
24
|
+
const state: any = createState({ date: new Date("2024-01-01") });
|
|
25
|
+
app(container, state, () => [DIV]);
|
|
26
|
+
|
|
27
|
+
state.patch({ date: new Date("2025-06-15") });
|
|
28
|
+
|
|
29
|
+
expect(state.date instanceof Date).toEqual(true);
|
|
30
|
+
expect(state.date.getFullYear()).toEqual(2025);
|
|
31
|
+
expect(state.date.getMonth()).toEqual(5);
|
|
32
|
+
expect(state.date.getDate()).toEqual(15);
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
"patch-merge: object replaces existing array property": () => {
|
|
36
|
+
const container = setup();
|
|
37
|
+
const state: any = createState({ data: [1, 2, 3] });
|
|
38
|
+
app(container, state, () => [DIV]);
|
|
39
|
+
|
|
40
|
+
state.patch({ data: { key: "value" } });
|
|
41
|
+
|
|
42
|
+
expect(Array.isArray(state.data)).toEqual(false);
|
|
43
|
+
expect(state.data.key).toEqual("value");
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
"patch-merge: object replaces existing primitive property": () => {
|
|
47
|
+
const container = setup();
|
|
48
|
+
const state: any = createState({ value: 42 });
|
|
49
|
+
app(container, state, () => [DIV]);
|
|
50
|
+
|
|
51
|
+
state.patch({ value: { nested: true } });
|
|
52
|
+
|
|
53
|
+
expect(state.value.nested).toEqual(true);
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
"patch-merge: new array property via patch": () => {
|
|
57
|
+
const container = setup();
|
|
58
|
+
const state: any = createState({ name: "test" });
|
|
59
|
+
app(container, state, () => [DIV]);
|
|
60
|
+
|
|
61
|
+
state.patch({ tags: ["a", "b", "c"] });
|
|
62
|
+
|
|
63
|
+
expect(Array.isArray(state.tags)).toEqual(true);
|
|
64
|
+
expect(state.tags).toEqual(["a", "b", "c"]);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { expect } from "./helper";
|
|
2
|
-
import { context } from "../src/state-context";
|
|
2
|
+
import { context, ProxySubContext } from "../src/state-context";
|
|
3
3
|
import { createState } from "../src/vode";
|
|
4
4
|
|
|
5
5
|
export default {
|
|
@@ -103,4 +103,35 @@ export default {
|
|
|
103
103
|
expect(patches[0])
|
|
104
104
|
.toEqual({ x: { y: { z: 100 } } });
|
|
105
105
|
},
|
|
106
|
+
|
|
107
|
+
"StateContext.put() with intermediate null creates objects along the path": () => {
|
|
108
|
+
const state = createState({ a: null as { b: number } | null });
|
|
109
|
+
const ctx = context(state);
|
|
110
|
+
|
|
111
|
+
ctx.a.b.put(42);
|
|
112
|
+
expect(state.a?.b).toEqual(42);
|
|
113
|
+
|
|
114
|
+
ctx.a.put(null);
|
|
115
|
+
expect(state.a).toEqual(null);
|
|
116
|
+
expect(state.a?.b).toEqual(undefined);
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
"StateContext.put() with three-level intermediate null": () => {
|
|
120
|
+
const state = createState({ a: null as { b: { c: number } } | null });
|
|
121
|
+
const ctx = context(state);
|
|
122
|
+
|
|
123
|
+
ctx.a.b.c.put(99);
|
|
124
|
+
|
|
125
|
+
expect(state.a?.b.c).toEqual(99);
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
"StateContext.put() with multiple intermediate nulls": () => {
|
|
129
|
+
const state = createState({ a: { x: null as { z: string } | null, y: 1 } });
|
|
130
|
+
const ctx = context(state);
|
|
131
|
+
|
|
132
|
+
ctx.a.x.z.put("deep");
|
|
133
|
+
|
|
134
|
+
expect(state.a.x?.z).toEqual("deep");
|
|
135
|
+
expect(state.a.y).toEqual(1);
|
|
136
|
+
},
|
|
106
137
|
};
|