zero-query 0.7.5 → 0.8.6

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.
Files changed (64) hide show
  1. package/README.md +37 -27
  2. package/cli/commands/build.js +110 -1
  3. package/cli/commands/bundle.js +107 -22
  4. package/cli/commands/create.js +1 -1
  5. package/cli/commands/dev/devtools/index.js +56 -0
  6. package/cli/commands/dev/devtools/js/components.js +49 -0
  7. package/cli/commands/dev/devtools/js/core.js +409 -0
  8. package/cli/commands/dev/devtools/js/elements.js +413 -0
  9. package/cli/commands/dev/devtools/js/network.js +166 -0
  10. package/cli/commands/dev/devtools/js/performance.js +73 -0
  11. package/cli/commands/dev/devtools/js/router.js +105 -0
  12. package/cli/commands/dev/devtools/js/source.js +132 -0
  13. package/cli/commands/dev/devtools/js/stats.js +35 -0
  14. package/cli/commands/dev/devtools/js/tabs.js +79 -0
  15. package/cli/commands/dev/devtools/panel.html +95 -0
  16. package/cli/commands/dev/devtools/styles.css +244 -0
  17. package/cli/commands/dev/index.js +28 -3
  18. package/cli/commands/dev/logger.js +6 -1
  19. package/cli/commands/dev/overlay.js +377 -0
  20. package/cli/commands/dev/server.js +8 -0
  21. package/cli/commands/dev/watcher.js +26 -1
  22. package/cli/help.js +8 -5
  23. package/cli/scaffold/{scripts → app}/app.js +1 -1
  24. package/cli/scaffold/{scripts → app}/components/about.js +4 -4
  25. package/cli/scaffold/{scripts → app}/components/api-demo.js +1 -1
  26. package/cli/scaffold/app/components/home.js +137 -0
  27. package/cli/scaffold/{scripts → app}/routes.js +1 -1
  28. package/cli/scaffold/{scripts → app}/store.js +6 -6
  29. package/cli/scaffold/assets/.gitkeep +0 -0
  30. package/cli/scaffold/{styles/styles.css → global.css} +3 -2
  31. package/cli/scaffold/index.html +11 -11
  32. package/dist/zquery.dist.zip +0 -0
  33. package/dist/zquery.js +746 -134
  34. package/dist/zquery.min.js +2 -2
  35. package/index.d.ts +11 -9
  36. package/index.js +15 -10
  37. package/package.json +3 -2
  38. package/src/component.js +161 -48
  39. package/src/core.js +57 -11
  40. package/src/diff.js +256 -58
  41. package/src/expression.js +33 -3
  42. package/src/reactive.js +37 -5
  43. package/src/router.js +195 -6
  44. package/tests/component.test.js +582 -0
  45. package/tests/core.test.js +251 -0
  46. package/tests/diff.test.js +333 -2
  47. package/tests/expression.test.js +148 -0
  48. package/tests/http.test.js +108 -0
  49. package/tests/reactive.test.js +148 -0
  50. package/tests/router.test.js +317 -0
  51. package/tests/store.test.js +126 -0
  52. package/tests/utils.test.js +161 -2
  53. package/types/collection.d.ts +17 -2
  54. package/types/component.d.ts +7 -0
  55. package/types/misc.d.ts +13 -0
  56. package/types/router.d.ts +30 -1
  57. package/cli/commands/dev.old.js +0 -520
  58. package/cli/scaffold/scripts/components/home.js +0 -137
  59. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.css +0 -0
  60. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +0 -0
  61. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.js +0 -0
  62. /package/cli/scaffold/{scripts → app}/components/counter.js +0 -0
  63. /package/cli/scaffold/{scripts → app}/components/not-found.js +0 -0
  64. /package/cli/scaffold/{scripts → app}/components/todos.js +0 -0
@@ -356,11 +356,58 @@ describe('ZQueryCollection', () => {
356
356
  expect(col.html()).toBe('<b>bold</b>');
357
357
  });
358
358
 
