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
package/dist/zquery.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * zQuery (zeroQuery) v0.7.5
2
+ * zQuery (zeroQuery) v0.8.6
3
3
  * Lightweight Frontend Library
4
4
  * https://github.com/tonywied17/zero-query
5
5
  * (c) 2026 Anthony Wiedman - MIT License
@@ -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,9 +330,19 @@ 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
  }
@@ -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
  // ---------------------------------------------------------------------------
@@ -541,21 +574,46 @@ class ZQueryCollection {
541
574
  // --- Classes -------------------------------------------------------------
542
575
 
543
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
+ }
544
583
  const classes = names.flatMap(n => n.split(/\s+/));
545
- 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;
546
586
  }
547
587
 
548
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
+ }
549
594
  const classes = names.flatMap(n => n.split(/\s+/));
550
- 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;
551
597
  }
552
598
 
553
599
  toggleClass(...args) {
554
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
+ }
555
609
  const classes = args.flatMap(n => n.split(/\s+/));
556
- return this.each((_, el) => {
557
- classes.forEach(c => force !== undefined ? el.classList.toggle(c, force) : el.classList.toggle(c));
558
- });
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;
559
617
  }
560
618
 
561
619
  hasClass(name) {
@@ -591,7 +649,8 @@ class ZQueryCollection {
591
649
 
592
650
  css(props) {
593
651
  if (typeof props === 'string') {
594
- return getComputedStyle(this.first())[props];
652
+ const el = this.first();
653
+ return el ? getComputedStyle(el)[props] : undefined;
595
654
  }
596
655
  return this.each((_, el) => Object.assign(el.style, props));
597
656
  }
@@ -667,7 +726,21 @@ class ZQueryCollection {
667
726
 
668
727
  html(content) {
669
728
  if (content === undefined) return this.first()?.innerHTML;
670
- 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); });
671
744
  }
672
745
 
