zero-query 0.9.0 → 0.9.5

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.
package/dist/zquery.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * zQuery (zeroQuery) v0.9.0
2
+ * zQuery (zeroQuery) v0.9.5
3
3
  * Lightweight Frontend Library
4
4
  * https://github.com/tonywied17/zero-query
5
5
  * (c) 2026 Anthony Wiedman - MIT License
@@ -300,7 +300,13 @@ function signal(initial) {
300
300
  */
301
301
  function computed(fn) {
302
302
  const s = new Signal(undefined);
303
- effect(() => { s._value = fn(); s._notify(); });
303
+ effect(() => {
304
+ const v = fn();
305
+ if (v !== s._value) {
306
+ s._value = v;
307
+ s._notify();
308
+ }
309
+ });
304
310
  return s;
305
311
  }
306
312
 
@@ -343,7 +349,7 @@ function effect(fn) {
343
349
  }
344
350
  execute._deps.clear();
345
351
  }
346
- Signal._activeEffect = null;
352
+ // Don't clobber _activeEffect another effect may be running
347
353
  };
348
354
  }
349
355
 
@@ -361,7 +367,7 @@ function effect(fn) {
361
367
  // ---------------------------------------------------------------------------
362
368
  class ZQueryCollection {
363
369
  constructor(elements) {
364
- this.elements = Array.isArray(elements) ? elements : [elements];
370
+ this.elements = Array.isArray(elements) ? elements : (elements ? [elements] : []);
365
371
  this.length = this.elements.length;
366
372
  this.elements.forEach((el, i) => { this[i] = el; });
367
373
  }
@@ -418,10 +424,12 @@ class ZQueryCollection {
418
424
  return new ZQueryCollection([...kids]);
419
425
  }
420
426
 
421
- siblings() {
427
+ siblings(selector) {
422
428
  const sibs = [];
423
429
  this.elements.forEach(el => {
424
- sibs.push(...[...el.parentElement.children].filter(c => c !== el));
430
+ if (!el.parentElement) return;
431
+ const all = [...el.parentElement.children].filter(c => c !== el);
432
+ sibs.push(...(selector ? all.filter(c => c.matches(selector)) : all));
425
433
  });
426
434
  return new ZQueryCollection(sibs);
427
435
  }
@@ -563,7 +571,8 @@ class ZQueryCollection {
563
571
  index(selector) {
564
572
  if (selector === undefined) {
565
573
  const el = this.first();
566
- return el ? Array.from(el.parentElement.children).indexOf(el) : -1;
574
+ if (!el || !el.parentElement) return -1;
575
+ return Array.from(el.parentElement.children).indexOf(el);
567
576
  }
568
577
  const target = (typeof selector === 'string')
569
578
  ? document.querySelector(selector)
@@ -623,6 +632,11 @@ class ZQueryCollection {
623
632
  // --- Attributes ----------------------------------------------------------
624
633
 
625
634
  attr(name, value) {
635
+ if (typeof name === 'object' && name !== null) {
636
+ return this.each((_, el) => {
637
+ for (const [k, v] of Object.entries(name)) el.setAttribute(k, v);
638
+ });
639
+ }
626
640
  if (value === undefined) return this.first()?.getAttribute(name);
627
641
  return this.each((_, el) => el.setAttribute(name, value));
628
642
  }
@@ -647,7 +661,10 @@ class ZQueryCollection {
647
661
 
648
662
  // --- CSS / Dimensions ----------------------------------------------------
649
663
 
650
- css(props) {
664
+ css(props, value) {
665
+ if (typeof props === 'string' && value !== undefined) {
666
+ return this.each((_, el) => { el.style[props] = value; });
667
+ }
651
668
  if (typeof props === 'string') {
652
669
  const el = this.first();
653
670
  return el ? getComputedStyle(el)[props] : undefined;
@@ -787,6 +804,7 @@ class ZQueryCollection {
787
804
  wrap(wrapper) {
788
805
  return this.each((_, el) => {
789
806
  const w = typeof wrapper === 'string' ? createFragment(wrapper).firstElementChild : wrapper.cloneNode(true);
807
+ if (!w || !el.parentNode) return;
790
808
  el.parentNode.insertBefore(w, el);
791
809
  w.appendChild(el);
792
810
  });
@@ -912,12 +930,18 @@ class ZQueryCollection {
912
930
  if (typeof selectorOrHandler === 'function') {
913
931
  el.addEventListener(evt, selectorOrHandler);
914
932
  } else if (typeof selectorOrHandler === 'string') {
915
- // Delegated event — only works on elements that support closest()
916
- el.addEventListener(evt, (e) => {
933
+ // Delegated event — store wrapper so off() can remove it
934
+ const wrapper = (e) => {
917
935
  if (!e.target || typeof e.target.closest !== 'function') return;
918
936
  const target = e.target.closest(selectorOrHandler);
919
937
  if (target && el.contains(target)) handler.call(target, e);
920
- });
938
+ };
939
+ wrapper._zqOriginal = handler;
940
+ wrapper._zqSelector = selectorOrHandler;
941
+ el.addEventListener(evt, wrapper);
942
+ // Track delegated handlers for removal
943
+ if (!el._zqDelegated) el._zqDelegated = [];
944
+ el._zqDelegated.push({ evt, wrapper });
921
945
  }
922
946
  });
923
947
  });
@@ -926,7 +950,20 @@ class ZQueryCollection {
926
950
  off(event, handler) {
927
951
  const events = event.split(/\s+/);
928
952
  return this.each((_, el) => {
929
- events.forEach(evt => el.removeEventListener(evt, handler));
953
+ events.forEach(evt => {
954
+ // Try direct removal first
955
+ el.removeEventListener(evt, handler);
956
+ // Also check delegated handlers
957
+ if (el._zqDelegated) {
958
+ el._zqDelegated = el._zqDelegated.filter(d => {
959
+ if (d.evt === evt && d.wrapper._zqOriginal === handler) {
960
+ el.removeEventListener(evt, d.wrapper);
961
+ return false;
962
+ }
963
+ return true;
964
+ });
965
+ }
966
+ });
930
967
  });
931
968
  }
932
969
 
@@ -955,8 +992,12 @@ class ZQueryCollection {
955
992
  // --- Animation -----------------------------------------------------------
956
993
 
957
994
  animate(props, duration = 300, easing = 'ease') {
995
+ // Empty collection — resolve immediately
996
+ if (this.length === 0) return Promise.resolve(this);
958
997
  return new Promise(resolve => {
998
+ let resolved = false;
959
999
  const count = { done: 0 };
1000
+ const listeners = [];
960
1001
  this.each((_, el) => {
961
1002
  el.style.transition = `all ${duration}ms ${easing}`;
962
1003
  requestAnimationFrame(() => {
@@ -964,13 +1005,27 @@ class ZQueryCollection {
964
1005
  const onEnd = () => {
965
1006
  el.removeEventListener('transitionend', onEnd);
966
1007
  el.style.transition = '';
967
- if (++count.done >= this.length) resolve(this);
1008
+ if (!resolved && ++count.done >= this.length) {
1009
+ resolved = true;
1010
+ resolve(this);
1011
+ }
968
1012
  };
969
1013
  el.addEventListener('transitionend', onEnd);
1014
+ listeners.push({ el, onEnd });
970
1015
  });
971
1016
  });
972
1017
  // Fallback in case transitionend doesn't fire
973
- setTimeout(() => resolve(this), duration + 50);
1018
+ setTimeout(() => {
1019
+ if (!resolved) {
1020
+ resolved = true;
1021
+ // Clean up any remaining transitionend listeners
1022
+ for (const { el, onEnd } of listeners) {
1023
+ el.removeEventListener('transitionend', onEnd);
1024
+ el.style.transition = '';
1025
+ }
1026
+ resolve(this);
1027
+ }
1028
+ }, duration + 50);
974
1029
  });
975
1030
  }
976
1031
 
@@ -984,7 +1039,8 @@ class ZQueryCollection {
984
1039
 
985
1040
  fadeToggle(duration = 300) {
986
1041
  return Promise.all(this.elements.map(el => {
987
- const visible = getComputedStyle(el).opacity !== '0' && getComputedStyle(el).display !== 'none';
1042
+ const cs = getComputedStyle(el);
1043
+ const visible = cs.opacity !== '0' && cs.display !== 'none';
988
1044
  const col = new ZQueryCollection([el]);
989
1045
  return visible ? col.fadeOut(duration) : col.fadeIn(duration);
990
1046
  })).then(() => this);
@@ -1162,6 +1218,8 @@ query.children = (parentId) => {
1162
1218
  const p = document.getElementById(parentId);
1163
1219
  return new ZQueryCollection(p ? Array.from(p.children) : []);
1164
1220
  };
1221
+ query.qs = (sel, ctx = document) => ctx.querySelector(sel);
1222
+ query.qsa = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel));
1165
1223
 
1166
1224
  // Create element shorthand — returns ZQueryCollection for chaining
1167
1225
  query.create = (tag, attrs = {}, ...children) => {
@@ -1169,7 +1227,7 @@ query.create = (tag, attrs = {}, ...children) => {
1169
1227
  for (const [k, v] of Object.entries(attrs)) {
1170
1228
  if (k === 'class') el.className = v;
1171
1229
  else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
1172
- else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);
1230
+ else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v);
1173
1231
  else if (k === 'data' && typeof v === 'object') Object.entries(v).forEach(([dk, dv]) => { el.dataset[dk] = dv; });
1174
1232
  else el.setAttribute(k, v);
1175
1233
  }
@@ -1389,6 +1447,11 @@ function tokenize(expr) {
1389
1447
  tokens.push({ t: T.OP, v: ch });
1390
1448
  i++; continue;
1391
1449
  }
1450
+ // Spread operator: ...
1451
+ if (ch === '.' && i + 2 < len && expr[i + 1] === '.' && expr[i + 2] === '.') {
1452
+ tokens.push({ t: T.OP, v: '...' });
1453
+ i += 3; continue;
1454
+ }
1392
1455
  if ('()[]{},.?:'.includes(ch)) {
1393
1456
  tokens.push({ t: T.PUNC, v: ch });
1394
1457
  i++; continue;
@@ -1452,7 +1515,7 @@ class Parser {
1452
1515
  this.next(); // consume ?
1453
1516
  const truthy = this.parseExpression(0);
1454
1517
  this.expect(T.PUNC, ':');
1455
- const falsy = this.parseExpression(1);
1518
+ const falsy = this.parseExpression(0);
1456
1519
  left = { type: 'ternary', cond: left, truthy, falsy };
1457
1520
  continue;
1458
1521
  }
@@ -1593,7 +1656,12 @@ class Parser {
1593
1656
  this.expect(T.PUNC, '(');
1594
1657
  const args = [];
1595
1658
  while (!(this.peek().t === T.PUNC && this.peek().v === ')') && this.peek().t !== T.EOF) {
1596
- args.push(this.parseExpression(0));
1659
+ if (this.peek().t === T.OP && this.peek().v === '...') {
1660
+ this.next();
1661
+ args.push({ type: 'spread', arg: this.parseExpression(0) });
1662
+ } else {
1663
+ args.push(this.parseExpression(0));
1664
+ }
1597
1665
  if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
1598
1666
  }
1599
1667
  this.expect(T.PUNC, ')');
@@ -1669,7 +1737,12 @@ class Parser {
1669
1737
  this.next();
1670
1738
  const elements = [];
1671
1739
  while (!(this.peek().t === T.PUNC && this.peek().v === ']') && this.peek().t !== T.EOF) {
1672
- elements.push(this.parseExpression(0));
1740
+ if (this.peek().t === T.OP && this.peek().v === '...') {
1741
+ this.next();
1742
+ elements.push({ type: 'spread', arg: this.parseExpression(0) });
1743
+ } else {
1744
+ elements.push(this.parseExpression(0));
1745
+ }
1673
1746
  if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
1674
1747
  }
1675
1748
  this.expect(T.PUNC, ']');
@@ -1681,6 +1754,14 @@ class Parser {
1681
1754
  this.next();
1682
1755
  const properties = [];
1683
1756
  while (!(this.peek().t === T.PUNC && this.peek().v === '}') && this.peek().t !== T.EOF) {
1757
+ // Spread in object: { ...obj }
1758
+ if (this.peek().t === T.OP && this.peek().v === '...') {
1759
+ this.next();
1760
+ properties.push({ spread: true, value: this.parseExpression(0) });
1761
+ if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
1762
+ continue;
1763
+ }
1764
+
1684
1765
  const keyTok = this.next();
1685
1766
  let key;
1686
1767
  if (keyTok.t === T.IDENT || keyTok.t === T.STR) key = keyTok.v;
@@ -1712,7 +1793,13 @@ class Parser {
1712
1793
 
1713
1794
  // new keyword
1714
1795
  if (tok.v === 'new') {
1715
- const classExpr = this.parsePostfix();
1796
+ let classExpr = this.parsePrimary();
1797
+ // Handle member access (e.g. ns.MyClass) without consuming call args
1798
+ while (this.peek().t === T.PUNC && this.peek().v === '.') {
1799
+ this.next();
1800
+ const prop = this.next();
1801
+ classExpr = { type: 'member', obj: classExpr, prop: prop.v, computed: false };
1802
+ }
1716
1803
  let args = [];
1717
1804
  if (this.peek().t === T.PUNC && this.peek().v === '(') {
1718
1805
  args = this._parseArgs();
@@ -1780,6 +1867,7 @@ function _isSafeAccess(obj, prop) {
1780
1867
  const BLOCKED = new Set([
1781
1868
  'constructor', '__proto__', 'prototype', '__defineGetter__',
1782
1869
  '__defineSetter__', '__lookupGetter__', '__lookupSetter__',
1870
+ 'call', 'apply', 'bind',
1783
1871
  ]);
1784
1872
  if (typeof prop === 'string' && BLOCKED.has(prop)) return false;
1785
1873
 
@@ -1823,6 +1911,12 @@ function evaluate(node, scope) {
1823
1911
  if (name === 'encodeURIComponent') return encodeURIComponent;
1824
1912
  if (name === 'decodeURIComponent') return decodeURIComponent;
1825
1913
  if (name === 'console') return console;
1914
+ if (name === 'Map') return Map;
1915
+ if (name === 'Set') return Set;
1916
+ if (name === 'RegExp') return RegExp;
1917
+ if (name === 'Error') return Error;
1918
+ if (name === 'URL') return URL;
1919
+ if (name === 'URLSearchParams') return URLSearchParams;
1826
1920
  return undefined;
1827
1921
  }
1828
1922
 
@@ -1861,10 +1955,21 @@ function evaluate(node, scope) {
1861
1955
  }
1862
1956
 
1863
1957
  case 'optional_call': {
1864
- const callee = evaluate(node.callee, scope);
1958
+ const calleeNode = node.callee;
1959
+ const args = _evalArgs(node.args, scope);
1960
+ // Method call: obj?.method() — bind `this` to obj
1961
+ if (calleeNode.type === 'member' || calleeNode.type === 'optional_member') {
1962
+ const obj = evaluate(calleeNode.obj, scope);
1963
+ if (obj == null) return undefined;
1964
+ const prop = calleeNode.computed ? evaluate(calleeNode.prop, scope) : calleeNode.prop;
1965
+ if (!_isSafeAccess(obj, prop)) return undefined;
1966
+ const fn = obj[prop];
1967
+ if (typeof fn !== 'function') return undefined;
1968
+ return fn.apply(obj, args);
1969
+ }
1970
+ const callee = evaluate(calleeNode, scope);
1865
1971
  if (callee == null) return undefined;
1866
1972
  if (typeof callee !== 'function') return undefined;
1867
- const args = node.args.map(a => evaluate(a, scope));
1868
1973
  return callee(...args);
1869
1974
  }
1870
1975
 
@@ -1874,7 +1979,7 @@ function evaluate(node, scope) {
1874
1979
  // Only allow safe constructors
1875
1980
  if (Ctor === Date || Ctor === Array || Ctor === Map || Ctor === Set ||
1876
1981
  Ctor === RegExp || Ctor === Error || Ctor === URL || Ctor === URLSearchParams) {
1877
- const args = node.args.map(a => evaluate(a, scope));
1982
+ const args = _evalArgs(node.args, scope);
1878
1983
  return new Ctor(...args);
1879
1984
  }
1880
1985
  return undefined;
@@ -1904,13 +2009,32 @@ function evaluate(node, scope) {
1904
2009
  return cond ? evaluate(node.truthy, scope) : evaluate(node.falsy, scope);
1905
2010
  }
1906
2011
 
1907
- case 'array':
1908
- return node.elements.map(e => evaluate(e, scope));
2012
+ case 'array': {
2013
+ const arr = [];
2014
+ for (const e of node.elements) {
2015
+ if (e.type === 'spread') {
2016
+ const iterable = evaluate(e.arg, scope);
2017
+ if (iterable != null && typeof iterable[Symbol.iterator] === 'function') {
2018
+ for (const v of iterable) arr.push(v);
2019
+ }
2020
+ } else {
2021
+ arr.push(evaluate(e, scope));
2022
+ }
2023
+ }
2024
+ return arr;
2025
+ }
1909
2026
 
1910
2027
  case 'object': {
1911
2028
  const obj = {};
1912
- for (const { key, value } of node.properties) {
1913
- obj[key] = evaluate(value, scope);
2029
+ for (const prop of node.properties) {
2030
+ if (prop.spread) {
2031
+ const source = evaluate(prop.value, scope);
2032
+ if (source != null && typeof source === 'object') {
2033
+ Object.assign(obj, source);
2034
+ }
2035
+ } else {
2036
+ obj[prop.key] = evaluate(prop.value, scope);
2037
+ }
1914
2038
  }
1915
2039
  return obj;
1916
2040
  }
@@ -1931,12 +2055,30 @@ function evaluate(node, scope) {
1931
2055
  }
1932
2056
  }
1933
2057
 
2058
+ /**
2059
+ * Evaluate a list of argument AST nodes, flattening any spread elements.
2060
+ */
2061
+ function _evalArgs(argNodes, scope) {
2062
+ const result = [];
2063
+ for (const a of argNodes) {
2064
+ if (a.type === 'spread') {
2065
+ const iterable = evaluate(a.arg, scope);
2066
+ if (iterable != null && typeof iterable[Symbol.iterator] === 'function') {
2067
+ for (const v of iterable) result.push(v);
2068
+ }
2069
+ } else {
2070
+ result.push(evaluate(a, scope));
2071
+ }
2072
+ }
2073
+ return result;
2074
+ }
2075
+
1934
2076
  /**
1935
2077
  * Resolve and execute a function call safely.
1936
2078
  */
1937
2079
  function _resolveCall(node, scope) {
1938
2080
  const callee = node.callee;
1939
- const args = node.args.map(a => evaluate(a, scope));
2081
+ const args = _evalArgs(node.args, scope);
1940
2082
 
1941
2083
  // Method call: obj.method() — bind `this` to obj
1942
2084
  if (callee.type === 'member' || callee.type === 'optional_member') {
@@ -2010,8 +2152,9 @@ function _evalBinary(node, scope) {
2010
2152
  * @returns {*} — evaluation result, or undefined on error
2011
2153
  */
2012
2154
 
2013
- // AST cache — avoids re-tokenizing and re-parsing the same expression string.
2014
- // Bounded to prevent unbounded memory growth in long-lived apps.
2155
+ // AST cache (LRU) — avoids re-tokenizing and re-parsing the same expression.
2156
+ // Uses Map insertion-order: on hit, delete + re-set moves entry to the end.
2157
+ // Eviction removes the least-recently-used (first) entry when at capacity.
2015
2158
  const _astCache = new Map();
2016
2159
  const _AST_CACHE_MAX = 512;
2017
2160
 
@@ -2031,9 +2174,12 @@ function safeEval(expr, scope) {
2031
2174
  // Fall through to full parser for built-in globals (Math, JSON, etc.)
2032
2175
  }
2033
2176
 
2034
- // Check AST cache
2177
+ // Check AST cache (LRU: move to end on hit)
2035
2178
  let ast = _astCache.get(trimmed);
2036
- if (!ast) {
2179
+ if (ast) {
2180
+ _astCache.delete(trimmed);
2181
+ _astCache.set(trimmed, ast);
2182
+ } else {
2037
2183
  const tokens = tokenize(trimmed);
2038
2184
  const parser = new Parser(tokens, scope);
2039
2185
  ast = parser.parse();
@@ -2297,8 +2443,11 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
2297
2443
  if (!lisSet.has(i)) {
2298
2444
  oldParent.insertBefore(oldNode, cursor);
2299
2445
  }
2446
+ // Capture next sibling BEFORE _morphNode — if _morphNode calls
2447
+ // replaceChild, oldNode is removed and nextSibling becomes stale.
2448
+ const nextSib = oldNode.nextSibling;
2300
2449
  _morphNode(oldParent, oldNode, newNode);
2301
- cursor = oldNode.nextSibling;
2450
+ cursor = nextSib;
2302
2451
  } else {
2303
2452
  // Insert new node
2304
2453
  const clone = newNode.cloneNode(true);
@@ -2476,10 +2625,13 @@ function _morphAttributes(oldEl, newEl) {
2476
2625
  }
2477
2626
  }
2478
2627
 
2479
- // Remove stale attributes
2480
- for (let i = oldLen - 1; i >= 0; i--) {
2481
- if (!newNames.has(oldAttrs[i].name)) {
2482
- oldEl.removeAttribute(oldAttrs[i].name);
2628
+ // Remove stale attributes — snapshot names first because oldAttrs
2629
+ // is a live NamedNodeMap that mutates on removeAttribute().
2630
+ const oldNames = new Array(oldLen);
2631
+ for (let i = 0; i < oldLen; i++) oldNames[i] = oldAttrs[i].name;
2632
+ for (let i = oldNames.length - 1; i >= 0; i--) {
2633
+ if (!newNames.has(oldNames[i])) {
2634
+ oldEl.removeAttribute(oldNames[i]);
2483
2635
  }
2484
2636
  }
2485
2637
  }
@@ -2801,7 +2953,7 @@ class Component {
2801
2953
  if (!watchers) return;
2802
2954
  for (const [key, handler] of Object.entries(watchers)) {
2803
2955
  // Match exact key or parent key (e.g. watcher on 'user' fires when 'user.name' changes)
2804
- if (changedKey === key || key.startsWith(changedKey + '.') || changedKey.startsWith(key + '.') || changedKey === key) {
2956
+ if (changedKey === key || key.startsWith(changedKey + '.') || changedKey.startsWith(key + '.')) {
2805
2957
  const currentVal = _getPath(this.state.__raw || this.state, key);
2806
2958
  const prevVal = this._prevWatchValues?.[key];
2807
2959
  if (currentVal !== prevVal) {
@@ -2950,20 +3102,26 @@ class Component {
2950
3102
  if (!this._mounted && combinedStyles) {
2951
3103
  const scopeAttr = `z-s${this._uid}`;
2952
3104
  this._el.setAttribute(scopeAttr, '');
2953
- let inAtBlock = 0;
3105
+ let noScopeDepth = 0; // brace depth at which a no-scope @-rule started (0 = none active)
3106
+ let braceDepth = 0; // overall brace depth
2954
3107
  const scoped = combinedStyles.replace(/([^{}]+)\{|\}/g, (match, selector) => {
2955
3108
  if (match === '}') {
2956
- if (inAtBlock > 0) inAtBlock--;
3109
+ if (noScopeDepth > 0 && braceDepth <= noScopeDepth) noScopeDepth = 0;
3110
+ braceDepth--;
2957
3111
  return match;
2958
3112
  }
3113
+ braceDepth++;
2959
3114
  const trimmed = selector.trim();
2960
- // Don't scope @-rules (@media, @keyframes, @supports, @container, @layer, @font-face, etc.)
3115
+ // Don't scope @-rules themselves
2961
3116
  if (trimmed.startsWith('@')) {
2962
- inAtBlock++;
3117
+ // @keyframes and @font-face contain non-selector content — skip scoping inside them
3118
+ if (/^@(keyframes|font-face)\b/.test(trimmed)) {
3119
+ noScopeDepth = braceDepth;
3120
+ }
2963
3121
  return match;
2964
3122
  }
2965
- // Don't scope keyframe stops (from, to, 0%, 50%, etc.)
2966
- if (inAtBlock > 0 && /^[\d%\s,fromto]+$/.test(trimmed.replace(/\s/g, ''))) {
3123
+ // Inside @keyframes or @font-face don't scope inner rules
3124
+ if (noScopeDepth > 0 && braceDepth > noScopeDepth) {
2967
3125
  return match;
2968
3126
  }
2969
3127
  return selector.split(',').map(s => `[${scopeAttr}] ${s.trim()}`).join(', ') + ' {';
@@ -3151,15 +3309,44 @@ class Component {
3151
3309
  const handler = (e) => {
3152
3310
  // Read bindings from live map — always up to date after re-renders
3153
3311
  const currentBindings = this._eventBindings?.get(event) || [];
3154
- for (const { selector, methodExpr, modifiers, el } of currentBindings) {
3155
- if (!e.target.closest(selector)) continue;
3312
+
3313
+ // Collect matching bindings with their matched elements, then sort
3314
+ // deepest-first so .stop correctly prevents ancestor handlers
3315
+ // (mimics real DOM bubbling order within delegated events).
3316
+ const hits = [];
3317
+ for (const binding of currentBindings) {
3318
+ const matched = e.target.closest(binding.selector);
3319
+ if (!matched) continue;
3320
+ hits.push({ ...binding, matched });
3321
+ }
3322
+ hits.sort((a, b) => {
3323
+ if (a.matched === b.matched) return 0;
3324
+ return a.matched.contains(b.matched) ? 1 : -1;
3325
+ });
3326
+
3327
+ let stoppedAt = null; // Track elements that called .stop
3328
+ for (const { selector, methodExpr, modifiers, el, matched } of hits) {
3329
+
3330
+ // In delegated events, .stop should prevent ancestor bindings from
3331
+ // firing — stopPropagation alone only stops real DOM bubbling.
3332
+ if (stoppedAt) {
3333
+ let blocked = false;
3334
+ for (const stopped of stoppedAt) {
3335
+ if (matched.contains(stopped) && matched !== stopped) { blocked = true; break; }
3336
+ }
3337
+ if (blocked) continue;
3338
+ }
3156
3339
 
3157
3340
  // .self — only fire if target is the element itself
3158
3341
  if (modifiers.includes('self') && e.target !== el) continue;
3159
3342
 
3160
3343
  // Handle modifiers
3161
3344
  if (modifiers.includes('prevent')) e.preventDefault();
3162
- if (modifiers.includes('stop')) e.stopPropagation();
3345
+ if (modifiers.includes('stop')) {
3346
+ e.stopPropagation();
3347
+ if (!stoppedAt) stoppedAt = [];
3348
+ stoppedAt.push(matched);
3349
+ }
3163
3350
 
3164
3351
  // Build the invocation function
3165
3352
  const invoke = (evt) => {
@@ -3591,6 +3778,21 @@ class Component {
3591
3778
  this._listeners = [];
3592
3779
  this._delegatedEvents = null;
3593
3780
  this._eventBindings = null;
3781
+ // Clear any pending debounce/throttle timers to prevent stale closures.
3782
+ // Timers are keyed by individual child elements, so iterate all descendants.
3783
+ const allEls = this._el.querySelectorAll('*');
3784
+ allEls.forEach(child => {
3785
+ const dTimers = _debounceTimers.get(child);
3786
+ if (dTimers) {
3787
+ for (const key in dTimers) clearTimeout(dTimers[key]);
3788
+ _debounceTimers.delete(child);
3789
+ }
3790
+ const tTimers = _throttleTimers.get(child);
3791
+ if (tTimers) {
3792
+ for (const key in tTimers) clearTimeout(tTimers[key]);
3793
+ _throttleTimers.delete(child);
3794
+ }
3795
+ });
3594
3796
  if (this._styleEl) this._styleEl.remove();
3595
3797
  _instances.delete(this._el);
3596
3798
  this._el.innerHTML = '';
@@ -3886,6 +4088,23 @@ function style(urls, opts = {}) {
3886
4088
  // Unique marker on history.state to identify zQuery-managed entries
3887
4089
  const _ZQ_STATE_KEY = '__zq';
3888
4090
 
4091
+ /**
4092
+ * Shallow-compare two flat objects (for params / query comparison).
4093
+ * Avoids JSON.stringify overhead on every navigation.
4094
+ */
4095
+ function _shallowEqual(a, b) {
4096
+ if (a === b) return true;
4097
+ if (!a || !b) return false;
4098
+ const keysA = Object.keys(a);
4099
+ const keysB = Object.keys(b);
4100
+ if (keysA.length !== keysB.length) return false;
4101
+ for (let i = 0; i < keysA.length; i++) {
4102
+ const k = keysA[i];
4103
+ if (a[k] !== b[k]) return false;
4104
+ }
4105
+ return true;
4106
+ }
4107
+
3889
4108
  class Router {
3890
4109
  constructor(config = {}) {
3891
4110
  this._el = null;
@@ -3933,11 +4152,12 @@ class Router {
3933
4152
  config.routes.forEach(r => this.add(r));
3934
4153
  }
3935
4154
 
3936
- // Listen for navigation
4155
+ // Listen for navigation — store handler references for cleanup in destroy()
3937
4156
  if (this._mode === 'hash') {
3938
- window.addEventListener('hashchange', () => this._resolve());
4157
+ this._onNavEvent = () => this._resolve();
4158
+ window.addEventListener('hashchange', this._onNavEvent);
3939
4159
  } else {
3940
- window.addEventListener('popstate', (e) => {
4160
+ this._onNavEvent = (e) => {
3941
4161
  // Check for substate pop first — if a listener handles it, don't route
3942
4162
  const st = e.state;
3943
4163
  if (st && st[_ZQ_STATE_KEY] === 'substate') {
@@ -3951,11 +4171,12 @@ class Router {
3951
4171
  this._fireSubstate(null, null, 'reset');
3952
4172
  }
3953
4173
  this._resolve();
3954
- });
4174
+ };
4175
+ window.addEventListener('popstate', this._onNavEvent);
3955
4176
  }
3956
4177
 
3957
4178
  // Intercept link clicks for SPA navigation
3958
- document.addEventListener('click', (e) => {
4179
+ this._onLinkClick = (e) => {
3959
4180
  // Don't intercept modified clicks (Ctrl/Cmd+click = new tab)
3960
4181
  if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
3961
4182
  const link = e.target.closest('[z-link]');
@@ -3977,7 +4198,8 @@ class Router {
3977
4198
  const scrollBehavior = link.getAttribute('z-to-top') || 'instant';
3978
4199
  window.scrollTo({ top: 0, behavior: scrollBehavior });
3979
4200
  }
3980
- });
4201
+ };
4202
+ document.addEventListener('click', this._onLinkClick);
3981
4203
 
3982
4204
  // Initial resolve
3983
4205
  if (this._el) {
@@ -4349,8 +4571,8 @@ class Router {
4349
4571
  // with the same params, skip the full destroy/mount cycle and just
4350
4572
  // update props. This prevents flashing and unnecessary DOM churn.
4351
4573
  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);
4574
+ const sameParams = _shallowEqual(params, from.params);
4575
+ const sameQuery = _shallowEqual(query, from.query);
4354
4576
  if (sameParams && sameQuery) {
4355
4577
  // Identical navigation — nothing to do
4356
4578
  return;
@@ -4425,7 +4647,12 @@ class Router {
4425
4647
  if (typeof matched.component === 'string') {
4426
4648
  const container = document.createElement(matched.component);
4427
4649
  this._el.appendChild(container);
4428
- this._instance = mount(container, matched.component, props);
4650
+ try {
4651
+ this._instance = mount(container, matched.component, props);
4652
+ } catch (err) {
4653
+ reportError(ErrorCode.COMP_NOT_FOUND, `Failed to mount component for route "${matched.path}"`, { component: matched.component, path: matched.path }, err);
4654
+ return;
4655
+ }
4429
4656
  if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', matched.component);
4430
4657
  }
4431
4658
  // If component is a render function
@@ -4447,6 +4674,15 @@ class Router {
4447
4674
  // --- Destroy -------------------------------------------------------------
4448
4675
 
4449
4676
  destroy() {
4677
+ // Remove window/document event listeners to prevent memory leaks
4678
+ if (this._onNavEvent) {
4679
+ window.removeEventListener(this._mode === 'hash' ? 'hashchange' : 'popstate', this._onNavEvent);
4680
+ this._onNavEvent = null;
4681
+ }
4682
+ if (this._onLinkClick) {
4683
+ document.removeEventListener('click', this._onLinkClick);
4684
+ this._onLinkClick = null;
4685
+ }
4450
4686
  if (this._instance) this._instance.destroy();
4451
4687
  this._listeners.clear();
4452
4688
  this._substateListeners = [];
@@ -4509,6 +4745,7 @@ class Store {
4509
4745
  this._getters = config.getters || {};
4510
4746
  this._middleware = [];
4511
4747
  this._history = []; // action log
4748
+ this._maxHistory = config.maxHistory || 1000;
4512
4749
  this._debug = config.debug || false;
4513
4750
 
4514
4751
  // Create reactive state
@@ -4568,6 +4805,10 @@ class Store {
4568
4805
  try {
4569
4806
  const result = action(this.state, ...args);
4570
4807
  this._history.push({ action: name, args, timestamp: Date.now() });
4808
+ // Cap history to prevent unbounded memory growth
4809
+ if (this._history.length > this._maxHistory) {
4810
+ this._history.splice(0, this._history.length - this._maxHistory);
4811
+ }
4571
4812
  return result;
4572
4813
  } catch (err) {
4573
4814
  reportError(ErrorCode.STORE_ACTION, `Action "${name}" threw`, { action: name, args }, err);
@@ -4725,11 +4966,28 @@ async function request(method, url, data, options = {}) {
4725
4966
 
4726
4967
  // Timeout via AbortController
4727
4968
  const controller = new AbortController();
4728
- fetchOpts.signal = options.signal || controller.signal;
4729
4969
  const timeout = options.timeout ?? _config.timeout;
4730
4970
  let timer;
4971
+ // Combine user signal with internal controller for proper timeout support
4972
+ if (options.signal) {
4973
+ // If AbortSignal.any is available, combine both signals
4974
+ if (typeof AbortSignal.any === 'function') {
4975
+ fetchOpts.signal = AbortSignal.any([options.signal, controller.signal]);
4976
+ } else {
4977
+ // Fallback: forward user signal's abort to our controller
4978
+ fetchOpts.signal = controller.signal;
4979
+ if (options.signal.aborted) {
4980
+ controller.abort(options.signal.reason);
4981
+ } else {
4982
+ options.signal.addEventListener('abort', () => controller.abort(options.signal.reason), { once: true });
4983
+ }
4984
+ }
4985
+ } else {
4986
+ fetchOpts.signal = controller.signal;
4987
+ }
4988
+ let _timedOut = false;
4731
4989
  if (timeout > 0) {
4732
- timer = setTimeout(() => controller.abort(), timeout);
4990
+ timer = setTimeout(() => { _timedOut = true; controller.abort(); }, timeout);
4733
4991
  }
4734
4992
 
4735
4993
  // Run request interceptors
@@ -4789,7 +5047,10 @@ async function request(method, url, data, options = {}) {
4789
5047
  } catch (err) {
4790
5048
  if (timer) clearTimeout(timer);
4791
5049
  if (err.name === 'AbortError') {
4792
- throw new Error(`Request timeout after ${timeout}ms: ${method} ${fullURL}`);
5050
+ if (_timedOut) {
5051
+ throw new Error(`Request timeout after ${timeout}ms: ${method} ${fullURL}`);
5052
+ }
5053
+ throw new Error(`Request aborted: ${method} ${fullURL}`);
4793
5054
  }
4794
5055
  throw err;
4795
5056
  }
@@ -4926,6 +5187,10 @@ function escapeHtml(str) {
4926
5187
  return String(str).replace(/[&<>"']/g, c => map[c]);
4927
5188
  }
4928
5189
 
5190
+ function stripHtml(str) {
5191
+ return String(str).replace(/<[^>]*>/g, '');
5192
+ }
5193
+
4929
5194
  /**
4930
5195
  * Template tag for auto-escaping interpolated values
4931
5196
  * Usage: $.html`<div>${userInput}</div>`
@@ -4971,7 +5236,10 @@ function camelCase(str) {
4971
5236
  * CamelCase to kebab-case
4972
5237
  */
4973
5238
  function kebabCase(str) {
4974
- return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
5239
+ return str
5240
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
5241
+ .replace(/([a-z\d])([A-Z])/g, '$1-$2')
5242
+ .toLowerCase();
4975
5243
  }
4976
5244
 
4977
5245
 
@@ -4991,30 +5259,40 @@ function deepClone(obj) {
4991
5259
  * Deep merge objects
4992
5260
  */
4993
5261
  function deepMerge(target, ...sources) {
4994
- for (const source of sources) {
4995
- for (const key of Object.keys(source)) {
4996
- if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
4997
- if (!target[key] || typeof target[key] !== 'object') target[key] = {};
4998
- deepMerge(target[key], source[key]);
5262
+ const seen = new WeakSet();
5263
+ function merge(tgt, src) {
5264
+ if (seen.has(src)) return tgt;
5265
+ seen.add(src);
5266
+ for (const key of Object.keys(src)) {
5267
+ if (src[key] && typeof src[key] === 'object' && !Array.isArray(src[key])) {
5268
+ if (!tgt[key] || typeof tgt[key] !== 'object') tgt[key] = {};
5269
+ merge(tgt[key], src[key]);
4999
5270
  } else {
5000
- target[key] = source[key];
5271
+ tgt[key] = src[key];
5001
5272
  }
5002
5273
  }
5274
+ return tgt;
5003
5275
  }
5276
+ for (const source of sources) merge(target, source);
5004
5277
  return target;
5005
5278
  }
5006
5279
 
5007
5280
  /**
5008
5281
  * Simple object equality check
5009
5282
  */
5010
- function isEqual(a, b) {
5283
+ function isEqual(a, b, _seen) {
5011
5284
  if (a === b) return true;
5012
5285
  if (typeof a !== typeof b) return false;
5013
5286
  if (typeof a !== 'object' || a === null || b === null) return false;
5287
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
5288
+ // Guard against circular references
5289
+ if (!_seen) _seen = new Set();
5290
+ if (_seen.has(a)) return true;
5291
+ _seen.add(a);
5014
5292
  const keysA = Object.keys(a);
5015
5293
  const keysB = Object.keys(b);
5016
5294
  if (keysA.length !== keysB.length) return false;
5017
- return keysA.every(k => isEqual(a[k], b[k]));
5295
+ return keysA.every(k => isEqual(a[k], b[k], _seen));
5018
5296
  }
5019
5297
 
5020
5298
 
@@ -5115,7 +5393,182 @@ class EventBus {
5115
5393
  clear() { this._handlers.clear(); }
5116
5394
  }
5117
5395
 
5118
- const bus = new EventBus();
5396
+ const bus = new EventBus();
5397
+
5398
+
5399
+ // ---------------------------------------------------------------------------
5400
+ // Array utilities
5401
+ // ---------------------------------------------------------------------------
5402
+
5403
+ function range(startOrEnd, end, step) {
5404
+ let s, e, st;
5405
+ if (end === undefined) { s = 0; e = startOrEnd; st = 1; }
5406
+ else { s = startOrEnd; e = end; st = step !== undefined ? step : 1; }
5407
+ if (st === 0) return [];
5408
+ const result = [];
5409
+ if (st > 0) { for (let i = s; i < e; i += st) result.push(i); }
5410
+ else { for (let i = s; i > e; i += st) result.push(i); }
5411
+ return result;
5412
+ }
5413
+
5414
+ function unique(arr, keyFn) {
5415
+ if (!keyFn) return [...new Set(arr)];
5416
+ const seen = new Set();
5417
+ return arr.filter(item => {
5418
+ const k = keyFn(item);
5419
+ if (seen.has(k)) return false;
5420
+ seen.add(k);
5421
+ return true;
5422
+ });
5423
+ }
5424
+
5425
+ function chunk(arr, size) {
5426
+ const result = [];
5427
+ for (let i = 0; i < arr.length; i += size) result.push(arr.slice(i, i + size));
5428
+ return result;
5429
+ }
5430
+
5431
+ function groupBy(arr, keyFn) {
5432
+ const result = {};
5433
+ for (const item of arr) {
5434
+ const k = keyFn(item);
5435
+ (result[k] ??= []).push(item);
5436
+ }
5437
+ return result;
5438
+ }
5439
+
5440
+
5441
+ // ---------------------------------------------------------------------------
5442
+ // Object utilities
5443
+ // ---------------------------------------------------------------------------
5444
+
5445
+ function pick(obj, keys) {
5446
+ const result = {};
5447
+ for (const k of keys) { if (k in obj) result[k] = obj[k]; }
5448
+ return result;
5449
+ }
5450
+
5451
+ function omit(obj, keys) {
5452
+ const exclude = new Set(keys);
5453
+ const result = {};
5454
+ for (const k of Object.keys(obj)) { if (!exclude.has(k)) result[k] = obj[k]; }
5455
+ return result;
5456
+ }
5457
+
5458
+ function getPath(obj, path, fallback) {
5459
+ const keys = path.split('.');
5460
+ let cur = obj;
5461
+ for (const k of keys) {
5462
+ if (cur == null || typeof cur !== 'object') return fallback;
5463
+ cur = cur[k];
5464
+ }
5465
+ return cur === undefined ? fallback : cur;
5466
+ }
5467
+
5468
+ function setPath(obj, path, value) {
5469
+ const keys = path.split('.');
5470
+ let cur = obj;
5471
+ for (let i = 0; i < keys.length - 1; i++) {
5472
+ const k = keys[i];
5473
+ if (cur[k] == null || typeof cur[k] !== 'object') cur[k] = {};
5474
+ cur = cur[k];
5475
+ }
5476
+ cur[keys[keys.length - 1]] = value;
5477
+ return obj;
5478
+ }
5479
+
5480
+ function isEmpty(val) {
5481
+ if (val == null) return true;
5482
+ if (typeof val === 'string' || Array.isArray(val)) return val.length === 0;
5483
+ if (val instanceof Map || val instanceof Set) return val.size === 0;
5484
+ if (typeof val === 'object') return Object.keys(val).length === 0;
5485
+ return false;
5486
+ }
5487
+
5488
+
5489
+ // ---------------------------------------------------------------------------
5490
+ // String utilities
5491
+ // ---------------------------------------------------------------------------
5492
+
5493
+ function capitalize(str) {
5494
+ if (!str) return '';
5495
+ return str[0].toUpperCase() + str.slice(1).toLowerCase();
5496
+ }
5497
+
5498
+ function truncate(str, maxLen, suffix = '…') {
5499
+ if (str.length <= maxLen) return str;
5500
+ const end = Math.max(0, maxLen - suffix.length);
5501
+ return str.slice(0, end) + suffix;
5502
+ }
5503
+
5504
+
5505
+ // ---------------------------------------------------------------------------
5506
+ // Number utilities
5507
+ // ---------------------------------------------------------------------------
5508
+
5509
+ function clamp(val, min, max) {
5510
+ return val < min ? min : val > max ? max : val;
5511
+ }
5512
+
5513
+
5514
+ // ---------------------------------------------------------------------------
5515
+ // Function utilities
5516
+ // ---------------------------------------------------------------------------
5517
+
5518
+ function memoize(fn, keyFnOrOpts) {
5519
+ let keyFn, maxSize = 0;
5520
+ if (typeof keyFnOrOpts === 'function') keyFn = keyFnOrOpts;
5521
+ else if (keyFnOrOpts && typeof keyFnOrOpts === 'object') maxSize = keyFnOrOpts.maxSize || 0;
5522
+
5523
+ const cache = new Map();
5524
+
5525
+ const memoized = (...args) => {
5526
+ const key = keyFn ? keyFn(...args) : args[0];
5527
+ if (cache.has(key)) return cache.get(key);
5528
+ const result = fn(...args);
5529
+ cache.set(key, result);
5530
+ if (maxSize > 0 && cache.size > maxSize) {
5531
+ cache.delete(cache.keys().next().value);
5532
+ }
5533
+ return result;
5534
+ };
5535
+
5536
+ memoized.clear = () => cache.clear();
5537
+ return memoized;
5538
+ }
5539
+
5540
+
5541
+ // ---------------------------------------------------------------------------
5542
+ // Async utilities
5543
+ // ---------------------------------------------------------------------------
5544
+
5545
+ function retry(fn, opts = {}) {
5546
+ const { attempts = 3, delay = 1000, backoff = 1 } = opts;
5547
+ return new Promise((resolve, reject) => {
5548
+ let attempt = 0, currentDelay = delay;
5549
+ const tryOnce = () => {
5550
+ attempt++;
5551
+ fn(attempt).then(resolve, (err) => {
5552
+ if (attempt >= attempts) return reject(err);
5553
+ const d = currentDelay;
5554
+ currentDelay *= backoff;
5555
+ setTimeout(tryOnce, d);
5556
+ });
5557
+ };
5558
+ tryOnce();
5559
+ });
5560
+ }
5561
+
5562
+ function timeout(promise, ms, message) {
5563
+ let timer;
5564
+ const race = Promise.race([
5565
+ promise,
5566
+ new Promise((_, reject) => {
5567
+ timer = setTimeout(() => reject(new Error(message || `Timed out after ${ms}ms`)), ms);
5568
+ })
5569
+ ]);
5570
+ return race.finally(() => clearTimeout(timer));
5571
+ }
5119
5572
 
5120
5573
  // --- index.js (assembly) ------------------------------------------
5121
5574
  /**
@@ -5174,6 +5627,8 @@ Object.defineProperty($, 'name', {
5174
5627
  value: query.name, writable: true, configurable: true
5175
5628
  });
5176
5629
  $.children = query.children;
5630
+ $.qs = query.qs;
5631
+ $.qsa = query.qsa;
5177
5632
 
5178
5633
  // --- Collection selector ---------------------------------------------------
5179
5634
  /**
@@ -5242,8 +5697,10 @@ $.pipe = pipe;
5242
5697
  $.once = once;
5243
5698
  $.sleep = sleep;
5244
5699
  $.escapeHtml = escapeHtml;
5700
+ $.stripHtml = stripHtml;
5245
5701
  $.html = html;
5246
5702
  $.trust = trust;
5703
+ $.TrustedHTML = TrustedHTML;
5247
5704
  $.uuid = uuid;
5248
5705
  $.camelCase = camelCase;
5249
5706
  $.kebabCase = kebabCase;
@@ -5254,7 +5711,23 @@ $.param = param;
5254
5711
  $.parseQuery = parseQuery;
5255
5712
  $.storage = storage;
5256
5713
  $.session = session;
5714
+ $.EventBus = EventBus;
5257
5715
  $.bus = bus;
5716
+ $.range = range;
5717
+ $.unique = unique;
5718
+ $.chunk = chunk;
5719
+ $.groupBy = groupBy;
5720
+ $.pick = pick;
5721
+ $.omit = omit;
5722
+ $.getPath = getPath;
5723
+ $.setPath = setPath;
5724
+ $.isEmpty = isEmpty;
5725
+ $.capitalize = capitalize;
5726
+ $.truncate = truncate;
5727
+ $.clamp = clamp;
5728
+ $.memoize = memoize;
5729
+ $.retry = retry;
5730
+ $.timeout = timeout;
5258
5731
 
5259
5732
  // --- Error handling --------------------------------------------------------
5260
5733
  $.onError = onError;
@@ -5264,8 +5737,8 @@ $.guardCallback = guardCallback;
5264
5737
  $.validate = validate;
5265
5738
 
5266
5739
  // --- Meta ------------------------------------------------------------------
5267
- $.version = '0.9.0';
5268
- $.libSize = '~89 KB';
5740
+ $.version = '0.9.5';
5741
+ $.libSize = '~98 KB';
5269
5742
  $.meta = {}; // populated at build time by CLI bundler
5270
5743
 
5271
5744
  $.noConflict = () => {