359
+ it('html() auto-morphs when element has existing children', () => {
360
+ const main = document.querySelector('#main');
361
+ main.innerHTML = '<p id="preserved">old text</p>';
362
+ const ref = main.children[0]; // grab DOM reference
363
+ const col = queryAll('#main');
364
+ col.html('<p id="preserved">new text</p>');
365
+ // Same DOM node preserved — morph, not innerHTML replace
366
+ expect(main.children[0]).toBe(ref);
367
+ expect(main.children[0].textContent).toBe('new text');
368
+ });
369
+
370
+ it('html() uses innerHTML for empty elements (fast first-paint)', () => {
371
+ const main = document.querySelector('#main');
372
+ main.innerHTML = ''; // make it empty
373
+ const col = queryAll('#main');
374
+ col.html('<p id="fresh">hello</p>');
375
+ expect(main.innerHTML).toBe('<p id="fresh">hello</p>');
376
+ });
377
+
378
+ it('empty().html() forces raw innerHTML (opt-out of morph)', () => {
379
+ const main = document.querySelector('#main');
380
+ main.innerHTML = '<p id="old">will be destroyed</p>';
381
+ const ref = main.children[0];
382
+ const col = queryAll('#main');
383
+ col.empty().html('<p id="old">replaced</p>');
384
+ // NOT the same node — empty() cleared children, so html() used innerHTML
385
+ expect(main.children[0]).not.toBe(ref);
386
+ expect(main.children[0].textContent).toBe('replaced');
387
+ });
388
+
359
389
  it('text get/set', () => {
360
390
  const col = queryAll('.text').eq(0);
361
391
  col.text('Changed');
362
392
  expect(col.text()).toBe('Changed');
363
393
  });
394
+
395
+ it('morph() diffs content instead of replacing', () => {
396
+ const main = document.querySelector('#main');
397
+ main.innerHTML = '<p id="keep">old</p>';
398
+ const ref = main.children[0];
399
+ const col = queryAll('#main');
400
+ col.morph('<p id="keep">new</p>');
401
+ // Same DOM node preserved (morph, not innerHTML)
402
+ expect(main.children[0]).toBe(ref);
403
+ expect(main.children[0].textContent).toBe('new');
404
+ });
405
+
406
+ it('morph() is chainable', () => {
407
+ const col = queryAll('#main');
408
+ const ret = col.morph('<p>m</p>');
409
+ expect(ret).toBe(col);
410
+ });
364
411
  });
365
412
 
366
413
 
@@ -700,6 +747,210 @@ describe('CSS dimension methods', () => {
700
747
  });
701
748
 
702
749
 
