zero-query 0.6.3 → 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 (72) hide show
  1. package/README.md +39 -29
  2. package/cli/commands/build.js +113 -4
  3. package/cli/commands/bundle.js +392 -29
  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 +29 -4
  18. package/cli/commands/dev/logger.js +6 -1
  19. package/cli/commands/dev/overlay.js +428 -2
  20. package/cli/commands/dev/server.js +42 -5
  21. package/cli/commands/dev/watcher.js +59 -1
  22. package/cli/help.js +8 -5
  23. package/cli/scaffold/{scripts → app}/app.js +16 -23
  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/{scripts → app}/components/contacts/contacts.css +0 -7
  27. package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +3 -3
  28. package/cli/scaffold/app/components/home.js +137 -0
  29. package/cli/scaffold/{scripts → app}/routes.js +1 -1
  30. package/cli/scaffold/{scripts → app}/store.js +6 -6
  31. package/cli/scaffold/assets/.gitkeep +0 -0
  32. package/cli/scaffold/{styles/styles.css → global.css} +4 -2
  33. package/cli/scaffold/index.html +12 -11
  34. package/cli/utils.js +111 -6
  35. package/dist/zquery.dist.zip +0 -0
  36. package/dist/zquery.js +1122 -158
  37. package/dist/zquery.min.js +3 -16
  38. package/index.d.ts +129 -1290
  39. package/index.js +15 -10
  40. package/package.json +7 -6
  41. package/src/component.js +172 -49
  42. package/src/core.js +359 -18
  43. package/src/diff.js +256 -58
  44. package/src/expression.js +33 -3
  45. package/src/reactive.js +37 -5
  46. package/src/router.js +243 -7
  47. package/tests/component.test.js +886 -0
  48. package/tests/core.test.js +977 -0
  49. package/tests/diff.test.js +525 -0
  50. package/tests/errors.test.js +162 -0
  51. package/tests/expression.test.js +482 -0
  52. package/tests/http.test.js +289 -0
  53. package/tests/reactive.test.js +339 -0
  54. package/tests/router.test.js +649 -0
  55. package/tests/store.test.js +379 -0
  56. package/tests/utils.test.js +512 -0
  57. package/types/collection.d.ts +383 -0
  58. package/types/component.d.ts +217 -0
  59. package/types/errors.d.ts +103 -0
  60. package/types/http.d.ts +81 -0
  61. package/types/misc.d.ts +179 -0
  62. package/types/reactive.d.ts +76 -0
  63. package/types/router.d.ts +161 -0
  64. package/types/ssr.d.ts +49 -0
  65. package/types/store.d.ts +107 -0
  66. package/types/utils.d.ts +142 -0
  67. package/cli/commands/dev.old.js +0 -520
  68. package/cli/scaffold/scripts/components/home.js +0 -137
  69. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.js +0 -0
  70. /package/cli/scaffold/{scripts → app}/components/counter.js +0 -0
  71. /package/cli/scaffold/{scripts → app}/components/not-found.js +0 -0
  72. /package/cli/scaffold/{scripts → app}/components/todos.js +0 -0
package/dist/zquery.js CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
- * zQuery (zeroQuery) v0.6.3
2
+ * zQuery (zeroQuery) v0.8.6
3
3
  * Lightweight Frontend Library
4
4
  * https://github.com/tonywied17/zero-query
5
- * (c) 2026 Anthony Wiedman MIT License
5
+ * (c) 2026 Anthony Wiedman - MIT License
6
6
  */
