@ryupold/vode 1.8.10 → 1.8.11

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/src/vode.ts CHANGED
@@ -72,7 +72,7 @@ export type PropertyValue<S> =
72
72
  | StyleProp | ClassProp
73
73
  | Patch<S>;
74
74
 
75
- export type Dispatch<S> = (action: Patch<S>) => void;
75
+ export type Dispatch<S> = (action: Patch<S>) => void | Promise<void>;
76
76
  export interface Patchable<S = object> { patch: Dispatch<S>; }
77
77
  export type PatchableState<S = object> = S & Patchable<S>;
78
78
 
@@ -149,7 +149,7 @@ export function app<S extends PatchableState = PatchableState>(
149
149
  _vode.qAsync = null;
150
150
  _vode.stats = { lastSyncRenderTime: 0, lastAsyncRenderTime: 0, syncRenderCount: 0, asyncRenderCount: 0, liveEffectCount: 0, patchCount: 0, syncRenderPatchCount: 0, asyncRenderPatchCount: 0 };
151
151
 
152
- const patchableState = state as PatchableState<S> & { patch: (action: Patch<S>, animate?: boolean) => void };
152
+ const patchableState = state as PatchableState<S> & { patch: (action: Patch<S>, animate?: boolean) => void | Promise<void> };
153
153
 
154
154
  if ("patch" in state && typeof state.patch === "function" && Array.isArray((state as any).patch.initialPatches)) {
155
155
  initialPatches = [...(state as any).patch.initialPatches, ...initialPatches];
@@ -159,7 +159,7 @@ export function app<S extends PatchableState = PatchableState>(
159
159
  _vode.stats.liveEffectCount++;
160
160
  try {
161
161
  const resolvedPatch = await (action as Promise<S>);
162
- patchableState.patch(<Patch<S>>resolvedPatch, isAnimated);
162
+ await patchableState.patch(<Patch<S>>resolvedPatch, isAnimated);
163
163
  } finally {
164
164
  _vode.stats.liveEffectCount--;
165
165
  }
@@ -173,13 +173,13 @@ export function app<S extends PatchableState = PatchableState>(
173
173
  while (v.done === false) {
174
174
  _vode.stats.liveEffectCount++;
175
175
  try {
176
- patchableState.patch(v.value, isAnimated);
176
+ await patchableState.patch(v.value, isAnimated);
177
177
  v = await generator.next();
178
178
  } finally {
179
179
  _vode.stats.liveEffectCount--;
180
180
  }
181
181
  }
182
- patchableState.patch(v.value as Patch<S>, isAnimated);
182
+ await patchableState.patch(v.value as Patch<S>, isAnimated);
183
183
  } finally {
184
184
  _vode.stats.liveEffectCount--;
185
185
  }
@@ -187,7 +187,7 @@ export function app<S extends PatchableState = PatchableState>(
187
187
 
188
188
  Object.defineProperty(state, "patch", {
189
189
  enumerable: false, configurable: true,
190
- writable: false, value: (action: Patch<S>, isAnimated?: boolean) => {
190
+ writable: false, value: (action: Patch<S>, isAnimated?: boolean): void | Promise<void> => {
191
191
  while (typeof action === "function") {
192
192
  action = (<(s: S) => unknown>action)(_vode.state);
193
193
  }
@@ -197,9 +197,9 @@ export function app<S extends PatchableState = PatchableState>(
197
197
  _vode.stats.patchCount++;
198
198
 
199
199
  if ((action as AsyncGenerator<Patch<S>>)?.next) {
200
- generatorPatch(action as AsyncGenerator<Patch<S>>, isAnimated);
200
+ return generatorPatch(action as AsyncGenerator<Patch<S>>, isAnimated);
201
201
  } else if ((action as Promise<S>).then) {
202
- promisePatch(action as Promise<S>, isAnimated);
202
+ return promisePatch(action as Promise<S>, isAnimated);
203
203
  } else if (Array.isArray(action)) {
204
204
  if (action.length > 0) {
205
205
  for (const p of action) {
package/test/helper.ts CHANGED
@@ -143,16 +143,18 @@ export class Expectation {
143
143
  );
144
144
  }
145
145
 
146
- toSucceed<Result>(): Result {
146
+ toSucceed<Result>(failMessage?: string): Result {
147
+ const failSuffix = failMessage ? `\n\n${failMessage}` : "";
147
148
  if (typeof this.what !== "function") {
148
- throw new ExpectationError(this, `expected a function\n\nbut it is a ${typeof this.what}`);
149
+ throw new ExpectationError(this, `expected a function\n\nbut it is a ${typeof this.what}${failSuffix}`);
149
150
  }
150
151
  return this.what();
151
152
  }
152
153
 
153
- toFail(): Error {
154
+ toFail(failMessage?: string): Error {
155
+ const failSuffix = failMessage ? `\n\n${failMessage}` : "";
154
156
  if (typeof this.what !== "function") {
155
- throw new ExpectationError(this, `expected a function\n\nbut it is a ${typeof this.what}`);
157
+ throw new ExpectationError(this, `expected a function\n\nbut it is a ${typeof this.what}${failSuffix}`);
156
158
  }
157
159
 
158
160
  let r: any;
@@ -161,28 +163,34 @@ export class Expectation {
161
163
  } catch (err: any) {
162
164
  return err;
163
165
  }
164
- throw new ExpectationError(this, `expected function to fail\n\nbut it succeeded with a result of type ${typeof r}\n\n${r}`);
166
+ throw new ExpectationError(this, `expected function to fail\n\nbut it succeeded with a result of type ${typeof r}\n\n${r}${failSuffix}`);
165
167
  }
166
168
 
167
- toSucceedAsync<Result>(waitTime: number = 100): Promise<Result> {
169
+ toSucceedAsync<Result>(failMessage?: string, waitTime: number = 100): Promise<Result> {
170
+ const failSuffix = failMessage ? `\n\n${failMessage}` : "";
168
171
  if (typeof this.what !== "function") {
169
- throw new ExpectationError(this, `expected a function\n\nbut it is a ${typeof this.what}`);
172
+ throw new ExpectationError(this, `expected a function\n\nbut it is a ${typeof this.what}${failSuffix}`);
170
173
  }
171
174
  return retry<Result>(() => this.what(), waitTime);
172
175
  }
173
176
 
174
- async toFailAsync(): Promise<Error> {
177
+ async toFailAsync(failMessage?: string): Promise<Error> {
178
+ const failSuffix = failMessage ? `\n\n${failMessage}` : "";
179
+
175
180
  if (typeof this.what !== "function") {
176
- throw new ExpectationError(this, `expected a function\n\nbut it is a ${typeof this.what}`);
181
+ throw new ExpectationError(this, `expected a function\n\nbut it is a ${typeof this.what}${failSuffix}`);
177
182
  }
178
183
 
179
184
  let r: any;
180
185
  try {
181
- r = await this.what();
186
+ if(typeof this.what === "function")
187
+ r = await this.what();
188
+ else
189
+ r = await this.what;
182
190
  } catch (err: any) {
183
191
  return err;
184
192
  }
185
- throw new ExpectationError(this, `expected function to fail\n\nbut it succeeded with a result of type ${typeof r}\n\n${r}`);
193
+ throw new ExpectationError(this, `expected function to fail\n\nbut it succeeded with a result of type ${typeof r}\n\n${r}${failSuffix}`);
186
194
  }
187
195
 
188
196
  async toMatch(v: ChildVode,
package/test/mocks.ts CHANGED
@@ -213,7 +213,7 @@ export function resetMocks() {
213
213
  }, 16);
214
214
  }
215
215
 
216
- const mockDoc: any = {
216
+ const fakeDocument: any = {
217
217
  createElement: (tag: string) => new FakeElement(tag),
218
218
  createTextNode: (text: string) => new FakeTextNode(text),
219
219
  createElementNS: (ns: string, tag: string) => new FakeElement(tag),
@@ -224,10 +224,11 @@ export function resetMocks() {
224
224
  updateCallbackDone: Promise.resolve(),
225
225
  skipTransition() { },
226
226
  };
227
- }
227
+ },
228
+ _fake: true,
228
229
  };
229
230
 
230
- Object.defineProperty(mockDoc, "hidden", {
231
+ Object.defineProperty(fakeDocument, "hidden", {
231
232
  enumerable: true,
232
233
  configurable: true,
233
234
  get: () => hidden,
@@ -239,7 +240,7 @@ export function resetMocks() {
239
240
  },
240
241
  });
241
242
 
242
- const mockWin: any = {
243
+ const fakeWindow: any = {
243
244
  requestAnimationFrame: (cb: FrameRequestCallback) => {
244
245
  const id = ++rafHandle;
245
246
  rafQueue.set(id, cb);
@@ -248,20 +249,31 @@ export function resetMocks() {
248
249
  },
249
250
  cancelAnimationFrame: (id: number) => {
250
251
  rafQueue.delete(id);
251
- }
252
+ },
253
+ _fake: true,
252
254
  };
253
255
 
254
- globalThis.document ??= mockDoc as Document;
255
- globalThis.window ??= mockWin as (Window & typeof globalThis);
256
+ if ((<typeof fakeDocument>globalThis.document)?._fake)
257
+ globalThis.document = undefined as any;
258
+ if ((<typeof fakeWindow>globalThis.window)?._fake)
259
+ globalThis.window = undefined as any;
260
+
261
+
262
+ globalThis.document ??= fakeDocument as Document;
263
+ globalThis.window ??= fakeWindow as (Window & typeof globalThis);
256
264
  globalThis.Node ??= NodeConstants as any;
257
265
 
258
- const raf = globalThis.window?.requestAnimationFrame;
259
- if (typeof raf === "function") {
260
- globals.requestAnimationFrame = raf.bind(globalThis.window);
266
+ if ((<typeof fakeWindow>globalThis.window)?._fake) {
267
+ const raf = globalThis.window?.requestAnimationFrame;
268
+ if (typeof raf === "function") {
269
+ globals.requestAnimationFrame = raf.bind(globalThis.window);
270
+ }
261
271
  }
262
272
 
263
- const startViewTransition = (globalThis.document as any)?.startViewTransition;
264
- globals.startViewTransition = typeof startViewTransition === "function"
265
- ? startViewTransition.bind(globalThis.document)
266
- : null;
273
+ if ((<typeof fakeDocument>globalThis.document)?._fake) {
274
+ const startViewTransition = (globalThis.document as any)?.startViewTransition;
275
+ globals.startViewTransition = typeof startViewTransition === "function"
276
+ ? startViewTransition.bind(globalThis.document)
277
+ : null;
278
+ }
267
279
  }
@@ -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
  }