750
+ // ---------------------------------------------------------------------------
751
+ // prop() method
752
+ // ---------------------------------------------------------------------------
753
+
754
+ describe('ZQueryCollection — prop()', () => {
755
+ it('gets a DOM property', () => {
756
+ document.body.innerHTML = '<input type="checkbox" checked>';
757
+ const col = queryAll('input');
758
+ expect(col.prop('checked')).toBe(true);
759
+ });
760
+
761
+ it('sets a DOM property', () => {
762
+ document.body.innerHTML = '<input type="checkbox">';
763
+ const col = queryAll('input');
764
+ col.prop('checked', true);
765
+ expect(col[0].checked).toBe(true);
766
+ });
767
+
768
+ it('sets multiple properties via sequential calls', () => {
769
+ document.body.innerHTML = '<input type="text">';
770
+ const col = queryAll('input');
771
+ col.prop('disabled', true);
772
+ col.prop('value', 'hello');
773
+ expect(col[0].disabled).toBe(true);
774
+ expect(col[0].value).toBe('hello');
775
+ });
776
+ });
777
+
778
+
779
+ // ---------------------------------------------------------------------------
780
+ // css() method
781
+ // ---------------------------------------------------------------------------
782
+
783
+ describe('ZQueryCollection — css()', () => {
784
+ beforeEach(() => {
785
+ document.body.innerHTML = '<div id="styled">test</div>';
786
+ });
787
+
788
+ it('sets style properties from object', () => {
789
+ queryAll('#styled').css({ color: 'red', 'font-size': '16px' });
790
+ const el = document.getElementById('styled');
791
+ expect(el.style.color).toBe('red');
792
+ });
793
+
794
+ it('returns collection for chaining', () => {
795
+ const col = queryAll('#styled').css({ color: 'blue' });
796
+ expect(col).toBeInstanceOf(ZQueryCollection);
797
+ });
798
+ });
799
+
800
+
801
+ // ---------------------------------------------------------------------------
802
+ // val() method
803
+ // ---------------------------------------------------------------------------
804
+
805
+ describe('ZQueryCollection — val()', () => {
806
+ it('gets input value', () => {
807
+ document.body.innerHTML = '<input value="test">';
808
+ expect(queryAll('input').val()).toBe('test');
809
+ });
810
+
811
+ it('sets input value', () => {
812
+ document.body.innerHTML = '<input value="">';
813
+ queryAll('input').val('new value');
814
+ expect(document.querySelector('input').value).toBe('new value');
815
+ });
816
+
817
+ it('gets select value', () => {
818
+ document.body.innerHTML = '<select><option value="a" selected>A</option><option value="b">B</option></select>';
819
+ expect(queryAll('select').val()).toBe('a');
820
+ });
821
+
822
+ it('gets textarea value', () => {
823
+ document.body.innerHTML = '<textarea>hello</textarea>';
824
+ expect(queryAll('textarea').val()).toBe('hello');
825
+ });
826
+ });
827
+
828
+
829
+ // ---------------------------------------------------------------------------
830
+ // after(), before() methods
831
+ // ---------------------------------------------------------------------------
832
+
833
+ describe('ZQueryCollection — after() / before()', () => {
834
+ beforeEach(() => {
835
+ document.body.innerHTML = '<div id="container"><p id="target">target</p></div>';
836
+ });
837
+
838
+ it('after() inserts content after element', () => {
839
+ queryAll('#target').after('<span class="after">after</span>');
840
+ const next = document.getElementById('target').nextElementSibling;
841
+ expect(next.className).toBe('after');
842
+ });
843
+
844
+ it('before() inserts content before element', () => {
845
+ queryAll('#target').before('<span class="before">before</span>');
846
+ const prev = document.getElementById('target').previousElementSibling;
847
+ expect(prev.className).toBe('before');
848
+ });
849
+ });
850
+
851
+
852
+ // ---------------------------------------------------------------------------
853
+ // wrap() method
854
+ // ---------------------------------------------------------------------------
855
+
856
+ describe('ZQueryCollection — wrap()', () => {
857
+ it('wraps element in new parent', () => {
858
+ document.body.innerHTML = '<div id="container"><p id="target">text</p></div>';
859
+ queryAll('#target').wrap('<div class="wrapper"></div>');
860
+ const wrapper = document.querySelector('.wrapper');
861
+ expect(wrapper).not.toBeNull();
862
+ expect(wrapper.querySelector('#target')).not.toBeNull();
863
+ });
864
+ });
865
+
866
+
867
+ // ---------------------------------------------------------------------------
868
+ // replaceWith() method
869
+ // ---------------------------------------------------------------------------
870
+
871
+ describe('ZQueryCollection — replaceWith()', () => {
872
+ it('replaces element with new content', () => {
873
+ document.body.innerHTML = '<div id="container"><p id="old">old</p></div>';
874
+ queryAll('#old').replaceWith('<span id="new">new</span>');
875
+ expect(document.querySelector('#old')).toBeNull();
876
+ expect(document.querySelector('#new')).not.toBeNull();
877
+ });
878
+
879
+ it('auto-morphs when tag name matches (preserves identity)', () => {
880
+ document.body.innerHTML = '<div id="container"><p id="target" class="old">old text</p></div>';
881
+ const target = document.querySelector('#target');
882
+ queryAll('#target').replaceWith('<p id="target" class="new">new text</p>');
883
+ // Same DOM node — morphed, not replaced
884
+ expect(document.querySelector('#target')).toBe(target);
885
+ expect(target.className).toBe('new');
886
+ expect(target.textContent).toBe('new text');
887
+ });
888
+
889
+ it('replaces when tag name differs', () => {
890
+ document.body.innerHTML = '<div id="container"><p id="old">old</p></div>';
891
+ const oldRef = document.querySelector('#old');
892
+ queryAll('#old').replaceWith('<section id="replaced">new</section>');
893
+ expect(document.querySelector('#replaced')).not.toBe(oldRef);
894
+ expect(document.querySelector('#replaced').tagName).toBe('SECTION');
895
+ });
896
+ });
897
+
898
+
899
+ // ---------------------------------------------------------------------------
900
+ // offset() and position()
901
+ // ---------------------------------------------------------------------------
902
+
903
+ describe('ZQueryCollection — offset() / position()', () => {
904
+ it('offset() returns object with top and left', () => {
905
+ document.body.innerHTML = '<div id="box">box</div>';
906
+ const off = queryAll('#box').offset();
907
+ expect(off).toHaveProperty('top');
908
+ expect(off).toHaveProperty('left');
909
+ expect(typeof off.top).toBe('number');
910
+ });
911
+
912
+ it('position() returns object with top and left', () => {
913
+ document.body.innerHTML = '<div id="box">box</div>';
914
+ const pos = queryAll('#box').position();
915
+ expect(pos).toHaveProperty('top');
916
+ expect(pos).toHaveProperty('left');
917
+ });
918
+ });
919
+
920
+
921
+ // ---------------------------------------------------------------------------
922
+ // width() and height()
923
+ // ---------------------------------------------------------------------------
924
+
925
+ describe('ZQueryCollection — width() / height()', () => {
926
+ it('width() returns a number', () => {
927
+ document.body.innerHTML = '<div id="box" style="width:100px">box</div>';
928
+ const val = queryAll('#box').width();
929
+ expect(typeof val).toBe('number');
930
+ });
931
+
932
+ it('height() returns a number', () => {
933
+ document.body.innerHTML = '<div id="box" style="height:50px">box</div>';
934
+ const val = queryAll('#box').height();
935
+ expect(typeof val).toBe('number');
936
+ });
937
+ });
938
+
939
+
940
+ // ---------------------------------------------------------------------------
941
+ // animate()
942
+ // ---------------------------------------------------------------------------
943
+
944
+ describe('ZQueryCollection — animate()', () => {
945
+ it('returns a promise', () => {
946
+ document.body.innerHTML = '<div id="box">box</div>';
947
+ const result = queryAll('#box').animate({ opacity: 0 }, 100);
948
+ // animate() returns a Promise
949
+ expect(result).toBeInstanceOf(Promise);
950
+ });
951
+ });
952
+
953
+
703
954
  // ---------------------------------------------------------------------------
