@ryupold/vode 1.8.6 → 1.8.7

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
@@ -5,7 +5,7 @@ export type JustTagVode = [tag: Tag];
5
5
  export type ChildVode<S = PatchableState> = Vode<S> | TextVode | NoVode | Component<S>;
6
6
  export type TextVode = string & {};
7
7
  export type NoVode = undefined | null | number | boolean | bigint | void;
8
- export type AttachedVode<S> = Vode<S> & { node: ChildNode, unmountCount: number, unmountStart: number } | Text & { node?: never, unmounts?: never, unmountStart?: never };
8
+ export type AttachedVode<S> = Vode<S> & { node: ChildNode } | Text & { node?: never };
9
9
  export type Tag = keyof (HTMLElementTagNameMap & SVGElementTagNameMap & MathMLElementTagNameMap) | (string & {});
10
10
  export type Component<S> = (s: S) => ChildVode<S>;
11
11
 
@@ -97,7 +97,6 @@ export interface ContainerNode<S = PatchableState> extends HTMLElement {
97
97
  qAsync: {} | undefined | null, // next render-patches to be animated after another
98
98
  isRendering: boolean,
99
99
  isAnimating: boolean,
100
- unmounts: (MountFunction<S> | null)[],
101
100
  /** stats about the overall patches & last render time */
102
101
  stats: {
103
102
  patchCount: number,
@@ -150,7 +149,6 @@ export function app<S extends PatchableState = PatchableState>(
150
149
  _vode.qSync = null;
151
150
  _vode.qAsync = null;
152
151
  _vode.stats = { lastSyncRenderTime: 0, lastAsyncRenderTime: 0, syncRenderCount: 0, asyncRenderCount: 0, liveEffectCount: 0, patchCount: 0, syncRenderPatchCount: 0, asyncRenderPatchCount: 0 };
153
- _vode.unmounts = [];
154
152
 
155
153
  const patchableState = state as PatchableState<S> & { patch: (action: Patch<S>, animate?: boolean) => void };
156
154
 
@@ -221,7 +219,7 @@ export function app<S extends PatchableState = PatchableState>(
221
219
  function renderDom(isAsync: boolean) {
222
220
  const sw = Date.now();
223
221
  const vom = dom(_vode.state);
224
- _vode.vode = render<S>(_vode.state, container.parentElement as Element, 0, 0, _vode.vode, vom, null, _vode.unmounts, 0)!;
222
+ _vode.vode = render<S>(_vode.state, container.parentElement as Element, 0, 0, _vode.vode, vom)!;
225
223
 
226
224
  if ((<ContainerNode<S>>container).tagName.toUpperCase() !== (vom[0] as Tag).toUpperCase()) { //the tag name was changed during render -> update reference to vode-app-root
227
225
  container = _vode.vode.node as Element;
@@ -282,6 +280,7 @@ export function app<S extends PatchableState = PatchableState>(
282
280
  const root = container as ContainerNode<S>;
283
281
  root._vode = _vode;
284
282
  const indexInParent = Array.from(container.parentElement.children).indexOf(container);
283
+
285
284
  _vode.isRendering = true;
286
285
  _vode.vode = render(
287
286
  <S>state,
@@ -289,10 +288,7 @@ export function app<S extends PatchableState = PatchableState>(
289
288
  indexInParent,
290
289
  indexInParent,
291
290
  hydrate<S>(container, true) as AttachedVode<S>,
292
- dom(<S>state),
293
- null,
294
- _vode.unmounts,
295
- 0
291
+ dom(<S>state)
296
292
  )!;
297
293
  _vode.isRendering = false;
298
294
  if (_vode.qSync) _vode.renderSync();
@@ -511,20 +507,14 @@ function mergeState(target: any, source: any, allowDeletion: boolean) {
511
507
  return target;
512
508
  };
513
509
 
514
- function render<S extends PatchableState>(
515
- state: S, parent: Element,
516
- childIndex: number, indexInParent: number,
517
- oldVode: AttachedVode<S> | undefined, newVode: ChildVode<S>,
518
- xmlns: string | null,
519
- unmounts: (MountFunction<S> | null)[], unmountStart: number
520
- ): AttachedVode<S> | undefined {
510
+ function render<S extends PatchableState>(state: S, parent: Element, childIndex: number, indexInParent: number, oldVode: AttachedVode<S> | undefined, newVode: ChildVode<S>, xmlns?: string | null): AttachedVode<S> | undefined {
521
511
  try {
522
512
  // unwrap component if it is memoized
523
513
  newVode = remember(state, newVode, oldVode) as ChildVode<S>;
524
514
 
525
515
  const isNoVode = !newVode || typeof newVode === "number" || typeof newVode === "boolean";
526
516
  if (newVode === oldVode || (!oldVode && isNoVode)) {
527
- return oldVode as AttachedVode<S>;
517
+ return oldVode;
528
518
  }
529
519
 
530
520
  const oldIsText = (oldVode as Text)?.nodeType === Node.TEXT_NODE;
@@ -532,17 +522,7 @@ function render<S extends PatchableState>(
532
522
 
533
523
  // falsy|text|element(A) -> undefined
534
524
  if (isNoVode) {
535
- if (!oldIsText && typeof (<any>oldVode)?.unmountCount === "number") {
536
- const start = (<any>oldVode).unmountStart;
537
- const count = (<any>oldVode).unmountCount;
538
- for (let i = count - 1; i >= 0; i--) {
539
- const fn = unmounts[start + i];
540
- if (fn) {
541
- state.patch(fn(state, oldNode as HTMLElement & SVGSVGElement & MathMLElement));
542
- unmounts[start + i] = null;
543
- }
544
- }
545
- }
525
+ unmountTree(state, oldVode as AttachedVode<S>);
546
526
  oldNode?.remove();
547
527
  return undefined;
548
528
  }
@@ -566,23 +546,13 @@ function render<S extends PatchableState>(
566
546
  if ((<Text>oldNode).nodeValue !== <string>newVode) {
567
547
  (<Text>oldNode).nodeValue = <string>newVode;
568
548
  }
569
- return oldVode as AttachedVode<S>;
549
+ return oldVode;
570
550
  }
571
551
  // falsy|element -> text
572
552
  if (isText && (!oldNode || !oldIsText)) {
573
553
  const text = document.createTextNode(newVode as string)
574
554
  if (oldNode) {
575
- if (!oldIsText && typeof (<any>oldVode)?.unmountCount === "number") {
576
- const start = (<any>oldVode).unmountStart;
577
- const count = (<any>oldVode).unmountCount;
578
- for (let i = count - 1; i >= 0; i--) {
579
- const fn = unmounts[start + i];
580
- if (fn) {
581
- state.patch(fn(state, oldNode as HTMLElement & SVGSVGElement & MathMLElement));
582
- unmounts[start + i] = null;
583
- }
584
- }
585
- }
555
+ unmountTree(state, oldVode as AttachedVode<S>);
586
556
  oldNode.replaceWith(text);
587
557
  } else {
588
558
  let inserted = false;
@@ -628,21 +598,9 @@ function render<S extends PatchableState>(
628
598
  }
629
599
 
630
600
  if (oldNode) {
631
- if (!oldIsText && typeof (<any>oldVode)?.unmountCount === "number") {
632
- const start = (<any>oldVode).unmountStart;
633
- const count = (<any>oldVode).unmountCount;
634
- for (let i = count - 1; i >= 0; i--) {
635
- const fn = unmounts[start + i];
636
- if (fn) {
637
- state.patch(fn(state, oldNode as HTMLElement & SVGSVGElement & MathMLElement));
638
- unmounts[start + i] = null;
639
- }
640
- }
641
- }
642
- unmounts[unmountStart] = properties?.onUnmount ?? null;
601
+ unmountTree(state, oldVode as AttachedVode<S>);
643
602
  oldNode.replaceWith(newNode);
644
603
  } else {
645
- unmounts[unmountStart] = properties?.onUnmount ?? null;
646
604
  let inserted = false;
647
605
  for (let i = indexInParent; i < parent.childNodes.length; i++) {
648
606
  const nextSibling = parent.childNodes[i];
@@ -657,28 +615,23 @@ function render<S extends PatchableState>(
657
615
  }
658
616
  }
659
617
 
660
- let totalChildUnmounts = 0;
661
- let childUnmountStart = unmountStart + 1;
662
618
  const newKids = children(newVode);
663
619
  if (newKids) {
664
620
  const childOffset = !!properties ? 2 : 1;
665
621
  let indexP = 0;
666
622
  for (let i = 0; i < newKids.length; i++) {
667
623
  const child = newKids[i];
668
- const attached = render(state, newNode as Element, i, indexP, undefined, child, xmlns ?? null, unmounts, childUnmountStart);
669
- (<Vode<S>>newVode!)[i + childOffset] = <Vode<S> | undefined>attached;
670
- if (attached) {
671
- indexP++;
672
- const childUnmounts = (<any>attached).unmountCount || 0;
673
- totalChildUnmounts += childUnmounts;
674
- childUnmountStart += childUnmounts;
675
- }
624
+ // render child in xml mode to prevent using the dom properties
625
+ const attached = render(state, newNode as Element, i, indexP, undefined, child, xmlns ?? null);
626
+ (<Vode<S>>newVode!)[i + childOffset] = <Vode<S>>attached;
627
+ if (attached) indexP++;
676
628
  }
677
629
  }
678
630
 
679
- (<any>newNode).onMount && state.patch((<any>newNode).onMount(newNode));
680
- (<any>newVode).unmountCount = 1 + totalChildUnmounts;
681
- (<any>newVode).unmountStart = unmountStart;
631
+ (<any>newVode)._unmountCount = (properties?.onUnmount ? 1 : 0) + sumChildUnmountCounts(newVode as Vode<S>);
632
+ if (typeof properties?.onMount === "function") {
633
+ state.patch(properties.onMount(state, newNode as HTMLElement & SVGSVGElement & MathMLElement));
634
+ }
682
635
  return <AttachedVode<S>>newVode;
683
636
  }
684
637
 
@@ -710,11 +663,6 @@ function render<S extends PatchableState>(
710
663
  (<any>newVode).node.removeAttribute('catch');
711
664
  }
712
665
 
713
- // own unmount slot (always reserved per element)
714
- unmounts[unmountStart] = properties?.onUnmount ?? null;
715
-
716
- let totalChildUnmounts = 0;
717
- let childUnmountStart = unmountStart + 1;
718
666
  const newKids = children(newVode);
719
667
  const oldKids = children(oldVode) as AttachedVode<S>[];
720
668
  if (newKids) {
@@ -723,26 +671,21 @@ function render<S extends PatchableState>(
723
671
  for (let i = 0; i < newKids.length; i++) {
724
672
  const child = newKids[i];
725
673
  const oldChild = oldKids && oldKids[i];
726
- const attached = render(state, oldNode as Element, i, indexP, oldChild, child, xmlns, unmounts, childUnmountStart);
674
+
675
+ const attached = render(state, oldNode as Element, i, indexP, oldChild, child, xmlns);
727
676
  (<Vode<S>>newVode)[i + childOffset] = <Vode<S>>attached;
728
- if (attached) {
729
- indexP++;
730
- const childUnmounts = (<any>attached).unmountCount || 0;
731
- totalChildUnmounts += childUnmounts;
732
- childUnmountStart += childUnmounts;
733
- }
677
+ if (attached) indexP++;
734
678
  }
735
679
  }
736
680
 
737
681
  if (oldKids) {
738
682
  const newKidsCount = newKids ? newKids.length : 0;
739
683
  for (let i = oldKids.length - 1; i >= newKidsCount; i--) {
740
- render(state, oldNode as Element, i, i, oldKids[i], undefined, xmlns, unmounts, (<any>oldKids[i]).unmountStart);
684
+ render(state, oldNode as Element, i, i, oldKids[i], undefined, xmlns);
741
685
  }
742
686
  }
743
687
 
744
- (<any>newVode).unmountCount = 1 + totalChildUnmounts;
745
- (<any>newVode).unmountStart = unmountStart;
688
+ (<any>newVode)._unmountCount = (properties?.onUnmount ? 1 : 0) + sumChildUnmountCounts(newVode as Vode<S>);
746
689
  return <AttachedVode<S>>newVode;
747
690
  }
748
691
  } catch (error) {
@@ -755,7 +698,7 @@ function render<S extends PatchableState>(
755
698
  return render(state, parent, childIndex, indexInParent,
756
699
  hydrate(((<AttachedVode<S>>newVode)?.node || oldVode?.node) as Element, true) as AttachedVode<S>,
757
700
  handledVode,
758
- xmlns, unmounts, unmountStart);
701
+ xmlns);
759
702
  } else {
760
703
  throw error;
761
704
  }
@@ -764,6 +707,35 @@ function render<S extends PatchableState>(
764
707
  return undefined;
765
708
  }
766
709
 
710
+ function unmountTree<S extends PatchableState>(state: S, v: AttachedVode<S> | ChildVode<S> | undefined): void {
711
+ if (!v || !Array.isArray(v)) return;
712
+ if (((<any>v)._unmountCount | 0) === 0) return;
713
+
714
+ const kids = children(v as Vode<S>) as AttachedVode<S>[] | null;
715
+ if (kids) {
716
+ for (let i = kids.length - 1; i >= 0; i--) {
717
+ unmountTree(state, kids[i]);
718
+ }
719
+ }
720
+
721
+ const p = props(v);
722
+ if (typeof p?.onUnmount === "function") {
723
+ state.patch(p.onUnmount(state, (v as AttachedVode<S>).node as HTMLElement & SVGSVGElement & MathMLElement));
724
+ }
725
+ }
726
+
727
+ function sumChildUnmountCounts<S>(v: Vode<S>): number {
728
+ const kids = children(v) as AttachedVode<S>[] | null;
729
+ if (!kids) return 0;
730
+ let n = 0;
731
+ for (const k of kids) {
732
+ if (k && Array.isArray(k)) {
733
+ n += ((<any>k)._unmountCount | 0);
734
+ }
735
+ }
736
+ return n;
737
+ }
738
+
767
739
  function isNaturalVode<S>(x: ChildVode<S>): x is Vode<S> {
768
740
  return Array.isArray(x) && x.length > 0 && typeof x[0] === "string";
769
741
  }
@@ -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
  }