@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.
package/src/vode.ts CHANGED
@@ -5,9 +5,9 @@ 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
- export type Component<S> = (s: S) => ChildVode<S>;
10
+ export type Component<S = PatchableState> = (s: S) => ChildVode<S>;
11
11
 
12
12
  export type Patch<S> =
13
13
  | IgnoredPatch // ignored
@@ -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
 
@@ -219,9 +217,9 @@ export function app<S extends PatchableState = PatchableState>(
219
217
  });
220
218
 
221
219
  function renderDom(isAsync: boolean) {
222
- const sw = Date.now();
220
+ const sw = performance.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;
@@ -229,7 +227,7 @@ export function app<S extends PatchableState = PatchableState>(
229
227
  }
230
228
 
231
229
  if (!isAsync) {
232
- _vode.stats.lastSyncRenderTime = Date.now() - sw;
230
+ _vode.stats.lastSyncRenderTime = performance.now() - sw;
233
231
  _vode.stats.syncRenderCount++;
234
232
  _vode.isRendering = false;
235
233
  if (_vode.qSync) _vode.renderSync();
@@ -260,7 +258,7 @@ export function app<S extends PatchableState = PatchableState>(
260
258
  if (_vode.isAnimating || !_vode.qAsync || document.hidden) return;
261
259
 
262
260
  _vode.isAnimating = true;
263
- const sw = Date.now();
261
+ const sw = performance.now();
264
262
  try {
265
263
  _vode.state = mergeState(_vode.state, _vode.qAsync, true);
266
264
  _vode.qAsync = null;
@@ -269,7 +267,7 @@ export function app<S extends PatchableState = PatchableState>(
269
267
 
270
268
  await globals.currentViewTransition?.updateCallbackDone;
271
269
  } finally {
272
- _vode.stats.lastAsyncRenderTime = Date.now() - sw;
270
+ _vode.stats.lastAsyncRenderTime = performance.now() - sw;
273
271
  _vode.stats.asyncRenderCount++;
274
272
  _vode.isAnimating = false;
275
273
  }
@@ -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();
@@ -388,13 +384,20 @@ export function hydrate<S = PatchableState>(element: Element | Text, prepareForR
388
384
  }
389
385
 
390
386
  /** memoizes the resulting component or props by comparing element by element (===) with the
391
- * `compare` of the previous render. otherwise skips the render step (not calling `componentOrProps`)*/
392
- export function memo<S = PatchableState>(compare: any[], componentOrProps: Component<S> | ((s: S) => Props<S>)): typeof componentOrProps extends ((s: S) => Props<S>) ? ((s: S) => Props<S>) : Component<S> {
387
+ * `compare` of the previous render. otherwise skips the render step (not calling `componentOrProps`)
388
+ */
389
+ export function memo<S = PatchableState>(compare: any[], component: Component<S>): Component<S> {
393
390
  if (!compare || !Array.isArray(compare)) throw new Error("first argument to memo() must be an array of values to compare");
394
- if (typeof componentOrProps !== "function") throw new Error("second argument to memo() must be a function that returns a vode or props object");
391
+ if (typeof component !== "function") throw new Error("second argument to memo() must be a function that returns a child vode");
392
+
393
+ if ((<any>component).__memo) { // wrap to prevent double memoization
394
+ const comp = component;
395
+ component = (s: S) => comp(s);
396
+ }
397
+
398
+ (<any>component).__memo = compare;
395
399
 
396
- (<any>componentOrProps).__memo = compare;
397
- return componentOrProps as typeof componentOrProps extends ((s: S) => Props<S>) ? ((s: S) => Props<S>) : Component<S>;
400
+ return component;
398
401
  }
399
402
 
400
403
  /**
@@ -511,20 +514,14 @@ function mergeState(target: any, source: any, allowDeletion: boolean) {
511
514
  return target;
512
515
  };
513
516
 
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 {
517
+ 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
518
  try {
522
519
  // unwrap component if it is memoized
523
520
  newVode = remember(state, newVode, oldVode) as ChildVode<S>;
524
521
 
525
522
  const isNoVode = !newVode || typeof newVode === "number" || typeof newVode === "boolean";
526
523
  if (newVode === oldVode || (!oldVode && isNoVode)) {
527
- return oldVode as AttachedVode<S>;
524
+ return oldVode;
528
525
  }
529
526
 
530
527
  const oldIsText = (oldVode as Text)?.nodeType === Node.TEXT_NODE;
@@ -532,17 +529,7 @@ function render<S extends PatchableState>(
532
529
 
533
530
  // falsy|text|element(A) -> undefined
534
531
  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
- }
532
+ unmountTree(state, oldVode as AttachedVode<S>);
546
533
  oldNode?.remove();
547
534
  return undefined;
548
535
  }
@@ -566,30 +553,20 @@ function render<S extends PatchableState>(
566
553
  if ((<Text>oldNode).nodeValue !== <string>newVode) {
567
554
  (<Text>oldNode).nodeValue = <string>newVode;
568
555
  }
569
- return oldVode as AttachedVode<S>;
556
+ return oldVode;
570
557
  }
571
558
  // falsy|element -> text
572
559
  if (isText && (!oldNode || !oldIsText)) {
573
560
  const text = document.createTextNode(newVode as string)
574
561
  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
- }
562
+ unmountTree(state, oldVode as AttachedVode<S>);
586
563
  oldNode.replaceWith(text);
587
564
  } else {
588
565
  let inserted = false;
589
566
  for (let i = indexInParent; i < parent.childNodes.length; i++) {
590
567
  const nextSibling = parent.childNodes[i];
591
568
  if (nextSibling) {
592
- nextSibling.before(text, nextSibling);
569
+ nextSibling.before(text);
593
570
  inserted = true;
594
571
  break;
595
572
  }
@@ -628,26 +605,14 @@ function render<S extends PatchableState>(
628
605
  }
629
606
 
630
607
  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;
608
+ unmountTree(state, oldVode as AttachedVode<S>);
643
609
  oldNode.replaceWith(newNode);
644
610
  } else {
645
- unmounts[unmountStart] = properties?.onUnmount ?? null;
646
611
  let inserted = false;
647
612
  for (let i = indexInParent; i < parent.childNodes.length; i++) {
648
613
  const nextSibling = parent.childNodes[i];
649
614
  if (nextSibling) {
650
- nextSibling.before(newNode, nextSibling);
615
+ nextSibling.before(newNode);
651
616
  inserted = true;
652
617
  break;
653
618
  }
@@ -657,28 +622,23 @@ function render<S extends PatchableState>(
657
622
  }
658
623
  }
659
624
 
660
- let totalChildUnmounts = 0;
661
- let childUnmountStart = unmountStart + 1;
662
- const newKids = children(newVode);
663
- if (newKids) {
625
+ const newStart = childrenStart(newVode);
626
+ if (newStart > 0) {
664
627
  const childOffset = !!properties ? 2 : 1;
665
628
  let indexP = 0;
666
- for (let i = 0; i < newKids.length; i++) {
667
- 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
- }
629
+ for (let i = 0; i < (<Vode<S>>newVode).length - newStart; i++) {
630
+ const child = (<Vode<S>>newVode)[i + newStart] as ChildVode<S>;
631
+ // render child in xml mode to prevent using the dom properties
632
+ const attached = render(state, newNode as Element, i, indexP, undefined, child, xmlns ?? null);
633
+ (<Vode<S>>newVode!)[i + childOffset] = <Vode<S>>attached;
634
+ if (attached) indexP++;
676
635
  }
677
636
  }
678
637
 
679
- (<any>newNode).onMount && state.patch((<any>newNode).onMount(newNode));
680
- (<any>newVode).unmountCount = 1 + totalChildUnmounts;
681
- (<any>newVode).unmountStart = unmountStart;
638
+ (<any>newVode)._unmountCount = (properties?.onUnmount ? 1 : 0) + sumChildUnmountCounts(newVode as Vode<S>);
639
+ if (typeof properties?.onMount === "function") {
640
+ state.patch(properties.onMount(state, newNode as HTMLElement & SVGSVGElement & MathMLElement));
641
+ }
682
642
  return <AttachedVode<S>>newVode;
683
643
  }
684
644
 
@@ -686,63 +646,41 @@ function render<S extends PatchableState>(
686
646
  if (!oldIsText && isNode && (<Vode<S>>oldVode)[0] === (<Vode<S>>newVode)[0]) {
687
647
  (<AttachedVode<S>>newVode).node = oldNode;
688
648
 
689
- const newvode = <Vode<S>>newVode;
690
- const oldvode = <Vode<S>>oldVode;
691
-
692
649
  const properties = props(newVode);
693
650
  const oldProps = props(oldVode);
694
651
 
695
- if (properties?.xmlns !== undefined) xmlns = properties.xmlns;
652
+ if (properties?.xmlns !== undefined)
653
+ xmlns = properties.xmlns;
696
654
 
697
- if ((<any>newvode[1])?.__memo) {
698
- const prev = newvode[1] as any;
699
- newvode[1] = remember(state, newvode[1], oldvode[1]) as Vode<S>;
700
- if (prev !== newvode[1]) {
701
- patchProperties(state, oldNode!, oldProps, properties, xmlns);
702
- }
703
- }
704
- else {
705
- patchProperties(state, oldNode!, oldProps, properties, xmlns);
706
- }
655
+ patchProperties(state, oldNode!, oldProps, properties, xmlns);
707
656
 
708
657
  if (!!properties?.catch && oldProps?.catch !== properties.catch) {
709
658
  (<any>newVode).node['catch'] = null;
710
659
  (<any>newVode).node.removeAttribute('catch');
711
660
  }
712
661
 
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
- const newKids = children(newVode);
719
- const oldKids = children(oldVode) as AttachedVode<S>[];
720
- if (newKids) {
721
- const childOffset = !!properties ? 2 : 1;
662
+ const newStart = childrenStart(newVode);
663
+ const oldStart = childrenStart(oldVode);
664
+ if (newStart > 0) {
722
665
  let indexP = 0;
723
- for (let i = 0; i < newKids.length; i++) {
724
- const child = newKids[i];
725
- const oldChild = oldKids && oldKids[i];
726
- const attached = render(state, oldNode as Element, i, indexP, oldChild, child, xmlns, unmounts, childUnmountStart);
727
- (<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
- }
666
+ for (let i = 0; i < (<Vode<S>>newVode).length - newStart; i++) {
667
+ const child = (<Vode<S>>newVode)[i + newStart] as ChildVode<S>;
668
+ const oldChild = oldStart > 0 ? (<Vode<S>>oldVode)[i + oldStart] as AttachedVode<S> : undefined;
669
+
670
+ const attached = render(state, oldNode as Element, i, indexP, oldChild, child, xmlns);
671
+ (<any>newVode)[i + newStart] = attached;
672
+ if (attached) indexP++;
734
673
  }
735
674
  }
736
675
 
737
- if (oldKids) {
738
- const newKidsCount = newKids ? newKids.length : 0;
739
- 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);
676
+ if (oldStart > 0) {
677
+ const newKidsCount = newStart > 0 ? (<Vode<S>>newVode).length - newStart : 0;
678
+ for (let i = (<Vode<S>>oldVode).length - 1 - oldStart; i >= newKidsCount; i--) {
679
+ render(state, oldNode as Element, i, i, (<any>oldVode)[i + oldStart], undefined, xmlns);
741
680
  }
742
681
  }
743
682
 
744
- (<any>newVode).unmountCount = 1 + totalChildUnmounts;
745
- (<any>newVode).unmountStart = unmountStart;
683
+ (<any>newVode)._unmountCount = (properties?.onUnmount ? 1 : 0) + sumChildUnmountCounts(newVode as Vode<S>);
746
684
  return <AttachedVode<S>>newVode;
747
685
  }
748
686
  } catch (error) {
@@ -755,7 +693,7 @@ function render<S extends PatchableState>(
755
693
  return render(state, parent, childIndex, indexInParent,
756
694
  hydrate(((<AttachedVode<S>>newVode)?.node || oldVode?.node) as Element, true) as AttachedVode<S>,
757
695
  handledVode,
758
- xmlns, unmounts, unmountStart);
696
+ xmlns);
759
697
  } else {
760
698
  throw error;
761
699
  }
@@ -764,6 +702,35 @@ function render<S extends PatchableState>(
764
702
  return undefined;
765
703
  }
766
704
 
705
+ function unmountTree<S extends PatchableState>(state: S, v: AttachedVode<S> | ChildVode<S> | undefined): void {
706
+ if (!v || !Array.isArray(v)) return;
707
+ if (((<any>v)._unmountCount | 0) === 0) return;
708
+
709
+ const kids = children(v as Vode<S>) as AttachedVode<S>[] | null;
710
+ if (kids) {
711
+ for (let i = kids.length - 1; i >= 0; i--) {
712
+ unmountTree(state, kids[i]);
713
+ }
714
+ }
715
+
716
+ const p = props(v);
717
+ if (typeof p?.onUnmount === "function") {
718
+ state.patch(p.onUnmount(state, (v as AttachedVode<S>).node as HTMLElement & SVGSVGElement & MathMLElement));
719
+ }
720
+ }
721
+
722
+ function sumChildUnmountCounts<S>(v: Vode<S>): number {
723
+ const kids = children(v) as AttachedVode<S>[] | null;
724
+ if (!kids) return 0;
725
+ let n = 0;
726
+ for (const k of kids) {
727
+ if (k && Array.isArray(k)) {
728
+ n += ((<any>k)._unmountCount | 0);
729
+ }
730
+ }
731
+ return n;
732
+ }
733
+
767
734
  function isNaturalVode<S>(x: ChildVode<S>): x is Vode<S> {
768
735
  return Array.isArray(x) && x.length > 0 && typeof x[0] === "string";
769
736
  }
@@ -773,14 +740,19 @@ function isTextVode<S>(x: ChildVode<S>): x is TextVode {
773
740
  }
774
741
 
775
742
  function remember<S>(state: S, present: any, past: any): ChildVode<S> | AttachedVode<S> {
743
+ while (typeof present === "function" && !present.__memo) {
744
+ present = present(state);
745
+ }
746
+
776
747
  if (typeof present !== "function")
777
748
  return present;
778
749
 
779
- const presentMemo = present?.__memo;
780
- const pastMemo = past?.__memo;
781
750
 
782
- if (Array.isArray(presentMemo)
783
- && Array.isArray(pastMemo)
751
+ const presentMemo: unknown[] = present?.__memo;
752
+ const pastMemo: unknown[] = past?.__memo;
753
+
754
+ if (
755
+ Array.isArray(presentMemo) && Array.isArray(pastMemo)
784
756
  && presentMemo.length === pastMemo.length
785
757
  ) {
786
758
  let same = true;
@@ -793,40 +765,17 @@ function remember<S>(state: S, present: any, past: any): ChildVode<S> | Attached
793
765
  if (same) return past;
794
766
  }
795
767
 
796
- const result = present(state);
797
-
798
- if (typeof result === "function" && result?.__memo) {
799
- const resultMemo = result.__memo;
800
- if (Array.isArray(resultMemo) && Array.isArray(pastMemo) && resultMemo.length === pastMemo.length) {
801
- let same = true;
802
- for (let i = 0; i < resultMemo.length; i++) {
803
- if (resultMemo[i] !== pastMemo[i]) {
804
- same = false;
805
- break;
806
- }
807
- }
808
- if (same) return past;
809
- }
810
- const innerRender = result(state);
811
- if (typeof innerRender === "object") {
812
- innerRender.__memo = resultMemo;
813
- }
814
- return innerRender;
768
+ // memos are not equal so we unwrap the present
769
+ while (typeof present === "function") {
770
+ present = present(state);
815
771
  }
816
772
 
817
- const newRender = typeof result === "function" ? unwrap(result, state) : result;
818
- if (typeof newRender === "object") {
819
- (<any>newRender).__memo = result?.__memo || present?.__memo;
773
+ // attach memo to the unwrapped present for future comparisons
774
+ if (typeof present === "object") {
775
+ (<any>present).__memo = presentMemo;
820
776
  }
821
- return newRender;
822
- }
823
777
 
824
- function unwrap<S>(c: Component<S> | ChildVode<S>, s: S): ChildVode<S> {
825
- if (typeof c === "function") {
826
- return unwrap(c(s), s);
827
- } else {
828
- return c;
829
- }
778
+ return present;
830
779
  }
831
780
 
832
781
  function patchProperties<S extends PatchableState>(
package/test/helper.ts CHANGED
@@ -1,16 +1,18 @@
1
- import { children, ChildVode, PatchableState, tag, Vode } from "../src/vode";
2
- import { MockElement, MockText } from "./mocks";
1
+ import { children, ChildVode, PatchableState, props, tag, Vode } from "../src/vode";
2
+ import { FakeElement, FakeTextNode } from "./mocks";
3
3
 
4
4
  export class Expectation {
5
5
  constructor(public readonly what: any) { }
6
6
 
7
- toBeA(type: "undefined" | "object" | "function" | "bigint" | "boolean" | "number" | "string" | "symbol") {
7
+ toBeA(type: "undefined" | "object" | "function" | "bigint" | "boolean" | "number" | "string" | "symbol", failMessage?: string) {
8
8
  if (typeof this.what !== type) {
9
- throw new ExpectationError(this, `expected \n\ntypeof ${this.what}\n\nto be \n\n${type}`);
9
+ throw new ExpectationError(this, `expected \n\ntypeof ${this.what}\n\nto be \n\n${type}${failMessage ? `\n\n${failMessage}` : ""}`);
10
10
  }
11
11
  }
12
12
 
13
- toEqual(other: any) {
13
+ toEqual(other: any, failMessage?: string) {
14
+ const failSuffix = failMessage ? `\n\n${failMessage}` : "";
15
+
14
16
  function deepCompare(a: any, b: any, path: string[]): string[] | null {
15
17
  if (typeof a !== typeof b) {
16
18
  if (path.length === 0) path.push(``);
@@ -46,12 +48,12 @@ export class Expectation {
46
48
  if (typeof this.what === "object" && typeof other === "object" && this.what !== null && other !== null) {
47
49
  const unequal = deepCompare(this.what, other, []);
48
50
  if (unequal) {
49
- throw new ExpectationError(this, `expected \n\n${JSON.stringify(this.what, null, 2)}\n\n to equal \n\n${JSON.stringify(other, null, 2)}\n\nThey differ in: ${unequal.join(".")}`);
51
+ throw new ExpectationError(this, `expected \n\n${JSON.stringify(this.what, null, 2)}\n\n to equal \n\n${JSON.stringify(other, null, 2)}\n\nThey differ in: ${unequal.join(".")}${failSuffix}`);
50
52
  }
51
53
  }
52
54
  else {
53
55
  if (this.what !== other) {
54
- throw new ExpectationError(this, `expected (${typeof this.what})\n\n${this.what}\n\nto equal (${typeof other})\n\n${other}`);
56
+ throw new ExpectationError(this, `expected (${typeof this.what})\n\n${this.what}\n\nto equal (${typeof other})\n\n${other}${failSuffix}`);
55
57
  }
56
58
  }
57
59
  }
@@ -77,82 +79,126 @@ export class Expectation {
77
79
  throw new ExpectationError(this, `expected function to fail\n\nbut it succeeded with a result of type ${typeof r}\n\n${r}`);
78
80
  }
79
81
 
80
- toMatch(v: ChildVode, state?: PatchableState) {
81
- if (this.what instanceof MockElement || this.what instanceof MockText || typeof this.what === "string" || Array.isArray(this.what) || typeof this.what === "function") {
82
+ toMatch(v: ChildVode, state?: PatchableState | null, failMessage?: string) {
83
+ const failSuffix = failMessage ? `\n\n${failMessage}` : "";
84
+
85
+ if (this.what instanceof FakeElement || this.what instanceof FakeTextNode || typeof this.what === "string" || Array.isArray(this.what) || typeof this.what === "function") {
82
86
  const that = this;
83
- function deepCompare(e: MockElement | MockText | ChildVode, cv: ChildVode, path: string[]): string[] | null {
87
+
88
+ function deepCompare(e: FakeElement | FakeTextNode | ChildVode, cv: ChildVode, path: string[]): string[] | null {
84
89
 
85
90
  // unwrap component
86
91
  while (typeof cv === "function") {
87
92
  if (!state) {
88
- throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\na Component\n\nbut got no state passed in [toMatch]`);
93
+ throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\na Component\n\nbut got no state passed in [toMatch]${failSuffix}`);
89
94
  }
90
95
  cv = cv(state);
91
96
  }
92
97
  while (typeof e === "function") {
93
98
  if (!state) {
94
- throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\na Component\n\nbut got no state passed in [toMatch]`);
99
+ throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\na Component\n\nbut got no state passed in [toMatch]${failSuffix}`);
95
100
  }
96
101
  e = e(state);
97
102
  }
98
103
 
99
- if (typeof cv === "string" && e instanceof MockText) {
104
+ // string matches TextNode
105
+ if (typeof cv === "string" && e instanceof FakeTextNode) {
100
106
  if (cv !== e.wholeText) {
101
- throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\na text node with\n${cv}\n\nbut text was\n${e.wholeText}`);
107
+ throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\na text node with\n${cv}\n\nbut text was\n${e.wholeText}${failSuffix}`);
102
108
  }
103
109
  }
110
+ // string matches string
104
111
  else if (typeof cv === "string" && typeof e === "string") {
105
112
  if (cv !== e) {
106
- throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\na text node with\n${cv}\n\nbut text was\n${e}`);
113
+ throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\na text node with\n${cv}\n\nbut text was\n${e}${failSuffix}`);
107
114
  }
108
115
  }
109
116
 
110
- else if (Array.isArray(cv) && e instanceof MockElement) {
111
- if (tag(cv)?.toLocaleUpperCase() !== e.tagName.toUpperCase()) {
112
- throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\nan element <${tag(cv)}>\n\nbut got <${e.tagName}>`);
117
+ // vode matches element
118
+ else if (Array.isArray(cv) && e instanceof FakeElement) {
119
+ if (tag(cv)?.toUpperCase() !== e.tagName.toUpperCase()) {
120
+ throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\nan element <${tag(cv)?.toUpperCase()}>\n\nbut got <${e.tagName.toUpperCase()}>${failSuffix}`);
121
+ }
122
+
123
+ // compare attributes/props
124
+ const properties = props(cv);
125
+ if (properties) {
126
+ for (const [k, v] of Object.entries(properties)) {
127
+ const attributeValue = e.fakeAttributes[k];
128
+ if (!attributeValue) {
129
+ throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\nan element <${tag(cv)?.toUpperCase()}>\n\nwith attribute [${k}="${v}"]\n\nbut it was not found${failSuffix}`);
130
+ }
131
+ if (attributeValue !== v) {
132
+ throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\nan element <${tag(cv)?.toUpperCase()}>\n\nwith attribute [${k}="${v}"]\n\nbut it was [${k}="${attributeValue}"]${failSuffix}`);
133
+ }
134
+ }
113
135
  }
136
+
137
+ // compare children
114
138
  const kids = children(cv) || [];
115
139
  for (let i = 0; i < kids.length; i++) {
116
- deepCompare(e.children[i], kids[i], [...path, `${tag(kids[i] as Vode) || "#text"}`]);
140
+ deepCompare(e.children.item(i) as any, kids[i], [...path, `[${i}]${tag(kids[i] as Vode)?.toUpperCase() || "#text"}`]);
117
141
  }
118
142
  if (kids.length !== e.children.length) {
119
- throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\n${kids.length} children\n\nbut <${e.tagName}> has ${e.children.length} children`);
143
+ throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\n${kids.length} children\n\nbut <${e.tagName.toUpperCase()}> has ${e.children.length} children${failSuffix}`);
120
144
  }
121
145
  }
146
+ // vode matches vode
122
147
  else if (Array.isArray(cv) && Array.isArray(e)) {
123
- if (tag(cv)?.toLocaleUpperCase() !== tag(e)?.toUpperCase()) {
124
- throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\nan element [${tag(cv)}]\n\nbut got [${tag(e)}]`);
148
+ if (tag(cv)?.toUpperCase() !== tag(e)?.toUpperCase()) {
149
+ throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\na vode [${tag(cv)?.toUpperCase()}]\n\nbut got [${tag(e)?.toUpperCase()}]${failSuffix}`);
150
+ }
151
+
152
+ // compare attributes/props
153
+ const properties = props(cv);
154
+ const otherProperties = props(e) || {};
155
+ if (properties) {
156
+ for (const [k, v] of Object.entries(properties)) {
157
+ const attributeValue = otherProperties[k];
158
+ if (!attributeValue) {
159
+ throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\na vode [${tag(cv)?.toUpperCase()}]\n\nwith attribute [${k}="${v}"]\n\nbut it was not found${failSuffix}`);
160
+ }
161
+ if (attributeValue !== v) {
162
+ throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\na vode [${tag(cv)?.toUpperCase()}]\n\nwith attribute [${k}="${v}"]\n\nbut its value was [${k}="${attributeValue}"]${failSuffix}`);
163
+ }
164
+ }
125
165
  }
166
+
167
+ // compare children
126
168
  const kids = children(cv) || [];
127
169
  const otherKids = children(e) || [];
128
170
  for (let i = 0; i < kids.length; i++) {
129
- deepCompare(otherKids[i], kids[i], [...path, `${tag(kids[i] as Vode) || "#text"}`]);
171
+ deepCompare(otherKids[i], kids[i], [...path, `[${i}]${tag(kids[i] as Vode)?.toUpperCase() || "#text"}`]);
130
172
  }
131
173
  if (kids.length !== otherKids.length) {
132
- throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\n${kids.length} children\n\nbut [${tag(e)}] has ${otherKids.length} children`);
174
+ throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\n${kids.length} children\n\nbut [${tag(e)?.toUpperCase()}] has ${otherKids.length} children${failSuffix}`);
133
175
  }
134
176
  }
135
177
 
136
- else if (typeof cv === "string" && e instanceof MockElement) {
137
- throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\na text node\n\nbut got <${e.tagName}>`);
178
+ // mismatch between text and element
179
+ else if (typeof cv === "string" && e instanceof FakeElement) {
180
+ throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\na text node\n\nbut got <${e.tagName.toUpperCase()}>${failSuffix}`);
138
181
  }
182
+ // mismatch between text and vode
139
183
  else if (typeof cv === "string" && Array.isArray(e)) {
140
- throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\na text node\n\nbut got [${tag(e)}]`);
184
+ throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\na text node\n\nbut got [${tag(e)?.toUpperCase()}]${failSuffix}`);
141
185
  }
142
186
 
143
- else if (Array.isArray(cv) && e instanceof MockText) {
144
- throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\nan element <${tag(cv)}>\n\nbut got #text (${e.wholeText})`);
187
+ // mismatch between vode and text node
188
+ else if (Array.isArray(cv) && e instanceof FakeTextNode) {
189
+ throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\nan element <${tag(cv)?.toUpperCase()}>\n\nbut got #text (${e.wholeText})${failSuffix}`);
145
190
  }
191
+ // mismatch between vode and text
146
192
  else if (Array.isArray(cv) && typeof e === "string") {
147
- throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\nan element <${tag(cv)}>\n\nbut got #text (${e})`);
193
+ throw new ExpectationError(that, `expected at\n${path.join(" > ")}\n\nan element <${tag(cv)?.toUpperCase()}>\n\nbut got #text (${e})${failSuffix}`);
148
194
  }
149
195
 
150
196
  return null;
151
197
  }
152
198
 
153
- deepCompare(this.what, v, [tag(v as Vode) || "#text"]);
199
+ deepCompare(this.what, v, [`${tag(v as Vode)?.toUpperCase() || "#text"}`]);
154
200
  } else {
155
- throw new ExpectationError(this, `expected an element or text node\n\nbut it is a ${typeof this.what}\n${this.what}`);
201
+ throw new ExpectationError(this, `expected an element or text node\n\nbut it is a ${typeof this.what}\n${this.what}${failSuffix}`);
156
202
  }
157
203
  }
158
204
  };