704
955
  // hover() convenience
705
956
  // ---------------------------------------------------------------------------
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
- import { morph } from '../src/diff.js';
2
+ import { morph, morphElement } from '../src/diff.js';
3
3
 
4
4
 
5
5
  // ---------------------------------------------------------------------------
@@ -54,6 +54,32 @@ describe('morph — basic', () => {
54
54
  const root = morphAndGet('<p>hello</p>', '');
55
55
  expect(root.children.length).toBe(0);
56
56
  });
57
+
58
+ it('handles multiple appends at once', () => {
59
+ const root = morphAndGet('<p>a</p>', '<p>a</p><p>b</p><p>c</p><p>d</p>');
60
+ expect(root.children.length).toBe(4);
61
+ });
62
+
63
+ it('handles complete replacement of all children', () => {
64
+ const root = morphAndGet('<p>a</p><p>b</p>', '<span>x</span><span>y</span>');
65
+ expect(root.children[0].tagName).toBe('SPAN');
66
+ expect(root.children[1].tagName).toBe('SPAN');
67
+ });
68
+
69
+ it('handles single text node to multiple elements', () => {
70
+ const root = document.createElement('div');
71
+ root.textContent = 'hello';
72
+ morph(root, '<p>a</p><p>b</p>');
73
+ expect(root.children.length).toBe(2);
74
+ });
75
+
76
+ it('handles morphing identical content (no-op)', () => {
77
+ const root = el('<p>same</p>');
78
+ const child = root.children[0];
79
+ morph(root, '<p>same</p>');
80
+ expect(root.children[0]).toBe(child); // identity preserved
81
+ expect(root.innerHTML).toBe('<p>same</p>');
82
+ });
57
83
  });
58
84
 
59
85
 
