zero-query 0.9.0 → 0.9.1

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.
@@ -523,3 +523,894 @@ describe('morphElement', () => {
523
523
  expect(result).toBe(target); // no-op on empty
524
524
  });
525
525
  });
526
+
527
+
528
+ // ---------------------------------------------------------------------------
529
+ // Deep keyed reconciliation edge cases
530
+ // ---------------------------------------------------------------------------
531
+
532
+ describe('morph — keyed edge cases', () => {
533
+ it('handles single keyed element swap', () => {
534
+ const root = el('<div z-key="a">A</div>');
535
+ const nodeA = root.children[0];
536
+ morph(root, '<div z-key="b">B</div>');
537
+ expect(root.children.length).toBe(1);
538
+ expect(root.children[0].getAttribute('z-key')).toBe('b');
539
+ expect(root.children[0]).not.toBe(nodeA);
540
+ });
541
+
542
+ it('preserves identity when keys are in sorted order (LIS full match)', () => {
543
+ const root = el(
544
+ '<div z-key="a">A</div><div z-key="b">B</div><div z-key="c">C</div>'
545
+ );
546
+ const nodes = [...root.children];
547
+ morph(root, '<div z-key="a">A2</div><div z-key="b">B2</div><div z-key="c">C2</div>');
548
+ expect(root.children[0]).toBe(nodes[0]);
549
+ expect(root.children[1]).toBe(nodes[1]);
550
+ expect(root.children[2]).toBe(nodes[2]);
551
+ expect(root.children[0].textContent).toBe('A2');
552
+ });
553
+
554
+ it('handles keyed list grow and shrink in same morph', () => {
555
+ const root = el(
556
+ '<div z-key="a">A</div><div z-key="b">B</div><div z-key="c">C</div>'
557
+ );
558
+ morph(root, '<div z-key="b">B</div><div z-key="d">D</div>');
559
+ expect(root.children.length).toBe(2);
560
+ expect(root.children[0].getAttribute('z-key')).toBe('b');
561
+ expect(root.children[1].getAttribute('z-key')).toBe('d');
562
+ });
563
+
564
+ it('handles all-new keys (full replacement)', () => {
565
+ const root = el('<div z-key="a">A</div><div z-key="b">B</div>');
566
+ morph(root, '<div z-key="x">X</div><div z-key="y">Y</div><div z-key="z">Z</div>');
567
+ expect(root.children.length).toBe(3);
568
+ expect([...root.children].map(c => c.getAttribute('z-key'))).toEqual(['x', 'y', 'z']);
569
+ });
570
+
571
+ it('handles keyed elements with attribute changes during reorder', () => {
572
+ const root = el(
573
+ '<div z-key="a" class="old-a">A</div><div z-key="b" class="old-b">B</div>'
574
+ );
575
+ const nodeA = root.children[0];
576
+ const nodeB = root.children[1];
577
+ morph(root, '<div z-key="b" class="new-b">B2</div><div z-key="a" class="new-a">A2</div>');
578
+ expect(root.children[0]).toBe(nodeB);
579
+ expect(root.children[1]).toBe(nodeA);
580
+ expect(nodeA.className).toBe('new-a');
581
+ expect(nodeB.className).toBe('new-b');
582
+ });
583
+
584
+ it('handles interleaved insert between existing keyed nodes', () => {
585
+ const root = el('<div z-key="a">A</div><div z-key="c">C</div>');
586
+ const nodeA = root.children[0];
587
+ const nodeC = root.children[1];
588
+ morph(root, '<div z-key="a">A</div><div z-key="b">B</div><div z-key="c">C</div>');
589
+ expect(root.children.length).toBe(3);
590
+ expect(root.children[0]).toBe(nodeA);
591
+ expect(root.children[2]).toBe(nodeC);
592
+ expect(root.children[1].textContent).toBe('B');
593
+ });
594
+
595
+ it('handles keyed list with duplicate key attributes gracefully', () => {
596
+ // When duplicate keys exist, later entries override in the map (last wins)
597
+ // morph should not crash, and at least one child gets the updated content
598
+ const root = el('<div z-key="a">A1</div><div z-key="a">A2</div>');
599
+ morph(root, '<div z-key="a">Updated</div>');
600
+ const texts = [...root.children].map(c => c.textContent);
601
+ expect(texts).toContain('Updated');
602
+ });
603
+
604
+ it('handles empty old with keyed new', () => {
605
+ const root = document.createElement('div');
606
+ morph(root, '<div z-key="a">A</div><div z-key="b">B</div>');
607
+ expect(root.children.length).toBe(2);
608
+ expect(root.children[0].getAttribute('z-key')).toBe('a');
609
+ });
610
+
611
+ it('handles keyed old to empty new', () => {
612
+ const root = el('<div z-key="a">A</div><div z-key="b">B</div>');
613
+ morph(root, '');
614
+ expect(root.children.length).toBe(0);
615
+ });
616
+ });
617
+
618
+
619
+ // ---------------------------------------------------------------------------
620
+ // Boolean attribute morphing
621
+ // ---------------------------------------------------------------------------
622
+
623
+ describe('morph — boolean attributes', () => {
624
+ it('adds hidden attribute', () => {
625
+ const root = morphAndGet('<div></div>', '<div hidden></div>');
626
+ expect(root.children[0].hasAttribute('hidden')).toBe(true);
627
+ });
628
+
629
+ it('removes hidden attribute', () => {
630
+ const root = morphAndGet('<div hidden></div>', '<div></div>');
631
+ expect(root.children[0].hasAttribute('hidden')).toBe(false);
632
+ });
633
+
634
+ it('morphs readonly on input', () => {
635
+ const root = morphAndGet('<input>', '<input readonly>');
636
+ expect(root.querySelector('input').hasAttribute('readonly')).toBe(true);
637
+ });
638
+
639
+ it('morphs autofocus attribute', () => {
640
+ const root = morphAndGet('<input>', '<input autofocus>');
641
+ expect(root.querySelector('input').hasAttribute('autofocus')).toBe(true);
642
+ });
643
+
644
+ it('toggles multiple boolean attributes at once', () => {
645
+ const root = morphAndGet('<input disabled readonly>', '<input>');
646
+ const input = root.querySelector('input');
647
+ expect(input.hasAttribute('disabled')).toBe(false);
648
+ expect(input.hasAttribute('readonly')).toBe(false);
649
+ });
650
+ });
651
+
652
+
653
+ // ---------------------------------------------------------------------------
654
+ // Complex form element morphing
655
+ // ---------------------------------------------------------------------------
656
+
657
+ describe('morph — complex form elements', () => {
658
+ it('syncs input type change', () => {
659
+ const root = morphAndGet('<input type="text" value="hello">', '<input type="password" value="secret">');
660
+ const input = root.querySelector('input');
661
+ expect(input.getAttribute('type')).toBe('password');
662
+ });
663
+
664
+ it('syncs multiple inputs simultaneously', () => {
665
+ const root = el('<input value="a"><input type="checkbox"><textarea>old</textarea>');
666
+ morph(root, '<input value="b"><input type="checkbox" checked><textarea>new</textarea>');
667
+ expect(root.querySelectorAll('input')[0].value).toBe('b');
668
+ expect(root.querySelectorAll('input')[1].checked).toBe(true);
669
+ expect(root.querySelector('textarea').value).toBe('new');
670
+ });
671
+
672
+ it('syncs radio group checked state', () => {
673
+ const root = el(
674
+ '<input type="radio" name="choice" value="a" checked>' +
675
+ '<input type="radio" name="choice" value="b">'
676
+ );
677
+ morph(root,
678
+ '<input type="radio" name="choice" value="a">' +
679
+ '<input type="radio" name="choice" value="b" checked>'
680
+ );
681
+ expect(root.querySelectorAll('input')[0].checked).toBe(false);
682
+ expect(root.querySelectorAll('input')[1].checked).toBe(true);
683
+ });
684
+
685
+ it('syncs textarea from non-empty to empty', () => {
686
+ const root = el('<textarea>content</textarea>');
687
+ morph(root, '<textarea></textarea>');
688
+ expect(root.querySelector('textarea').value).toBe('');
689
+ });
690
+
691
+ it('syncs select with added option', () => {
692
+ const root = el('<select><option value="a">A</option></select>');
693
+ morph(root, '<select><option value="a">A</option><option value="b">B</option></select>');
694
+ expect(root.querySelectorAll('option').length).toBe(2);
695
+ });
696
+
697
+ it('handles input disabled toggle', () => {
698
+ const root = el('<input disabled>');
699
+ morph(root, '<input>');
700
+ expect(root.querySelector('input').disabled).toBe(false);
701
+ });
702
+ });
703
+
704
+
705
+ // ---------------------------------------------------------------------------
706
+ // Text / comment node edge cases
707
+ // ---------------------------------------------------------------------------
708
+
709
+ describe('morph — text and comment edge cases', () => {
710
+ it('morphs comment to text node', () => {
711
+ const root = document.createElement('div');
712
+ root.appendChild(document.createComment('comment'));
713
+ morph(root, 'text content');
714
+ expect(root.childNodes[0].nodeType).toBe(3);
715
+ expect(root.textContent).toBe('text content');
716
+ });
717
+
718
+ it('morphs text to comment node', () => {
719
+ const root = document.createElement('div');
720
+ root.textContent = 'text';
721
+ morph(root, '<!-- comment -->');
722
+ expect(root.childNodes[0].nodeType).toBe(8);
723
+ });
724
+
725
+ it('preserves identical text nodes', () => {
726
+ const root = document.createElement('div');
727
+ root.textContent = 'same';
728
+ const textNode = root.childNodes[0];
729
+ morph(root, 'same');
730
+ expect(root.childNodes[0]).toBe(textNode);
731
+ });
732
+
733
+ it('handles mixed text and element children', () => {
734
+ const root = document.createElement('div');
735
+ root.innerHTML = 'text before <span>middle</span> text after';
736
+ morph(root, 'new before <span>new middle</span> new after');
737
+ expect(root.querySelector('span').textContent).toBe('new middle');
738
+ });
739
+
740
+ it('handles empty comment node', () => {
741
+ const root = document.createElement('div');
742
+ root.appendChild(document.createComment(''));
743
+ morph(root, '<!---->');
744
+ expect(root.childNodes[0].nodeType).toBe(8);
745
+ });
746
+ });
747
+
748
+
749
+ // ---------------------------------------------------------------------------
750
+ // Nested structure edge cases
751
+ // ---------------------------------------------------------------------------
752
+
753
+ describe('morph — nested structure edge cases', () => {
754
+ it('handles 5-level deep nesting change', () => {
755
+ const root = morphAndGet(
756
+ '<div><div><div><div><div>deep old</div></div></div></div></div>',
757
+ '<div><div><div><div><div>deep new</div></div></div></div></div>'
758
+ );
759
+ let node = root;
760
+ for (let i = 0; i < 5; i++) node = node.firstElementChild;
761
+ expect(node.textContent).toBe('deep new');
762
+ });
763
+
764
+ it('handles structure change at intermediate level', () => {
765
+ const root = morphAndGet(
766
+ '<div><ul><li>a</li></ul></div>',
767
+ '<div><ol><li>a</li></ol></div>'
768
+ );
769
+ expect(root.querySelector('ol')).not.toBeNull();
770
+ expect(root.querySelector('ul')).toBeNull();
771
+ });
772
+
773
+ it('handles adding nested children inside empty element', () => {
774
+ const root = morphAndGet('<div></div>', '<div><p><span>nested</span></p></div>');
775
+ expect(root.querySelector('span').textContent).toBe('nested');
776
+ });
777
+
778
+ it('handles removing all nested children', () => {
779
+ const root = morphAndGet(
780
+ '<div><p><span>nested</span></p><ul><li>item</li></ul></div>',
781
+ '<div></div>'
782
+ );
783
+ expect(root.children[0].children.length).toBe(0);
784
+ });
785
+
786
+ it('handles sibling addition with nested keyed content', () => {
787
+ const root = el(
788
+ '<ul><li z-key="a"><span>A</span></li></ul>'
789
+ );
790
+ morph(root,
791
+ '<ul><li z-key="a"><span>A updated</span></li><li z-key="b"><span>B</span></li></ul>'
792
+ );
793
+ expect(root.querySelectorAll('li').length).toBe(2);
794
+ expect(root.querySelector('li[z-key="a"] span').textContent).toBe('A updated');
795
+ });
796
+ });
797
+
798
+
799
+ // ---------------------------------------------------------------------------
800
+ // z-skip with keyed children
801
+ // ---------------------------------------------------------------------------
802
+
803
+ describe('morph — z-skip + keyed interactions', () => {
804
+ it('z-skip on parent skips entire keyed children', () => {
805
+ const root = el('<div z-skip><p z-key="a">A</p><p z-key="b">B</p></div>');
806
+ const pA = root.querySelector('p[z-key="a"]');
807
+ morph(root, '<div z-skip><p z-key="b">B2</p><p z-key="a">A2</p></div>');
808
+ // Children should NOT have been reordered
809
+ expect(root.querySelector('p[z-key="a"]')).toBe(pA);
810
+ expect(pA.textContent).toBe('A');
811
+ });
812
+
813
+ it('z-skip on one sibling does not affect other siblings', () => {
814
+ const root = el(
815
+ '<div z-skip><p>frozen</p></div><div><p>mutable</p></div>'
816
+ );
817
+ morph(root,
818
+ '<div z-skip><p>changed?</p></div><div><p>changed!</p></div>'
819
+ );
820
+ expect(root.children[0].querySelector('p').textContent).toBe('frozen');
821
+ expect(root.children[1].querySelector('p').textContent).toBe('changed!');
822
+ });
823
+ });
824
+
825
+
826
+ // ---------------------------------------------------------------------------
827
+ // morphElement advanced cases
828
+ // ---------------------------------------------------------------------------
829
+
830
+ describe('morphElement — advanced', () => {
831
+ it('morphs only attributes without child changes', () => {
832
+ const root = el('<p class="old" data-x="1">text</p>');
833
+ const target = root.children[0];
834
+ const result = morphElement(target, '<p class="new" data-y="2">text</p>');
835
+ expect(result).toBe(target);
836
+ expect(target.className).toBe('new');
837
+ expect(target.hasAttribute('data-x')).toBe(false);
838
+ expect(target.getAttribute('data-y')).toBe('2');
839
+ });
840
+
841
+ it('morphs children while keeping same attributes', () => {
842
+ const root = el('<div class="keep"><span>old</span></div>');
843
+ const target = root.children[0];
844
+ const result = morphElement(target, '<div class="keep"><span>new</span><span>added</span></div>');
845
+ expect(result).toBe(target);
846
+ expect(target.className).toBe('keep');
847
+ expect(target.children.length).toBe(2);
848
+ expect(target.children[1].textContent).toBe('added');
849
+ });
850
+
851
+ it('handles morphElement on nested structures', () => {
852
+ const root = el('<div><ul><li>old</li></ul></div>');
853
+ const target = root.children[0];
854
+ morphElement(target, '<div><ul><li>new</li><li>added</li></ul></div>');
855
+ expect(target.querySelectorAll('li').length).toBe(2);
856
+ expect(target.querySelector('li').textContent).toBe('new');
857
+ });
858
+ });
859
+
860
+
861
+ // ---------------------------------------------------------------------------
862
+ // Stress tests
863
+ // ---------------------------------------------------------------------------
864
+
865
+ describe('morph — stress tests', () => {
866
+ it('handles 500 unkeyed children update', () => {
867
+ const oldItems = Array.from({ length: 500 }, (_, i) => `<span>${i}</span>`).join('');
868
+ const newItems = Array.from({ length: 500 }, (_, i) => `<span>${i + 1}</span>`).join('');
869
+ const root = el(oldItems);
870
+ morph(root, newItems);
871
+ expect(root.children.length).toBe(500);
872
+ expect(root.children[0].textContent).toBe('1');
873
+ expect(root.children[499].textContent).toBe('500');
874
+ });
875
+
876
+ it('handles 100 keyed elements with middle insertion', () => {
877
+ const ids = Array.from({ length: 100 }, (_, i) => i);
878
+ const oldHTML = ids.map(i => `<div z-key="${i}">${i}</div>`).join('');
879
+ // Insert new item in the middle
880
+ const newIds = [...ids.slice(0, 50), 999, ...ids.slice(50)];
881
+ const newHTML = newIds.map(i => `<div z-key="${i}">${i}</div>`).join('');
882
+ const root = el(oldHTML);
883
+ morph(root, newHTML);
884
+ expect(root.children.length).toBe(101);
885
+ expect(root.children[50].getAttribute('z-key')).toBe('999');
886
+ });
887
+
888
+ it('handles alternating insert/remove pattern', () => {
889
+ const root = el('<p>a</p><p>b</p><p>c</p><p>d</p><p>e</p>');
890
+ morph(root, '<p>a</p><p>c</p><p>e</p>');
891
+ expect(root.children.length).toBe(3);
892
+ expect([...root.children].map(c => c.textContent)).toEqual(['a', 'c', 'e']);
893
+ });
894
+
895
+ it('handles deep attribute churn', () => {
896
+ const root = el(
897
+ Array.from({ length: 20 }, (_, i) =>
898
+ `<div class="c${i}" data-val="${i}" id="d${i}">item ${i}</div>`
899
+ ).join('')
900
+ );
901
+ morph(root,
902
+ Array.from({ length: 20 }, (_, i) =>
903
+ `<div class="new-c${i}" data-val="${i * 2}" id="d${i}">updated ${i}</div>`
904
+ ).join('')
905
+ );
906
+ expect(root.children[5].className).toBe('new-c5');
907
+ expect(root.children[5].getAttribute('data-val')).toBe('10');
908
+ expect(root.children[5].textContent).toBe('updated 5');
909
+ });
910
+ });
911
+
912
+
913
+ // ---------------------------------------------------------------------------
914
+ // BUG FIX: keyed morph cursor after node replacement
915
+ // ---------------------------------------------------------------------------
916
+
917
+ describe('morph — keyed cursor after replaceChild', () => {
918
+ it('correctly positions nodes when tag changes during keyed morph', () => {
919
+ const root = el(
920
+ '<div z-key="a">A</div>' +
921
+ '<span z-key="b">B</span>' +
922
+ '<div z-key="c">C</div>'
923
+ );
924
+ // Change <span> to <div> (triggers replaceChild inside _morphNode)
925
+ // and reorder to force the LIS path
926
+ morph(root,
927
+ '<div z-key="c">C!</div>' +
928
+ '<div z-key="b">B!</div>' +
929
+ '<div z-key="a">A!</div>'
930
+ );
931
+ expect(root.children.length).toBe(3);
932
+ expect(root.children[0].textContent).toBe('C!');
933
+ expect(root.children[1].textContent).toBe('B!');
934
+ expect(root.children[2].textContent).toBe('A!');
935
+ // All should be <div> now (the span was replaced)
936
+ expect(root.children[1].tagName).toBe('DIV');
937
+ });
938
+ });
939
+
940
+
941
+ // ---------------------------------------------------------------------------
942
+ // BUG FIX: attribute removal on live NamedNodeMap
943
+ // ---------------------------------------------------------------------------
944
+
945
+ describe('morph — attribute removal stability', () => {
946
+ it('removes multiple stale attributes without index errors', () => {
947
+ const root = el('<div class="a" data-x="1" data-y="2" data-z="3" id="t1">hi</div>');
948
+ morph(root, '<div id="t1">hi</div>');
949
+ const child = root.firstElementChild;
950
+ expect(child.hasAttribute('class')).toBe(false);
951
+ expect(child.hasAttribute('data-x')).toBe(false);
952
+ expect(child.hasAttribute('data-y')).toBe(false);
953
+ expect(child.hasAttribute('data-z')).toBe(false);
954
+ expect(child.id).toBe('t1');
955
+ });
956
+
957
+ it('adds new attributes and removes old ones in the same pass', () => {
958
+ const root = el('<div class="old" data-old="1">test</div>');
959
+ morph(root, '<div class="new" data-new="2" aria-label="item">test</div>');
960
+ const child = root.firstElementChild;
961
+ expect(child.className).toBe('new');
962
+ expect(child.getAttribute('data-new')).toBe('2');
963
+ expect(child.getAttribute('aria-label')).toBe('item');
964
+ expect(child.hasAttribute('data-old')).toBe(false);
965
+ });
966
+ });
967
+
968
+
969
+ // ---------------------------------------------------------------------------
970
+ // Round 3 — Extended coverage
971
+ // ---------------------------------------------------------------------------
972
+
973
+ describe('morph — __zqMorphHook performance hook', () => {
974
+ it('calls the hook with root element and elapsed time for morph()', () => {
975
+ const calls = [];
976
+ window.__zqMorphHook = (el, ms) => calls.push({ el, ms });
977
+ const root = el('<p>a</p>');
978
+ morph(root, '<p>b</p>');
979
+ delete window.__zqMorphHook;
980
+ expect(calls.length).toBe(1);
981
+ expect(calls[0].el).toBe(root);
982
+ expect(typeof calls[0].ms).toBe('number');
983
+ expect(calls[0].ms).toBeGreaterThanOrEqual(0);
984
+ });
985
+
986
+ it('calls the hook for morphElement same-tag path', () => {
987
+ const calls = [];
988
+ window.__zqMorphHook = (el, ms) => calls.push({ el, ms });
989
+ const node = document.createElement('div');
990
+ node.textContent = 'old';
991
+ document.body.appendChild(node);
992
+ const result = morphElement(node, '<div>new</div>');
993
+ delete window.__zqMorphHook;
994
+ expect(calls.length).toBe(1);
995
+ expect(calls[0].el).toBe(node);
996
+ expect(result).toBe(node);
997
+ });
998
+
999
+ it('calls the hook for morphElement different-tag path', () => {
1000
+ const calls = [];
1001
+ window.__zqMorphHook = (el, ms) => calls.push({ el, ms });
1002
+ const node = document.createElement('div');
1003
+ node.textContent = 'old';
1004
+ document.body.appendChild(node);
1005
+ const result = morphElement(node, '<span>new</span>');
1006
+ delete window.__zqMorphHook;
1007
+ expect(calls.length).toBe(1);
1008
+ expect(calls[0].el).toBe(result);
1009
+ expect(result.nodeName).toBe('SPAN');
1010
+ });
1011
+
1012
+ it('does not call hook when __zqMorphHook is not defined', () => {
1013
+ delete window.__zqMorphHook;
1014
+ const root = el('<p>a</p>');
1015
+ // Should not throw
1016
+ morph(root, '<p>b</p>');
1017
+ expect(root.innerHTML).toBe('<p>b</p>');
1018
+ });
1019
+ });
1020
+
1021
+ describe('morph — element to text node type change', () => {
1022
+ it('replaces element with text node at same position', () => {
1023
+ const root = el('<p>hello</p><div>world</div>');
1024
+ morph(root, 'just text');
1025
+ expect(root.textContent).toBe('just text');
1026
+ });
1027
+
1028
+ it('replaces text node with element at same position', () => {
1029
+ const root = document.createElement('div');
1030
+ root.appendChild(document.createTextNode('plain text'));
1031
+ morph(root, '<p>element now</p>');
1032
+ expect(root.innerHTML).toBe('<p>element now</p>');
1033
+ });
1034
+
1035
+ it('replaces comment with element', () => {
1036
+ const root = document.createElement('div');
1037
+ root.appendChild(document.createComment('a comment'));
1038
+ morph(root, '<span>replaced</span>');
1039
+ expect(root.innerHTML).toBe('<span>replaced</span>');
1040
+ });
1041
+
1042
+ it('replaces element with comment', () => {
1043
+ const root = el('<p>text</p>');
1044
+ morph(root, '<!--comment-->');
1045
+ expect(root.childNodes[0].nodeType).toBe(8);
1046
+ expect(root.childNodes[0].nodeValue).toBe('comment');
1047
+ });
1048
+ });
1049
+
1050
+ describe('morph — attributes fast-path bypass', () => {
1051
+ it('skips attribute update when count and values are identical but children differ', () => {
1052
+ const root = el('<div class="a" data-x="1"><p>old child</p></div>');
1053
+ const child = root.firstElementChild;
1054
+ morph(root, '<div class="a" data-x="1"><p>new child</p></div>');
1055
+ // Identity preserved because attrs are identical (fast path)
1056
+ expect(root.firstElementChild).toBe(child);
1057
+ expect(root.firstElementChild.querySelector('p').textContent).toBe('new child');
1058
+ });
1059
+
1060
+ it('detects changed attribute value even when count is same', () => {
1061
+ const root = el('<div class="a" data-x="1">text</div>');
1062
+ morph(root, '<div class="b" data-x="1">text</div>');
1063
+ expect(root.firstElementChild.className).toBe('b');
1064
+ });
1065
+
1066
+ it('handles zero attributes on both sides', () => {
1067
+ const root = el('<div>text</div>');
1068
+ morph(root, '<div>updated</div>');
1069
+ expect(root.firstElementChild.textContent).toBe('updated');
1070
+ expect(root.firstElementChild.attributes.length).toBe(0);
1071
+ });
1072
+ });
1073
+
1074
+ describe('morph — textarea edge cases', () => {
1075
+ it('syncs textarea with null-like textContent', () => {
1076
+ const root = el('<textarea>old value</textarea>');
1077
+ morph(root, '<textarea></textarea>');
1078
+ expect(root.querySelector('textarea').value).toBe('');
1079
+ });
1080
+
1081
+ it('preserves textarea value when content unchanged', () => {
1082
+ const root = el('<textarea>same</textarea>');
1083
+ morph(root, '<textarea>same</textarea>');
1084
+ expect(root.querySelector('textarea').value).toBe('same');
1085
+ });
1086
+ });
1087
+
1088
+ describe('morph — select value sync', () => {
1089
+ it('syncs select value after morphing options', () => {
1090
+ const root = el('<select><option value="a">A</option><option value="b">B</option></select>');
1091
+ root.querySelector('select').value = 'a';
1092
+ morph(root, '<select><option value="a">A</option><option value="b" selected>B</option></select>');
1093
+ // jsdom may not fully support select.value sync, but we verify no crash
1094
+ expect(root.querySelector('select')).toBeTruthy();
1095
+ });
1096
+
1097
+ it('adds new option to select', () => {
1098
+ const root = el('<select><option value="a">A</option></select>');
1099
+ morph(root, '<select><option value="a">A</option><option value="b">B</option></select>');
1100
+ expect(root.querySelectorAll('option').length).toBe(2);
1101
+ });
1102
+ });
1103
+
1104
+ describe('morph — input sync edge cases', () => {
1105
+ it('syncs input value when new element has no value attribute', () => {
1106
+ const root = el('<input type="text" value="old">');
1107
+ morph(root, '<input type="text">');
1108
+ expect(root.querySelector('input').value).toBe('');
1109
+ });
1110
+
1111
+ it('syncs disabled attribute toggle', () => {
1112
+ const root = el('<input type="text" disabled>');
1113
+ morph(root, '<input type="text">');
1114
+ expect(root.querySelector('input').disabled).toBe(false);
1115
+ });
1116
+
1117
+ it('syncs from non-disabled to disabled', () => {
1118
+ const root = el('<input type="text">');
1119
+ morph(root, '<input type="text" disabled>');
1120
+ expect(root.querySelector('input').disabled).toBe(true);
1121
+ });
1122
+
1123
+ it('syncs checkbox from unchecked to checked', () => {
1124
+ const root = el('<input type="checkbox">');
1125
+ morph(root, '<input type="checkbox" checked>');
1126
+ expect(root.querySelector('input').checked).toBe(true);
1127
+ });
1128
+
1129
+ it('syncs radio from checked to unchecked', () => {
1130
+ const root = el('<input type="radio" checked>');
1131
+ morph(root, '<input type="radio">');
1132
+ expect(root.querySelector('input').checked).toBe(false);
1133
+ });
1134
+ });
1135
+
1136
+ describe('morph — keyed with multiple unkeyed leftover', () => {
1137
+ it('consumes some unkeyed and removes remaining', () => {
1138
+ const root = el(
1139
+ '<div z-key="a">A</div><p>unkeyed1</p><p>unkeyed2</p><p>unkeyed3</p><div z-key="b">B</div>'
1140
+ );
1141
+ morph(root,
1142
+ '<div z-key="b">B</div><p>kept</p><div z-key="a">A</div>'
1143
+ );
1144
+ // b should be first, one unkeyed kept (morphed), a should follow, extra unkeyed removed
1145
+ const children = [...root.children];
1146
+ expect(children[0].textContent).toBe('B');
1147
+ expect(children[1].textContent).toBe('kept');
1148
+ expect(children[2].textContent).toBe('A');
1149
+ expect(children.length).toBe(3);
1150
+ });
1151
+
1152
+ it('removes all unkeyed when new tree has none', () => {
1153
+ const root = el(
1154
+ '<div z-key="a">A</div><p>extra1</p><p>extra2</p><div z-key="b">B</div>'
1155
+ );
1156
+ morph(root, '<div z-key="b">B</div><div z-key="a">A</div>');
1157
+ expect(root.children.length).toBe(2);
1158
+ expect(root.children[0].textContent).toBe('B');
1159
+ expect(root.children[1].textContent).toBe('A');
1160
+ });
1161
+ });
1162
+
1163
+ describe('morph — LIS edge cases', () => {
1164
+ it('handles all entries being unmatched (-1)', () => {
1165
+ // All new keyed elements with no matching old keys
1166
+ const root = el('<div z-key="x">X</div><div z-key="y">Y</div>');
1167
+ morph(root, '<div z-key="a">A</div><div z-key="b">B</div>');
1168
+ expect(root.children[0].textContent).toBe('A');
1169
+ expect(root.children[1].textContent).toBe('B');
1170
+ });
1171
+
1172
+ it('handles single keyed element', () => {
1173
+ const root = el('<div z-key="a">old</div>');
1174
+ morph(root, '<div z-key="a">new</div>');
1175
+ expect(root.children[0].textContent).toBe('new');
1176
+ expect(root.children[0].getAttribute('z-key')).toBe('a');
1177
+ });
1178
+
1179
+ it('handles already-sorted keyed list (LIS = full array)', () => {
1180
+ const root = el(
1181
+ '<div z-key="a">A</div><div z-key="b">B</div><div z-key="c">C</div>'
1182
+ );
1183
+ const refs = [...root.children];
1184
+ morph(root,
1185
+ '<div z-key="a">A2</div><div z-key="b">B2</div><div z-key="c">C2</div>'
1186
+ );
1187
+ // All in order — identity preserved, no moves
1188
+ expect(root.children[0]).toBe(refs[0]);
1189
+ expect(root.children[1]).toBe(refs[1]);
1190
+ expect(root.children[2]).toBe(refs[2]);
1191
+ });
1192
+
1193
+ it('handles fully reversed keyed list (LIS length = 1)', () => {
1194
+ const root = el(
1195
+ '<div z-key="a">A</div><div z-key="b">B</div><div z-key="c">C</div><div z-key="d">D</div>'
1196
+ );
1197
+ morph(root,
1198
+ '<div z-key="d">D</div><div z-key="c">C</div><div z-key="b">B</div><div z-key="a">A</div>'
1199
+ );
1200
+ expect(root.children[0].textContent).toBe('D');
1201
+ expect(root.children[1].textContent).toBe('C');
1202
+ expect(root.children[2].textContent).toBe('B');
1203
+ expect(root.children[3].textContent).toBe('A');
1204
+ });
1205
+ });
1206
+
1207
+ describe('morph — mixed content type transitions', () => {
1208
+ it('morphs from elements to mixed text and elements', () => {
1209
+ const root = el('<p>one</p><p>two</p>');
1210
+ morph(root, 'text<p>element</p>more text');
1211
+ expect(root.childNodes.length).toBe(3);
1212
+ expect(root.childNodes[0].nodeType).toBe(3); // text
1213
+ expect(root.childNodes[1].nodeName).toBe('P');
1214
+ expect(root.childNodes[2].nodeType).toBe(3); // text
1215
+ });
1216
+
1217
+ it('morphs from text to comment', () => {
1218
+ const root = document.createElement('div');
1219
+ root.appendChild(document.createTextNode('hello'));
1220
+ morph(root, '<!--comment-->');
1221
+ expect(root.childNodes[0].nodeType).toBe(8);
1222
+ });
1223
+
1224
+ it('morphs from comment to text', () => {
1225
+ const root = document.createElement('div');
1226
+ root.appendChild(document.createComment('old'));
1227
+ morph(root, 'text node');
1228
+ expect(root.childNodes[0].nodeType).toBe(3);
1229
+ expect(root.childNodes[0].nodeValue).toBe('text node');
1230
+ });
1231
+ });
1232
+
1233
+ describe('morph — deeply nested keyed reorder', () => {
1234
+ it('reorders keyed elements inside a nested structure', () => {
1235
+ const root = el('<ul><li z-key="c">C</li><li z-key="a">A</li><li z-key="b">B</li></ul>');
1236
+ const ul = root.querySelector('ul');
1237
+ const liC = ul.children[0];
1238
+ morph(root, '<ul><li z-key="a">A</li><li z-key="b">B</li><li z-key="c">C</li></ul>');
1239
+ // liC should be the same node, just moved to end
1240
+ expect(ul.children[2]).toBe(liC);
1241
+ expect(ul.children[0].textContent).toBe('A');
1242
+ expect(ul.children[1].textContent).toBe('B');
1243
+ expect(ul.children[2].textContent).toBe('C');
1244
+ });
1245
+ });
1246
+
1247
+ describe('morph — large scale stress tests', () => {
1248
+ it('handles 1000 unkeyed elements', () => {
1249
+ const genItems = n => Array.from({ length: n }, (_, i) => `<li>${i}</li>`).join('');
1250
+ const root = el(genItems(1000));
1251
+ morph(root, genItems(1000));
1252
+ expect(root.children.length).toBe(1000);
1253
+ });
1254
+
1255
+ it('handles 200 keyed elements shuffled randomly', () => {
1256
+ const keys = Array.from({ length: 200 }, (_, i) => i);
1257
+ const shuffled = [...keys].sort(() => Math.random() - 0.5);
1258
+ const makeHTML = arr => arr.map(k => `<div z-key="${k}">${k}</div>`).join('');
1259
+ const root = el(makeHTML(keys));
1260
+ morph(root, makeHTML(shuffled));
1261
+ expect(root.children.length).toBe(200);
1262
+ for (let i = 0; i < 200; i++) {
1263
+ expect(root.children[i].textContent).toBe(String(shuffled[i]));
1264
+ }
1265
+ });
1266
+
1267
+ it('handles rapid sequential morphs', () => {
1268
+ const root = el('<p>start</p>');
1269
+ for (let i = 0; i < 50; i++) {
1270
+ morph(root, `<p>step ${i}</p>`);
1271
+ }
1272
+ expect(root.innerHTML).toBe('<p>step 49</p>');
1273
+ });
1274
+ });
1275
+
1276
+ describe('morph — auto-key edge cases', () => {
1277
+ it('handles mixed auto-key types (id, data-id, data-key)', () => {
1278
+ const root = el(
1279
+ '<div id="x">X</div><div data-id="y">Y</div><div data-key="z">Z</div>'
1280
+ );
1281
+ morph(root,
1282
+ '<div data-key="z">Z2</div><div id="x">X2</div><div data-id="y">Y2</div>'
1283
+ );
1284
+ expect(root.children[0].getAttribute('data-key')).toBe('z');
1285
+ expect(root.children[1].id).toBe('x');
1286
+ expect(root.children[2].getAttribute('data-id')).toBe('y');
1287
+ });
1288
+
1289
+ it('prefers z-key over id', () => {
1290
+ const root = el('<div z-key="a" id="b">old</div>');
1291
+ morph(root, '<div z-key="a" id="b">new</div>');
1292
+ const child = root.firstElementChild;
1293
+ expect(child.getAttribute('z-key')).toBe('a');
1294
+ expect(child.textContent).toBe('new');
1295
+ });
1296
+ });
1297
+
1298
+ describe('morph — whitespace and boundary edge cases', () => {
1299
+ it('handles only whitespace in new HTML', () => {
1300
+ const root = el('<p>content</p>');
1301
+ morph(root, ' \n\t ');
1302
+ // Whitespace-only text nodes
1303
+ expect(root.children.length).toBe(0);
1304
+ });
1305
+
1306
+ it('handles HTML with leading/trailing whitespace text nodes', () => {
1307
+ const root = el('<p>a</p>');
1308
+ morph(root, ' <p>b</p> ');
1309
+ expect(root.querySelector('p').textContent).toBe('b');
1310
+ });
1311
+
1312
+ it('handles special characters in text content', () => {
1313
+ const root = el('<p>old</p>');
1314
+ morph(root, '<p>&amp; &lt; &gt; &quot;</p>');
1315
+ expect(root.querySelector('p').textContent).toBe('& < > "');
1316
+ });
1317
+ });
1318
+
1319
+ describe('morph — z-skip edge cases', () => {
1320
+ it('preserves z-skip subtree even when parent changes', () => {
1321
+ const root = el('<div class="outer"><div z-skip>KEEP ME</div><p>old</p></div>');
1322
+ const skipDiv = root.querySelector('[z-skip]');
1323
+ morph(root, '<div class="outer"><div z-skip>CHANGED</div><p>new</p></div>');
1324
+ // z-skip child preserved
1325
+ expect(root.querySelector('[z-skip]')).toBe(skipDiv);
1326
+ expect(skipDiv.textContent).toBe('KEEP ME');
1327
+ expect(root.querySelector('p').textContent).toBe('new');
1328
+ });
1329
+
1330
+ it('z-skip on root element of subtree blocks all descendant changes', () => {
1331
+ const root = el('<div z-skip><span>a</span><span>b</span><span>c</span></div>');
1332
+ const children = [...root.firstElementChild.children];
1333
+ morph(root, '<div z-skip><span>x</span><span>y</span></div>');
1334
+ // All children preserved because z-skip blocks everything
1335
+ expect(root.firstElementChild.children[0]).toBe(children[0]);
1336
+ expect(root.firstElementChild.children[0].textContent).toBe('a');
1337
+ });
1338
+ });
1339
+
1340
+ describe('morph — morphElement edge cases', () => {
1341
+ it('handles morphElement with complex nested content', () => {
1342
+ const node = document.createElement('div');
1343
+ node.innerHTML = '<ul><li>a</li><li>b</li></ul>';
1344
+ document.body.appendChild(node);
1345
+ const result = morphElement(node, '<div><ul><li>x</li><li>y</li><li>z</li></ul></div>');
1346
+ expect(result).toBe(node); // same tag, identity preserved
1347
+ expect(result.querySelectorAll('li').length).toBe(3);
1348
+ expect(result.querySelectorAll('li')[2].textContent).toBe('z');
1349
+ });
1350
+
1351
+ it('handles morphElement from div to section (different tag) with nested content', () => {
1352
+ const node = document.createElement('div');
1353
+ node.innerHTML = '<p>content</p>';
1354
+ document.body.appendChild(node);
1355
+ const result = morphElement(node, '<section><p>new content</p></section>');
1356
+ expect(result.nodeName).toBe('SECTION');
1357
+ expect(result.querySelector('p').textContent).toBe('new content');
1358
+ });
1359
+
1360
+ it('returns original element when new HTML string is empty', () => {
1361
+ const node = document.createElement('div');
1362
+ node.textContent = 'content';
1363
+ document.body.appendChild(node);
1364
+ const result = morphElement(node, '');
1365
+ expect(result).toBe(node);
1366
+ expect(result.textContent).toBe('content');
1367
+ });
1368
+
1369
+ it('returns original element when new HTML produces only text (no element)', () => {
1370
+ const node = document.createElement('div');
1371
+ node.textContent = 'old';
1372
+ document.body.appendChild(node);
1373
+ const result = morphElement(node, 'just text no element');
1374
+ expect(result).toBe(node); // no firstElementChild → returns old
1375
+ });
1376
+ });
1377
+
1378
+ describe('morph — keyed nodes with tag changes during reorder', () => {
1379
+ it('handles keyed nodes where matched node has different tag (replaceChild path)', () => {
1380
+ // This tests the cursor stability fix: capture nextSibling before _morphNode
1381
+ const root = el(
1382
+ '<div z-key="a">A</div><span z-key="b">B</span><div z-key="c">C</div>'
1383
+ );
1384
+ morph(root,
1385
+ '<span z-key="c">C2</span><div z-key="a">A2</div><div z-key="b">B2</div>'
1386
+ );
1387
+ expect(root.children[0].nodeName).toBe('SPAN');
1388
+ expect(root.children[0].textContent).toBe('C2');
1389
+ expect(root.children[1].textContent).toBe('A2');
1390
+ expect(root.children[2].textContent).toBe('B2');
1391
+ });
1392
+ });
1393
+
1394
+ describe('morph — identity preservation across morphs', () => {
1395
+ it('preserves element identity through multiple consecutive morphs', () => {
1396
+ const root = el('<div id="target"><p>v1</p></div>');
1397
+ const target = root.firstElementChild;
1398
+ morph(root, '<div id="target"><p>v2</p></div>');
1399
+ expect(root.firstElementChild).toBe(target);
1400
+ morph(root, '<div id="target"><p>v3</p></div>');
1401
+ expect(root.firstElementChild).toBe(target);
1402
+ morph(root, '<div id="target"><p>v4</p><span>extra</span></div>');
1403
+ expect(root.firstElementChild).toBe(target);
1404
+ expect(target.children.length).toBe(2);
1405
+ });
1406
+
1407
+ it('preserves input focus state across morph', () => {
1408
+ const root = el('<input type="text" value="hello"><p>text</p>');
1409
+ document.body.appendChild(root);
1410
+ const input = root.querySelector('input');
1411
+ input.focus();
1412
+ morph(root, '<input type="text" value="hello"><p>updated</p>');
1413
+ // Input should still be the same element
1414
+ expect(root.querySelector('input')).toBe(input);
1415
+ });
1416
+ });