@ryupold/vode 1.8.10 → 1.8.12

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,4 +1,4 @@
1
- import { app, ContainerNode, createState, memo } from "../src/vode"
1
+ import { app, Component, ContainerNode, 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, ExpectationError } from "./helper";
4
4
 
@@ -448,6 +448,92 @@ export default {
448
448
  await expect(mounts).toEqual(["mount span"]);
449
449
  },
450
450
 
451
+ "onMount(): with catched component, replacement vode's onMount fires when error occurs": async () => {
452
+ const container = setup();
453
+ const mounts: string[] = [];
454
+ const broken: any = () => { throw new Error("boom"); };
455
+ app(container, {}, () =>
456
+ [DIV,
457
+ {
458
+ catch: [SECTION,
459
+ {
460
+ onMount: (s: unknown, ele: HTMLElement) => {
461
+ mounts.push("mount fallback");
462
+ }
463
+ },
464
+ "fallback"
465
+ ]
466
+ },
467
+ broken
468
+ ]
469
+ );
470
+
471
+ await expect(mounts).toEqual(["mount fallback"]);
472
+ },
473
+
474
+ "onMount(): with catched component, returned vode's onMount fires and receives error": async () => {
475
+ const container = setup();
476
+ const mounts: string[] = [];
477
+ const caughtErrors: string[] = [];
478
+ const broken: any = () => { throw new Error("boom"); };
479
+ app(container, {}, () =>
480
+ [DIV,
481
+ {
482
+ catch: (s: unknown, err: Error) => {
483
+ caughtErrors.push(err.message);
484
+ return [SECTION,
485
+ {
486
+ onMount: (s: unknown, ele: HTMLElement) => {
487
+ mounts.push("mount fallback");
488
+ }
489
+ },
490
+ "fallback"
491
+ ];
492
+ }
493
+ },
494
+ broken
495
+ ]
496
+ );
497
+
498
+ await expect(mounts).toEqual(["mount fallback"]);
499
+ await expect(caughtErrors).toEqual(["boom"]);
500
+ },
501
+
502
+ "onMount(): with catched component, original element's onMount does NOT fire when error caused replacement": async () => {
503
+ const container = setup();
504
+ const logs: string[] = [];
505
+ const broken: any = () => { throw new Error("boom"); };
506
+ app(container, {}, () =>
507
+ [DIV,
508
+ {
509
+ catch: [ARTICLE,
510
+ {
511
+ onMount: (s: unknown, ele: HTMLElement) => {
512
+ logs.push("mount fallback");
513
+ }
514
+ },
515
+ "fallback"
516
+ ]
517
+ },
518
+ [SECTION,
519
+ {
520
+ onMount: (s: unknown, ele: HTMLElement) => {
521
+ logs.push("mount original section");
522
+ },
523
+ onUnmount: (s: unknown, ele: HTMLElement) => {
524
+ logs.push("unmount original section");
525
+ }
526
+ },
527
+ broken
528
+ ]
529
+ ]
530
+ );
531
+
532
+ // SECTION never finishes mounting (its child broke), so its onMount must not fire.
533
+ // The catch on DIV replaces the broken subtree with ARTICLE whose onMount must fire.
534
+ await expect(logs).toEqual(["mount fallback"]);
535
+ },
536
+
451
537
  "onUnmount(): called when node is removed from the DOM": async () => {
452
538
  const container = setup();
453
539
  const unmounts: string[] = [];
@@ -1173,121 +1259,6 @@ export default {
1173
1259
  await expect(fired).toEqual(["unmount B"]);
1174
1260
  },
1175
1261
 
1176
- "onMount() + onUnmount: symmetry of calls": async () => {
1177
- const container = setup();
1178
- const state = createState({
1179
- startTime: 0,
1180
- inputReady: false,
1181
- showInput: true,
1182
- showTimer: true
1183
- });
1184
- type State = typeof state;
1185
- const logs: string[] = [];
1186
-
1187
- const patch = app<State>(container, state, (s) => {
1188
- return [DIV,
1189
- s.showInput && [INPUT, {
1190
- type: 'text',
1191
- placeholder: 'Auto-focused on mount',
1192
- onMount: (s: State, ele: HTMLElement) => {
1193
- logs.push('Input mounted');
1194
- return { inputReady: true };
1195
- },
1196
- onUnmount: (s: State, ele: HTMLElement) => {
1197
- logs.push('Input removed');
1198
- return { inputReady: false };
1199
- }
1200
- }],
1201
-
1202
- s.showTimer && [P, {
1203
- onMount: (s: State, ele: HTMLElement) => {
1204
- logs.push('Timer started');
1205
- return { startTime: Date.now() };
1206
- },
1207
- onUnmount: (s: State, ele: HTMLElement) => {
1208
- logs.push('Timer removed');
1209
- }
1210
- }, 'Mount/unmount lifecycle demo']
1211
- ]
1212
- }
1213
- );
1214
-
1215
- await expect(state.inputReady)
1216
- .toEqual(true);
1217
- await expect(state.startTime != 0)
1218
- .toEqual(true);
1219
- patch({ showInput: false });
1220
-
1221
- await expect(
1222
- async () => await expect(state.inputReady).toEqual(false, "expected: inputReady == false")
1223
- ).toSucceedAsync();
1224
-
1225
- patch({ showTimer: false });
1226
-
1227
- await expect(
1228
- async () => await expect(container._vode.stats.syncRenderCount >= 4)
1229
- .toEqual(true)
1230
- ).toSucceedAsync();
1231
-
1232
- await expect(logs).toEqual([
1233
- 'Input mounted',
1234
- 'Timer started',
1235
- 'Input removed',
1236
- 'Timer removed'
1237
- ]);
1238
- },
1239
-
1240
- "onMount(): with catched component, replacement vode's onMount fires when error occurs": async () => {
1241
- const container = setup();
1242
- const mounts: string[] = [];
1243
- const broken: any = () => { throw new Error("boom"); };
1244
- app(container, {}, () =>
1245
- [DIV,
1246
- {
1247
- catch: [SECTION,
1248
- {
1249
- onMount: (s: unknown, ele: HTMLElement) => {
1250
- mounts.push("mount fallback");
1251
- }
1252
- },
1253
- "fallback"
1254
- ]
1255
- },
1256
- broken
1257
- ]
1258
- );
1259
-
1260
- await expect(mounts).toEqual(["mount fallback"]);
1261
- },
1262
-
1263
- "onMount(): with catched component, returned vode's onMount fires and receives error": async () => {
1264
- const container = setup();
1265
- const mounts: string[] = [];
1266
- const caughtErrors: string[] = [];
1267
- const broken: any = () => { throw new Error("boom"); };
1268
- app(container, {}, () =>
1269
- [DIV,
1270
- {
1271
- catch: (s: unknown, err: Error) => {
1272
- caughtErrors.push(err.message);
1273
- return [SECTION,
1274
- {
1275
- onMount: (s: unknown, ele: HTMLElement) => {
1276
- mounts.push("mount fallback");
1277
- }
1278
- },
1279
- "fallback"
1280
- ];
1281
- }
1282
- },
1283
- broken
1284
- ]
1285
- );
1286
-
1287
- await expect(mounts).toEqual(["mount fallback"]);
1288
- await expect(caughtErrors).toEqual(["boom"]);
1289
- },
1290
-
1291
1262
  "onUnmount(): with catched component, replacement vode's onUnmount fires when removed": async () => {
1292
1263
  const container = setup();
1293
1264
  const unmounts: string[] = [];
@@ -1359,7 +1330,7 @@ export default {
1359
1330
  await expect(unmounts).toEqual(["unmount span", "unmount p", "unmount article"]);
1360
1331
  },
1361
1332
 
1362
- "onMount()/onUnmount(): with catched component, full lifecycle symmetry of catch replacement": async () => {
1333
+ "onMount() + onUnmount(): with catched component, full lifecycle symmetry of catch replacement": async () => {
1363
1334
  const container = setup();
1364
1335
  const logs: string[] = [];
1365
1336
  const state = createState({ show: true });
@@ -1390,38 +1361,144 @@ export default {
1390
1361
  await expect(logs).toEqual(["mount article", "unmount article"]);
1391
1362
  },
1392
1363
 
1393
- "onMount(): with catched component, original element's onMount does NOT fire when error caused replacement": async () => {
1364
+ "onMount() + onUnmount: symmetry of calls": async () => {
1394
1365
  const container = setup();
1366
+ const state = createState({
1367
+ startTime: 0,
1368
+ inputReady: false,
1369
+ showInput: true,
1370
+ showTimer: true
1371
+ });
1372
+ type State = typeof state;
1395
1373
  const logs: string[] = [];
1396
- const broken: any = () => { throw new Error("boom"); };
1397
- app(container, {}, () =>
1374
+
1375
+ const patch = app<State>(container, state, (s) => {
1376
+ return [DIV,
1377
+ s.showInput && [INPUT, {
1378
+ type: 'text',
1379
+ placeholder: 'Auto-focused on mount',
1380
+ onMount: (s: State, ele: HTMLElement) => {
1381
+ logs.push('Input mounted');
1382
+ return { inputReady: true };
1383
+ },
1384
+ onUnmount: (s: State, ele: HTMLElement) => {
1385
+ logs.push('Input removed');
1386
+ return { inputReady: false };
1387
+ }
1388
+ }],
1389
+
1390
+ s.showTimer && [P, {
1391
+ onMount: (s: State, ele: HTMLElement) => {
1392
+ logs.push('Timer started');
1393
+ return { startTime: Date.now() };
1394
+ },
1395
+ onUnmount: (s: State, ele: HTMLElement) => {
1396
+ logs.push('Timer removed');
1397
+ }
1398
+ }, 'Mount/unmount lifecycle demo']
1399
+ ]
1400
+ }
1401
+ );
1402
+
1403
+ await expect(state.inputReady)
1404
+ .toEqual(true);
1405
+ await expect(state.startTime != 0)
1406
+ .toEqual(true);
1407
+ patch({ showInput: false });
1408
+
1409
+ await expect(
1410
+ async () => await expect(state.inputReady).toEqual(false, "expected: inputReady == false")
1411
+ ).toSucceedAsync();
1412
+
1413
+ patch({ showTimer: false });
1414
+
1415
+ await expect(
1416
+ async () => await expect(container._vode.stats.syncRenderCount >= 4)
1417
+ .toEqual(true)
1418
+ ).toSucceedAsync();
1419
+
1420
+ await expect(logs).toEqual([
1421
+ 'Input mounted',
1422
+ 'Timer started',
1423
+ 'Input removed',
1424
+ 'Timer removed'
1425
+ ]);
1426
+ },
1427
+
1428
+ "onMount() + onUnmount(): Not called when DOM does not require element creation or removal (same TAGs)": async () => {
1429
+ const container = setup();
1430
+ const logs = <string[]>[];
1431
+
1432
+ const Comp: (name: string) => Component = (name: string) => () => [ARTICLE,
1398
1433
  [DIV,
1399
1434
  {
1400
- catch: [ARTICLE,
1401
- {
1402
- onMount: (s: unknown, ele: HTMLElement) => {
1403
- logs.push("mount fallback");
1404
- }
1405
- },
1406
- "fallback"
1407
- ]
1435
+ onMount: () => logs.push("mount " + name),
1436
+ onUnmount: () => logs.push("unmount " + name)
1408
1437
  },
1409
- [SECTION,
1410
- {
1411
- onMount: (s: unknown, ele: HTMLElement) => {
1412
- logs.push("mount original section");
1413
- },
1414
- onUnmount: (s: unknown, ele: HTMLElement) => {
1415
- logs.push("unmount original section");
1416
- }
1417
- },
1418
- broken
1419
- ]
1438
+ "Component " + name]
1439
+ ];
1440
+
1441
+ const state = createState({ showB: false, showD: false });
1442
+ app<typeof state>(container, state, s => [DIV,
1443
+ // this way they both "share a slot"
1444
+ s.showB ? Comp("B") : Comp("A"),
1445
+
1446
+ // this way each component occupies its own "slot"
1447
+ !s.showD && Comp("C"),
1448
+ s.showD && Comp("D"),
1449
+ ]);
1450
+
1451
+ await expect(container).toMatch(
1452
+ [DIV,
1453
+ [ARTICLE,
1454
+ [DIV, "Component A"],
1455
+ ],
1456
+ [ARTICLE,
1457
+ [DIV, "Component C"],
1458
+ ],
1420
1459
  ]
1421
1460
  );
1461
+ await expect(logs).toEqual(["mount A", "mount C"]);
1422
1462
 
1423
- // SECTION never finishes mounting (its child broke), so its onMount must not fire.
1424
- // The catch on DIV replaces the broken subtree with ARTICLE whose onMount must fire.
1425
- await expect(logs).toEqual(["mount fallback"]);
1463
+ state.patch({ showB: true });
1464
+
1465
+ await expect(container).toMatch(
1466
+ [DIV,
1467
+ [ARTICLE,
1468
+ [DIV, "Component B"],
1469
+ ],
1470
+ [ARTICLE,
1471
+ [DIV, "Component C"],
1472
+ ],
1473
+ ]
1474
+ );
1475
+
1476
+ // as both components result in the same structure
1477
+ // of element types the unmount of A
1478
+ // and mount of B does not occur
1479
+ await expect(logs).toEqual(["mount A", "mount C"]);
1480
+
1481
+
1482
+ state.patch({ showD: true });
1483
+
1484
+ await expect(container).toMatch(
1485
+ [DIV,
1486
+ [ARTICLE,
1487
+ [DIV, "Component B"],
1488
+ ],
1489
+ [ARTICLE,
1490
+ [DIV, "Component D"],
1491
+ ],
1492
+ ]
1493
+ );
1494
+
1495
+ // when the components occupy different slots in the vdom
1496
+ // their mount/unmount functions are called
1497
+ await expect(logs).toEqual([
1498
+ "mount A",
1499
+ "mount C",
1500
+ "unmount C",
1501
+ "mount D",
1502
+ ]);
1426
1503
  },
1427
1504
  }
@@ -1,11 +1,11 @@
1
1
  import { delay, expect } from "./helper";
2
- import { app, createState, DIV } from "../index";
2
+ import { app, ContainerNode, createState, DIV } from "../index";
3
3
 
4
4
  function setup() {
5
5
  const root = document.createElement("div");
6
6
  const container = document.createElement("div");
7
7
  root.appendChild(container);
8
- return container;
8
+ return container as unknown as ContainerNode;
9
9
  }
10
10
 
11
11
  export default {
@@ -36,8 +36,11 @@ export default {
36
36
  await expect(state.phase).toEqual("start");
37
37
 
38
38
  state.patch(async function* () {
39
+ await expect(container._vode.stats.syncRenderPatchCount).toEqual(0);
39
40
  yield { phase: "working", value: 10 };
41
+ await expect(container._vode.stats.syncRenderPatchCount).toEqual(1);
40
42
  yield { phase: "almost", value: 20 };
43
+ await expect(container._vode.stats.syncRenderPatchCount).toEqual(2);
41
44
  return { phase: "done", value: 30 };
42
45
  }());
43
46
 
@@ -83,4 +86,107 @@ export default {
83
86
  await expect(state.x).toEqual(10);
84
87
  await expect(state.y).toEqual(20);
85
88
  },
89
+
90
+ "patch(): returns Promise for generator functions, can be awaited": async () => {
91
+ const container = setup();
92
+ const state = createState({ count: 0 });
93
+ app<typeof state>(container, state, (s) => [DIV, String(s.count)]);
94
+
95
+ await expect(container._vode.stats.patchCount).toEqual(0);
96
+ const result = state.patch(function* () {
97
+ yield { count: 1 };
98
+ return { count: 2 };
99
+ });
100
+ await expect(container._vode.stats.patchCount).toEqual(1);
101
+
102
+ expect(result).toBeA("object");
103
+ await expect(result instanceof Promise).toEqual(true);
104
+
105
+ await result;
106
+ await expect(container._vode.stats.patchCount).toEqual(3);
107
+
108
+ await expect(state.count).toEqual(2);
109
+ await expect(container).toMatch([DIV, "2"]);
110
+ },
111
+
112
+ "patch(): returns Promise for Promise patches, can be awaited": async () => {
113
+ const container = setup();
114
+ const state = createState({ msg: "before" });
115
+ app<typeof state>(container, state, (s) => [DIV, s.msg]);
116
+
117
+ const result = state.patch(Promise.resolve({ msg: "after" }));
118
+
119
+ expect(result).toBeA("object");
120
+ await expect(result instanceof Promise).toEqual(true);
121
+
122
+ await result;
123
+
124
+ await expect(state.msg).toEqual("after");
125
+ await expect(container).toMatch([DIV, "after"]);
126
+ },
127
+
128
+ "patch(): returns void for object patches": async () => {
129
+ const container = setup();
130
+ const state = createState({ x: 1 });
131
+ app<typeof state>(container, state, (s) => [DIV, String(s.x)]);
132
+
133
+ const result = state.patch({ x: 2 });
134
+
135
+ expect(result).toBeA("undefined");
136
+
137
+ await expect(state.x).toEqual(2);
138
+ await expect(container).toMatch([DIV, "2"]);
139
+ },
140
+
141
+ "patch(): forward promise error when one happens during patch": async () => {
142
+ const container = setup();
143
+ const state = createState({ msg: "before" });
144
+ app<typeof state>(container, state, (s) => [DIV, s.msg]);
145
+
146
+ const mockPromise = Promise.withResolvers<void>();
147
+ const promisePatchResult = state.patch(mockPromise.promise);
148
+ mockPromise.reject(new Error("promise error"));
149
+
150
+ let err = await expect(() => promisePatchResult)
151
+ .toFailAsync("promise (1) error expected");
152
+ expect(err.message).toEqual("promise error");
153
+
154
+ err = await expect(() => state.patch(async () => {
155
+ await delay(1);
156
+ throw new Error("promise error")
157
+ })).toFailAsync("promise (2) error expected");
158
+ expect(err.message).toEqual("promise error");
159
+ },
160
+
161
+ "patch(): forward generator error when one happens during patch": async () => {
162
+ const container = setup();
163
+ const state = createState({ msg: "before" });
164
+ app<typeof state>(container, state, (s) => [DIV, s.msg]);
165
+
166
+ const err = await expect(
167
+ () => state.patch(
168
+ async function* () {
169
+ yield {};
170
+ await delay(1);
171
+ yield {};
172
+ throw new Error("generator error");
173
+ }
174
+ )
175
+ ).toFailAsync("generator error expected");
176
+ expect(err.message).toEqual("generator error");
177
+ },
178
+ "patch(): forward error when one happens during patch": async () => {
179
+ const container = setup();
180
+ const state = createState({ msg: "before" });
181
+ app<typeof state>(container, state, (s) => [DIV, s.msg]);
182
+
183
+ const err = await expect(
184
+ () => state.patch(
185
+ () => {
186
+ throw new Error("void error");
187
+ }
188
+ )
189
+ ).toFailAsync("void error expected");
190
+ expect(err.message).toEqual("void error");
191
+ },
86
192
  };
@@ -134,4 +134,12 @@ export default {
134
134
  await expect(state.a.x?.z).toEqual("deep");
135
135
  await expect(state.a.y).toEqual(1);
136
136
  },
137
+
138
+ "StateContext.put() merges into existing object properties via Object.assign": async () => {
139
+ const state = createState({ items: { count: 0, name: "test", hidden: false } });
140
+ const ctx = context(state);
141
+ // Line 110-111: when existing value is object and new value is object, Object.assign merges
142
+ ctx.items.put({ count: 5 });
143
+ await expect(state.items).toEqual({ count: 5, name: "test", hidden: false });
144
+ },
137
145
  };