@@ -76,6 +102,30 @@ describe('morph — attributes', () => {
76
102
  const root = morphAndGet('<div class="x" id="y"></div>', '<div class="x"></div>');
77
103
  expect(root.children[0].hasAttribute('id')).toBe(false);
78
104
  });
105
+
106
+ it('handles multiple attribute changes simultaneously', () => {
107
+ const root = morphAndGet(
108
+ '<div class="a" id="old" data-x="1"></div>',
109
+ '<div class="b" title="new" data-y="2"></div>'
110
+ );
111
+ const d = root.children[0];
112
+ expect(d.className).toBe('b');
113
+ expect(d.hasAttribute('id')).toBe(false);
114
+ expect(d.hasAttribute('data-x')).toBe(false);
115
+ expect(d.getAttribute('title')).toBe('new');
116
+ expect(d.getAttribute('data-y')).toBe('2');
117
+ });
118
+
119
+ it('preserves attributes that have not changed', () => {
120
+ const root = morphAndGet('<div class="keep" id="a"></div>', '<div class="keep" id="b"></div>');
121
+ expect(root.children[0].className).toBe('keep');
122
+ expect(root.children[0].id).toBe('b');
123
+ });
124
+
125
+ it('handles attribute with empty value', () => {
126
+ const root = morphAndGet('<div></div>', '<div hidden></div>');
127
+ expect(root.children[0].hasAttribute('hidden')).toBe(true);
128
+ });
79
129
  });
80
130
 
81
131
 
@@ -87,7 +137,6 @@ describe('morph — keyed', () => {
87
137
  it('matches elements by z-key', () => {
88
138
  const root = el('<div z-key="a">A</div><div z-key="b">B</div>');
89
139
  morph(root, '<div z-key="b">B-updated</div><div z-key="a">A-updated</div>');
90
- // Keys should match — b first, then a
91
140
  const kids = [...root.children];
92
141
  expect(kids[0].getAttribute('z-key')).toBe('b');
93
142
  expect(kids[0].textContent).toBe('B-updated');
@@ -109,6 +158,47 @@ describe('morph — keyed', () => {
109
158
  expect(root.children.length).toBe(2);
110
159
  expect(root.children[1].getAttribute('z-key')).toBe('b');
111
160
  });
161
+
162
+ it('preserves keyed node identity on reorder', () => {
163
+ const root = el('<div z-key="a">A</div><div z-key="b">B</div><div z-key="c">C</div>');
164
+ const nodeA = root.children[0];
165
+ const nodeB = root.children[1];
166
+ const nodeC = root.children[2];
167
+ morph(root, '<div z-key="c">C</div><div z-key="a">A</div><div z-key="b">B</div>');
168
+ // Same DOM nodes, just reordered
169
+ expect(root.children[0]).toBe(nodeC);
170
+ expect(root.children[1]).toBe(nodeA);
171
+ expect(root.children[2]).toBe(nodeB);
172
+ });
173
+
174
+ it('handles mixed keyed and unkeyed nodes', () => {
175
+ const root = el('<div z-key="a">A</div><p>unkeyed</p><div z-key="b">B</div>');
176
+ morph(root, '<div z-key="b">B2</div><span>new-unkeyed</span><div z-key="a">A2</div>');
177
+ expect(root.children[0].getAttribute('z-key')).toBe('b');
178
+ expect(root.children[0].textContent).toBe('B2');
179
+ expect(root.children[2].getAttribute('z-key')).toBe('a');
180
+ expect(root.children[2].textContent).toBe('A2');
181
+ });
182
+
183
+ it('handles complete key set swap', () => {
184
+ const root = el('<div z-key="a">A</div><div z-key="b">B</div>');
185
+ morph(root, '<div z-key="c">C</div><div z-key="d">D</div>');
186
+ expect(root.children.length).toBe(2);
187
+ expect(root.children[0].getAttribute('z-key')).toBe('c');
188
+ expect(root.children[1].getAttribute('z-key')).toBe('d');
189
+ });
190
+
191
+ it('handles reverse order (worst case for naive diff)', () => {
192
+ const root = el(
193
+ '<div z-key="1">1</div><div z-key="2">2</div><div z-key="3">3</div>' +
194
+ '<div z-key="4">4</div><div z-key="5">5</div>'
195
+ );
196
+ morph(root,
197
+ '<div z-key="5">5</div><div z-key="4">4</div><div z-key="3">3</div>' +
198
+ '<div z-key="2">2</div><div z-key="1">1</div>'
199
+ );
200
+ expect([...root.children].map(c => c.getAttribute('z-key'))).toEqual(['5','4','3','2','1']);
201
+ });
112
202
  });
113
203
 
114
204
 
@@ -135,6 +225,27 @@ describe('morph — form elements', () => {
135
225
  morph(root, '<textarea>new</textarea>');
136
226
  expect(root.querySelector('textarea').value).toBe('new');
137
227
  });
228
+
229
+ it('syncs input disabled state', () => {
230
+ const root = el('<input>');
231
+ morph(root, '<input disabled>');
232
+ expect(root.querySelector('input').disabled).toBe(true);
233
+ });
234
+
235
+ it('syncs radio button', () => {
236
+ const root = el('<input type="radio" value="a">');
237
+ root.querySelector('input').checked = false;
238
+ morph(root, '<input type="radio" value="a" checked>');
239
+ expect(root.querySelector('input').checked).toBe(true);
240
+ });
241
+
242
+ it('syncs select element value', () => {
243
+ const root = el('<select><option value="a">A</option><option value="b">B</option></select>');
244
+ root.querySelector('select').value = 'a';
245
+ morph(root, '<select><option value="a">A</option><option value="b" selected>B</option></select>');
246
+ // Note: jsdom may handle selected differently, but the morph should attempt to sync
247
+ expect(root.querySelector('select')).toBeTruthy();
248
+ });
138
249
  });