7
7
  (function(global) {
8
8
  'use strict';
9
9
 
10
- // --- src/errors.js ———————————————————————————————————————————————
10
+ // --- src/errors.js -----------------------------------------------
11
11
  /**
12
12
  * zQuery Errors — Structured error handling system
13
13
  *
@@ -164,7 +164,7 @@ function validate(value, name, expectedType) {
164
164
  }
165
165
  }
166
166
 
167
- // --- src/reactive.js —————————————————————————————————————————————
167
+ // --- src/reactive.js ---------------------------------------------
168
168
  /**
169
169
  * zQuery Reactive — Proxy-based deep reactivity system
170
170
  *
@@ -205,6 +205,8 @@ function reactive(target, onChange, _path = '') {
205
205
  const old = obj[key];
206
206
  if (old === value) return true;
207
207
  obj[key] = value;
208
+ // Invalidate proxy cache for the old value (it may have been replaced)
209
+ if (old && typeof old === 'object') proxyCache.delete(old);
208
210
  try {
209
211
  onChange(key, value, old);
210
212
  } catch (err) {
@@ -216,6 +218,7 @@ function reactive(target, onChange, _path = '') {
216
218
  deleteProperty(obj, key) {
217
219
  const old = obj[key];
218
220
  delete obj[key];
221
+ if (old && typeof old === 'object') proxyCache.delete(old);
219
222
  try {
220
223
  onChange(key, undefined, old);
221
224
  } catch (err) {
@@ -242,6 +245,10 @@ class Signal {
242
245
  // Track dependency if there's an active effect
243
246
  if (Signal._activeEffect) {
244
247
  this._subscribers.add(Signal._activeEffect);
248
+ // Record this signal in the effect's dependency set for proper cleanup
249
+ if (Signal._activeEffect._deps) {
250
+ Signal._activeEffect._deps.add(this);
251
+ }
245
252
  }
246
253
  return this._value;
247
254
  }
@@ -255,12 +262,15 @@ class Signal {
255
262
  peek() { return this._value; }
256
263
 
257
264
  _notify() {
258
- this._subscribers.forEach(fn => {
259
- try { fn(); }
265
+ // Snapshot subscribers before iterating — a subscriber might modify
266
+ // the set (e.g., an effect re-running, adding itself back)
267
+ const subs = [...this._subscribers];
268
+ for (let i = 0; i < subs.length; i++) {
269
+ try { subs[i](); }
260
270
  catch (err) {
261
271
  reportError(ErrorCode.SIGNAL_CALLBACK, 'Signal subscriber threw', { signal: this }, err);
262
272
  }
263
- });
273
+ }
264
274
  }
265
275
 
266
276
  subscribe(fn) {
@@ -295,12 +305,24 @@ function computed(fn) {
295
305
  }
296
306
 
297
307
  /**
298
- * Create a side-effect that auto-tracks signal dependencies
308
+ * Create a side-effect that auto-tracks signal dependencies.
309
+ * Returns a dispose function that removes the effect from all
310
+ * signals it subscribed to — prevents memory leaks.
311
+ *
299
312
  * @param {Function} fn — effect function
300
313
  * @returns {Function} — dispose function
301
314
  */
302
315
  function effect(fn) {
303
316
  const execute = () => {
317
+ // Clean up old subscriptions before re-running so stale
318
+ // dependencies from a previous run are properly removed
319
+ if (execute._deps) {
320
+ for (const sig of execute._deps) {
321
+ sig._subscribers.delete(execute);
322
+ }
323
+ execute._deps.clear();
324
+ }
325
+
304
326
  Signal._activeEffect = execute;
305
327
  try { fn(); }
306
328
  catch (err) {
@@ -308,14 +330,24 @@ function effect(fn) {
308
330
  }
309
331
  finally { Signal._activeEffect = null; }
310
332
  };
333
+
334
+ // Track which signals this effect reads from
335
+ execute._deps = new Set();
336
+
311
337
  execute();
312
338
  return () => {
313
- // Remove this effect from all signals that track it
339
+ // Dispose: remove this effect from every signal it subscribed to
340
+ if (execute._deps) {
341
+ for (const sig of execute._deps) {
342
+ sig._subscribers.delete(execute);
343
+ }
344
+ execute._deps.clear();
345
+ }
314
346
  Signal._activeEffect = null;
315
347
  };
316
348
  }
317
349
 
318
- // --- src/core.js —————————————————————————————————————————————————
350
+ // --- src/core.js -------------------------------------------------
319
351
  /**
320
352
  * zQuery Core — Selector engine & chainable DOM collection
321
353
  *
@@ -323,6 +355,7 @@ function effect(fn) {
323
355
  * into a full jQuery-like chainable wrapper with modern APIs.
324
356
  */
325
357
 
358
+
326
359
  // ---------------------------------------------------------------------------
327
360
  // ZQueryCollection — wraps an array of elements with chainable methods
328
361
  // ---------------------------------------------------------------------------
@@ -393,8 +426,96 @@ class ZQueryCollection {
393
426
  return new ZQueryCollection(sibs);
394
427
  }
395
428
 
396
- next() { return new ZQueryCollection(this.elements.map(el => el.nextElementSibling).filter(Boolean)); }
397
- prev() { return new ZQueryCollection(this.elements.map(el => el.previousElementSibling).filter(Boolean)); }
429
+ next(selector) {
430
+ const els = this.elements.map(el => el.nextElementSibling).filter(Boolean);
431
+ return new ZQueryCollection(selector ? els.filter(el => el.matches(selector)) : els);
432
+ }
433
+
434
+ prev(selector) {
435
+ const els = this.elements.map(el => el.previousElementSibling).filter(Boolean);
436
+ return new ZQueryCollection(selector ? els.filter(el => el.matches(selector)) : els);
437
+ }
438
+
439
+ nextAll(selector) {
440
+ const result = [];
441
+ this.elements.forEach(el => {
442
+ let sib = el.nextElementSibling;
443
+ while (sib) {
444
+ if (!selector || sib.matches(selector)) result.push(sib);
445
+ sib = sib.nextElementSibling;
446
+ }
447
+ });
448
+ return new ZQueryCollection(result);
449
+ }
450
+
451
+ nextUntil(selector, filter) {
452
+ const result = [];
453
+ this.elements.forEach(el => {
454
+ let sib = el.nextElementSibling;
455
+ while (sib) {
456
+ if (selector && sib.matches(selector)) break;
457
+ if (!filter || sib.matches(filter)) result.push(sib);
458
+ sib = sib.nextElementSibling;
459
+ }
460
+ });
461
+ return new ZQueryCollection(result);
462
+ }
463
+
464
+ prevAll(selector) {
465
+ const result = [];
466
+ this.elements.forEach(el => {
467
+ let sib = el.previousElementSibling;
468
+ while (sib) {
469
+ if (!selector || sib.matches(selector)) result.push(sib);
470
+ sib = sib.previousElementSibling;
471
+ }
472
+ });
473
+ return new ZQueryCollection(result);
474
+ }
475
+
476
+ prevUntil(selector, filter) {
477
+ const result = [];
478
+ this.elements.forEach(el => {
479
+ let sib = el.previousElementSibling;
480
+ while (sib) {
481
+ if (selector && sib.matches(selector)) break;
482
+ if (!filter || sib.matches(filter)) result.push(sib);
483
+ sib = sib.previousElementSibling;
484
+ }
485
+ });
486
+ return new ZQueryCollection(result);
487
+ }
488
+
489
+ parents(selector) {
490
+ const result = [];
491
+ this.elements.forEach(el => {
492
+ let parent = el.parentElement;
493
+ while (parent) {
494
+ if (!selector || parent.matches(selector)) result.push(parent);
495
+ parent = parent.parentElement;
496
+ }
497
+ });
498
+ return new ZQueryCollection([...new Set(result)]);
499
+ }
500
+
501
+ parentsUntil(selector, filter) {
502
+ const result = [];
503
+ this.elements.forEach(el => {
504
+ let parent = el.parentElement;
505
+ while (parent) {
506
+ if (selector && parent.matches(selector)) break;
507
+ if (!filter || parent.matches(filter)) result.push(parent);
508
+ parent = parent.parentElement;
509
+ }
510
+ });
511
+ return new ZQueryCollection([...new Set(result)]);
512
+ }
513
+
514
+ contents() {
515
+ const result = [];
516
+ this.elements.forEach(el => result.push(...el.childNodes));
517
+ return new ZQueryCollection(result);
518
+ }
398
519
 
399
520
  filter(selector) {
400
521
  if (typeof selector === 'function') {
@@ -414,20 +535,85 @@ class ZQueryCollection {
414
535
  return new ZQueryCollection(this.elements.filter(el => el.querySelector(selector)));
415
536
  }
416
537
 
538
+ is(selector) {
539
+ if (typeof selector === 'function') {
540
+ return this.elements.some((el, i) => selector.call(el, i, el));
541
+ }
542
+ return this.elements.some(el => el.matches(selector));
543
+ }
544
+
545
+ slice(start, end) {
546
+ return new ZQueryCollection(this.elements.slice(start, end));
547
+ }
548
+
549
+ add(selector, context) {
550
+ const toAdd = (selector instanceof ZQueryCollection)
551
+ ? selector.elements
552
+ : (selector instanceof Node)
553
+ ? [selector]
554
+ : Array.from((context || document).querySelectorAll(selector));
555
+ return new ZQueryCollection([...this.elements, ...toAdd]);
556
+ }
557
+
558
+ get(index) {
559
+ if (index === undefined) return [...this.elements];
560
+ return index < 0 ? this.elements[this.length + index] : this.elements[index];
561
+ }
562
+
563
+ index(selector) {
564
+ if (selector === undefined) {
565
+ const el = this.first();
566
+ return el ? Array.from(el.parentElement.children).indexOf(el) : -1;
567
+ }
568
+ const target = (typeof selector === 'string')
569
+ ? document.querySelector(selector)
570
+ : selector;
571
+ return this.elements.indexOf(target);
572
+ }
573
+
417
574
  // --- Classes -------------------------------------------------------------
418
575
 
419
576
  addClass(...names) {
577
+ // Fast path: single class, no spaces — avoids flatMap + regex split allocation
578
+ if (names.length === 1 && names[0].indexOf(' ') === -1) {
579
+ const c = names[0];
580
+ for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.add(c);
581
+ return this;
582
+ }
420
583
  const classes = names.flatMap(n => n.split(/\s+/));
421
- return this.each((_, el) => el.classList.add(...classes));
584
+ for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.add(...classes);
585
+ return this;
422
586
  }
423
587
 
424
588
  removeClass(...names) {
589
+ if (names.length === 1 && names[0].indexOf(' ') === -1) {
590
+ const c = names[0];
591
+ for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.remove(c);
592
+ return this;
593
+ }
425
594
  const classes = names.flatMap(n => n.split(/\s+/));
426
- return this.each((_, el) => el.classList.remove(...classes));
595
+ for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.remove(...classes);
596
+ return this;
427
597
  }
428
598
 
429
- toggleClass(name, force) {
430
- return this.each((_, el) => el.classList.toggle(name, force));
599
+ toggleClass(...args) {
600
+ const force = typeof args[args.length - 1] === 'boolean' ? args.pop() : undefined;
601
+ // Fast path: single class, no spaces
602
+ if (args.length === 1 && args[0].indexOf(' ') === -1) {
603
+ const c = args[0];
604
+ for (let i = 0; i < this.elements.length; i++) {
605
+ force !== undefined ? this.elements[i].classList.toggle(c, force) : this.elements[i].classList.toggle(c);
606
+ }
607
+ return this;
608
+ }
609
+ const classes = args.flatMap(n => n.split(/\s+/));
610
+ for (let i = 0; i < this.elements.length; i++) {
611
+ const el = this.elements[i];
612
+ for (let j = 0; j < classes.length; j++) {
613
+ force !== undefined ? el.classList.toggle(classes[j], force) : el.classList.toggle(classes[j]);
614
+ }
615
+ }
616
+ return this;
431
617
  }
432
618
 
433
619
  hasClass(name) {
@@ -463,7 +649,8 @@ class ZQueryCollection {
463
649
 
464
650
  css(props) {
465
651
  if (typeof props === 'string') {
466
- return getComputedStyle(this.first())[props];
652
+ const el = this.first();
653
+ return el ? getComputedStyle(el)[props] : undefined;
467
654
  }
468
655
  return this.each((_, el) => Object.assign(el.style, props));
469
656
  }
@@ -481,11 +668,79 @@ class ZQueryCollection {
481
668
  return el ? { top: el.offsetTop, left: el.offsetLeft } : null;
482
669
  }
483
670
 
671
+ scrollTop(value) {
672
+ if (value === undefined) {
673
+ const el = this.first();
674
+ return el === window ? window.scrollY : el?.scrollTop;
675
+ }
676
+ return this.each((_, el) => {
677
+ if (el === window) window.scrollTo(window.scrollX, value);
678
+ else el.scrollTop = value;
679
+ });
680
+ }
681
+
682
+ scrollLeft(value) {
683
+ if (value === undefined) {
684
+ const el = this.first();
685
+ return el === window ? window.scrollX : el?.scrollLeft;
686
+ }
687
+ return this.each((_, el) => {
688
+ if (el === window) window.scrollTo(value, window.scrollY);
689
+ else el.scrollLeft = value;
690
+ });
691
+ }
692
+
693
+ innerWidth() {
694
+ const el = this.first();
695
+ return el?.clientWidth;
696
+ }
697
+
698
+ innerHeight() {
699
+ const el = this.first();
700
+ return el?.clientHeight;
701
+ }
702
+
703
+ outerWidth(includeMargin = false) {
704
+ const el = this.first();
705
+ if (!el) return undefined;
706
+ let w = el.offsetWidth;
707
+ if (includeMargin) {
708
+ const style = getComputedStyle(el);
709
+ w += parseFloat(style.marginLeft) + parseFloat(style.marginRight);
710
+ }
711
+ return w;
712
+ }
713
+
714
+ outerHeight(includeMargin = false) {
715
+ const el = this.first();
716
+ if (!el) return undefined;
717
+ let h = el.offsetHeight;
718
+ if (includeMargin) {
719
+ const style = getComputedStyle(el);
720
+ h += parseFloat(style.marginTop) + parseFloat(style.marginBottom);
721
+ }
722
+ return h;
723
+ }
724
+
484
725
  // --- Content -------------------------------------------------------------
485
726
 
486
727
  html(content) {
487
728
  if (content === undefined) return this.first()?.innerHTML;
488
- return this.each((_, el) => { el.innerHTML = content; });
729
+ // Auto-morph: if the element already has children, use the diff engine
730
+ // to patch the DOM (preserves focus, scroll, state, keyed reorder via LIS).
731
+ // Empty elements get raw innerHTML for fast first-paint — same strategy
732
+ // the component system uses (first render = innerHTML, updates = morph).
733
+ return this.each((_, el) => {
734
+ if (el.childNodes.length > 0) {
735
+ _morph(el, content);
736
+ } else {
737
+ el.innerHTML = content;
738
+ }
739
+ });
740
+ }
741
+
742
+ morph(content) {
743
+ return this.each((_, el) => { _morph(el, content); });
489
744
  }
490
745
 
491
746
  text(content) {
@@ -542,7 +797,8 @@ class ZQueryCollection {
542
797
  }
543
798
 
544
799
  empty() {
545
- return this.each((_, el) => { el.innerHTML = ''; });
800
+ // textContent = '' clears all children without invoking the HTML parser
801
+ return this.each((_, el) => { el.textContent = ''; });
546
802
  }
547
803
 
548
804
  clone(deep = true) {
@@ -552,14 +808,82 @@ class ZQueryCollection {
552
808
  replaceWith(content) {
553
809
  return this.each((_, el) => {
554
810
  if (typeof content === 'string') {
555
- el.insertAdjacentHTML('afterend', content);
556
- el.remove();
811
+ // Auto-morph: diff attributes + children when the tag name matches
812
+ // instead of destroying and re-creating the element.
813
+ _morphElement(el, content);
557
814
  } else if (content instanceof Node) {
558
815
  el.parentNode.replaceChild(content, el);
559
816
  }
560
817
  });
561
818
  }
562
819
 
820
+ appendTo(target) {
821
+ const dest = typeof target === 'string' ? document.querySelector(target) : target instanceof ZQueryCollection ? target.first() : target;
822
+ if (dest) this.each((_, el) => dest.appendChild(el));
823
+ return this;
824
+ }
825
+
826
+ prependTo(target) {
827
+ const dest = typeof target === 'string' ? document.querySelector(target) : target instanceof ZQueryCollection ? target.first() : target;
828
+ if (dest) this.each((_, el) => dest.insertBefore(el, dest.firstChild));
829
+ return this;
830
+ }
831
+
832
+ insertAfter(target) {
833
+ const ref = typeof target === 'string' ? document.querySelector(target) : target instanceof ZQueryCollection ? target.first() : target;
834
+ if (ref && ref.parentNode) this.each((_, el) => ref.parentNode.insertBefore(el, ref.nextSibling));
835
+ return this;
836
+ }
837
+
838
+ insertBefore(target) {
839
+ const ref = typeof target === 'string' ? document.querySelector(target) : target instanceof ZQueryCollection ? target.first() : target;
840
+ if (ref && ref.parentNode) this.each((_, el) => ref.parentNode.insertBefore(el, ref));
841
+ return this;
842
+ }
843
+
844
+ replaceAll(target) {
845
+ const targets = typeof target === 'string'
846
+ ? Array.from(document.querySelectorAll(target))
847
+ : target instanceof ZQueryCollection ? target.elements : [target];
848
+ targets.forEach((t, i) => {
849
+ const nodes = i === 0 ? this.elements : this.elements.map(el => el.cloneNode(true));
850
+ nodes.forEach(el => t.parentNode.insertBefore(el, t));
851
+ t.remove();
852
+ });
853
+ return this;
854
+ }
855
+
856
+ unwrap(selector) {
857
+ this.elements.forEach(el => {
858
+ const parent = el.parentElement;
859
+ if (!parent || parent === document.body) return;
860
+ if (selector && !parent.matches(selector)) return;
861
+ parent.replaceWith(...parent.childNodes);
862
+ });
863
+ return this;
864
+ }
865
+
866
+ wrapAll(wrapper) {
867
+ const w = typeof wrapper === 'string' ? createFragment(wrapper).firstElementChild : wrapper.cloneNode(true);
868
+ const first = this.first();
869
+ if (!first) return this;
870
+ first.parentNode.insertBefore(w, first);
871
+ this.each((_, el) => w.appendChild(el));
872
+ return this;
873
+ }
874
+
875
+ wrapInner(wrapper) {
876
+ return this.each((_, el) => {
877
+ const w = typeof wrapper === 'string' ? createFragment(wrapper).firstElementChild : wrapper.cloneNode(true);
878
+ while (el.firstChild) w.appendChild(el.firstChild);
879
+ el.appendChild(w);
880
+ });
881
+ }
882
+
883
+ detach() {
884
+ return this.each((_, el) => el.remove());
885
+ }
886
+
563
887
  // --- Visibility ----------------------------------------------------------
564
888
 
565
889
  show(display = '') {
@@ -572,7 +896,9 @@ class ZQueryCollection {
572
896
 
573
897
  toggle(display = '') {
574
898
  return this.each((_, el) => {
575
- el.style.display = (el.style.display === 'none' || getComputedStyle(el).display === 'none') ? display : 'none';
899
+ // Check inline style first (cheap) before forcing layout via getComputedStyle
900
+ const hidden = el.style.display === 'none' || (el.style.display !== '' ? false : getComputedStyle(el).display === 'none');
901
+ el.style.display = hidden ? display : 'none';
576
902
  });
577
903
  }
578
904
 
@@ -585,9 +911,10 @@ class ZQueryCollection {
585
911
  events.forEach(evt => {
586
912
  if (typeof selectorOrHandler === 'function') {
587
913
  el.addEventListener(evt, selectorOrHandler);
588
- } else {
589
- // Delegated event
914
+ } else if (typeof selectorOrHandler === 'string') {
915
+ // Delegated event — only works on elements that support closest()
590
916
  el.addEventListener(evt, (e) => {
917
+ if (!e.target || typeof e.target.closest !== 'function') return;
591
918
  const target = e.target.closest(selectorOrHandler);
592
919
  if (target && el.contains(target)) handler.call(target, e);
593
920
  });
@@ -620,6 +947,10 @@ class ZQueryCollection {
620
947
  submit(fn) { return fn ? this.on('submit', fn) : this.trigger('submit'); }
621
948
  focus() { this.first()?.focus(); return this; }
622
949
  blur() { this.first()?.blur(); return this; }
950
+ hover(enterFn, leaveFn) {
951
+ this.on('mouseenter', enterFn);
952
+ return this.on('mouseleave', leaveFn || enterFn);
953
+ }
623
954
 
624
955
  // --- Animation -----------------------------------------------------------
625
956
 
@@ -651,6 +982,40 @@ class ZQueryCollection {
651
982
  return this.animate({ opacity: '0' }, duration).then(col => col.hide());
652
983
  }
653
984
 
985
+ fadeToggle(duration = 300) {
986
+ return Promise.all(this.elements.map(el => {
987
+ const visible = getComputedStyle(el).opacity !== '0' && getComputedStyle(el).display !== 'none';
988
+ const col = new ZQueryCollection([el]);
989
+ return visible ? col.fadeOut(duration) : col.fadeIn(duration);
990
+ })).then(() => this);
991
+ }
992
+
993
+ fadeTo(duration, opacity) {
994
+ return this.animate({ opacity: String(opacity) }, duration);
995
+ }
996
+
997
+ slideDown(duration = 300) {
998
+ return this.each((_, el) => {
999
+ el.style.display = '';
1000
+ el.style.overflow = 'hidden';
1001
+ const h = el.scrollHeight + 'px';
1002
+ el.style.maxHeight = '0';
1003
+ el.style.transition = `max-height ${duration}ms ease`;
1004
+ requestAnimationFrame(() => { el.style.maxHeight = h; });
1005
+ setTimeout(() => { el.style.maxHeight = ''; el.style.overflow = ''; el.style.transition = ''; }, duration);
1006
+ });
1007
+ }
1008
+
1009
+ slideUp(duration = 300) {
1010
+ return this.each((_, el) => {
1011
+ el.style.overflow = 'hidden';
1012
+ el.style.maxHeight = el.scrollHeight + 'px';
1013
+ el.style.transition = `max-height ${duration}ms ease`;
1014
+ requestAnimationFrame(() => { el.style.maxHeight = '0'; });
1015
+ setTimeout(() => { el.style.display = 'none'; el.style.maxHeight = ''; el.style.overflow = ''; el.style.transition = ''; }, duration);
1016
+ });
1017
+ }
1018
+
654
1019
  slideToggle(duration = 300) {
655
1020
  return this.each((_, el) => {
656
1021
  if (el.style.display === 'none' || getComputedStyle(el).display === 'none') {
@@ -798,7 +1163,7 @@ query.children = (parentId) => {
798
1163
  return new ZQueryCollection(p ? Array.from(p.children) : []);
799
1164
  };
800
1165
 
801
- // Create element shorthand
1166
+ // Create element shorthand — returns ZQueryCollection for chaining
802
1167
  query.create = (tag, attrs = {}, ...children) => {
803
1168
  const el = document.createElement(tag);
804
1169
  for (const [k, v] of Object.entries(attrs)) {
@@ -812,7 +1177,7 @@ query.create = (tag, attrs = {}, ...children) => {
812
1177
  if (typeof child === 'string') el.appendChild(document.createTextNode(child));
813
1178
  else if (child instanceof Node) el.appendChild(child);
814
1179
  });
815
- return el;
1180
+ return new ZQueryCollection(el);
816
1181
  };
817
1182
 
818
1183
  // DOM ready
@@ -821,17 +1186,24 @@ query.ready = (fn) => {
821
1186
  else document.addEventListener('DOMContentLoaded', fn);
822
1187
  };
823
1188
 
824
- // Global event listeners — supports direct and delegated forms
1189
+ // Global event listeners — supports direct, delegated, and target-bound forms
825
1190
  // $.on('keydown', handler) → direct listener on document
826
1191
  // $.on('click', '.btn', handler) → delegated via closest()
1192
+ // $.on('scroll', window, handler) → direct listener on target
827
1193
  query.on = (event, selectorOrHandler, handler) => {
828
1194
  if (typeof selectorOrHandler === 'function') {
829
1195
  // 2-arg: direct document listener (keydown, resize, etc.)
830
1196
  document.addEventListener(event, selectorOrHandler);
831
1197
  return;
832
1198
  }
833
- // 3-arg: delegated
1199
+ // EventTarget (window, element, etc.) — direct listener on target
1200
+ if (typeof selectorOrHandler === 'object' && typeof selectorOrHandler.addEventListener === 'function') {
1201
+ selectorOrHandler.addEventListener(event, handler);
1202
+ return;
1203
+ }
1204
+ // 3-arg string: delegated
834
1205
  document.addEventListener(event, (e) => {
1206
+ if (!e.target || typeof e.target.closest !== 'function') return;
835
1207
  const target = e.target.closest(selectorOrHandler);
836
1208
  if (target) handler.call(target, e);
837
1209
  });
@@ -845,7 +1217,7 @@ query.off = (event, handler) => {
845
1217
  // Extend collection prototype (like $.fn in jQuery)
846
1218
  query.fn = ZQueryCollection.prototype;
847
1219
 
848
- // --- src/expression.js ———————————————————————————————————————————
1220
+ // --- src/expression.js -------------------------------------------
849
1221
  /**
850
1222
  * zQuery Expression Parser — CSP-safe expression evaluator
851
1223
  *
@@ -1637,13 +2009,43 @@ function _evalBinary(node, scope) {
1637
2009
  * Typical: [loopVars, state, { props, refs, $ }]
1638
2010
  * @returns {*} — evaluation result, or undefined on error
1639
2011
  */
2012
+
2013
+ // AST cache — avoids re-tokenizing and re-parsing the same expression string.
2014
+ // Bounded to prevent unbounded memory growth in long-lived apps.
2015
+ const _astCache = new Map();
2016
+ const _AST_CACHE_MAX = 512;
2017
+
1640
2018
  function safeEval(expr, scope) {
1641
2019
  try {
1642
2020
  const trimmed = expr.trim();
1643
2021
  if (!trimmed) return undefined;
1644
- const tokens = tokenize(trimmed);
1645
- const parser = new Parser(tokens, scope);
1646
- const ast = parser.parse();
2022
+
2023
+ // Fast path for simple identifiers: "count", "name", "visible"
2024
+ // Avoids full tokenize→parse→evaluate overhead for the most common case.
2025
+ if (/^[a-zA-Z_$][\w$]*$/.test(trimmed)) {
2026
+ for (const layer of scope) {
2027
+ if (layer && typeof layer === 'object' && trimmed in layer) {
2028
+ return layer[trimmed];
2029
+ }
2030
+ }
2031
+ // Fall through to full parser for built-in globals (Math, JSON, etc.)
2032
+ }
2033
+
2034
+ // Check AST cache
2035
+ let ast = _astCache.get(trimmed);
2036
+ if (!ast) {
2037
+ const tokens = tokenize(trimmed);
2038
+ const parser = new Parser(tokens, scope);
2039
+ ast = parser.parse();
2040
+
2041
+ // Evict oldest entries when cache is full
2042
+ if (_astCache.size >= _AST_CACHE_MAX) {
2043
+ const first = _astCache.keys().next().value;
2044
+ _astCache.delete(first);
2045
+ }
2046
+ _astCache.set(trimmed, ast);
2047
+ }
2048
+
1647
2049
  return evaluate(ast, scope);
1648
2050
  } catch (err) {
1649
2051
  if (typeof console !== 'undefined' && console.debug) {
@@ -1653,7 +2055,7 @@ function safeEval(expr, scope) {
1653
2055
  }
1654
2056
  }
1655
2057
 
1656
- // --- src/diff.js —————————————————————————————————————————————————
2058
+ // --- src/diff.js -------------------------------------------------
1657
2059
  /**
1658
2060
  * zQuery Diff — Lightweight DOM morphing engine
1659
2061
  *
@@ -1663,8 +2065,27 @@ function safeEval(expr, scope) {
1663
2065
  *
1664
2066
  * Approach: walk old and new trees in parallel, reconcile node by node.
1665
2067
  * Keyed elements (via `z-key`) get matched across position changes.
2068
+ *
2069
+ * Performance advantages over virtual DOM (React/Angular):
2070
+ * - No virtual tree allocation or diffing — works directly on real DOM
2071
+ * - Skips unchanged subtrees via fast isEqualNode() check
2072
+ * - z-skip attribute to opt out of diffing entire subtrees
2073
+ * - Reuses a single template element for HTML parsing (zero GC pressure)
2074
+ * - Keyed reconciliation uses LIS (Longest Increasing Subsequence) to
2075
+ * minimize DOM moves — same algorithm as Vue 3 / ivi
2076
+ * - Minimal attribute diffing with early bail-out
1666
2077
  */
1667
2078
 
2079
+ // ---------------------------------------------------------------------------
2080
+ // Reusable template element — avoids per-call allocation
2081
+ // ---------------------------------------------------------------------------
2082
+ let _tpl = null;
2083
+
2084
+ function _getTemplate() {
2085
+ if (!_tpl) _tpl = document.createElement('template');
2086
+ return _tpl;
2087
+ }
2088
+
1668
2089
  // ---------------------------------------------------------------------------
1669
2090
  // morph(existingRoot, newHTML) — patch existing DOM to match newHTML
1670
2091
  // ---------------------------------------------------------------------------
@@ -1677,15 +2098,53 @@ function safeEval(expr, scope) {
1677
2098
  * @param {string} newHTML — The desired HTML string
1678
2099
  */
1679
2100
  function morph(rootEl, newHTML) {
1680
- const template = document.createElement('template');
1681
- template.innerHTML = newHTML;
1682
- const newRoot = template.content;
2101
+ const start = typeof window !== 'undefined' && window.__zqMorphHook ? performance.now() : 0;
2102
+ const tpl = _getTemplate();
2103
+ tpl.innerHTML = newHTML;
2104
+ const newRoot = tpl.content;
1683
2105
 
1684
- // Convert to element for consistent handling — wrap in a div if needed
2106
+ // Move children into a wrapper for consistent handling.
2107
+ // We move (not clone) from the template — cheaper than cloning.
1685
2108
  const tempDiv = document.createElement('div');
1686
2109
  while (newRoot.firstChild) tempDiv.appendChild(newRoot.firstChild);
1687
2110
 
1688
2111
  _morphChildren(rootEl, tempDiv);
2112
+
2113
+ if (start) window.__zqMorphHook(rootEl, performance.now() - start);
2114
+ }
2115
+
2116
+ /**
2117
+ * Morph a single element in place — diffs attributes and children
2118
+ * without replacing the node reference. Useful for replaceWith-style
2119
+ * updates where you want to keep the element identity when the tag
2120
+ * name matches.
2121
+ *
2122
+ * If the new HTML produces a different tag, falls back to native replace.
2123
+ *
2124
+ * @param {Element} oldEl — The live DOM element to patch
2125
+ * @param {string} newHTML — HTML string for the replacement element
2126
+ * @returns {Element} — The resulting element (same ref if morphed, new if replaced)
2127
+ */
2128
+ function morphElement(oldEl, newHTML) {
2129
+ const start = typeof window !== 'undefined' && window.__zqMorphHook ? performance.now() : 0;
2130
+ const tpl = _getTemplate();
2131
+ tpl.innerHTML = newHTML;
2132
+ const newEl = tpl.content.firstElementChild;
2133
+ if (!newEl) return oldEl;
2134
+
2135
+ // Same tag — morph in place (preserves identity, event listeners, refs)
2136
+ if (oldEl.nodeName === newEl.nodeName) {
2137
+ _morphAttributes(oldEl, newEl);
2138
+ _morphChildren(oldEl, newEl);
2139
+ if (start) window.__zqMorphHook(oldEl, performance.now() - start);
2140
+ return oldEl;
2141
+ }
2142
+
2143
+ // Different tag — must replace (can't morph <div> into <span>)
2144
+ const clone = newEl.cloneNode(true);
2145
+ oldEl.parentNode.replaceChild(clone, oldEl);
2146
+ if (start) window.__zqMorphHook(clone, performance.now() - start);
2147
+ return clone;
1689
2148
  }
1690
2149
 
1691
2150
  /**
@@ -1695,25 +2154,42 @@ function morph(rootEl, newHTML) {
1695
2154
  * @param {Element} newParent — desired state parent
1696
2155
  */
1697
2156
  function _morphChildren(oldParent, newParent) {
1698
- const oldChildren = [...oldParent.childNodes];
1699
- const newChildren = [...newParent.childNodes];
1700
-
1701
- // Build key maps for keyed element matching
1702
- const oldKeyMap = new Map();
1703
- const newKeyMap = new Map();
1704
-
1705
- for (let i = 0; i < oldChildren.length; i++) {
1706
- const key = _getKey(oldChildren[i]);
1707
- if (key != null) oldKeyMap.set(key, i);
1708
- }
1709
- for (let i = 0; i < newChildren.length; i++) {
1710
- const key = _getKey(newChildren[i]);
1711
- if (key != null) newKeyMap.set(key, i);
2157
+ // Snapshot live NodeLists into arrays — childNodes is live and
2158
+ // mutates during insertBefore/removeChild. Using a for loop to push
2159
+ // avoids spread operator overhead for large child lists.
2160
+ const oldCN = oldParent.childNodes;
2161
+ const newCN = newParent.childNodes;
2162
+ const oldLen = oldCN.length;
2163
+ const newLen = newCN.length;
2164
+ const oldChildren = new Array(oldLen);
2165
+ const newChildren = new Array(newLen);
2166
+ for (let i = 0; i < oldLen; i++) oldChildren[i] = oldCN[i];
2167
+ for (let i = 0; i < newLen; i++) newChildren[i] = newCN[i];
2168
+
2169
+ // Scan for keyed elements — only build maps if keys exist
2170
+ let hasKeys = false;
2171
+ let oldKeyMap, newKeyMap;
2172
+
2173
+ for (let i = 0; i < oldLen; i++) {
2174
+ if (_getKey(oldChildren[i]) != null) { hasKeys = true; break; }
2175
+ }
2176
+ if (!hasKeys) {
2177
+ for (let i = 0; i < newLen; i++) {
2178
+ if (_getKey(newChildren[i]) != null) { hasKeys = true; break; }
2179
+ }
1712
2180
  }
1713
2181
 
1714
- const hasKeys = oldKeyMap.size > 0 || newKeyMap.size > 0;
1715
-
1716
2182
  if (hasKeys) {
2183
+ oldKeyMap = new Map();
2184
+ newKeyMap = new Map();
2185
+ for (let i = 0; i < oldLen; i++) {
2186
+ const key = _getKey(oldChildren[i]);
2187
+ if (key != null) oldKeyMap.set(key, i);
2188
+ }
2189
+ for (let i = 0; i < newLen; i++) {
2190
+ const key = _getKey(newChildren[i]);
2191
+ if (key != null) newKeyMap.set(key, i);
2192
+ }
1717
2193
  _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap);
1718
2194
  } else {
1719
2195
  _morphChildrenUnkeyed(oldParent, oldChildren, newChildren);
@@ -1724,35 +2200,42 @@ function _morphChildren(oldParent, newParent) {
1724
2200
  * Unkeyed reconciliation — positional matching.
1725
2201
  */
1726
2202
  function _morphChildrenUnkeyed(oldParent, oldChildren, newChildren) {
1727
- const maxLen = Math.max(oldChildren.length, newChildren.length);
2203
+ const oldLen = oldChildren.length;
2204
+ const newLen = newChildren.length;
2205
+ const minLen = oldLen < newLen ? oldLen : newLen;
1728
2206
 
1729
- for (let i = 0; i < maxLen; i++) {
1730
- const oldNode = oldChildren[i];
1731
- const newNode = newChildren[i];
2207
+ // Morph overlapping range
2208
+ for (let i = 0; i < minLen; i++) {
2209
+ _morphNode(oldParent, oldChildren[i], newChildren[i]);
2210
+ }
1732
2211
 
1733
- if (!oldNode && newNode) {
1734
- // New node append
1735
- oldParent.appendChild(newNode.cloneNode(true));
1736
- } else if (oldNode && !newNode) {
1737
- // Extra old node — remove
1738
- oldParent.removeChild(oldNode);
1739
- } else if (oldNode && newNode) {
1740
- _morphNode(oldParent, oldNode, newNode);
2212
+ // Append new nodes
2213
+ if (newLen > oldLen) {
2214
+ for (let i = oldLen; i < newLen; i++) {
2215
+ oldParent.appendChild(newChildren[i].cloneNode(true));
2216
+ }
2217
+ }
2218
+
2219
+ // Remove excess old nodes (iterate backwards to avoid index shifting)
2220
+ if (oldLen > newLen) {
2221
+ for (let i = oldLen - 1; i >= newLen; i--) {
2222
+ oldParent.removeChild(oldChildren[i]);
1741
2223
  }
1742
2224
  }
1743
2225
  }
1744
2226
 
1745
2227
  /**
1746
- * Keyed reconciliation — match by z-key, reorder minimal moves.
2228
+ * Keyed reconciliation — match by z-key, reorder with minimal moves
2229
+ * using Longest Increasing Subsequence (LIS) to find the maximum set
2230
+ * of nodes that are already in the correct relative order, then only
2231
+ * move the remaining nodes.
1747
2232
  */
1748
2233
  function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap) {
1749
- // Track which old nodes are consumed
1750
2234
  const consumed = new Set();
1751
-
1752
- // Step 1: Build ordered list of matched old nodes for new children
1753
2235
  const newLen = newChildren.length;
1754
- const matched = new Array(newLen); // matched[newIdx] = oldNode | null
2236
+ const matched = new Array(newLen);
1755
2237
 
2238
+ // Step 1: Match new children to old children by key
1756
2239
  for (let i = 0; i < newLen; i++) {
1757
2240
  const key = _getKey(newChildren[i]);
1758
2241
  if (key != null && oldKeyMap.has(key)) {
@@ -1764,21 +2247,40 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
1764
2247
  }
1765
2248
  }
1766
2249
 
1767
- // Step 2: Remove old nodes that are not in the new tree
2250
+ // Step 2: Remove old keyed nodes not in the new tree
1768
2251
  for (let i = oldChildren.length - 1; i >= 0; i--) {
1769
2252
  if (!consumed.has(i)) {
1770
2253
  const key = _getKey(oldChildren[i]);
1771
2254
  if (key != null && !newKeyMap.has(key)) {
1772
2255
  oldParent.removeChild(oldChildren[i]);
1773
- } else if (key == null) {
1774
- // Unkeyed old node — will be handled positionally below
1775
2256
  }
1776
2257
  }
1777
2258
  }
1778
2259
 
1779
- // Step 3: Insert/reorder/morph
2260
+ // Step 3: Build index array for LIS of matched old indices.
2261
+ // This finds the largest set of keyed nodes already in order,
2262
+ // so we only need to move the rest — O(n log n) instead of O(n²).
2263
+ const oldIndices = []; // Maps new-position → old-position (or -1)
2264
+ for (let i = 0; i < newLen; i++) {
2265
+ if (matched[i]) {
2266
+ const key = _getKey(newChildren[i]);
2267
+ oldIndices.push(oldKeyMap.get(key));
2268
+ } else {
2269
+ oldIndices.push(-1);
2270
+ }
2271
+ }
2272
+
2273
+ const lisSet = _lis(oldIndices);
2274
+
2275
+ // Step 4: Insert / reorder / morph — walk new children forward,
2276
+ // using LIS to decide which nodes stay in place.
1780
2277
  let cursor = oldParent.firstChild;
1781
- const unkeyedOld = oldChildren.filter((n, i) => !consumed.has(i) && _getKey(n) == null);
2278
+ const unkeyedOld = [];
2279
+ for (let i = 0; i < oldChildren.length; i++) {
2280
+ if (!consumed.has(i) && _getKey(oldChildren[i]) == null) {
2281
+ unkeyedOld.push(oldChildren[i]);
2282
+ }
2283
+ }
1782
2284
  let unkeyedIdx = 0;
1783
2285
 
1784
2286
  for (let i = 0; i < newLen; i++) {
@@ -1787,16 +2289,14 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
1787
2289
  let oldNode = matched[i];
1788
2290
 
1789
2291
  if (!oldNode && newKey == null) {
1790
- // Try to match an unkeyed old node positionally
1791
2292
  oldNode = unkeyedOld[unkeyedIdx++] || null;
1792
2293
  }
1793
2294
 
1794
2295
  if (oldNode) {
1795
- // Move into position if needed
1796
- if (oldNode !== cursor) {
2296
+ // If this node is NOT part of the LIS, it needs to be moved
2297
+ if (!lisSet.has(i)) {
1797
2298
  oldParent.insertBefore(oldNode, cursor);
1798
2299
  }
1799
- // Morph in place
1800
2300
  _morphNode(oldParent, oldNode, newNode);
1801
2301
  cursor = oldNode.nextSibling;
1802
2302
  } else {
@@ -1807,11 +2307,10 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
1807
2307
  } else {
1808
2308
  oldParent.appendChild(clone);
1809
2309
  }
1810
- // cursor stays the same — new node is before it
1811
2310
  }
1812
2311
  }
1813
2312
 
1814
- // Remove any remaining unkeyed old nodes at the end
2313
+ // Remove remaining unkeyed old nodes
1815
2314
  while (unkeyedIdx < unkeyedOld.length) {
1816
2315
  const leftover = unkeyedOld[unkeyedIdx++];
1817
2316
  if (leftover.parentNode === oldParent) {
@@ -1830,6 +2329,54 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
1830
2329
  }
1831
2330
  }
1832
2331
 
2332
+ /**
2333
+ * Compute the Longest Increasing Subsequence of an index array.
2334
+ * Returns a Set of positions (in the input) that form the LIS.
2335
+ * Entries with value -1 (unmatched) are excluded.
2336
+ *
2337
+ * O(n log n) — same algorithm used by Vue 3 and ivi.
2338
+ *
2339
+ * @param {number[]} arr — array of old-tree indices (-1 = unmatched)
2340
+ * @returns {Set<number>} — positions in arr belonging to the LIS
2341
+ */
2342
+ function _lis(arr) {
2343
+ const len = arr.length;
2344
+ const result = new Set();
2345
+ if (len === 0) return result;
2346
+
2347
+ // tails[i] = index in arr of the smallest tail element for LIS of length i+1
2348
+ const tails = [];
2349
+ // prev[i] = predecessor index in arr for the LIS ending at arr[i]
2350
+ const prev = new Array(len).fill(-1);
2351
+ const tailIndices = []; // parallel to tails: actual positions
2352
+
2353
+ for (let i = 0; i < len; i++) {
2354
+ if (arr[i] === -1) continue;
2355
+ const val = arr[i];
2356
+
2357
+ // Binary search for insertion point in tails
2358
+ let lo = 0, hi = tails.length;
2359
+ while (lo < hi) {
2360
+ const mid = (lo + hi) >> 1;
2361
+ if (tails[mid] < val) lo = mid + 1;
2362
+ else hi = mid;
2363
+ }
2364
+
2365
+ tails[lo] = val;
2366
+ tailIndices[lo] = i;
2367
+ prev[i] = lo > 0 ? tailIndices[lo - 1] : -1;
2368
+ }
2369
+
2370
+ // Reconstruct: walk backwards from the last element of LIS
2371
+ let k = tailIndices[tails.length - 1];
2372
+ for (let i = tails.length - 1; i >= 0; i--) {
2373
+ result.add(k);
2374
+ k = prev[k];
2375
+ }
2376
+
2377
+ return result;
2378
+ }
2379
+
1833
2380
  /**
1834
2381
  * Morph a single node in place.
1835
2382
  */
@@ -1856,10 +2403,18 @@ function _morphNode(parent, oldNode, newNode) {
1856
2403
 
1857
2404
  // Both are elements — diff attributes then recurse children
1858
2405
  if (oldNode.nodeType === 1) {
2406
+ // z-skip: developer opt-out — skip diffing this subtree entirely.
2407
+ // Useful for third-party widgets, canvas, video, or large static content.
2408
+ if (oldNode.hasAttribute('z-skip')) return;
2409
+
2410
+ // Fast bail-out: if the elements are identical, skip everything.
2411
+ // isEqualNode() is a native C++ comparison — much faster than walking
2412
+ // attributes + children in JS when trees haven't changed.
2413
+ if (oldNode.isEqualNode(newNode)) return;
2414
+
1859
2415
  _morphAttributes(oldNode, newNode);
1860
2416
 
1861
2417
  // Special elements: don't recurse into their children
1862
- // (textarea value, input value, select, etc.)
1863
2418
  const tag = oldNode.nodeName;
1864
2419
  if (tag === 'INPUT') {
1865
2420
  _syncInputValue(oldNode, newNode);
@@ -1872,7 +2427,6 @@ function _morphNode(parent, oldNode, newNode) {
1872
2427
  return;
1873
2428
  }
1874
2429
  if (tag === 'SELECT') {
1875
- // Recurse children (options) then sync value
1876
2430
  _morphChildren(oldNode, newNode);
1877
2431
  if (oldNode.value !== newNode.value) {
1878
2432
  oldNode.value = newNode.value;
@@ -1887,23 +2441,45 @@ function _morphNode(parent, oldNode, newNode) {
1887
2441
 
1888
2442
  /**
1889
2443
  * Sync attributes from newEl onto oldEl.
2444
+ * Uses a single pass: build a set of new attribute names, iterate
2445
+ * old attrs for removals, then apply new attrs.
1890
2446
  */
1891
2447
  function _morphAttributes(oldEl, newEl) {
1892
- // Add/update attributes
1893
2448
  const newAttrs = newEl.attributes;
1894
- for (let i = 0; i < newAttrs.length; i++) {
2449
+ const oldAttrs = oldEl.attributes;
2450
+ const newLen = newAttrs.length;
2451
+ const oldLen = oldAttrs.length;
2452
+
2453
+ // Fast path: if both have same number of attributes, check if they're identical
2454
+ if (newLen === oldLen) {
2455
+ let same = true;
2456
+ for (let i = 0; i < newLen; i++) {
2457
+ const na = newAttrs[i];
2458
+ if (oldEl.getAttribute(na.name) !== na.value) { same = false; break; }
2459
+ }
2460
+ if (same) {
2461
+ // Also verify no extra old attrs (names mismatch)
2462
+ for (let i = 0; i < oldLen; i++) {
2463
+ if (!newEl.hasAttribute(oldAttrs[i].name)) { same = false; break; }
2464
+ }
2465
+ }
2466
+ if (same) return;
2467
+ }
2468
+
2469
+ // Build set of new attr names for O(1) lookup during removal pass
2470
+ const newNames = new Set();
2471
+ for (let i = 0; i < newLen; i++) {
1895
2472
  const attr = newAttrs[i];
2473
+ newNames.add(attr.name);
1896
2474
  if (oldEl.getAttribute(attr.name) !== attr.value) {
1897
2475
  oldEl.setAttribute(attr.name, attr.value);
1898
2476
  }
1899
2477
  }
1900
2478
 
1901
2479
  // Remove stale attributes
1902
- const oldAttrs = oldEl.attributes;
1903
- for (let i = oldAttrs.length - 1; i >= 0; i--) {
1904
- const attr = oldAttrs[i];
1905
- if (!newEl.hasAttribute(attr.name)) {
1906
- oldEl.removeAttribute(attr.name);
2480
+ for (let i = oldLen - 1; i >= 0; i--) {
2481
+ if (!newNames.has(oldAttrs[i].name)) {
2482
+ oldEl.removeAttribute(oldAttrs[i].name);
1907
2483
  }
1908
2484
  }
1909
2485
  }
@@ -1927,15 +2503,39 @@ function _syncInputValue(oldEl, newEl) {
1927
2503
  }
1928
2504
 
1929
2505
  /**
1930
- * Get the reconciliation key from a node (z-key attribute).
2506
+ * Get the reconciliation key from a node.
2507
+ *
2508
+ * Priority: z-key attribute → id attribute → data-id / data-key.
2509
+ * Auto-detected keys use a `\0` prefix to avoid collisions with
2510
+ * explicit z-key values.
2511
+ *
2512
+ * This means the LIS-optimised keyed path activates automatically
2513
+ * whenever elements carry `id` or `data-id` / `data-key` attributes
2514
+ * — no extra markup required.
2515
+ *
1931
2516
  * @returns {string|null}
1932
2517
  */
1933
2518
  function _getKey(node) {
1934
2519
  if (node.nodeType !== 1) return null;
1935
- return node.getAttribute('z-key') || null;
2520
+
2521
+ // Explicit z-key — highest priority
2522
+ const zk = node.getAttribute('z-key');
2523
+ if (zk) return zk;
2524
+
2525
+ // Auto-key: id attribute (unique by spec)
2526
+ if (node.id) return '\0id:' + node.id;
2527
+
2528
+ // Auto-key: data-id or data-key attributes
2529
+ const ds = node.dataset;
2530
+ if (ds) {
2531
+ if (ds.id) return '\0data-id:' + ds.id;
2532
+ if (ds.key) return '\0data-key:' + ds.key;
2533
+ }
2534
+
2535
+ return null;
1936
2536
  }
1937
2537
 
1938
- // --- src/component.js ————————————————————————————————————————————
2538
+ // --- src/component.js --------------------------------------------
1939
2539
  /**
1940
2540
  * zQuery Component — Lightweight reactive component system
1941
2541
  *
@@ -2276,7 +2876,7 @@ class Component {
2276
2876
  // Normalize items → [{id, label}, …]
2277
2877
  def._pages = (p.items || []).map(item => {
2278
2878
  if (typeof item === 'string') return { id: item, label: _titleCase(item) };
2279
- return { id: item.id, label: item.label || _titleCase(item.id) };
2879
+ return { ...item, label: item.label || _titleCase(item.id) };
2280
2880
  });
2281
2881
 
2282
2882
  // Build URL map for lazy per-page loading.
@@ -2318,11 +2918,14 @@ class Component {
2318
2918
  if (def.styleUrl && !def._styleLoaded) {
2319
2919
  const su = def.styleUrl;
2320
2920
  if (typeof su === 'string') {
2321
- def._externalStyles = await _fetchResource(_resolveUrl(su, base));
2921
+ const resolved = _resolveUrl(su, base);
2922
+ def._externalStyles = await _fetchResource(resolved);
2923
+ def._resolvedStyleUrls = [resolved];
2322
2924
  } else if (Array.isArray(su)) {
2323
2925
  const urls = su.map(u => _resolveUrl(u, base));
2324
2926
  const results = await Promise.all(urls.map(u => _fetchResource(u)));
2325
2927
  def._externalStyles = results.join('\n');
2928
+ def._resolvedStyleUrls = urls;
2326
2929
  }
2327
2930
  def._styleLoaded = true;
2328
2931
  }
@@ -2406,6 +3009,11 @@ class Component {
2406
3009
  html = '';
2407
3010
  }
2408
3011
 
3012
+ // Pre-expand z-html and z-text at string level so the morph engine
3013
+ // can diff their content properly (instead of clearing + re-injecting
3014
+ // on every re-render). Same pattern as z-for: parse → evaluate → serialize.
3015
+ html = this._expandContentDirectives(html);
3016
+
2409
3017
  // -- Slot distribution ----------------------------------------
2410
3018
  // Replace <slot> elements with captured slot content from parent.
2411
3019
  // <slot> → default slot content
@@ -2449,6 +3057,13 @@ class Component {
2449
3057
  const styleEl = document.createElement('style');
2450
3058
  styleEl.textContent = scoped;
2451
3059
  styleEl.setAttribute('data-zq-component', this._def._name || '');
3060
+ styleEl.setAttribute('data-zq-scope', scopeAttr);
3061
+ if (this._def._resolvedStyleUrls) {
3062
+ styleEl.setAttribute('data-zq-style-urls', this._def._resolvedStyleUrls.join(' '));
3063
+ if (this._def.styles) {
3064
+ styleEl.setAttribute('data-zq-inline', this._def.styles);
3065
+ }
3066
+ }
2452
3067
  document.head.appendChild(styleEl);
2453
3068
  this._styleEl = styleEl;
2454
3069
  }
@@ -2488,8 +3103,10 @@ class Component {
2488
3103
 
2489
3104
  // Update DOM via morphing (diffing) — preserves unchanged nodes
2490
3105
  // First render uses innerHTML for speed; subsequent renders morph.
3106
+ const _renderStart = typeof window !== 'undefined' && (window.__zqMorphHook || window.__zqRenderHook) ? performance.now() : 0;
2491
3107
  if (!this._mounted) {
2492
3108
  this._el.innerHTML = html;
3109
+ if (_renderStart && window.__zqRenderHook) window.__zqRenderHook(this._el, performance.now() - _renderStart, 'mount', this._def._name);
2493
3110
  } else {
2494
3111
  morph(this._el, html);
2495
3112
  }
@@ -2532,31 +3149,31 @@ class Component {
2532
3149
  }
2533
3150
  }
2534
3151
 
2535
- // Bind @event="method" and z-on:event="method" handlers via delegation
3152
+ // Bind @event="method" and z-on:event="method" handlers via delegation.
3153
+ //
3154
+ // Optimization: on the FIRST render, we scan for event attributes, build
3155
+ // a delegated handler map, and attach one listener per event type to the
3156
+ // component root. On subsequent renders (re-bind), we only rebuild the
3157
+ // internal binding map — existing DOM listeners are reused since they
3158
+ // delegate to event.target.closest(selector) at fire time.
2536
3159
  _bindEvents() {
2537
- // Clean up old delegated listeners
2538
- this._listeners.forEach(({ event, handler }) => {
2539
- this._el.removeEventListener(event, handler);
2540
- });
2541
- this._listeners = [];
3160
+ // Always rebuild the binding map from current DOM
3161
+ const eventMap = new Map(); // event → [{ selector, methodExpr, modifiers, el }]
2542
3162
 
2543
- // Find all elements with @event or z-on:event attributes
2544
3163
  const allEls = this._el.querySelectorAll('*');
2545
- const eventMap = new Map(); // event → [{ selector, method, modifiers }]
2546
-
2547
3164
  allEls.forEach(child => {
2548
- // Skip elements inside z-pre subtrees
2549
3165
  if (child.closest('[z-pre]')) return;
2550
3166
 
2551
- [...child.attributes].forEach(attr => {
2552
- // Support both @event and z-on:event syntax
3167
+ const attrs = child.attributes;
3168
+ for (let a = 0; a < attrs.length; a++) {
3169
+ const attr = attrs[a];
2553
3170
  let raw;
2554
- if (attr.name.startsWith('@')) {
2555
- raw = attr.name.slice(1); // @click.prevent → click.prevent
3171
+ if (attr.name.charCodeAt(0) === 64) { // '@'
3172
+ raw = attr.name.slice(1);
2556
3173
  } else if (attr.name.startsWith('z-on:')) {
2557
- raw = attr.name.slice(5); // z-on:click.prevent → click.prevent
3174
+ raw = attr.name.slice(5);
2558
3175
  } else {
2559
- return;
3176
+ continue;
2560
3177
  }
2561
3178
 
2562
3179
  const parts = raw.split('.');
@@ -2572,12 +3189,45 @@ class Component {
2572
3189
 
2573
3190
  if (!eventMap.has(event)) eventMap.set(event, []);
2574
3191
  eventMap.get(event).push({ selector, methodExpr, modifiers, el: child });
2575
- });
3192
+ }
2576
3193
  });
2577
3194
 
3195
+ // Store binding map for the delegated handlers to reference
3196
+ this._eventBindings = eventMap;
3197
+
3198
+ // Only attach DOM listeners once — reuse on subsequent renders.
3199
+ // The handlers close over `this` and read `this._eventBindings`
3200
+ // at fire time, so they always use the latest binding map.
3201
+ if (this._delegatedEvents) {
3202
+ // Already attached — just make sure new event types are covered
3203
+ for (const event of eventMap.keys()) {
3204
+ if (!this._delegatedEvents.has(event)) {
3205
+ this._attachDelegatedEvent(event, eventMap.get(event));
3206
+ }
3207
+ }
3208
+ // Remove listeners for event types no longer in the template
3209
+ for (const event of this._delegatedEvents.keys()) {
3210
+ if (!eventMap.has(event)) {
3211
+ const { handler, opts } = this._delegatedEvents.get(event);
3212
+ this._el.removeEventListener(event, handler, opts);
3213
+ this._delegatedEvents.delete(event);
3214
+ // Also remove from _listeners array
3215
+ this._listeners = this._listeners.filter(l => l.event !== event);
3216
+ }
3217
+ }
3218
+ return;
3219
+ }
3220
+
3221
+ this._delegatedEvents = new Map();
3222
+
2578
3223
  // Register delegated listeners on the component root
2579
3224
  for (const [event, bindings] of eventMap) {
2580
- // Determine listener options from modifiers
3225
+ this._attachDelegatedEvent(event, bindings);
3226
+ }
3227
+ }
3228
+
3229
+ // Attach a single delegated listener for an event type
3230
+ _attachDelegatedEvent(event, bindings) {
2581
3231
  const needsCapture = bindings.some(b => b.modifiers.includes('capture'));
2582
3232
  const needsPassive = bindings.some(b => b.modifiers.includes('passive'));
2583
3233
  const listenerOpts = (needsCapture || needsPassive)
@@ -2585,7 +3235,9 @@ class Component {
2585
3235
  : false;
2586
3236
 
2587
3237
  const handler = (e) => {
2588
- for (const { selector, methodExpr, modifiers, el } of bindings) {
3238
+ // Read bindings from live map always up to date after re-renders
3239
+ const currentBindings = this._eventBindings?.get(event) || [];
3240
+ for (const { selector, methodExpr, modifiers, el } of currentBindings) {
2589
3241
  if (!e.target.closest(selector)) continue;
2590
3242
 
2591
3243
  // .self — only fire if target is the element itself
@@ -2655,7 +3307,7 @@ class Component {
2655
3307
  };
2656
3308
  this._el.addEventListener(event, handler, listenerOpts);
2657
3309
  this._listeners.push({ event, handler });
2658
- }
3310
+ this._delegatedEvents.set(event, { handler, opts: listenerOpts });
2659
3311
  }
2660
3312
 
2661
3313
  // Bind z-ref="name" → this.refs.name
@@ -2694,7 +3346,7 @@ class Component {
2694
3346
  // Read current state value (supports dot-path keys)
2695
3347
  const currentVal = _getPath(this.state, key);
2696
3348
 
2697
- // -- Set initial DOM value from state ------------------------
3349
+ // -- Set initial DOM value from state (always sync) ----------
2698
3350
  if (tag === 'input' && type === 'checkbox') {
2699
3351
  el.checked = !!currentVal;
2700
3352
  } else if (tag === 'input' && type === 'radio') {
@@ -2716,6 +3368,11 @@ class Component {
2716
3368
  : isEditable ? 'input' : 'input';
2717
3369
 
2718
3370
  // -- Handler: read DOM → write to reactive state -------------
3371
+ // Skip if already bound (morph preserves existing elements,
3372
+ // so re-binding would stack duplicate listeners)
3373
+ if (el._zqModelBound) return;
3374
+ el._zqModelBound = true;
3375
+
2719
3376
  const handler = () => {
2720
3377
  let val;
2721
3378
  if (type === 'checkbox') val = el.checked;
@@ -2835,6 +3492,41 @@ class Component {
2835
3492
  return temp.innerHTML;
2836
3493
  }
2837
3494
 
3495
+ // ---------------------------------------------------------------------------
3496
+ // _expandContentDirectives — Pre-morph z-html & z-text expansion
3497
+ //
3498
+ // Evaluates z-html and z-text directives at the string level so the morph
3499
+ // engine receives HTML with the actual content inline. This lets the diff
3500
+ // algorithm properly compare old vs new content (text nodes, child elements)
3501
+ // instead of clearing + re-injecting on every re-render.
3502
+ //
3503
+ // Same parse → evaluate → serialize pattern as _expandZFor.
3504
+ // ---------------------------------------------------------------------------
3505
+ _expandContentDirectives(html) {
3506
+ if (!html.includes('z-html') && !html.includes('z-text')) return html;
3507
+
3508
+ const temp = document.createElement('div');
3509
+ temp.innerHTML = html;
3510
+
3511
+ // z-html: evaluate expression → inject as innerHTML
3512
+ temp.querySelectorAll('[z-html]').forEach(el => {
3513
+ if (el.closest('[z-pre]')) return;
3514
+ const val = this._evalExpr(el.getAttribute('z-html'));
3515
+ el.innerHTML = val != null ? String(val) : '';
3516
+ el.removeAttribute('z-html');
3517
+ });
3518
+
3519
+ // z-text: evaluate expression → inject as textContent (HTML-safe)
3520
+ temp.querySelectorAll('[z-text]').forEach(el => {
3521
+ if (el.closest('[z-pre]')) return;
3522
+ const val = this._evalExpr(el.getAttribute('z-text'));
3523
+ el.textContent = val != null ? String(val) : '';
3524
+ el.removeAttribute('z-text');
3525
+ });
3526
+
3527
+ return temp.innerHTML;
3528
+ }
3529
+
2838
3530
  // ---------------------------------------------------------------------------
2839
3531
  // _processDirectives — Post-innerHTML DOM-level directive processing
2840
3532
  // ---------------------------------------------------------------------------
@@ -2887,25 +3579,36 @@ class Component {
2887
3579
  });
2888
3580
 
2889
3581
  // -- z-bind:attr / :attr (dynamic attribute binding) -----------
2890
- this._el.querySelectorAll('*').forEach(el => {
2891
- if (el.closest('[z-pre]')) return;
2892
- [...el.attributes].forEach(attr => {
3582
+ // Use TreeWalker instead of querySelectorAll('*') avoids
3583
+ // creating a flat array of every single descendant element.
3584
+ // TreeWalker visits nodes lazily; FILTER_REJECT skips z-pre subtrees
3585
+ // at the walker level (faster than per-node closest('[z-pre]') checks).
3586
+ const walker = document.createTreeWalker(this._el, NodeFilter.SHOW_ELEMENT, {
3587
+ acceptNode(n) {
3588
+ return n.hasAttribute('z-pre') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
3589
+ }
3590
+ });
3591
+ let node;
3592
+ while ((node = walker.nextNode())) {
3593
+ const attrs = node.attributes;
3594
+ for (let i = attrs.length - 1; i >= 0; i--) {
3595
+ const attr = attrs[i];
2893
3596
  let attrName;
2894
3597
  if (attr.name.startsWith('z-bind:')) attrName = attr.name.slice(7);
2895
- else if (attr.name.startsWith(':') && !attr.name.startsWith('::')) attrName = attr.name.slice(1);
2896
- else return;
3598
+ else if (attr.name.charCodeAt(0) === 58 && attr.name.charCodeAt(1) !== 58) attrName = attr.name.slice(1); // ':' but not '::'
3599
+ else continue;
2897
3600
 
2898
3601
  const val = this._evalExpr(attr.value);
2899
- el.removeAttribute(attr.name);
3602
+ node.removeAttribute(attr.name);
2900
3603
  if (val === false || val === null || val === undefined) {
2901
- el.removeAttribute(attrName);
3604
+ node.removeAttribute(attrName);
2902
3605
  } else if (val === true) {
2903
- el.setAttribute(attrName, '');
3606
+ node.setAttribute(attrName, '');
2904
3607
  } else {
2905
- el.setAttribute(attrName, String(val));
3608
+ node.setAttribute(attrName, String(val));
2906
3609
  }
2907
- });
2908
- });
3610
+ }
3611
+ }
2909
3612
 
2910
3613
  // -- z-class (dynamic class binding) ---------------------------
2911
3614
  this._el.querySelectorAll('[z-class]').forEach(el => {
@@ -2937,21 +3640,9 @@ class Component {
2937
3640
  el.removeAttribute('z-style');
2938
3641
  });
2939
3642
 
2940
- // -- z-html (innerHTML injection) ------------------------------
2941
- this._el.querySelectorAll('[z-html]').forEach(el => {
2942
- if (el.closest('[z-pre]')) return;
2943
- const val = this._evalExpr(el.getAttribute('z-html'));
2944
- el.innerHTML = val != null ? String(val) : '';
2945
- el.removeAttribute('z-html');
2946
- });
2947
-
2948
- // -- z-text (safe textContent binding) -------------------------
2949
- this._el.querySelectorAll('[z-text]').forEach(el => {
2950
- if (el.closest('[z-pre]')) return;
2951
- const val = this._evalExpr(el.getAttribute('z-text'));
2952
- el.textContent = val != null ? String(val) : '';
2953
- el.removeAttribute('z-text');
2954
- });
3643
+ // z-html and z-text are now pre-expanded at string level (before
3644
+ // morph) via _expandContentDirectives(), so the diff engine can
3645
+ // properly diff their content instead of clearing + re-injecting.
2955
3646
 
2956
3647
  // -- z-cloak (remove after render) -----------------------------
2957
3648
  this._el.querySelectorAll('[z-cloak]').forEach(el => {
@@ -2984,6 +3675,8 @@ class Component {
2984
3675
  }
2985
3676
  this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
2986
3677
  this._listeners = [];
3678
+ this._delegatedEvents = null;
3679
+ this._eventBindings = null;
2987
3680
  if (this._styleEl) this._styleEl.remove();
2988
3681
  _instances.delete(this._el);
2989
3682
  this._el.innerHTML = '';
@@ -3134,6 +3827,36 @@ function getRegistry() {
3134
3827
  return Object.fromEntries(_registry);
3135
3828
  }
3136
3829
 
3830
+ /**
3831
+ * Pre-load a component's external templates and styles so the next mount
3832
+ * renders synchronously (no blank flash while fetching).
3833
+ * Safe to call multiple times — skips if already loaded.
3834
+ * @param {string} name — registered component name
3835
+ * @returns {Promise<void>}
3836
+ */
3837
+ async function prefetch(name) {
3838
+ const def = _registry.get(name);
3839
+ if (!def) return;
3840
+
3841
+ // Load templateUrl, styleUrl, and normalize pages config
3842
+ if ((def.templateUrl && !def._templateLoaded) ||
3843
+ (def.styleUrl && !def._styleLoaded) ||
3844
+ (def.pages && !def._pagesNormalized)) {
3845
+ await Component.prototype._loadExternals.call({ _def: def });
3846
+ }
3847
+
3848
+ // For pages-based components, prefetch ALL page templates so any
3849
+ // active page renders instantly on mount.
3850
+ if (def._pageUrls && def._externalTemplates) {
3851
+ const missing = Object.entries(def._pageUrls)
3852
+ .filter(([id]) => !(id in def._externalTemplates));
3853
+ if (missing.length) {
3854
+ const results = await Promise.all(missing.map(([, url]) => _fetchResource(url)));
3855
+ missing.forEach(([id], i) => { def._externalTemplates[id] = results[i]; });
3856
+ }
3857
+ }
3858
+ }
3859
+
3137
3860
 
3138
3861
  // ---------------------------------------------------------------------------
3139
3862
  // Global stylesheet loader
@@ -3235,12 +3958,13 @@ function style(urls, opts = {}) {
3235
3958
  };
3236
3959
  }
3237
3960
 
3238
- // --- src/router.js ———————————————————————————————————————————————
3961
+ // --- src/router.js -----------------------------------------------
3239
3962
  /**
3240
3963
  * zQuery Router — Client-side SPA router
3241
3964
  *
3242
3965
  * Supports hash mode (#/path) and history mode (/path).
3243
3966
  * Route params, query strings, navigation guards, and lazy loading.
3967
+ * Sub-route history substates for in-page UI changes (modals, tabs, etc.).
3244
3968
  *
3245
3969
  * Usage:
3246
3970
  * $.router({
@@ -3257,6 +3981,9 @@ function style(urls, opts = {}) {
3257
3981
 
3258
3982
 
3259
3983
 
3984
+ // Unique marker on history.state to identify zQuery-managed entries
3985
+ const _ZQ_STATE_KEY = '__zq';
3986
+
3260
3987
  class Router {
3261
3988
  constructor(config = {}) {
3262
3989
  this._el = null;
@@ -3290,6 +4017,10 @@ class Router {
3290
4017
  this._instance = null; // current mounted component
3291
4018
  this._resolving = false; // re-entrancy guard
3292
4019
 
4020
+ // Sub-route history substates
4021
+ this._substateListeners = []; // [(key, data) => bool|void]
4022
+ this._inSubstate = false; // true while substate entries are in the history stack
4023
+
3293
4024
  // Set outlet element
3294
4025
  if (config.el) {
3295
4026
  this._el = typeof config.el === 'string' ? document.querySelector(config.el) : config.el;
@@ -3304,7 +4035,21 @@ class Router {
3304
4035
  if (this._mode === 'hash') {
3305
4036
  window.addEventListener('hashchange', () => this._resolve());
3306
4037
  } else {
3307
- window.addEventListener('popstate', () => this._resolve());
4038
+ window.addEventListener('popstate', (e) => {
4039
+ // Check for substate pop first — if a listener handles it, don't route
4040
+ const st = e.state;
4041
+ if (st && st[_ZQ_STATE_KEY] === 'substate') {
4042
+ const handled = this._fireSubstate(st.key, st.data, 'pop');
4043
+ if (handled) return;
4044
+ // Unhandled substate — fall through to route resolve
4045
+ // _inSubstate stays true so the next non-substate pop triggers reset
4046
+ } else if (this._inSubstate) {
4047
+ // Popped past all substates — notify listeners to reset to defaults
4048
+ this._inSubstate = false;
4049
+ this._fireSubstate(null, null, 'reset');
4050
+ }
4051
+ this._resolve();
4052
+ });
3308
4053
  }
3309
4054
 
3310
4055
  // Intercept link clicks for SPA navigation
@@ -3315,7 +4060,21 @@ class Router {
3315
4060
  if (!link) return;
3316
4061
  if (link.getAttribute('target') === '_blank') return;
3317
4062
  e.preventDefault();
3318
- this.navigate(link.getAttribute('z-link'));
4063
+ let href = link.getAttribute('z-link');
4064
+ // Support z-link-params for dynamic :param interpolation
4065
+ const paramsAttr = link.getAttribute('z-link-params');
4066
+ if (paramsAttr) {
4067
+ try {
4068
+ const params = JSON.parse(paramsAttr);
4069
+ href = this._interpolateParams(href, params);
4070
+ } catch { /* ignore malformed JSON */ }
4071
+ }
4072
+ this.navigate(href);
4073
+ // z-to-top modifier: scroll to top after navigation
4074
+ if (link.hasAttribute('z-to-top')) {
4075
+ const scrollBehavior = link.getAttribute('z-to-top') || 'instant';
4076
+ window.scrollTo({ top: 0, behavior: scrollBehavior });
4077
+ }
3319
4078
  });
3320
4079
 
3321
4080
  // Initial resolve
@@ -3359,7 +4118,36 @@ class Router {
3359
4118
 
3360
4119
  // --- Navigation ----------------------------------------------------------
3361
4120
 
4121
+ /**
4122
+ * Interpolate :param placeholders in a path with the given values.
4123
+ * @param {string} path — e.g. '/user/:id/posts/:pid'
4124
+ * @param {Object} params — e.g. { id: 42, pid: 7 }
4125
+ * @returns {string}
4126
+ */
4127
+ _interpolateParams(path, params) {
4128
+ if (!params || typeof params !== 'object') return path;
4129
+ return path.replace(/:([\w]+)/g, (_, key) => {
4130
+ const val = params[key];
4131
+ return val != null ? encodeURIComponent(String(val)) : ':' + key;
4132
+ });
4133
+ }
4134
+
4135
+ /**
4136
+ * Get the full current URL (path + hash) for same-URL detection.
4137
+ * @returns {string}
4138
+ */
4139
+ _currentURL() {
4140
+ if (this._mode === 'hash') {
4141
+ return window.location.hash.slice(1) || '/';
4142
+ }
4143
+ const pathname = window.location.pathname || '/';
4144
+ const hash = window.location.hash || '';
4145
+ return pathname + hash;
4146
+ }
4147
+
3362
4148
  navigate(path, options = {}) {
4149
+ // Interpolate :param placeholders if options.params is provided
4150
+ if (options.params) path = this._interpolateParams(path, options.params);
3363
4151
  // Separate hash fragment (e.g. /docs/getting-started#cli-bundler)
3364
4152
  const [cleanPath, fragment] = (path || '').split('#');
3365
4153
  let normalized = this._normalizePath(cleanPath);
@@ -3368,15 +4156,56 @@ class Router {
3368
4156
  // Hash mode uses the URL hash for routing, so a #fragment can't live
3369
4157
  // in the URL. Store it as a scroll target for the destination component.
3370
4158
  if (fragment) window.__zqScrollTarget = fragment;
3371
- window.location.hash = '#' + normalized;
4159
+ const targetHash = '#' + normalized;
4160
+ // Skip if already at this exact hash (prevents duplicate entries)
4161
+ if (window.location.hash === targetHash && !options.force) return this;
4162
+ window.location.hash = targetHash;
3372
4163
  } else {
3373
- window.history.pushState(options.state || {}, '', this._base + normalized + hash);
4164
+ const targetURL = this._base + normalized + hash;
4165
+ const currentURL = (window.location.pathname || '/') + (window.location.hash || '');
4166
+
4167
+ if (targetURL === currentURL && !options.force) {
4168
+ // Same full URL (path + hash) — don't push duplicate entry.
4169
+ // If only the hash changed to a fragment target, scroll to it.
4170
+ if (fragment) {
4171
+ const el = document.getElementById(fragment);
4172
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
4173
+ }
4174
+ return this;
4175
+ }
4176
+
4177
+ // Same route path but different hash fragment — use replaceState
4178
+ // so back goes to the previous *route*, not the previous scroll position.
4179
+ const targetPathOnly = this._base + normalized;
4180
+ const currentPathOnly = window.location.pathname || '/';
4181
+ if (targetPathOnly === currentPathOnly && hash && !options.force) {
4182
+ window.history.replaceState(
4183
+ { ...options.state, [_ZQ_STATE_KEY]: 'route' },
4184
+ '',
4185
+ targetURL
4186
+ );
4187
+ // Scroll to the fragment target
4188
+ if (fragment) {
4189
+ const el = document.getElementById(fragment);
4190
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
4191
+ }
4192
+ // Don't re-resolve — same route, just a hash change
4193
+ return this;
4194
+ }
4195
+
4196
+ window.history.pushState(
4197
+ { ...options.state, [_ZQ_STATE_KEY]: 'route' },
4198
+ '',
4199
+ targetURL
4200
+ );
3374
4201
  this._resolve();
3375
4202
  }
3376
4203
  return this;
3377
4204
  }
3378
4205
 
3379
4206
  replace(path, options = {}) {
4207
+ // Interpolate :param placeholders if options.params is provided
4208
+ if (options.params) path = this._interpolateParams(path, options.params);
3380
4209
  const [cleanPath, fragment] = (path || '').split('#');
3381
4210
  let normalized = this._normalizePath(cleanPath);
3382
4211
  const hash = fragment ? '#' + fragment : '';
@@ -3384,7 +4213,11 @@ class Router {
3384
4213
  if (fragment) window.__zqScrollTarget = fragment;
3385
4214
  window.location.replace('#' + normalized);
3386
4215
  } else {
3387
- window.history.replaceState(options.state || {}, '', this._base + normalized + hash);
4216
+ window.history.replaceState(
4217
+ { ...options.state, [_ZQ_STATE_KEY]: 'route' },
4218
+ '',
4219
+ this._base + normalized + hash
4220
+ );
3388
4221
  this._resolve();
3389
4222
  }
3390
4223
  return this;
@@ -3439,6 +4272,80 @@ class Router {
3439
4272
  return () => this._listeners.delete(fn);
3440
4273
  }
3441
4274
 
4275
+ // --- Sub-route history substates -----------------------------------------
4276
+
4277
+ /**
4278
+ * Push a lightweight history entry for in-component UI state.
4279
+ * The URL path does NOT change — only a history entry is added so the
4280
+ * back button can undo the UI change (close modal, revert tab, etc.)
4281
+ * before navigating away.
4282
+ *
4283
+ * @param {string} key — identifier (e.g. 'modal', 'tab', 'panel')
4284
+ * @param {*} data — arbitrary state (serializable)
4285
+ * @returns {Router}
4286
+ *
4287
+ * @example
4288
+ * // Open a modal and push a substate
4289
+ * router.pushSubstate('modal', { id: 'confirm-delete' });
4290
+ * // User hits back → onSubstate fires → close the modal
4291
+ */
4292
+ pushSubstate(key, data) {
4293
+ this._inSubstate = true;
4294
+ if (this._mode === 'hash') {
4295
+ // Hash mode: stash the substate in a global — hashchange will check.
4296
+ // We still push a history entry via a sentinel hash suffix.
4297
+ const current = window.location.hash || '#/';
4298
+ window.history.pushState(
4299
+ { [_ZQ_STATE_KEY]: 'substate', key, data },
4300
+ '',
4301
+ window.location.href
4302
+ );
4303
+ } else {
4304
+ window.history.pushState(
4305
+ { [_ZQ_STATE_KEY]: 'substate', key, data },
4306
+ '',
4307
+ window.location.href // keep same URL
4308
+ );
4309
+ }
4310
+ return this;
4311
+ }
4312
+
4313
+ /**
4314
+ * Register a listener for substate pops (back button on a substate entry).
4315
+ * The callback receives `(key, data)` and should return `true` if it
4316
+ * handled the pop (prevents route resolution). If no listener returns
4317
+ * `true`, normal route resolution proceeds.
4318
+ *
4319
+ * @param {(key: string, data: any, action: string) => boolean|void} fn
4320
+ * @returns {() => void} unsubscribe function
4321
+ *
4322
+ * @example
4323
+ * const unsub = router.onSubstate((key, data) => {
4324
+ * if (key === 'modal') { closeModal(); return true; }
4325
+ * });
4326
+ */
4327
+ onSubstate(fn) {
4328
+ this._substateListeners.push(fn);
4329
+ return () => {
4330
+ this._substateListeners = this._substateListeners.filter(f => f !== fn);
4331
+ };
4332
+ }
4333
+
4334
+ /**
4335
+ * Fire substate listeners. Returns true if any listener handled it.
4336
+ * @private
4337
+ */
4338
+ _fireSubstate(key, data, action) {
4339
+ for (const fn of this._substateListeners) {
4340
+ try {
4341
+ if (fn(key, data, action) === true) return true;
4342
+ } catch (err) {
4343
+ reportError(ErrorCode.ROUTER_GUARD, 'onSubstate listener threw', { key, data }, err);
4344
+ }
4345
+ }
4346
+ return false;
4347
+ }
4348
+
3442
4349
  // --- Current state -------------------------------------------------------
3443
4350
 
3444
4351
  get current() { return this._current; }
@@ -3490,6 +4397,7 @@ class Router {
3490
4397
  // Prevent re-entrant calls (e.g. listener triggering navigation)
3491
4398
  if (this._resolving) return;
3492
4399
  this._resolving = true;
4400
+ this._redirectCount = 0;
3493
4401
  try {
3494
4402
  await this.__resolve();
3495
4403
  } finally {
@@ -3498,6 +4406,16 @@ class Router {
3498
4406
  }
3499
4407
 
3500
4408
  async __resolve() {
4409
+ // Check if we're landing on a substate entry (e.g. page refresh on a
4410
+ // substate bookmark, or hash-mode popstate). Fire listeners and bail
4411
+ // if handled — the URL hasn't changed so there's no route to resolve.
4412
+ const histState = window.history.state;
4413
+ if (histState && histState[_ZQ_STATE_KEY] === 'substate') {
4414
+ const handled = this._fireSubstate(histState.key, histState.data, 'resolve');
4415
+ if (handled) return;
4416
+ // No listener handled it — fall through to normal routing
4417
+ }
4418
+
3501
4419
  const fullPath = this.path;
3502
4420
  const [pathPart, queryString] = fullPath.split('?');
3503
4421
  const path = pathPart || '/';
@@ -3525,13 +4443,43 @@ class Router {
3525
4443
  const to = { route: matched, params, query, path };
3526
4444
  const from = this._current;
3527
4445
 
4446
+ // Same-route optimization: if the resolved route is the same component
4447
+ // with the same params, skip the full destroy/mount cycle and just
4448
+ // update props. This prevents flashing and unnecessary DOM churn.
4449
+ if (from && this._instance && matched.component === from.route.component) {
4450
+ const sameParams = JSON.stringify(params) === JSON.stringify(from.params);
4451
+ const sameQuery = JSON.stringify(query) === JSON.stringify(from.query);
4452
+ if (sameParams && sameQuery) {
4453
+ // Identical navigation — nothing to do
4454
+ return;
4455
+ }
4456
+ }
4457
+
3528
4458
  // Run before guards
3529
4459
  for (const guard of this._guards.before) {
3530
4460
  try {
3531
4461
  const result = await guard(to, from);
3532
4462
  if (result === false) return; // Cancel
3533
4463
  if (typeof result === 'string') { // Redirect
3534
- return this.navigate(result);
4464
+ if (++this._redirectCount > 10) {
4465
+ reportError(ErrorCode.ROUTER_GUARD, 'Too many guard redirects (possible loop)', { to }, null);
4466
+ return;
4467
+ }
4468
+ // Update URL directly and re-resolve (avoids re-entrancy block)
4469
+ const [rPath, rFrag] = result.split('#');
4470
+ const rNorm = this._normalizePath(rPath || '/');
4471
+ const rHash = rFrag ? '#' + rFrag : '';
4472
+ if (this._mode === 'hash') {
4473
+ if (rFrag) window.__zqScrollTarget = rFrag;
4474
+ window.location.replace('#' + rNorm);
4475
+ } else {
4476
+ window.history.replaceState(
4477
+ { [_ZQ_STATE_KEY]: 'route' },
4478
+ '',
4479
+ this._base + rNorm + rHash
4480
+ );
4481
+ }
4482
+ return this.__resolve();
3535
4483
  }
3536
4484
  } catch (err) {
3537
4485
  reportError(ErrorCode.ROUTER_GUARD, 'Before-guard threw', { to, from }, err);
@@ -3552,6 +4500,12 @@ class Router {
3552
4500
 
3553
4501
  // Mount component into outlet
3554
4502
  if (this._el && matched.component) {
4503
+ // Pre-load external templates/styles so the mount renders synchronously
4504
+ // (keeps old content visible during the fetch instead of showing blank)
4505
+ if (typeof matched.component === 'string') {
4506
+ await prefetch(matched.component);
4507
+ }
4508
+
3555
4509
  // Destroy previous
3556
4510
  if (this._instance) {
3557
4511
  this._instance.destroy();
@@ -3559,6 +4513,7 @@ class Router {
3559
4513
  }
3560
4514
 
3561
4515
  // Create container
4516
+ const _routeStart = typeof window !== 'undefined' && window.__zqRenderHook ? performance.now() : 0;
3562
4517
  this._el.innerHTML = '';
3563
4518
 
3564
4519
  // Pass route params and query as props
@@ -3569,10 +4524,12 @@ class Router {
3569
4524
  const container = document.createElement(matched.component);
3570
4525
  this._el.appendChild(container);
3571
4526
  this._instance = mount(container, matched.component, props);
4527
+ if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', matched.component);
3572
4528
  }
3573
4529
  // If component is a render function
3574
4530
  else if (typeof matched.component === 'function') {
3575
4531
  this._el.innerHTML = matched.component(to);
4532
+ if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', to);
3576
4533
  }
3577
4534
  }
3578
4535
 
@@ -3590,6 +4547,8 @@ class Router {
3590
4547
  destroy() {
3591
4548
  if (this._instance) this._instance.destroy();
3592
4549
  this._listeners.clear();
4550
+ this._substateListeners = [];
4551
+ this._inSubstate = false;
3593
4552
  this._routes = [];
3594
4553
  this._guards = { before: [], after: [] };
3595
4554
  }
@@ -3610,7 +4569,7 @@ function getRouter() {
3610
4569
  return _activeRouter;
3611
4570
  }
3612
4571
 
3613
- // --- src/store.js ————————————————————————————————————————————————
4572
+ // --- src/store.js ------------------------------------------------
3614
4573
  /**
3615
4574
  * zQuery Store — Global reactive state management
3616
4575
  *
@@ -3796,7 +4755,7 @@ function getStore(name = 'default') {
3796
4755
  return _stores.get(name) || null;
3797
4756
  }
3798
4757
 
3799
- // --- src/http.js —————————————————————————————————————————————————
4758
+ // --- src/http.js -------------------------------------------------
3800
4759
  /**
3801
4760
  * zQuery HTTP — Lightweight fetch wrapper
3802
4761
  *
@@ -3983,7 +4942,7 @@ const http = {
3983
4942
  raw: (url, opts) => fetch(url, opts),
3984
4943
  };
3985
4944
 
3986
- // --- src/utils.js ————————————————————————————————————————————————
4945
+ // --- src/utils.js ------------------------------------------------
3987
4946
  /**
3988
4947
  * zQuery Utils — Common utility functions
3989
4948
  *
@@ -4256,7 +5215,7 @@ class EventBus {
4256
5215
 
4257
5216
  const bus = new EventBus();
4258
5217
 
4259
- // --- index.js (assembly) ——————————————————————————————————————————
5218
+ // --- index.js (assembly) ------------------------------------------
4260
5219
  /**
4261
5220
  * ┌---------------------------------------------------------┐
4262
5221
  * │ zQuery (zeroQuery) — Lightweight Frontend Library │
@@ -4352,8 +5311,10 @@ $.mountAll = mountAll;
4352
5311
  $.getInstance = getInstance;
4353
5312
  $.destroy = destroy;
4354
5313
  $.components = getRegistry;
5314
+ $.prefetch = prefetch;
4355
5315
  $.style = style;
4356
- $.morph = morph;
5316
+ $.morph = morph;
5317
+ $.morphElement = morphElement;
4357
5318
  $.safeEval = safeEval;
4358
5319
 
4359
5320
  // --- Router ----------------------------------------------------------------
@@ -4394,12 +5355,15 @@ $.session = session;
4394
5355
  $.bus = bus;
4395
5356
 
4396
5357
  // --- Error handling --------------------------------------------------------
4397
- $.onError = onError;
4398
- $.ZQueryError = ZQueryError;
4399
- $.ErrorCode = ErrorCode;
5358
+ $.onError = onError;
5359
+ $.ZQueryError = ZQueryError;
5360
+ $.ErrorCode = ErrorCode;
5361
+ $.guardCallback = guardCallback;
5362
+ $.validate = validate;
4400
5363
 
4401
5364
  // --- Meta ------------------------------------------------------------------
4402
- $.version = '0.6.3';
5365
+ $.version = '0.8.6';
5366
+ $.libSize = '~91 KB';
4403
5367
  $.meta = {}; // populated at build time by CLI bundler
4404
5368
 
4405
5369
  $.noConflict = () => {