@ryupold/vode 1.8.6 → 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.
@@ -1,17 +1,17 @@
1
1
  import { expect } from "./helper";
2
2
  import { hydrate, DIV, SPAN, P } from "../index";
3
- import { MockElement, MockText } from "./mocks";
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 MockText("hello world");
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 MockElement("div");
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 MockElement("div");
23
- const child = new MockElement("span");
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 MockElement("p");
32
- const text = new MockText("hello");
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 MockElement("div");
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 MockText(" ");
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
  };
@@ -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 or props object");
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(): works also with props factory": () => {
57
- const state = createState({ count: 12, prefix: "Count is: " });
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
- [DIV,
65
- memo(
66
- [s.count],
67
- (s) => {
68
- callCount++;
69
- return {
70
- class: {
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); // unchanged count should not cause re-render
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 a nested component function": () => {
99
- const state = createState({ count: 12 });
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
- () => memo(
107
- [s.count],
108
- (s) => {
109
- callCount++;
110
- return [DIV, [SPAN, `${s.count}`]];
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
- state.patch({ count: 12 }); //same value, should not re-render
117
- expect(callCount).toEqual(1);
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
  };
@@ -1,4 +1,4 @@
1
- import { app, createState } from "../src/vode"
1
+ import { app, createState, memo } from "../src/vode"
2
2
  import { ARTICLE, ASIDE, DIV, INPUT, MAIN, NAV, P, SECTION, SPAN } from "../src/vode-tags";
3
3
  import { expect } from "./helper";
4
4
 
@@ -1081,6 +1081,82 @@ export default {
1081
1081
  expect(unmounts).toEqual(["unmount p-inner"]);
1082
1082
  },
1083
1083
 
1084
+ "onUnmount(): memo hit + earlier sibling growth corrupts unmount indices": () => {
1085
+ const container = setup();
1086
+ const fired: string[] = [];
1087
+ const state = createState({ expanded: false, showB: true });
1088
+ const patch = app<typeof state>(container, state, (s) =>
1089
+ [DIV,
1090
+ [SPAN,
1091
+ {
1092
+ onUnmount: (s: unknown, ele: HTMLElement) => {
1093
+ fired.push("unmount A");
1094
+ }
1095
+ },
1096
+ s.expanded && [ASIDE,
1097
+ {
1098
+ onUnmount: (s: unknown, ele: HTMLElement) => {
1099
+ fired.push("unmount A-child");
1100
+ }
1101
+ },
1102
+ "x"
1103
+ ],
1104
+ ],
1105
+ s.showB && memo([], () => [SECTION,
1106
+ {
1107
+ onUnmount: (s: unknown, ele: HTMLElement) => {
1108
+ fired.push("unmount B");
1109
+ }
1110
+ },
1111
+ ])
1112
+ ]
1113
+ );
1114
+
1115
+ expect(fired).toEqual([]);
1116
+
1117
+ patch({ expanded: true });
1118
+ expect(fired).toEqual([]);
1119
+
1120
+ patch({ showB: false });
1121
+ expect(fired).toEqual(["unmount B"]);
1122
+ },
1123
+
1124
+ "onUnmount(): excess child removal + same-render sibling growth": () => {
1125
+ const container = setup();
1126
+ const fired: string[] = [];
1127
+ const state = createState({ expanded: false, showB: true });
1128
+ const patch = app<typeof state>(container, state, (s) =>
1129
+ [DIV,
1130
+ [SPAN,
1131
+ {
1132
+ onUnmount: (s: unknown, ele: HTMLElement) => {
1133
+ fired.push("unmount A");
1134
+ }
1135
+ },
1136
+ s.expanded && [ASIDE,
1137
+ {
1138
+ onUnmount: (s: unknown, ele: HTMLElement) => {
1139
+ fired.push("unmount A-child");
1140
+ }
1141
+ },
1142
+ "x"
1143
+ ],
1144
+ ],
1145
+ s.showB && [P,
1146
+ {
1147
+ onUnmount: (s: unknown, ele: HTMLElement) => {
1148
+ fired.push("unmount B");
1149
+ }
1150
+ },
1151
+ ]
1152
+ ]
1153
+ );
1154
+
1155
+ expect(fired).toEqual([]);
1156
+ patch({ expanded: true, showB: false });
1157
+ expect(fired).toEqual(["unmount B"]);
1158
+ },
1159
+
1084
1160
  "onMount() + onUnmount: symmetry of calls": () => {
1085
1161
  const container = setup();
1086
1162
  const state = createState({
@@ -1137,4 +1213,192 @@ export default {
1137
1213
  'Timer removed'
1138
1214
  ]);
1139
1215
  },
1216
+
1217
+ "onMount(): with catched component, replacement vode's onMount fires when error occurs": () => {
1218
+ const container = setup();
1219
+ const mounts: string[] = [];
1220
+ const broken: any = () => { throw new Error("boom"); };
1221
+ app(container, {}, () =>
1222
+ [DIV,
1223
+ {
1224
+ catch: [SECTION,
1225
+ {
1226
+ onMount: (s: unknown, ele: HTMLElement) => {
1227
+ mounts.push("mount fallback");
1228
+ }
1229
+ },
1230
+ "fallback"
1231
+ ]
1232
+ },
1233
+ broken
1234
+ ]
1235
+ );
1236
+
1237
+ expect(mounts).toEqual(["mount fallback"]);
1238
+ },
1239
+
1240
+ "onMount(): with catched component, returned vode's onMount fires and receives error": () => {
1241
+ const container = setup();
1242
+ const mounts: string[] = [];
1243
+ const caughtErrors: string[] = [];
1244
+ const broken: any = () => { throw new Error("boom"); };
1245
+ app(container, {}, () =>
1246
+ [DIV,
1247
+ {
1248
+ catch: (s: unknown, err: Error) => {
1249
+ caughtErrors.push(err.message);
1250
+ return [SECTION,
1251
+ {
1252
+ onMount: (s: unknown, ele: HTMLElement) => {
1253
+ mounts.push("mount fallback");
1254
+ }
1255
+ },
1256
+ "fallback"
1257
+ ];
1258
+ }
1259
+ },
1260
+ broken
1261
+ ]
1262
+ );
1263
+
1264
+ expect(mounts).toEqual(["mount fallback"]);
1265
+ expect(caughtErrors).toEqual(["boom"]);
1266
+ },
1267
+
1268
+ "onUnmount(): with catched component, replacement vode's onUnmount fires when removed": () => {
1269
+ const container = setup();
1270
+ const unmounts: string[] = [];
1271
+ const state = createState({ show: true });
1272
+ const broken: any = () => { throw new Error("boom"); };
1273
+ const patch = app<typeof state>(container, state, (s) =>
1274
+ [DIV,
1275
+ s.show && [SECTION,
1276
+ {
1277
+ catch: [ARTICLE,
1278
+ {
1279
+ onUnmount: (s: unknown, ele: HTMLElement) => {
1280
+ unmounts.push("unmount fallback");
1281
+ }
1282
+ },
1283
+ "fallback"
1284
+ ]
1285
+ },
1286
+ broken
1287
+ ]
1288
+ ]
1289
+ );
1290
+
1291
+ expect(unmounts).toEqual([]);
1292
+ patch({ show: false });
1293
+ expect(unmounts).toEqual(["unmount fallback"]);
1294
+ },
1295
+
1296
+ "onUnmount(): with catched component, deep replacement tree fires in post-order": () => {
1297
+ const container = setup();
1298
+ const unmounts: string[] = [];
1299
+ const state = createState({ show: true });
1300
+ const broken: any = () => { throw new Error("boom"); };
1301
+ const patch = app<typeof state>(container, state, (s) =>
1302
+ [DIV,
1303
+ s.show && [SECTION,
1304
+ {
1305
+ catch: [ARTICLE,
1306
+ {
1307
+ onUnmount: (s: unknown, ele: HTMLElement) => {
1308
+ unmounts.push("unmount article");
1309
+ }
1310
+ },
1311
+ [P,
1312
+ {
1313
+ onUnmount: (s: unknown, ele: HTMLElement) => {
1314
+ unmounts.push("unmount p");
1315
+ }
1316
+ },
1317
+ "x"
1318
+ ],
1319
+ [SPAN,
1320
+ {
1321
+ onUnmount: (s: unknown, ele: HTMLElement) => {
1322
+ unmounts.push("unmount span");
1323
+ }
1324
+ },
1325
+ "y"
1326
+ ]
1327
+ ]
1328
+ },
1329
+ broken
1330
+ ]
1331
+ ]
1332
+ );
1333
+
1334
+ expect(unmounts).toEqual([]);
1335
+ patch({ show: false });
1336
+ expect(unmounts).toEqual(["unmount span", "unmount p", "unmount article"]);
1337
+ },
1338
+
1339
+ "onMount()/onUnmount(): with catched component, full lifecycle symmetry of catch replacement": () => {
1340
+ const container = setup();
1341
+ const logs: string[] = [];
1342
+ const state = createState({ show: true });
1343
+ const broken: any = () => { throw new Error("boom"); };
1344
+ const patch = app<typeof state>(container, state, (s) =>
1345
+ [DIV,
1346
+ s.show && [SECTION,
1347
+ {
1348
+ catch: [ARTICLE,
1349
+ {
1350
+ onMount: (s: unknown, ele: HTMLElement) => {
1351
+ logs.push("mount article");
1352
+ },
1353
+ onUnmount: (s: unknown, ele: HTMLElement) => {
1354
+ logs.push("unmount article");
1355
+ }
1356
+ },
1357
+ "fallback"
1358
+ ]
1359
+ },
1360
+ broken
1361
+ ]
1362
+ ]
1363
+ );
1364
+
1365
+ expect(logs).toEqual(["mount article"]);
1366
+ patch({ show: false });
1367
+ expect(logs).toEqual(["mount article", "unmount article"]);
1368
+ },
1369
+
1370
+ "onMount(): with catched component, original element's onMount does NOT fire when error caused replacement": () => {
1371
+ const container = setup();
1372
+ const logs: string[] = [];
1373
+ const broken: any = () => { throw new Error("boom"); };
1374
+ app(container, {}, () =>
1375
+ [DIV,
1376
+ {
1377
+ catch: [ARTICLE,
1378
+ {
1379
+ onMount: (s: unknown, ele: HTMLElement) => {
1380
+ logs.push("mount fallback");
1381
+ }
1382
+ },
1383
+ "fallback"
1384
+ ]
1385
+ },
1386
+ [SECTION,
1387
+ {
1388
+ onMount: (s: unknown, ele: HTMLElement) => {
1389
+ logs.push("mount original section");
1390
+ },
1391
+ onUnmount: (s: unknown, ele: HTMLElement) => {
1392
+ logs.push("unmount original section");
1393
+ }
1394
+ },
1395
+ broken
1396
+ ]
1397
+ ]
1398
+ );
1399
+
1400
+ // SECTION never finishes mounting (its child broke), so its onMount must not fire.
1401
+ // The catch on DIV replaces the broken subtree with ARTICLE whose onMount must fire.
1402
+ expect(logs).toEqual(["mount fallback"]);
1403
+ },
1140
1404
  }
@@ -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
+ };