139
250
 
140
251
 
@@ -149,6 +260,22 @@ describe('morph — text nodes', () => {
149
260
  morph(root, 'new');
150
261
  expect(root.textContent).toBe('new');
151
262
  });
263
+
264
+ it('handles multiple text nodes', () => {
265
+ const root = document.createElement('div');
266
+ root.appendChild(document.createTextNode('a'));
267
+ root.appendChild(document.createTextNode('b'));
268
+ morph(root, 'xy');
269
+ expect(root.textContent).toBe('xy');
270
+ });
271
+
272
+ it('handles comment nodes', () => {
273
+ const root = document.createElement('div');
274
+ root.appendChild(document.createComment('old comment'));
275
+ morph(root, '<!-- new comment -->');
276
+ expect(root.childNodes[0].nodeType).toBe(8); // Comment node
277
+ expect(root.childNodes[0].nodeValue).toBe(' new comment ');
278
+ });
152
279
  });
153
280
 
154
281
 
@@ -176,6 +303,25 @@ describe('morph — nested', () => {
176
303
  expect(root.querySelector('p').textContent).toBe('new text');
177
304
  expect(root.querySelector('span')).toBeNull();
178
305
  });
306
+
307
+ it('handles deeply nested changes (3 levels)', () => {
308
+ const root = morphAndGet(
309
+ '<div><div><div><span>deep</span></div></div></div>',
310
+ '<div><div><div><span>updated</span></div></div></div>'
311
+ );
312
+ expect(root.querySelector('span').textContent).toBe('updated');
313
+ });
314
+
315
+ it('handles nested list reorder with keys', () => {
316
+ const root = morphAndGet(
317
+ '<ul><li z-key="a">A</li><li z-key="b">B</li><li z-key="c">C</li></ul>',
318
+ '<ul><li z-key="c">C</li><li z-key="a">A</li><li z-key="b">B</li></ul>'
319
+ );
320
+ const items = root.querySelectorAll('li');
321
+ expect(items[0].getAttribute('z-key')).toBe('c');
322
+ expect(items[1].getAttribute('z-key')).toBe('a');
323
+ expect(items[2].getAttribute('z-key')).toBe('b');
324
+ });
179
325
  });
180
326
 
181
327
 
@@ -191,4 +337,189 @@ describe('morph — preservation', () => {
191
337
  expect(root.children[0]).toBe(firstP); // same DOM node
192
338
  expect(root.children[1].textContent).toBe('changed');
193
339
  });