673
746
  text(content) {
@@ -724,7 +797,8 @@ class ZQueryCollection {
724
797
  }
725
798
 
726
799
  empty() {
727
- return this.each((_, el) => { el.innerHTML = ''; });
800
+ // textContent = '' clears all children without invoking the HTML parser
801
+ return this.each((_, el) => { el.textContent = ''; });
728
802
  }
729
803
 
730
804
  clone(deep = true) {
@@ -734,8 +808,9 @@ class ZQueryCollection {
734
808
  replaceWith(content) {
735
809
  return this.each((_, el) => {
736
810
  if (typeof content === 'string') {
737
- el.insertAdjacentHTML('afterend', content);
738
- 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);
739
814
  } else if (content instanceof Node) {
740
815
  el.parentNode.replaceChild(content, el);
741
816
  }
@@ -821,7 +896,9 @@ class ZQueryCollection {
821
896
 
822
897
  toggle(display = '') {
823
898
  return this.each((_, el) => {
824
- 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';
825
902
  });
826
903
  }
827
904
 
@@ -1932,13 +2009,43 @@ function _evalBinary(node, scope) {
1932
2009
  * Typical: [loopVars, state, { props, refs, $ }]
1933
2010
  * @returns {*} — evaluation result, or undefined on error
1934
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
+
1935
2018
  function safeEval(expr, scope) {
1936
2019
  try {
1937
2020
  const trimmed = expr.trim();
1938
2021
  if (!trimmed) return undefined;
1939
- const tokens = tokenize(trimmed);
1940
- const parser = new Parser(tokens, scope);
1941
- 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
+
1942
2049
  return evaluate(ast, scope);
1943
2050
  } catch (err) {
1944
2051
  if (typeof console !== 'undefined' && console.debug) {
@@ -1958,8 +2065,27 @@ function safeEval(expr, scope) {
1958
2065
  *
1959
2066
  * Approach: walk old and new trees in parallel, reconcile node by node.
1960
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
1961
2077
  */
1962
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
+
1963
2089
  // ---------------------------------------------------------------------------
1964
2090
  // morph(existingRoot, newHTML) — patch existing DOM to match newHTML
1965
2091
  // ---------------------------------------------------------------------------
@@ -1972,15 +2098,53 @@ function safeEval(expr, scope) {
1972
2098
  * @param {string} newHTML — The desired HTML string
1973
2099
  */
1974
2100
  function morph(rootEl, newHTML) {
1975
- const template = document.createElement('template');
1976
- template.innerHTML = newHTML;
1977
- 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;
1978
2105
 
1979
- // 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.
1980
2108
  const tempDiv = document.createElement('div');
1981
2109
  while (newRoot.firstChild) tempDiv.appendChild(newRoot.firstChild);
1982
2110
 
1983
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;
1984
2148
  }
1985
2149
 
1986
2150
  /**
@@ -1990,25 +2154,42 @@ function morph(rootEl, newHTML) {
1990
2154
  * @param {Element} newParent — desired state parent
1991
2155
  */
1992
2156
  function _morphChildren(oldParent, newParent) {
1993
- const oldChildren = [...oldParent.childNodes];
1994
- const newChildren = [...newParent.childNodes];
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];
1995
2168
 
1996
- // Build key maps for keyed element matching
1997
- const oldKeyMap = new Map();
1998
- const newKeyMap = new Map();
2169
+ // Scan for keyed elements — only build maps if keys exist
2170
+ let hasKeys = false;
2171
+ let oldKeyMap, newKeyMap;
1999
2172
 
2000
- for (let i = 0; i < oldChildren.length; i++) {
2001
- const key = _getKey(oldChildren[i]);
2002
- if (key != null) oldKeyMap.set(key, i);
2173
+ for (let i = 0; i < oldLen; i++) {
2174
+ if (_getKey(oldChildren[i]) != null) { hasKeys = true; break; }
2003
2175
  }
2004
- for (let i = 0; i < newChildren.length; i++) {
2005
- const key = _getKey(newChildren[i]);
2006
- if (key != null) newKeyMap.set(key, i);
2176
+ if (!hasKeys) {
2177
+ for (let i = 0; i < newLen; i++) {
2178
+ if (_getKey(newChildren[i]) != null) { hasKeys = true; break; }
2179
+ }
2007
2180
  }
2008
2181
 
2009
- const hasKeys = oldKeyMap.size > 0 || newKeyMap.size > 0;
2010
-
2011
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
+ }
2012
2193
  _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap);
2013
2194
  } else {
2014
2195
  _morphChildrenUnkeyed(oldParent, oldChildren, newChildren);
@@ -2019,35 +2200,42 @@ function _morphChildren(oldParent, newParent) {
2019
2200
  * Unkeyed reconciliation — positional matching.
2020
2201
  */
2021
2202
  function _morphChildrenUnkeyed(oldParent, oldChildren, newChildren) {
2022
- 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;
2023
2206
 
2024
- for (let i = 0; i < maxLen; i++) {
2025
- const oldNode = oldChildren[i];
2026
- const newNode = newChildren[i];
2207
+ // Morph overlapping range
2208
+ for (let i = 0; i < minLen; i++) {
2209
+ _morphNode(oldParent, oldChildren[i], newChildren[i]);
2210
+ }
2027
2211
 
2028
- if (!oldNode && newNode) {
2029
- // New node append
2030
- oldParent.appendChild(newNode.cloneNode(true));
2031
- } else if (oldNode && !newNode) {
2032
- // Extra old node — remove
2033
- oldParent.removeChild(oldNode);
2034
- } else if (oldNode && newNode) {
2035
- _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]);
2036
2223
  }
2037
2224
  }
2038
2225
  }
2039
2226
 
2040
2227
  /**
2041
- * 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.
2042
2232
  */
2043
2233
  function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap) {
2044
- // Track which old nodes are consumed
2045
2234
  const consumed = new Set();
2046
-
2047
- // Step 1: Build ordered list of matched old nodes for new children
2048
2235
  const newLen = newChildren.length;
2049
- const matched = new Array(newLen); // matched[newIdx] = oldNode | null
2236
+ const matched = new Array(newLen);
2050
2237
 
2238
+ // Step 1: Match new children to old children by key
2051
2239
  for (let i = 0; i < newLen; i++) {
2052
2240
  const key = _getKey(newChildren[i]);
2053
2241
  if (key != null && oldKeyMap.has(key)) {
@@ -2059,21 +2247,40 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
2059
2247
  }
2060
2248
  }
2061
2249
 
2062
- // Step 2: Remove old nodes that are not in the new tree
2250
+ // Step 2: Remove old keyed nodes not in the new tree
2063
2251
  for (let i = oldChildren.length - 1; i >= 0; i--) {
2064
2252
  if (!consumed.has(i)) {
2065
2253
  const key = _getKey(oldChildren[i]);
2066
2254
  if (key != null && !newKeyMap.has(key)) {
2067
2255
  oldParent.removeChild(oldChildren[i]);
2068
- } else if (key == null) {
2069
- // Unkeyed old node — will be handled positionally below
2070
2256
  }
2071
2257
  }
2072
2258
  }
2073
2259
 
2074
- // 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.
2075
2277
  let cursor = oldParent.firstChild;
2076
- 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
+ }
2077
2284
  let unkeyedIdx = 0;
2078
2285
 
2079
2286
  for (let i = 0; i < newLen; i++) {
@@ -2082,16 +2289,14 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
2082
2289
  let oldNode = matched[i];
2083
2290
 
2084
2291
  if (!oldNode && newKey == null) {
2085
- // Try to match an unkeyed old node positionally
2086
2292
  oldNode = unkeyedOld[unkeyedIdx++] || null;
2087
2293
  }
2088
2294
 
2089
2295
  if (oldNode) {
2090
- // Move into position if needed
2091
- if (oldNode !== cursor) {
2296
+ // If this node is NOT part of the LIS, it needs to be moved
2297
+ if (!lisSet.has(i)) {
2092
2298
  oldParent.insertBefore(oldNode, cursor);
2093
2299
  }
2094
- // Morph in place
2095
2300
  _morphNode(oldParent, oldNode, newNode);
2096
2301
  cursor = oldNode.nextSibling;
2097
2302
  } else {
@@ -2102,11 +2307,10 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
2102
2307
  } else {
2103
2308
  oldParent.appendChild(clone);
2104
2309
  }
2105
- // cursor stays the same — new node is before it
2106
2310
  }
2107
2311
  }
2108
2312
 
2109
- // Remove any remaining unkeyed old nodes at the end
2313
+ // Remove remaining unkeyed old nodes
2110
2314
  while (unkeyedIdx < unkeyedOld.length) {
2111
2315
  const leftover = unkeyedOld[unkeyedIdx++];
2112
2316
  if (leftover.parentNode === oldParent) {
@@ -2125,6 +2329,54 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
2125
2329
  }
2126
2330
  }
2127
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
+
2128
2380
  /**
2129
2381
  * Morph a single node in place.
2130
2382
  */
@@ -2151,10 +2403,18 @@ function _morphNode(parent, oldNode, newNode) {
2151
2403
 
2152
2404
  // Both are elements — diff attributes then recurse children
2153
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
+
2154
2415
  _morphAttributes(oldNode, newNode);
2155
2416
 
2156
2417
  // Special elements: don't recurse into their children
2157
- // (textarea value, input value, select, etc.)
2158
2418
  const tag = oldNode.nodeName;
2159
2419
  if (tag === 'INPUT') {
2160
2420
  _syncInputValue(oldNode, newNode);
@@ -2167,7 +2427,6 @@ function _morphNode(parent, oldNode, newNode) {
2167
2427
  return;
2168
2428
  }
2169
2429
  if (tag === 'SELECT') {
2170
- // Recurse children (options) then sync value
2171
2430
  _morphChildren(oldNode, newNode);
2172
2431
  if (oldNode.value !== newNode.value) {
2173
2432
  oldNode.value = newNode.value;
@@ -2182,23 +2441,45 @@ function _morphNode(parent, oldNode, newNode) {
2182
2441
 
2183
2442
  /**
2184
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.
2185
2446
  */
2186
2447
  function _morphAttributes(oldEl, newEl) {
2187
- // Add/update attributes
2188
2448
  const newAttrs = newEl.attributes;
2189
- 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++) {
2190
2472
  const attr = newAttrs[i];
2473
+ newNames.add(attr.name);
2191
2474
  if (oldEl.getAttribute(attr.name) !== attr.value) {
2192
2475
  oldEl.setAttribute(attr.name, attr.value);
2193
2476
  }
2194
2477
  }
2195
2478
 
2196
2479
  // Remove stale attributes
2197
- const oldAttrs = oldEl.attributes;
2198
- for (let i = oldAttrs.length - 1; i >= 0; i--) {
2199
- const attr = oldAttrs[i];
2200
- if (!newEl.hasAttribute(attr.name)) {
2201
- 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);
2202
2483
  }
2203
2484
  }
2204
2485
  }
@@ -2222,12 +2503,36 @@ function _syncInputValue(oldEl, newEl) {
2222
2503
  }
2223
2504
 
2224
2505
  /**
2225
- * 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
+ *
2226
2516
  * @returns {string|null}
2227
2517
  */
2228
2518
  function _getKey(node) {
2229
2519
  if (node.nodeType !== 1) return null;
2230
- 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;
2231
2536
  }
2232
2537
 
2233
2538
  // --- src/component.js --------------------------------------------
@@ -2571,7 +2876,7 @@ class Component {
2571
2876
  // Normalize items → [{id, label}, …]
2572
2877
  def._pages = (p.items || []).map(item => {
2573
2878
  if (typeof item === 'string') return { id: item, label: _titleCase(item) };
2574
- return { id: item.id, label: item.label || _titleCase(item.id) };
2879
+ return { ...item, label: item.label || _titleCase(item.id) };
2575
2880
  });
2576
2881
 
2577
2882
  // Build URL map for lazy per-page loading.
@@ -2704,6 +3009,11 @@ class Component {
2704
3009
  html = '';
2705
3010
  }
2706
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
+
2707
3017
  // -- Slot distribution ----------------------------------------
2708
3018
  // Replace <slot> elements with captured slot content from parent.
2709
3019
  // <slot> → default slot content
@@ -2793,8 +3103,10 @@ class Component {
2793
3103
 
2794
3104
  // Update DOM via morphing (diffing) — preserves unchanged nodes
2795
3105
  // First render uses innerHTML for speed; subsequent renders morph.
3106
+ const _renderStart = typeof window !== 'undefined' && (window.__zqMorphHook || window.__zqRenderHook) ? performance.now() : 0;
2796
3107
  if (!this._mounted) {
2797
3108
  this._el.innerHTML = html;
3109
+ if (_renderStart && window.__zqRenderHook) window.__zqRenderHook(this._el, performance.now() - _renderStart, 'mount', this._def._name);
2798
3110
  } else {
2799
3111
  morph(this._el, html);
2800
3112
  }
@@ -2837,31 +3149,31 @@ class Component {
2837
3149
  }
2838
3150
  }
2839
3151
 
2840
- // 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.
2841
3159
  _bindEvents() {
2842
- // Clean up old delegated listeners
2843
- this._listeners.forEach(({ event, handler }) => {
2844
- this._el.removeEventListener(event, handler);
2845
- });
2846
- this._listeners = [];
3160
+ // Always rebuild the binding map from current DOM
3161
+ const eventMap = new Map(); // event → [{ selector, methodExpr, modifiers, el }]
2847
3162
 
2848
- // Find all elements with @event or z-on:event attributes
2849
3163
  const allEls = this._el.querySelectorAll('*');
2850
- const eventMap = new Map(); // event → [{ selector, method, modifiers }]
2851
-
2852
3164
  allEls.forEach(child => {
2853
- // Skip elements inside z-pre subtrees
2854
3165
  if (child.closest('[z-pre]')) return;
2855
3166
 
2856
- [...child.attributes].forEach(attr => {
2857
- // 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];
2858
3170
  let raw;
2859
- if (attr.name.startsWith('@')) {
2860
- raw = attr.name.slice(1); // @click.prevent → click.prevent
3171
+ if (attr.name.charCodeAt(0) === 64) { // '@'
3172
+ raw = attr.name.slice(1);
2861
3173
  } else if (attr.name.startsWith('z-on:')) {
2862
- raw = attr.name.slice(5); // z-on:click.prevent → click.prevent
3174
+ raw = attr.name.slice(5);
2863
3175
  } else {
2864
- return;
3176
+ continue;
2865
3177
  }
2866
3178
 
2867
3179
  const parts = raw.split('.');
@@ -2877,12 +3189,45 @@ class Component {
2877
3189
 
2878
3190
  if (!eventMap.has(event)) eventMap.set(event, []);
2879
3191
  eventMap.get(event).push({ selector, methodExpr, modifiers, el: child });
2880
- });
3192
+ }
2881
3193
  });
2882
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
+
2883
3223
  // Register delegated listeners on the component root
2884
3224
  for (const [event, bindings] of eventMap) {
2885
- // 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) {
2886
3231
  const needsCapture = bindings.some(b => b.modifiers.includes('capture'));
2887
3232
  const needsPassive = bindings.some(b => b.modifiers.includes('passive'));
2888
3233
  const listenerOpts = (needsCapture || needsPassive)
@@ -2890,7 +3235,9 @@ class Component {
2890
3235
  : false;
2891
3236
 
2892
3237
  const handler = (e) => {
2893
- 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) {
2894
3241
  if (!e.target.closest(selector)) continue;
2895
3242
 
2896
3243
  // .self — only fire if target is the element itself
@@ -2960,7 +3307,7 @@ class Component {
2960
3307
  };
2961
3308
  this._el.addEventListener(event, handler, listenerOpts);
2962
3309
  this._listeners.push({ event, handler });
2963
- }
3310
+ this._delegatedEvents.set(event, { handler, opts: listenerOpts });
2964
3311
  }
2965
3312
 
2966
3313
  // Bind z-ref="name" → this.refs.name
@@ -2999,7 +3346,7 @@ class Component {
2999
3346
  // Read current state value (supports dot-path keys)
3000
3347
  const currentVal = _getPath(this.state, key);
3001
3348
 
3002
- // -- Set initial DOM value from state ------------------------
3349
+ // -- Set initial DOM value from state (always sync) ----------
3003
3350
  if (tag === 'input' && type === 'checkbox') {
3004
3351
  el.checked = !!currentVal;
3005
3352
  } else if (tag === 'input' && type === 'radio') {
@@ -3021,6 +3368,11 @@ class Component {
3021
3368
  : isEditable ? 'input' : 'input';
3022
3369
 
3023
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
+
3024
3376
  const handler = () => {
3025
3377
  let val;
3026
3378
  if (type === 'checkbox') val = el.checked;
@@ -3140,6 +3492,41 @@ class Component {
3140
3492
  return temp.innerHTML;
3141
3493
  }
3142
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
+
3143
3530
  // ---------------------------------------------------------------------------
3144
3531
  // _processDirectives — Post-innerHTML DOM-level directive processing
3145
3532
  // ---------------------------------------------------------------------------
@@ -3192,25 +3579,36 @@ class Component {
3192
3579
  });
3193
3580
 
3194
3581
  // -- z-bind:attr / :attr (dynamic attribute binding) -----------
3195
- this._el.querySelectorAll('*').forEach(el => {
3196
- if (el.closest('[z-pre]')) return;
3197
- [...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];
3198
3596
  let attrName;
3199
3597
  if (attr.name.startsWith('z-bind:')) attrName = attr.name.slice(7);
3200
- else if (attr.name.startsWith(':') && !attr.name.startsWith('::')) attrName = attr.name.slice(1);
3201
- 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;
3202
3600
 
3203
3601
  const val = this._evalExpr(attr.value);
3204
- el.removeAttribute(attr.name);
3602
+ node.removeAttribute(attr.name);
3205
3603
  if (val === false || val === null || val === undefined) {
3206
- el.removeAttribute(attrName);
3604
+ node.removeAttribute(attrName);
3207
3605
  } else if (val === true) {
3208
- el.setAttribute(attrName, '');
3606
+ node.setAttribute(attrName, '');
3209
3607
  } else {
3210
- el.setAttribute(attrName, String(val));
3608
+ node.setAttribute(attrName, String(val));
3211
3609
  }
3212
- });
3213
- });
3610
+ }
3611
+ }
3214
3612
 
3215
3613
  // -- z-class (dynamic class binding) ---------------------------
3216
3614
  this._el.querySelectorAll('[z-class]').forEach(el => {
@@ -3242,21 +3640,9 @@ class Component {
3242
3640
  el.removeAttribute('z-style');
3243
3641
  });
3244
3642
 
3245
- // -- z-html (innerHTML injection) ------------------------------
3246
- this._el.querySelectorAll('[z-html]').forEach(el => {
3247
- if (el.closest('[z-pre]')) return;
3248
- const val = this._evalExpr(el.getAttribute('z-html'));
3249
- el.innerHTML = val != null ? String(val) : '';
3250
- el.removeAttribute('z-html');
3251
- });
3252
-
3253
- // -- z-text (safe textContent binding) -------------------------
3254
- this._el.querySelectorAll('[z-text]').forEach(el => {
3255
- if (el.closest('[z-pre]')) return;
3256
- const val = this._evalExpr(el.getAttribute('z-text'));
3257
- el.textContent = val != null ? String(val) : '';
3258
- el.removeAttribute('z-text');
3259
- });
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.
3260
3646
 
3261
3647
  // -- z-cloak (remove after render) -----------------------------
3262
3648
  this._el.querySelectorAll('[z-cloak]').forEach(el => {
@@ -3289,6 +3675,8 @@ class Component {
3289
3675
  }
3290
3676
  this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
3291
3677
  this._listeners = [];
3678
+ this._delegatedEvents = null;
3679
+ this._eventBindings = null;
3292
3680
  if (this._styleEl) this._styleEl.remove();
3293
3681
  _instances.delete(this._el);
3294
3682
  this._el.innerHTML = '';
@@ -3439,6 +3827,36 @@ function getRegistry() {
3439
3827
  return Object.fromEntries(_registry);
3440
3828
  }
3441
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
+
3442
3860
 
3443
3861
  // ---------------------------------------------------------------------------
3444
3862
  // Global stylesheet loader
@@ -3546,6 +3964,7 @@ function style(urls, opts = {}) {
3546
3964
  *
3547
3965
  * Supports hash mode (#/path) and history mode (/path).
3548
3966
  * Route params, query strings, navigation guards, and lazy loading.
3967
+ * Sub-route history substates for in-page UI changes (modals, tabs, etc.).
3549
3968
  *
3550
3969
  * Usage:
3551
3970
  * $.router({
@@ -3562,6 +3981,9 @@ function style(urls, opts = {}) {
3562
3981
 
3563
3982
 
3564
3983
 
3984
+ // Unique marker on history.state to identify zQuery-managed entries
3985
+ const _ZQ_STATE_KEY = '__zq';
3986
+
3565
3987
  class Router {
3566
3988
  constructor(config = {}) {
3567
3989
  this._el = null;
@@ -3595,6 +4017,10 @@ class Router {
3595
4017
  this._instance = null; // current mounted component
3596
4018
  this._resolving = false; // re-entrancy guard
3597
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
+
3598
4024
  // Set outlet element
3599
4025
  if (config.el) {
3600
4026
  this._el = typeof config.el === 'string' ? document.querySelector(config.el) : config.el;
@@ -3609,7 +4035,21 @@ class Router {
3609
4035
  if (this._mode === 'hash') {
3610
4036
  window.addEventListener('hashchange', () => this._resolve());
3611
4037
  } else {
3612
- 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
+ });
3613
4053
  }
3614
4054
 
3615
4055
  // Intercept link clicks for SPA navigation
@@ -3692,6 +4132,19 @@ class Router {
3692
4132
  });
3693
4133
  }
3694
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
+
3695
4148
  navigate(path, options = {}) {
3696
4149
  // Interpolate :param placeholders if options.params is provided
3697
4150
  if (options.params) path = this._interpolateParams(path, options.params);
@@ -3703,9 +4156,48 @@ class Router {
3703
4156
  // Hash mode uses the URL hash for routing, so a #fragment can't live
3704
4157
  // in the URL. Store it as a scroll target for the destination component.
3705
4158
  if (fragment) window.__zqScrollTarget = fragment;
3706
- 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;
3707
4163
  } else {
3708
- 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
+ );
3709
4201
  this._resolve();
3710
4202
  }
3711
4203
  return this;
@@ -3721,7 +4213,11 @@ class Router {
3721
4213
  if (fragment) window.__zqScrollTarget = fragment;
3722
4214
  window.location.replace('#' + normalized);
3723
4215
  } else {
3724
- 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
+ );
3725
4221
  this._resolve();
3726
4222
  }
3727
4223
  return this;
@@ -3776,6 +4272,80 @@ class Router {
3776
4272
  return () => this._listeners.delete(fn);
3777
4273
  }
3778
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
+
3779
4349
  // --- Current state -------------------------------------------------------
3780
4350
 
3781
4351
  get current() { return this._current; }
@@ -3836,6 +4406,16 @@ class Router {
3836
4406
  }
3837
4407
 
3838
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
+
3839
4419
  const fullPath = this.path;
3840
4420
  const [pathPart, queryString] = fullPath.split('?');
3841
4421
  const path = pathPart || '/';
@@ -3863,6 +4443,18 @@ class Router {
3863
4443
  const to = { route: matched, params, query, path };
3864
4444
  const from = this._current;
3865
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
+
3866
4458
  // Run before guards
3867
4459
  for (const guard of this._guards.before) {
3868
4460
  try {
@@ -3881,7 +4473,11 @@ class Router {
3881
4473
  if (rFrag) window.__zqScrollTarget = rFrag;
3882
4474
  window.location.replace('#' + rNorm);
3883
4475
  } else {
3884
- window.history.replaceState({}, '', this._base + rNorm + rHash);
4476
+ window.history.replaceState(
4477
+ { [_ZQ_STATE_KEY]: 'route' },
4478
+ '',
4479
+ this._base + rNorm + rHash
4480
+ );
3885
4481
  }
3886
4482
  return this.__resolve();
3887
4483
  }
@@ -3904,6 +4500,12 @@ class Router {
3904
4500
 
3905
4501
  // Mount component into outlet
3906
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
+
3907
4509
  // Destroy previous
3908
4510
  if (this._instance) {
3909
4511
  this._instance.destroy();
@@ -3911,6 +4513,7 @@ class Router {
3911
4513
  }
3912
4514
 
3913
4515
  // Create container
4516
+ const _routeStart = typeof window !== 'undefined' && window.__zqRenderHook ? performance.now() : 0;
3914
4517
  this._el.innerHTML = '';
3915
4518
 
3916
4519
  // Pass route params and query as props
@@ -3921,10 +4524,12 @@ class Router {
3921
4524
  const container = document.createElement(matched.component);
3922
4525
  this._el.appendChild(container);
3923
4526
  this._instance = mount(container, matched.component, props);
4527
+ if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', matched.component);
3924
4528
  }
3925
4529
  // If component is a render function
3926
4530
  else if (typeof matched.component === 'function') {
3927
4531
  this._el.innerHTML = matched.component(to);
4532
+ if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', to);
3928
4533
  }
3929
4534
  }
3930
4535
 
@@ -3942,6 +4547,8 @@ class Router {
3942
4547
  destroy() {
3943
4548
  if (this._instance) this._instance.destroy();
3944
4549
  this._listeners.clear();
4550
+ this._substateListeners = [];
4551
+ this._inSubstate = false;
3945
4552
  this._routes = [];
3946
4553
  this._guards = { before: [], after: [] };
3947
4554
  }
@@ -4704,8 +5311,10 @@ $.mountAll = mountAll;
4704
5311
  $.getInstance = getInstance;
4705
5312
  $.destroy = destroy;
4706
5313
  $.components = getRegistry;
5314
+ $.prefetch = prefetch;
4707
5315
  $.style = style;
4708
- $.morph = morph;
5316
+ $.morph = morph;
5317
+ $.morphElement = morphElement;
4709
5318
  $.safeEval = safeEval;
4710
5319
 
4711
5320
  // --- Router ----------------------------------------------------------------
@@ -4746,12 +5355,15 @@ $.session = session;
4746
5355
  $.bus = bus;
4747
5356
 
4748
5357
  // --- Error handling --------------------------------------------------------
4749
- $.onError = onError;
4750
- $.ZQueryError = ZQueryError;
4751
- $.ErrorCode = ErrorCode;
5358
+ $.onError = onError;
5359
+ $.ZQueryError = ZQueryError;
5360
+ $.ErrorCode = ErrorCode;
5361
+ $.guardCallback = guardCallback;
5362
+ $.validate = validate;
4752
5363
 
4753
5364
  // --- Meta ------------------------------------------------------------------
4754
- $.version = '0.7.5';
5365
+ $.version = '0.8.6';
5366
+ $.libSize = '~91 KB';
4755
5367
  $.meta = {}; // populated at build time by CLI bundler
4756
5368
 
4757
5369
  $.noConflict = () => {