zero-query 0.7.5 → 0.8.7

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 (65) hide show
  1. package/README.md +39 -30
  2. package/cli/commands/build.js +110 -1
  3. package/cli/commands/bundle.js +127 -50
  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 +740 -226
  34. package/dist/zquery.min.js +2 -2
  35. package/index.d.ts +11 -11
  36. package/index.js +15 -10
  37. package/package.json +3 -2
  38. package/src/component.js +154 -139
  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 +196 -7
  44. package/src/ssr.js +1 -1
  45. package/tests/component.test.js +582 -0
  46. package/tests/core.test.js +251 -0
  47. package/tests/diff.test.js +333 -2
  48. package/tests/expression.test.js +148 -0
  49. package/tests/http.test.js +108 -0
  50. package/tests/reactive.test.js +148 -0
  51. package/tests/router.test.js +317 -0
  52. package/tests/store.test.js +126 -0
  53. package/tests/utils.test.js +161 -2
  54. package/types/collection.d.ts +17 -2
  55. package/types/component.d.ts +10 -34
  56. package/types/misc.d.ts +13 -0
  57. package/types/router.d.ts +30 -1
  58. package/cli/commands/dev.old.js +0 -520
  59. package/cli/scaffold/scripts/components/home.js +0 -137
  60. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.css +0 -0
  61. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +0 -0
  62. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.js +0 -0
  63. /package/cli/scaffold/{scripts → app}/components/counter.js +0 -0
  64. /package/cli/scaffold/{scripts → app}/components/not-found.js +0 -0
  65. /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.7
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 --------------------------------------------
@@ -2248,7 +2553,7 @@ function _getKey(node) {
2248
2553
  * - Scoped styles (inline or via styleUrl)
2249
2554
  * - External templates via templateUrl (with {{expression}} interpolation)
2250
2555
  * - External styles via styleUrl (fetched & scoped automatically)
2251
- * - Relative path resolution — templateUrl, styleUrl, and pages.dir
2556
+ * - Relative path resolution — templateUrl and styleUrl
2252
2557
  * resolve relative to the component file automatically
2253
2558
  */
2254
2559
 
@@ -2318,16 +2623,6 @@ function _fetchResource(url) {
2318
2623
  return promise;
2319
2624
  }
2320
2625
 
2321
- /**
2322
- * Convert a kebab-case id to Title Case.
2323
- * 'getting-started' → 'Getting Started'
2324
- * @param {string} id
2325
- * @returns {string}
2326
- */
2327
- function _titleCase(id) {
2328
- return id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
2329
- }
2330
-
2331
2626
  /**
2332
2627
  * Resolve a relative URL against a base.
2333
2628
  *
@@ -2546,47 +2841,10 @@ class Component {
2546
2841
  // - string → single stylesheet
2547
2842
  // - string[] → array of URLs → all fetched & concatenated
2548
2843
  //
2549
- // pages config (shorthand for multi-template + route-param page switching):
2550
- // pages: {
2551
- // dir: 'pages', // relative to component file (or base)
2552
- // param: 'section', // route param name → this.activePage
2553
- // default: 'getting-started', // fallback when param is absent
2554
- // ext: '.html', // file extension (default '.html')
2555
- // items: ['page-a', { id: 'page-b', label: 'Page B' }, ...]
2556
- // }
2557
- // Exposes this.pages (array of {id,label}), this.activePage (current id)
2558
- // Pages are lazy-loaded: only the active page is fetched on first render,
2559
- // remaining pages are prefetched in the background for instant navigation.
2560
- //
2561
2844
  async _loadExternals() {
2562
2845
  const def = this._def;
2563
2846
  const base = def._base; // auto-detected or explicit
2564
2847
 
2565
- // -- Pages config ---------------------------------------------
2566
- if (def.pages && !def._pagesNormalized) {
2567
- const p = def.pages;
2568
- const ext = p.ext || '.html';
2569
- const dir = _resolveUrl((p.dir || '').replace(/\/+$/, ''), base);
2570
-
2571
- // Normalize items → [{id, label}, …]
2572
- def._pages = (p.items || []).map(item => {
2573
- if (typeof item === 'string') return { id: item, label: _titleCase(item) };
2574
- return { id: item.id, label: item.label || _titleCase(item.id) };
2575
- });
2576
-
2577
- // Build URL map for lazy per-page loading.
2578
- // Pages are fetched on demand (active page first, rest prefetched in
2579
- // the background) so the component renders as soon as the visible
2580
- // page is ready instead of waiting for every page to download.
2581
- def._pageUrls = {};
2582
- for (const { id } of def._pages) {
2583
- def._pageUrls[id] = `${dir}/${id}${ext}`;
2584
- }
2585
- if (!def._externalTemplates) def._externalTemplates = {};
2586
-
2587
- def._pagesNormalized = true;
2588
- }
2589
-
2590
2848
  // -- External templates --------------------------------------
2591
2849
  if (def.templateUrl && !def._templateLoaded) {
2592
2850
  const tu = def.templateUrl;
@@ -2599,9 +2857,8 @@ class Component {
2599
2857
  results.forEach((html, i) => { def._externalTemplates[i] = html; });
2600
2858
  } else if (typeof tu === 'object') {
2601
2859
  const entries = Object.entries(tu);
2602
- // Pages config already resolved; plain objects still need resolving
2603
2860
  const results = await Promise.all(
2604
- entries.map(([, url]) => _fetchResource(def._pagesNormalized ? url : _resolveUrl(url, base)))
2861
+ entries.map(([, url]) => _fetchResource(_resolveUrl(url, base)))
2605
2862
  );
2606
2863
  def._externalTemplates = {};
2607
2864
  entries.forEach(([key], i) => { def._externalTemplates[key] = results[i]; });
@@ -2630,8 +2887,7 @@ class Component {
2630
2887
  _render() {
2631
2888
  // If externals haven't loaded yet, trigger async load then re-render
2632
2889
  if ((this._def.templateUrl && !this._def._templateLoaded) ||
2633
- (this._def.styleUrl && !this._def._styleLoaded) ||
2634
- (this._def.pages && !this._def._pagesNormalized)) {
2890
+ (this._def.styleUrl && !this._def._styleLoaded)) {
2635
2891
  this._loadExternals().then(() => {
2636
2892
  if (!this._destroyed) this._render();
2637
2893
  });
@@ -2643,43 +2899,6 @@ class Component {
2643
2899
  this.templates = this._def._externalTemplates;
2644
2900
  }
2645
2901
 
2646
- // Expose pages metadata and active page (derived from route param)
2647
- if (this._def._pages) {
2648
- this.pages = this._def._pages;
2649
- const pc = this._def.pages;
2650
- let active = (pc.param && this.props.$params?.[pc.param]) || pc.default || this._def._pages[0]?.id || '';
2651
-
2652
- // Fall back to default if the param doesn't match any known page
2653
- if (this._def._pageUrls && !(active in this._def._pageUrls)) {
2654
- active = pc.default || this._def._pages[0]?.id || '';
2655
- }
2656
- this.activePage = active;
2657
-
2658
- // Lazy-load: fetch only the active page's template on demand
2659
- if (this._def._pageUrls && !(active in this._def._externalTemplates)) {
2660
- const url = this._def._pageUrls[active];
2661
- if (url) {
2662
- _fetchResource(url).then(html => {
2663
- this._def._externalTemplates[active] = html;
2664
- if (!this._destroyed) this._render();
2665
- });
2666
- return; // Wait for active page before rendering
2667
- }
2668
- }
2669
-
2670
- // Prefetch remaining pages in background (once, after active page is ready)
2671
- if (this._def._pageUrls && !this._def._pagesPrefetched) {
2672
- this._def._pagesPrefetched = true;
2673
- for (const [id, url] of Object.entries(this._def._pageUrls)) {
2674
- if (!(id in this._def._externalTemplates)) {
2675
- _fetchResource(url).then(html => {
2676
- this._def._externalTemplates[id] = html;
2677
- });
2678
- }
2679
- }
2680
- }
2681
- }
2682
-
2683
2902
  // Determine HTML content
2684
2903
  let html;
2685
2904
  if (this._def.render) {
@@ -2704,6 +2923,11 @@ class Component {
2704
2923
  html = '';
2705
2924
  }
2706
2925
 
2926
+ // Pre-expand z-html and z-text at string level so the morph engine
2927
+ // can diff their content properly (instead of clearing + re-injecting
2928
+ // on every re-render). Same pattern as z-for: parse → evaluate → serialize.
2929
+ html = this._expandContentDirectives(html);
2930
+
2707
2931
  // -- Slot distribution ----------------------------------------
2708
2932
  // Replace <slot> elements with captured slot content from parent.
2709
2933
  // <slot> → default slot content
@@ -2793,8 +3017,10 @@ class Component {
2793
3017
 
2794
3018
  // Update DOM via morphing (diffing) — preserves unchanged nodes
2795
3019
  // First render uses innerHTML for speed; subsequent renders morph.
3020
+ const _renderStart = typeof window !== 'undefined' && (window.__zqMorphHook || window.__zqRenderHook) ? performance.now() : 0;
2796
3021
  if (!this._mounted) {
2797
3022
  this._el.innerHTML = html;
3023
+ if (_renderStart && window.__zqRenderHook) window.__zqRenderHook(this._el, performance.now() - _renderStart, 'mount', this._def._name);
2798
3024
  } else {
2799
3025
  morph(this._el, html);
2800
3026
  }
@@ -2837,31 +3063,31 @@ class Component {
2837
3063
  }
2838
3064
  }
2839
3065
 
2840
- // Bind @event="method" and z-on:event="method" handlers via delegation
3066
+ // Bind @event="method" and z-on:event="method" handlers via delegation.
3067
+ //
3068
+ // Optimization: on the FIRST render, we scan for event attributes, build
3069
+ // a delegated handler map, and attach one listener per event type to the
3070
+ // component root. On subsequent renders (re-bind), we only rebuild the
3071
+ // internal binding map — existing DOM listeners are reused since they
3072
+ // delegate to event.target.closest(selector) at fire time.
2841
3073
  _bindEvents() {
2842
- // Clean up old delegated listeners
2843
- this._listeners.forEach(({ event, handler }) => {
2844
- this._el.removeEventListener(event, handler);
2845
- });
2846
- this._listeners = [];
3074
+ // Always rebuild the binding map from current DOM
3075
+ const eventMap = new Map(); // event → [{ selector, methodExpr, modifiers, el }]
2847
3076
 
2848
- // Find all elements with @event or z-on:event attributes
2849
3077
  const allEls = this._el.querySelectorAll('*');
2850
- const eventMap = new Map(); // event → [{ selector, method, modifiers }]
2851
-
2852
3078
  allEls.forEach(child => {
2853
- // Skip elements inside z-pre subtrees
2854
3079
  if (child.closest('[z-pre]')) return;
2855
3080
 
2856
- [...child.attributes].forEach(attr => {
2857
- // Support both @event and z-on:event syntax
3081
+ const attrs = child.attributes;
3082
+ for (let a = 0; a < attrs.length; a++) {
3083
+ const attr = attrs[a];
2858
3084
  let raw;
2859
- if (attr.name.startsWith('@')) {
2860
- raw = attr.name.slice(1); // @click.prevent → click.prevent
3085
+ if (attr.name.charCodeAt(0) === 64) { // '@'
3086
+ raw = attr.name.slice(1);
2861
3087
  } else if (attr.name.startsWith('z-on:')) {
2862
- raw = attr.name.slice(5); // z-on:click.prevent → click.prevent
3088
+ raw = attr.name.slice(5);
2863
3089
  } else {
2864
- return;
3090
+ continue;
2865
3091
  }
2866
3092
 
2867
3093
  const parts = raw.split('.');
@@ -2877,12 +3103,45 @@ class Component {
2877
3103
 
2878
3104
  if (!eventMap.has(event)) eventMap.set(event, []);
2879
3105
  eventMap.get(event).push({ selector, methodExpr, modifiers, el: child });
2880
- });
3106
+ }
2881
3107
  });
2882
3108
 
3109
+ // Store binding map for the delegated handlers to reference
3110
+ this._eventBindings = eventMap;
3111
+
3112
+ // Only attach DOM listeners once — reuse on subsequent renders.
3113
+ // The handlers close over `this` and read `this._eventBindings`
3114
+ // at fire time, so they always use the latest binding map.
3115
+ if (this._delegatedEvents) {
3116
+ // Already attached — just make sure new event types are covered
3117
+ for (const event of eventMap.keys()) {
3118
+ if (!this._delegatedEvents.has(event)) {
3119
+ this._attachDelegatedEvent(event, eventMap.get(event));
3120
+ }
3121
+ }
3122
+ // Remove listeners for event types no longer in the template
3123
+ for (const event of this._delegatedEvents.keys()) {
3124
+ if (!eventMap.has(event)) {
3125
+ const { handler, opts } = this._delegatedEvents.get(event);
3126
+ this._el.removeEventListener(event, handler, opts);
3127
+ this._delegatedEvents.delete(event);
3128
+ // Also remove from _listeners array
3129
+ this._listeners = this._listeners.filter(l => l.event !== event);
3130
+ }
3131
+ }
3132
+ return;
3133
+ }
3134
+
3135
+ this._delegatedEvents = new Map();
3136
+
2883
3137
  // Register delegated listeners on the component root
2884
3138
  for (const [event, bindings] of eventMap) {
2885
- // Determine listener options from modifiers
3139
+ this._attachDelegatedEvent(event, bindings);
3140
+ }
3141
+ }
3142
+
3143
+ // Attach a single delegated listener for an event type
3144
+ _attachDelegatedEvent(event, bindings) {
2886
3145
  const needsCapture = bindings.some(b => b.modifiers.includes('capture'));
2887
3146
  const needsPassive = bindings.some(b => b.modifiers.includes('passive'));
2888
3147
  const listenerOpts = (needsCapture || needsPassive)
@@ -2890,7 +3149,9 @@ class Component {
2890
3149
  : false;
2891
3150
 
2892
3151
  const handler = (e) => {
2893
- for (const { selector, methodExpr, modifiers, el } of bindings) {
3152
+ // Read bindings from live map always up to date after re-renders
3153
+ const currentBindings = this._eventBindings?.get(event) || [];
3154
+ for (const { selector, methodExpr, modifiers, el } of currentBindings) {
2894
3155
  if (!e.target.closest(selector)) continue;
2895
3156
 
2896
3157
  // .self — only fire if target is the element itself
@@ -2960,7 +3221,7 @@ class Component {
2960
3221
  };
2961
3222
  this._el.addEventListener(event, handler, listenerOpts);
2962
3223
  this._listeners.push({ event, handler });
2963
- }
3224
+ this._delegatedEvents.set(event, { handler, opts: listenerOpts });
2964
3225
  }
2965
3226
 
2966
3227
  // Bind z-ref="name" → this.refs.name
@@ -2999,7 +3260,7 @@ class Component {
2999
3260
  // Read current state value (supports dot-path keys)
3000
3261
  const currentVal = _getPath(this.state, key);
3001
3262
 
3002
- // -- Set initial DOM value from state ------------------------
3263
+ // -- Set initial DOM value from state (always sync) ----------
3003
3264
  if (tag === 'input' && type === 'checkbox') {
3004
3265
  el.checked = !!currentVal;
3005
3266
  } else if (tag === 'input' && type === 'radio') {
@@ -3021,6 +3282,11 @@ class Component {
3021
3282
  : isEditable ? 'input' : 'input';
3022
3283
 
3023
3284
  // -- Handler: read DOM → write to reactive state -------------
3285
+ // Skip if already bound (morph preserves existing elements,
3286
+ // so re-binding would stack duplicate listeners)
3287
+ if (el._zqModelBound) return;
3288
+ el._zqModelBound = true;
3289
+
3024
3290
  const handler = () => {
3025
3291
  let val;
3026
3292
  if (type === 'checkbox') val = el.checked;
@@ -3140,6 +3406,41 @@ class Component {
3140
3406
  return temp.innerHTML;
3141
3407
  }
3142
3408
 
3409
+ // ---------------------------------------------------------------------------
3410
+ // _expandContentDirectives — Pre-morph z-html & z-text expansion
3411
+ //
3412
+ // Evaluates z-html and z-text directives at the string level so the morph
3413
+ // engine receives HTML with the actual content inline. This lets the diff
3414
+ // algorithm properly compare old vs new content (text nodes, child elements)
3415
+ // instead of clearing + re-injecting on every re-render.
3416
+ //
3417
+ // Same parse → evaluate → serialize pattern as _expandZFor.
3418
+ // ---------------------------------------------------------------------------
3419
+ _expandContentDirectives(html) {
3420
+ if (!html.includes('z-html') && !html.includes('z-text')) return html;
3421
+
3422
+ const temp = document.createElement('div');
3423
+ temp.innerHTML = html;
3424
+
3425
+ // z-html: evaluate expression → inject as innerHTML
3426
+ temp.querySelectorAll('[z-html]').forEach(el => {
3427
+ if (el.closest('[z-pre]')) return;
3428
+ const val = this._evalExpr(el.getAttribute('z-html'));
3429
+ el.innerHTML = val != null ? String(val) : '';
3430
+ el.removeAttribute('z-html');
3431
+ });
3432
+
3433
+ // z-text: evaluate expression → inject as textContent (HTML-safe)
3434
+ temp.querySelectorAll('[z-text]').forEach(el => {
3435
+ if (el.closest('[z-pre]')) return;
3436
+ const val = this._evalExpr(el.getAttribute('z-text'));
3437
+ el.textContent = val != null ? String(val) : '';
3438
+ el.removeAttribute('z-text');
3439
+ });
3440
+
3441
+ return temp.innerHTML;
3442
+ }
3443
+
3143
3444
  // ---------------------------------------------------------------------------
3144
3445
  // _processDirectives — Post-innerHTML DOM-level directive processing
3145
3446
  // ---------------------------------------------------------------------------
@@ -3192,25 +3493,36 @@ class Component {
3192
3493
  });
3193
3494
 
3194
3495
  // -- 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 => {
3496
+ // Use TreeWalker instead of querySelectorAll('*') avoids
3497
+ // creating a flat array of every single descendant element.
3498
+ // TreeWalker visits nodes lazily; FILTER_REJECT skips z-pre subtrees
3499
+ // at the walker level (faster than per-node closest('[z-pre]') checks).
3500
+ const walker = document.createTreeWalker(this._el, NodeFilter.SHOW_ELEMENT, {
3501
+ acceptNode(n) {
3502
+ return n.hasAttribute('z-pre') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
3503
+ }
3504
+ });
3505
+ let node;
3506
+ while ((node = walker.nextNode())) {
3507
+ const attrs = node.attributes;
3508
+ for (let i = attrs.length - 1; i >= 0; i--) {
3509
+ const attr = attrs[i];
3198
3510
  let attrName;
3199
3511
  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;
3512
+ else if (attr.name.charCodeAt(0) === 58 && attr.name.charCodeAt(1) !== 58) attrName = attr.name.slice(1); // ':' but not '::'
3513
+ else continue;
3202
3514
 
3203
3515
  const val = this._evalExpr(attr.value);
3204
- el.removeAttribute(attr.name);
3516
+ node.removeAttribute(attr.name);
3205
3517
  if (val === false || val === null || val === undefined) {
3206
- el.removeAttribute(attrName);
3518
+ node.removeAttribute(attrName);
3207
3519
  } else if (val === true) {
3208
- el.setAttribute(attrName, '');
3520
+ node.setAttribute(attrName, '');
3209
3521
  } else {
3210
- el.setAttribute(attrName, String(val));
3522
+ node.setAttribute(attrName, String(val));
3211
3523
  }
3212
- });
3213
- });
3524
+ }
3525
+ }
3214
3526
 
3215
3527
  // -- z-class (dynamic class binding) ---------------------------
3216
3528
  this._el.querySelectorAll('[z-class]').forEach(el => {
@@ -3242,21 +3554,9 @@ class Component {
3242
3554
  el.removeAttribute('z-style');
3243
3555
  });
3244
3556
 
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
- });
3557
+ // z-html and z-text are now pre-expanded at string level (before
3558
+ // morph) via _expandContentDirectives(), so the diff engine can
3559
+ // properly diff their content instead of clearing + re-injecting.
3260
3560
 
3261
3561
  // -- z-cloak (remove after render) -----------------------------
3262
3562
  this._el.querySelectorAll('[z-cloak]').forEach(el => {
@@ -3289,6 +3589,8 @@ class Component {
3289
3589
  }
3290
3590
  this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
3291
3591
  this._listeners = [];
3592
+ this._delegatedEvents = null;
3593
+ this._eventBindings = null;
3292
3594
  if (this._styleEl) this._styleEl.remove();
3293
3595
  _instances.delete(this._el);
3294
3596
  this._el.innerHTML = '';
@@ -3299,7 +3601,7 @@ class Component {
3299
3601
  // Reserved definition keys (not user methods)
3300
3602
  const _reservedKeys = new Set([
3301
3603
  'state', 'render', 'styles', 'init', 'mounted', 'updated', 'destroyed', 'props',
3302
- 'templateUrl', 'styleUrl', 'templates', 'pages', 'activePage', 'base',
3604
+ 'templateUrl', 'styleUrl', 'templates', 'base',
3303
3605
  'computed', 'watch'
3304
3606
  ]);
3305
3607
 
@@ -3322,8 +3624,8 @@ function component(name, definition) {
3322
3624
  }
3323
3625
  definition._name = name;
3324
3626
 
3325
- // Auto-detect the calling module's URL so that relative templateUrl,
3326
- // styleUrl, and pages.dir paths resolve relative to the component file.
3627
+ // Auto-detect the calling module's URL so that relative templateUrl
3628
+ // and styleUrl paths resolve relative to the component file.
3327
3629
  // An explicit `base` string on the definition overrides auto-detection.
3328
3630
  if (definition.base !== undefined) {
3329
3631
  definition._base = definition.base; // explicit override
@@ -3439,6 +3741,24 @@ function getRegistry() {
3439
3741
  return Object.fromEntries(_registry);
3440
3742
  }
3441
3743
 
3744
+ /**
3745
+ * Pre-load a component's external templates and styles so the next mount
3746
+ * renders synchronously (no blank flash while fetching).
3747
+ * Safe to call multiple times — skips if already loaded.
3748
+ * @param {string} name — registered component name
3749
+ * @returns {Promise<void>}
3750
+ */
3751
+ async function prefetch(name) {
3752
+ const def = _registry.get(name);
3753
+ if (!def) return;
3754
+
3755
+ // Load templateUrl and styleUrl if not already loaded.
3756
+ if ((def.templateUrl && !def._templateLoaded) ||
3757
+ (def.styleUrl && !def._styleLoaded)) {
3758
+ await Component.prototype._loadExternals.call({ _def: def });
3759
+ }
3760
+ }
3761
+
3442
3762
 
3443
3763
  // ---------------------------------------------------------------------------
3444
3764
  // Global stylesheet loader
@@ -3546,6 +3866,7 @@ function style(urls, opts = {}) {
3546
3866
  *
3547
3867
  * Supports hash mode (#/path) and history mode (/path).
3548
3868
  * Route params, query strings, navigation guards, and lazy loading.
3869
+ * Sub-route history substates for in-page UI changes (modals, tabs, etc.).
3549
3870
  *
3550
3871
  * Usage:
3551
3872
  * $.router({
@@ -3562,6 +3883,9 @@ function style(urls, opts = {}) {
3562
3883
 
3563
3884
 
3564
3885
 
3886
+ // Unique marker on history.state to identify zQuery-managed entries
3887
+ const _ZQ_STATE_KEY = '__zq';
3888
+
3565
3889
  class Router {
3566
3890
  constructor(config = {}) {
3567
3891
  this._el = null;
@@ -3595,6 +3919,10 @@ class Router {
3595
3919
  this._instance = null; // current mounted component
3596
3920
  this._resolving = false; // re-entrancy guard
3597
3921
 
3922
+ // Sub-route history substates
3923
+ this._substateListeners = []; // [(key, data) => bool|void]
3924
+ this._inSubstate = false; // true while substate entries are in the history stack
3925
+
3598
3926
  // Set outlet element
3599
3927
  if (config.el) {
3600
3928
  this._el = typeof config.el === 'string' ? document.querySelector(config.el) : config.el;
@@ -3609,7 +3937,21 @@ class Router {
3609
3937
  if (this._mode === 'hash') {
3610
3938
  window.addEventListener('hashchange', () => this._resolve());
3611
3939
  } else {
3612
- window.addEventListener('popstate', () => this._resolve());
3940
+ window.addEventListener('popstate', (e) => {
3941
+ // Check for substate pop first — if a listener handles it, don't route
3942
+ const st = e.state;
3943
+ if (st && st[_ZQ_STATE_KEY] === 'substate') {
3944
+ const handled = this._fireSubstate(st.key, st.data, 'pop');
3945
+ if (handled) return;
3946
+ // Unhandled substate — fall through to route resolve
3947
+ // _inSubstate stays true so the next non-substate pop triggers reset
3948
+ } else if (this._inSubstate) {
3949
+ // Popped past all substates — notify listeners to reset to defaults
3950
+ this._inSubstate = false;
3951
+ this._fireSubstate(null, null, 'reset');
3952
+ }
3953
+ this._resolve();
3954
+ });
3613
3955
  }
3614
3956
 
3615
3957
  // Intercept link clicks for SPA navigation
@@ -3658,7 +4000,7 @@ class Router {
3658
4000
 
3659
4001
  // Per-route fallback: register an alias path for the same component.
3660
4002
  // e.g. { path: '/docs/:section', fallback: '/docs', component: 'docs-page' }
3661
- // When matched via fallback, missing params are undefined → pages `default` kicks in.
4003
+ // When matched via fallback, missing params are undefined.
3662
4004
  if (route.fallback) {
3663
4005
  const fbKeys = [];
3664
4006
  const fbPattern = route.fallback
@@ -3692,6 +4034,19 @@ class Router {
3692
4034
  });
3693
4035
  }
3694
4036
 
4037
+ /**
4038
+ * Get the full current URL (path + hash) for same-URL detection.
4039
+ * @returns {string}
4040
+ */
4041
+ _currentURL() {
4042
+ if (this._mode === 'hash') {
4043
+ return window.location.hash.slice(1) || '/';
4044
+ }
4045
+ const pathname = window.location.pathname || '/';
4046
+ const hash = window.location.hash || '';
4047
+ return pathname + hash;
4048
+ }
4049
+
3695
4050
  navigate(path, options = {}) {
3696
4051
  // Interpolate :param placeholders if options.params is provided
3697
4052
  if (options.params) path = this._interpolateParams(path, options.params);
@@ -3703,9 +4058,48 @@ class Router {
3703
4058
  // Hash mode uses the URL hash for routing, so a #fragment can't live
3704
4059
  // in the URL. Store it as a scroll target for the destination component.
3705
4060
  if (fragment) window.__zqScrollTarget = fragment;
3706
- window.location.hash = '#' + normalized;
4061
+ const targetHash = '#' + normalized;
4062
+ // Skip if already at this exact hash (prevents duplicate entries)
4063
+ if (window.location.hash === targetHash && !options.force) return this;
4064
+ window.location.hash = targetHash;
3707
4065
  } else {
3708
- window.history.pushState(options.state || {}, '', this._base + normalized + hash);
4066
+ const targetURL = this._base + normalized + hash;
4067
+ const currentURL = (window.location.pathname || '/') + (window.location.hash || '');
4068
+
4069
+ if (targetURL === currentURL && !options.force) {
4070
+ // Same full URL (path + hash) — don't push duplicate entry.
4071
+ // If only the hash changed to a fragment target, scroll to it.
4072
+ if (fragment) {
4073
+ const el = document.getElementById(fragment);
4074
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
4075
+ }
4076
+ return this;
4077
+ }
4078
+
4079
+ // Same route path but different hash fragment — use replaceState
4080
+ // so back goes to the previous *route*, not the previous scroll position.
4081
+ const targetPathOnly = this._base + normalized;
4082
+ const currentPathOnly = window.location.pathname || '/';
4083
+ if (targetPathOnly === currentPathOnly && hash && !options.force) {
4084
+ window.history.replaceState(
4085
+ { ...options.state, [_ZQ_STATE_KEY]: 'route' },
4086
+ '',
4087
+ targetURL
4088
+ );
4089
+ // Scroll to the fragment target
4090
+ if (fragment) {
4091
+ const el = document.getElementById(fragment);
4092
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
4093
+ }
4094
+ // Don't re-resolve — same route, just a hash change
4095
+ return this;
4096
+ }
4097
+
4098
+ window.history.pushState(
4099
+ { ...options.state, [_ZQ_STATE_KEY]: 'route' },
4100
+ '',
4101
+ targetURL
4102
+ );
3709
4103
  this._resolve();
3710
4104
  }
3711
4105
  return this;
@@ -3721,7 +4115,11 @@ class Router {
3721
4115
  if (fragment) window.__zqScrollTarget = fragment;
3722
4116
  window.location.replace('#' + normalized);
3723
4117
  } else {
3724
- window.history.replaceState(options.state || {}, '', this._base + normalized + hash);
4118
+ window.history.replaceState(
4119
+ { ...options.state, [_ZQ_STATE_KEY]: 'route' },
4120
+ '',
4121
+ this._base + normalized + hash
4122
+ );
3725
4123
  this._resolve();
3726
4124
  }
3727
4125
  return this;
@@ -3776,6 +4174,80 @@ class Router {
3776
4174
  return () => this._listeners.delete(fn);
3777
4175
  }
3778
4176
 
4177
+ // --- Sub-route history substates -----------------------------------------
4178
+
4179
+ /**
4180
+ * Push a lightweight history entry for in-component UI state.
4181
+ * The URL path does NOT change — only a history entry is added so the
4182
+ * back button can undo the UI change (close modal, revert tab, etc.)
4183
+ * before navigating away.
4184
+ *
4185
+ * @param {string} key — identifier (e.g. 'modal', 'tab', 'panel')
4186
+ * @param {*} data — arbitrary state (serializable)
4187
+ * @returns {Router}
4188
+ *
4189
+ * @example
4190
+ * // Open a modal and push a substate
4191
+ * router.pushSubstate('modal', { id: 'confirm-delete' });
4192
+ * // User hits back → onSubstate fires → close the modal
4193
+ */
4194
+ pushSubstate(key, data) {
4195
+ this._inSubstate = true;
4196
+ if (this._mode === 'hash') {
4197
+ // Hash mode: stash the substate in a global — hashchange will check.
4198
+ // We still push a history entry via a sentinel hash suffix.
4199
+ const current = window.location.hash || '#/';
4200
+ window.history.pushState(
4201
+ { [_ZQ_STATE_KEY]: 'substate', key, data },
4202
+ '',
4203
+ window.location.href
4204
+ );
4205
+ } else {
4206
+ window.history.pushState(
4207
+ { [_ZQ_STATE_KEY]: 'substate', key, data },
4208
+ '',
4209
+ window.location.href // keep same URL
4210
+ );
4211
+ }
4212
+ return this;
4213
+ }
4214
+
4215
+ /**
4216
+ * Register a listener for substate pops (back button on a substate entry).
4217
+ * The callback receives `(key, data)` and should return `true` if it
4218
+ * handled the pop (prevents route resolution). If no listener returns
4219
+ * `true`, normal route resolution proceeds.
4220
+ *
4221
+ * @param {(key: string, data: any, action: string) => boolean|void} fn
4222
+ * @returns {() => void} unsubscribe function
4223
+ *
4224
+ * @example
4225
+ * const unsub = router.onSubstate((key, data) => {
4226
+ * if (key === 'modal') { closeModal(); return true; }
4227
+ * });
4228
+ */
4229
+ onSubstate(fn) {
4230
+ this._substateListeners.push(fn);
4231
+ return () => {
4232
+ this._substateListeners = this._substateListeners.filter(f => f !== fn);
4233
+ };
4234
+ }
4235
+
4236
+ /**
4237
+ * Fire substate listeners. Returns true if any listener handled it.
4238
+ * @private
4239
+ */
4240
+ _fireSubstate(key, data, action) {
4241
+ for (const fn of this._substateListeners) {
4242
+ try {
4243
+ if (fn(key, data, action) === true) return true;
4244
+ } catch (err) {
4245
+ reportError(ErrorCode.ROUTER_GUARD, 'onSubstate listener threw', { key, data }, err);
4246
+ }
4247
+ }
4248
+ return false;
4249
+ }
4250
+
3779
4251
  // --- Current state -------------------------------------------------------
3780
4252
 
3781
4253
  get current() { return this._current; }
@@ -3836,6 +4308,16 @@ class Router {
3836
4308
  }
3837
4309
 
3838
4310
  async __resolve() {
4311
+ // Check if we're landing on a substate entry (e.g. page refresh on a
4312
+ // substate bookmark, or hash-mode popstate). Fire listeners and bail
4313
+ // if handled — the URL hasn't changed so there's no route to resolve.
4314
+ const histState = window.history.state;
4315
+ if (histState && histState[_ZQ_STATE_KEY] === 'substate') {
4316
+ const handled = this._fireSubstate(histState.key, histState.data, 'resolve');
4317
+ if (handled) return;
4318
+ // No listener handled it — fall through to normal routing
4319
+ }
4320
+
3839
4321
  const fullPath = this.path;
3840
4322
  const [pathPart, queryString] = fullPath.split('?');
3841
4323
  const path = pathPart || '/';
@@ -3863,6 +4345,18 @@ class Router {
3863
4345
  const to = { route: matched, params, query, path };
3864
4346
  const from = this._current;
3865
4347
 
4348
+ // Same-route optimization: if the resolved route is the same component
4349
+ // with the same params, skip the full destroy/mount cycle and just
4350
+ // update props. This prevents flashing and unnecessary DOM churn.
4351
+ if (from && this._instance && matched.component === from.route.component) {
4352
+ const sameParams = JSON.stringify(params) === JSON.stringify(from.params);
4353
+ const sameQuery = JSON.stringify(query) === JSON.stringify(from.query);
4354
+ if (sameParams && sameQuery) {
4355
+ // Identical navigation — nothing to do
4356
+ return;
4357
+ }
4358
+ }
4359
+
3866
4360
  // Run before guards
3867
4361
  for (const guard of this._guards.before) {
3868
4362
  try {
@@ -3881,7 +4375,11 @@ class Router {
3881
4375
  if (rFrag) window.__zqScrollTarget = rFrag;
3882
4376
  window.location.replace('#' + rNorm);
3883
4377
  } else {
3884
- window.history.replaceState({}, '', this._base + rNorm + rHash);
4378
+ window.history.replaceState(
4379
+ { [_ZQ_STATE_KEY]: 'route' },
4380
+ '',
4381
+ this._base + rNorm + rHash
4382
+ );
3885
4383
  }
3886
4384
  return this.__resolve();
3887
4385
  }
@@ -3904,6 +4402,12 @@ class Router {
3904
4402
 
3905
4403
  // Mount component into outlet
3906
4404
  if (this._el && matched.component) {
4405
+ // Pre-load external templates/styles so the mount renders synchronously
4406
+ // (keeps old content visible during the fetch instead of showing blank)
4407
+ if (typeof matched.component === 'string') {
4408
+ await prefetch(matched.component);
4409
+ }
4410
+
3907
4411
  // Destroy previous
3908
4412
  if (this._instance) {
3909
4413
  this._instance.destroy();
@@ -3911,6 +4415,7 @@ class Router {
3911
4415
  }
3912
4416
 
3913
4417
  // Create container
4418
+ const _routeStart = typeof window !== 'undefined' && window.__zqRenderHook ? performance.now() : 0;
3914
4419
  this._el.innerHTML = '';
3915
4420
 
3916
4421
  // Pass route params and query as props
@@ -3921,10 +4426,12 @@ class Router {
3921
4426
  const container = document.createElement(matched.component);
3922
4427
  this._el.appendChild(container);
3923
4428
  this._instance = mount(container, matched.component, props);
4429
+ if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', matched.component);
3924
4430
  }
3925
4431
  // If component is a render function
3926
4432
  else if (typeof matched.component === 'function') {
3927
4433
  this._el.innerHTML = matched.component(to);
4434
+ if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', to);
3928
4435
  }
3929
4436
  }
3930
4437
 
@@ -3942,6 +4449,8 @@ class Router {
3942
4449
  destroy() {
3943
4450
  if (this._instance) this._instance.destroy();
3944
4451
  this._listeners.clear();
4452
+ this._substateListeners = [];
4453
+ this._inSubstate = false;
3945
4454
  this._routes = [];
3946
4455
  this._guards = { before: [], after: [] };
3947
4456
  }
@@ -4704,8 +5213,10 @@ $.mountAll = mountAll;
4704
5213
  $.getInstance = getInstance;
4705
5214
  $.destroy = destroy;
4706
5215
  $.components = getRegistry;
5216
+ $.prefetch = prefetch;
4707
5217
  $.style = style;
4708
- $.morph = morph;
5218
+ $.morph = morph;
5219
+ $.morphElement = morphElement;
4709
5220
  $.safeEval = safeEval;
4710
5221
 
4711
5222
  // --- Router ----------------------------------------------------------------
@@ -4746,12 +5257,15 @@ $.session = session;
4746
5257
  $.bus = bus;
4747
5258
 
4748
5259
  // --- Error handling --------------------------------------------------------
4749
- $.onError = onError;
4750
- $.ZQueryError = ZQueryError;
4751
- $.ErrorCode = ErrorCode;
5260
+ $.onError = onError;
5261
+ $.ZQueryError = ZQueryError;
5262
+ $.ErrorCode = ErrorCode;
5263
+ $.guardCallback = guardCallback;
5264
+ $.validate = validate;
4752
5265
 
4753
5266
  // --- Meta ------------------------------------------------------------------
4754
- $.version = '0.7.5';
5267
+ $.version = '0.8.7';
5268
+ $.libSize = '~89 KB';
4755
5269
  $.meta = {}; // populated at build time by CLI bundler
4756
5270
 
4757
5271
  $.noConflict = () => {