340
+
341
+ it('preserves identity of deeply nested unchanged nodes', () => {
342
+ const root = el('<div><ul><li>item</li></ul></div>');
343
+ const li = root.querySelector('li');
344
+ morph(root, '<div><ul><li>item</li></ul></div>');
345
+ expect(root.querySelector('li')).toBe(li);
346
+ });
347
+ });
348
+
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // z-skip directive
352
+ // ---------------------------------------------------------------------------
353
+
354
+ describe('morph — z-skip', () => {
355
+ it('skips subtrees with z-skip attribute', () => {
356
+ const root = el('<div z-skip><p>original</p></div>');
357
+ const p = root.querySelector('p');
358
+ morph(root, '<div z-skip><p>updated</p></div>');
359
+ // The inner content should NOT change because z-skip is set
360
+ expect(root.querySelector('p')).toBe(p);
361
+ expect(root.querySelector('p').textContent).toBe('original');
362
+ });
363
+
364
+ it('morphs sibling of z-skip normally', () => {
365
+ const root = el('<div z-skip><p>skip me</p></div><div><p>morph me</p></div>');
366
+ morph(root, '<div z-skip><p>different</p></div><div><p>morphed</p></div>');
367
+ expect(root.children[0].querySelector('p').textContent).toBe('skip me');
368
+ expect(root.children[1].querySelector('p').textContent).toBe('morphed');
369
+ });
370
+ });
371
+
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // isEqualNode fast bail-out
375
+ // ---------------------------------------------------------------------------
376
+
377
+ describe('morph — fast bail-out', () => {
378
+ it('uses isEqualNode to skip identical elements', () => {
379
+ const root = el('<div><p class="a" id="b">text</p></div>');
380
+ const p = root.querySelector('p');
381
+ morph(root, '<div><p class="a" id="b">text</p></div>');
382
+ // isEqualNode returns true, so the node should be kept
383
+ expect(root.querySelector('p')).toBe(p);
384
+ });
385
+ });
386
+
387
+
388
+ // ---------------------------------------------------------------------------
389
+ // Edge cases & stress
390
+ // ---------------------------------------------------------------------------
391
+
392
+ describe('morph — edge cases', () => {
393
+ it('handles whitespace-only text nodes', () => {
394
+ const root = morphAndGet('<div> </div>', '<div>text</div>');
395
+ expect(root.children[0].textContent).toBe('text');
396
+ });
397
+
398
+ it('handles node type change (text → element)', () => {
399
+ const root = document.createElement('div');
400
+ root.appendChild(document.createTextNode('text'));
401
+ morph(root, '<p>element</p>');
402
+ expect(root.children[0].tagName).toBe('P');
403
+ });
404
+
405
+ it('handles large number of children', () => {
406
+ const oldItems = Array.from({ length: 100 }, (_, i) => `<li>${i}</li>`).join('');
407
+ const newItems = Array.from({ length: 100 }, (_, i) => `<li>${i * 2}</li>`).join('');
408
+ const root = morphAndGet(`<ul>${oldItems}</ul>`, `<ul>${newItems}</ul>`);
409
+ expect(root.querySelectorAll('li').length).toBe(100);
410
+ expect(root.querySelectorAll('li')[50].textContent).toBe('100');
411
+ });
412
+
413
+ it('handles large keyed list shuffle', () => {
414
+ const ids = Array.from({ length: 50 }, (_, i) => i);
415
+ const shuffled = [...ids].sort(() => Math.random() - 0.5);
416
+ const oldHTML = ids.map(i => `<div z-key="${i}">${i}</div>`).join('');
417
+ const newHTML = shuffled.map(i => `<div z-key="${i}">${i}</div>`).join('');
418
+ const root = el(oldHTML);
419
+ morph(root, newHTML);
420
+ const result = [...root.children].map(c => c.getAttribute('z-key'));
421
+ expect(result).toEqual(shuffled.map(String));
422
+ });
423
+
424
+ it('handles empty string to empty string', () => {
425
+ const root = morphAndGet('', '');
426
+ expect(root.childNodes.length).toBe(0);
427
+ });
428
+ });
429
+
430
+
431
+ // ---------------------------------------------------------------------------
432
+ // Auto-keyed reconciliation (id / data-id / data-key)
433
+ // ---------------------------------------------------------------------------
434
+
435
+ describe('morph — auto-keyed via id attribute', () => {
436
+ it('reorders elements by id without z-key', () => {
437
+ const root = el(
438
+ '<div id="a">A</div><div id="b">B</div><div id="c">C</div>'
439
+ );
440
+ const refA = root.children[0];
441
+ const refB = root.children[1];
442
+ morph(root, '<div id="c">C</div><div id="a">A</div><div id="b">B</div>');
443
+ // Node identity preserved — same DOM nodes, different order
444
+ expect(root.children[1]).toBe(refA);
445
+ expect(root.children[2]).toBe(refB);
446
+ expect([...root.children].map(c => c.id)).toEqual(['c', 'a', 'b']);
447
+ });
448
+
449
+ it('reorders elements by data-id without z-key', () => {
450
+ const root = el(
451
+ '<li data-id="1">One</li><li data-id="2">Two</li><li data-id="3">Three</li>'
452
+ );
453
+ const ref1 = root.children[0];
454
+ morph(root, '<li data-id="3">Three</li><li data-id="1">One</li><li data-id="2">Two</li>');
455
+ expect(root.children[1]).toBe(ref1);
456
+ expect([...root.children].map(c => c.dataset.id)).toEqual(['3', '1', '2']);
457
+ });
458
+
459
+ it('reorders elements by data-key without z-key', () => {
460
+ const root = el(
461
+ '<span data-key="x">X</span><span data-key="y">Y</span>'
462
+ );
463
+ const refX = root.children[0];
464
+ morph(root, '<span data-key="y">Y</span><span data-key="x">X</span>');
465
+ expect(root.children[1]).toBe(refX);
466
+ });
467
+
468
+ it('removes auto-keyed elements not in new tree', () => {
469
+ const root = morphAndGet(
470
+ '<p id="a">A</p><p id="b">B</p><p id="c">C</p>',
471
+ '<p id="a">A</p><p id="c">C</p>'
472
+ );
473
+ expect(root.children.length).toBe(2);
474
+ expect([...root.children].map(c => c.id)).toEqual(['a', 'c']);
475
+ });
476
+
477
+ it('inserts new auto-keyed elements', () => {
478
+ const root = morphAndGet(
479
+ '<p id="a">A</p><p id="c">C</p>',
480
+ '<p id="a">A</p><p id="b">B</p><p id="c">C</p>'
481
+ );
482
+ expect(root.children.length).toBe(3);
483
+ expect([...root.children].map(c => c.id)).toEqual(['a', 'b', 'c']);
484
+ });
485
+ });
486
+
487
+
488
+ // ---------------------------------------------------------------------------
489
+ // morphElement — single-element morph
490
+ // ---------------------------------------------------------------------------
491
+
492
+ describe('morphElement', () => {
493
+ it('morphs attributes and children, preserving identity', () => {
494
+ const root = el('<div class="old"><span>old</span></div>');
495
+ const target = root.children[0];
496
+ const result = morphElement(target, '<div class="new"><span>new</span></div>');
497
+ expect(result).toBe(target); // same node
498
+ expect(target.className).toBe('new');
499
+ expect(target.children[0].textContent).toBe('new');
500
+ });
501
+
502
+ it('replaces element when tag name differs', () => {
503
+ const root = el('<div>old</div>');
504
+ const target = root.children[0];
505
+ const result = morphElement(target, '<section>new</section>');
506
+ expect(result).not.toBe(target);
507
+ expect(result.tagName).toBe('SECTION');
508
+ expect(result.textContent).toBe('new');
509
+ expect(root.children[0]).toBe(result);
510
+ });
511
+
512
+ it('returns original element when nothing changes', () => {
513
+ const root = el('<p class="x">same</p>');
514
+ const target = root.children[0];
515
+ const result = morphElement(target, '<p class="x">same</p>');
516
+ expect(result).toBe(target);
517
+ });
518
+
519
+ it('handles empty new HTML gracefully', () => {
520
+ const root = el('<div>content</div>');
521
+ const target = root.children[0];
522
+ const result = morphElement(target, '');
523
+ expect(result).toBe(target); // no-op on empty
524
+ });
194
525
  });