zero-query 1.2.0 → 1.2.3

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) v1.2.0
2
+ * zQuery (zeroQuery) v1.2.3
3
3
  * Lightweight Frontend Library
4
4
  * https://github.com/tonywied17/zero-query
5
5
  * (c) 2026 Anthony Wiedman - MIT License
@@ -3865,11 +3865,27 @@ const webrtc = {
3865
3865
  */
3866
3866
 
3867
3867
 
3868
+ // ---------------------------------------------------------------------------
3869
+ // Host-object passthrough
3870
+ // ---------------------------------------------------------------------------
3871
+ // The reactive proxy is meant for plain data: state bags, arrays, nested
3872
+ // records. Wrapping host/native objects (MediaStream, RTCPeerConnection,
3873
+ // Blob, Map, Date, etc.) breaks them - the proxy receiver is not the real
3874
+ // underlying object, so any internal-slot method call throws
3875
+ // "Illegal invocation" in the browser. Only proxy values whose prototype is
3876
+ // Object.prototype/null (plain objects) or that are Arrays.
3877
+ function _isProxyable(value) {
3878
+ if (value === null || typeof value !== 'object') return false;
3879
+ if (Array.isArray(value)) return true;
3880
+ const proto = Object.getPrototypeOf(value);
3881
+ return proto === Object.prototype || proto === null;
3882
+ }
3883
+
3868
3884
  // ---------------------------------------------------------------------------
3869
3885
  // Deep reactive proxy
3870
3886
  // ---------------------------------------------------------------------------
3871
3887
  function reactive(target, onChange, _path = '') {
3872
- if (typeof target !== 'object' || target === null) return target;
3888
+ if (!_isProxyable(target)) return target;
3873
3889
  if (typeof onChange !== 'function') {
3874
3890
  reportError(ErrorCode.REACTIVE_CALLBACK, 'reactive() onChange must be a function', { received: typeof onChange });
3875
3891
  onChange = () => {};
@@ -3883,7 +3899,7 @@ function reactive(target, onChange, _path = '') {
3883
3899
  if (key === '__raw') return obj;
3884
3900
 
3885
3901
  const value = obj[key];
3886
- if (typeof value === 'object' && value !== null) {
3902
+ if (_isProxyable(value)) {
3887
3903
  // Return cached proxy or create new one
3888
3904
  if (proxyCache.has(value)) return proxyCache.get(value);
3889
3905
  const childProxy = new Proxy(value, handler);
@@ -4896,11 +4912,17 @@ class ZQueryCollection {
4896
4912
  });
4897
4913
  }
4898
4914
  if (value === undefined) return this.first()?.getAttribute(name);
4899
- return this.each((_, el) => el.setAttribute(name, value));
4915
+ // Fast path: tight loop, no closure allocation, no `each` overhead.
4916
+ // Mirrors the addClass/removeClass single-name fast path.
4917
+ const els = this.elements;
4918
+ for (let i = 0; i < els.length; i++) els[i].setAttribute(name, value);
4919
+ return this;
4900
4920
  }
4901
4921
 
4902
4922
  removeAttr(name) {
4903
- return this.each((_, el) => el.removeAttribute(name));
4923
+ const els = this.elements;
4924
+ for (let i = 0; i < els.length; i++) els[i].removeAttribute(name);
4925
+ return this;
4904
4926
  }
4905
4927
 
4906
4928
  prop(name, value) {
@@ -5465,7 +5487,9 @@ function queryAll(selector, context) {
5465
5487
  // Quick-ref shortcuts, on $ namespace)
5466
5488
  // ---------------------------------------------------------------------------
5467
5489
  query.id = (id) => document.getElementById(id);
5468
- query.class = (name) => document.querySelector(`.${name}`);
5490
+ // Escape the class name so callers can safely pass identifiers containing
5491
+ // dots, colons, leading digits, etc. without breaking the selector.
5492
+ query.class = (name) => document.querySelector(`.${typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(name) : name}`);
5469
5493
  query.classes = (name) => new ZQueryCollection(Array.from(document.getElementsByClassName(name)));
5470
5494
  query.tag = (name) => new ZQueryCollection(Array.from(document.getElementsByTagName(name)));
5471
5495
  Object.defineProperty(query, 'name', {
@@ -5754,12 +5778,28 @@ class Parser {
5754
5778
 
5755
5779
  // Main entry
5756
5780
  parse() {
5781
+ this.depth = 0;
5757
5782
  const result = this.parseExpression(0);
5758
5783
  return result;
5759
5784
  }
5760
5785
 
5761
5786
  // Precedence climbing
5762
5787
  parseExpression(minPrec) {
5788
+ // Cap nesting depth so a deeply parenthesised or recursive expression
5789
+ // can’t stack-overflow the host. Threshold (96) is generous - real
5790
+ // template expressions are flat - but stops pathological inputs cold.
5791
+ if (++this.depth > 96) {
5792
+ this.depth--;
5793
+ throw new Error('Expression nesting depth exceeded (max 96)');
5794
+ }
5795
+ try {
5796
+ return this._parseExpressionImpl(minPrec);
5797
+ } finally {
5798
+ this.depth--;
5799
+ }
5800
+ }
5801
+
5802
+ _parseExpressionImpl(minPrec) {
5763
5803
  let left = this.parseUnary();
5764
5804
 
5765
5805
  while (true) {
@@ -6086,13 +6126,6 @@ class Parser {
6086
6126
  // ---------------------------------------------------------------------------
6087
6127
 
6088
6128
  /** Safe property access whitelist for built-in prototypes */
6089
- const SAFE_ARRAY_METHODS = new Set([
6090
- 'length', 'map', 'filter', 'find', 'findIndex', 'some', 'every',
6091
- 'reduce', 'reduceRight', 'forEach', 'includes', 'indexOf', 'lastIndexOf',
6092
- 'join', 'slice', 'concat', 'flat', 'flatMap', 'reverse', 'sort',
6093
- 'fill', 'keys', 'values', 'entries', 'at', 'toString',
6094
- ]);
6095
-
6096
6129
  const SAFE_STRING_METHODS = new Set([
6097
6130
  'length', 'charAt', 'charCodeAt', 'includes', 'indexOf', 'lastIndexOf',
6098
6131
  'slice', 'substring', 'trim', 'trimStart', 'trimEnd', 'toLowerCase',
@@ -6105,18 +6138,6 @@ const SAFE_NUMBER_METHODS = new Set([
6105
6138
  'toFixed', 'toPrecision', 'toString', 'valueOf',
6106
6139
  ]);
6107
6140
 
6108
- const SAFE_OBJECT_METHODS = new Set([
6109
- 'hasOwnProperty', 'toString', 'valueOf',
6110
- ]);
6111
-
6112
- const SAFE_MATH_PROPS = new Set([
6113
- 'PI', 'E', 'LN2', 'LN10', 'LOG2E', 'LOG10E', 'SQRT2', 'SQRT1_2',
6114
- 'abs', 'ceil', 'floor', 'round', 'trunc', 'max', 'min', 'pow',
6115
- 'sqrt', 'sign', 'random', 'log', 'log2', 'log10',
6116
- ]);
6117
-
6118
- const SAFE_JSON_PROPS = new Set(['parse', 'stringify']);
6119
-
6120
6141
  /**
6121
6142
  * Check if property access is safe
6122
6143
  */
@@ -6414,8 +6435,18 @@ function _evalBinary(node, scope) {
6414
6435
  const _astCache = new Map();
6415
6436
  const _AST_CACHE_MAX = 512;
6416
6437
 
6438
+ // Maximum source length accepted by safeEval. Real template expressions
6439
+ // are tens of bytes; anything beyond 8 KB is almost certainly an attack
6440
+ // payload or accidental dump. Reject up front to avoid feeding the
6441
+ // tokenizer and parser unbounded input.
6442
+ const _EXPR_MAX_LEN = 8192;
6443
+
6417
6444
  function safeEval(expr, scope) {
6418
6445
  try {
6446
+ if (typeof expr !== 'string') return undefined;
6447
+ if (expr.length > _EXPR_MAX_LEN) {
6448
+ throw new Error(`Expression exceeds max length (${_EXPR_MAX_LEN} bytes)`);
6449
+ }
6419
6450
  const trimmed = expr.trim();
6420
6451
  if (!trimmed) return undefined;
6421
6452
 
@@ -6649,6 +6680,10 @@ class Component {
6649
6680
  this._updateQueued = false;
6650
6681
  this._listeners = [];
6651
6682
  this._watchCleanups = [];
6683
+ // Elements that have scheduled debounce/throttle timers under this
6684
+ // component instance. Tracked so destroy() can clear them even if the
6685
+ // element is detached from the subtree before teardown.
6686
+ this._timerEls = new Set();
6652
6687
 
6653
6688
  // Refs map
6654
6689
  this.refs = {};
@@ -6673,15 +6708,21 @@ class Component {
6673
6708
  if (definition.props && typeof definition.props === 'object' && !Array.isArray(definition.props)) {
6674
6709
  // Reactive props with type coercion and defaults
6675
6710
  this.props = this._resolveReactiveProps(definition.props, props);
6676
- // MutationObserver to re-read props when parent re-renders and changes attributes
6711
+ // MutationObserver to re-read props when parent re-renders and changes attributes.
6712
+ // attributeFilter limits callbacks to the declared prop names (plus their
6713
+ // dynamic `:name` aliases) — unrelated attribute churn no longer fires.
6714
+ const propNames = Object.keys(definition.props);
6715
+ const filter = [];
6716
+ for (const n of propNames) {
6717
+ filter.push(n.toLowerCase());
6718
+ filter.push(':' + n.toLowerCase());
6719
+ }
6677
6720
  this._propObserver = new MutationObserver((mutations) => {
6678
6721
  if (this._destroyed) return;
6679
6722
  let changed = false;
6680
6723
  for (const mut of mutations) {
6681
6724
  if (mut.type === 'attributes') {
6682
6725
  const attrName = mut.attributeName;
6683
- // Skip internal attributes
6684
- if (attrName.startsWith('z-') || attrName.startsWith('@') || attrName.startsWith(':') || attrName.startsWith('data-zq')) continue;
6685
6726
  // Check if this is a defined prop (attribute names are lowercase)
6686
6727
  const propName = attrName.startsWith(':') ? attrName.slice(1) : attrName;
6687
6728
  if (propName in definition.props) {
@@ -6694,7 +6735,7 @@ class Component {
6694
6735
  this._scheduleUpdate();
6695
6736
  }
6696
6737
  });
6697
- this._propObserver.observe(el, { attributes: true });
6738
+ this._propObserver.observe(el, { attributes: true, attributeFilter: filter });
6698
6739
  } else {
6699
6740
  // Legacy: frozen props from parent
6700
6741
  this.props = Object.freeze({ ...props });
@@ -6976,44 +7017,60 @@ class Component {
6976
7017
 
6977
7018
  // Apply scoped styles on first render
6978
7019
  if (!this._mounted && combinedStyles) {
6979
- const scopeAttr = `z-s${this._uid}`;
7020
+ const def = this._def;
7021
+ // Use a stable per-definition scope attr so all instances share one
7022
+ // <style> tag (ref-counted). Anonymous defs fall back to per-instance
7023
+ // scoping for safety.
7024
+ let scopeAttr = def._scopeAttr;
7025
+ if (!scopeAttr) {
7026
+ scopeAttr = def._name ? `z-s-${def._name}` : `z-s${this._uid}`;
7027
+ def._scopeAttr = scopeAttr;
7028
+ }
6980
7029
  this._el.setAttribute(scopeAttr, '');
6981
- let noScopeDepth = 0; // brace depth at which a no-scope @-rule started (0 = none active)
6982
- let braceDepth = 0; // overall brace depth
6983
- const scoped = combinedStyles.replace(/([^{}]+)\{|\}/g, (match, selector) => {
6984
- if (match === '}') {
6985
- if (noScopeDepth > 0 && braceDepth <= noScopeDepth) noScopeDepth = 0;
6986
- braceDepth--;
6987
- return match;
6988
- }
6989
- braceDepth++;
6990
- const trimmed = selector.trim();
6991
- // Don't scope @-rules themselves
6992
- if (trimmed.startsWith('@')) {
6993
- // @keyframes and @font-face contain non-selector content - skip scoping inside them
6994
- if (/^@(keyframes|font-face)\b/.test(trimmed)) {
6995
- noScopeDepth = braceDepth;
7030
+ this._scopeAttr = scopeAttr;
7031
+
7032
+ if (def._styleEl && def._styleEl.isConnected) {
7033
+ // Already injected by a previous instance — just bump refcount.
7034
+ def._styleRefCount = (def._styleRefCount || 0) + 1;
7035
+ } else {
7036
+ let noScopeDepth = 0; // brace depth at which a no-scope @-rule started (0 = none active)
7037
+ let braceDepth = 0; // overall brace depth
7038
+ const scoped = combinedStyles.replace(/([^{}]+)\{|\}/g, (match, selector) => {
7039
+ if (match === '}') {
7040
+ if (noScopeDepth > 0 && braceDepth <= noScopeDepth) noScopeDepth = 0;
7041
+ braceDepth--;
7042
+ return match;
7043
+ }
7044
+ braceDepth++;
7045
+ const trimmed = selector.trim();
7046
+ // Don't scope @-rules themselves
7047
+ if (trimmed.startsWith('@')) {
7048
+ // @keyframes and @font-face contain non-selector content - skip scoping inside them
7049
+ if (/^@(keyframes|font-face)\b/.test(trimmed)) {
7050
+ noScopeDepth = braceDepth;
7051
+ }
7052
+ return match;
7053
+ }
7054
+ // Inside @keyframes or @font-face - don't scope inner rules
7055
+ if (noScopeDepth > 0 && braceDepth > noScopeDepth) {
7056
+ return match;
7057
+ }
7058
+ return selector.split(',').map(s => `[${scopeAttr}] ${s.trim()}`).join(', ') + ' {';
7059
+ });
7060
+ const styleEl = document.createElement('style');
7061
+ styleEl.textContent = scoped;
7062
+ styleEl.setAttribute('data-zq-component', def._name || '');
7063
+ styleEl.setAttribute('data-zq-scope', scopeAttr);
7064
+ if (def._resolvedStyleUrls) {
7065
+ styleEl.setAttribute('data-zq-style-urls', def._resolvedStyleUrls.join(' '));
7066
+ if (def.styles) {
7067
+ styleEl.setAttribute('data-zq-inline', def.styles);
6996
7068
  }
6997
- return match;
6998
- }
6999
- // Inside @keyframes or @font-face - don't scope inner rules
7000
- if (noScopeDepth > 0 && braceDepth > noScopeDepth) {
7001
- return match;
7002
- }
7003
- return selector.split(',').map(s => `[${scopeAttr}] ${s.trim()}`).join(', ') + ' {';
7004
- });
7005
- const styleEl = document.createElement('style');
7006
- styleEl.textContent = scoped;
7007
- styleEl.setAttribute('data-zq-component', this._def._name || '');
7008
- styleEl.setAttribute('data-zq-scope', scopeAttr);
7009
- if (this._def._resolvedStyleUrls) {
7010
- styleEl.setAttribute('data-zq-style-urls', this._def._resolvedStyleUrls.join(' '));
7011
- if (this._def.styles) {
7012
- styleEl.setAttribute('data-zq-inline', this._def.styles);
7013
7069
  }
7070
+ document.head.appendChild(styleEl);
7071
+ def._styleEl = styleEl;
7072
+ def._styleRefCount = 1;
7014
7073
  }
7015
- document.head.appendChild(styleEl);
7016
- this._styleEl = styleEl;
7017
7074
  }
7018
7075
 
7019
7076
  // -- Focus preservation ----------------------------------------
@@ -7222,7 +7279,7 @@ class Component {
7222
7279
  });
7223
7280
 
7224
7281
  let stoppedAt = null; // Track elements that called .stop
7225
- for (const { selector, methodExpr, modifiers, el, matched } of hits) {
7282
+ for (const { methodExpr, modifiers, el, matched } of hits) {
7226
7283
 
7227
7284
  // In delegated events, .stop should prevent ancestor bindings from
7228
7285
  // firing - stopPropagation alone only stops real DOM bubbling.
@@ -7316,6 +7373,7 @@ class Component {
7316
7373
  clearTimeout(timers[event]);
7317
7374
  timers[event] = setTimeout(() => invoke(e), ms);
7318
7375
  _debounceTimers.set(el, timers);
7376
+ this._timerEls.add(el);
7319
7377
  continue;
7320
7378
  }
7321
7379
 
@@ -7328,6 +7386,7 @@ class Component {
7328
7386
  invoke(e);
7329
7387
  timers[event] = setTimeout(() => { timers[event] = null; }, ms);
7330
7388
  _throttleTimers.set(el, timers);
7389
+ this._timerEls.add(el);
7331
7390
  continue;
7332
7391
  }
7333
7392
 
@@ -7410,10 +7469,15 @@ class Component {
7410
7469
  : isEditable ? 'input' : 'input';
7411
7470
 
7412
7471
  // -- Handler: read DOM → write to reactive state -------------
7413
- // Skip if already bound (morph preserves existing elements,
7414
- // so re-binding would stack duplicate listeners)
7415
- if (el._zqModelBound) return;
7416
- el._zqModelBound = true;
7472
+ // Remove any previously-bound z-model listener on this element so
7473
+ // re-runs (re-render, keyed reconciliation, modifier change) replace
7474
+ // instead of stacking. A boolean "already bound" flag is not enough:
7475
+ // if the bound state key / modifiers ever change, the stale handler
7476
+ // would keep writing to the old key.
7477
+ if (el._zqModelUnbind) {
7478
+ try { el._zqModelUnbind(); } catch { /* ignore */ }
7479
+ el._zqModelUnbind = null;
7480
+ }
7417
7481
 
7418
7482
  const handler = () => {
7419
7483
  let val;
@@ -7435,12 +7499,18 @@ class Component {
7435
7499
 
7436
7500
  if (hasDebounce) {
7437
7501
  let timer = null;
7438
- el.addEventListener(event, () => {
7502
+ const debounced = () => {
7439
7503
  clearTimeout(timer);
7440
7504
  timer = setTimeout(handler, debounceMs);
7441
- });
7505
+ };
7506
+ el.addEventListener(event, debounced);
7507
+ el._zqModelUnbind = () => {
7508
+ el.removeEventListener(event, debounced);
7509
+ clearTimeout(timer);
7510
+ };
7442
7511
  } else {
7443
7512
  el.addEventListener(event, handler);
7513
+ el._zqModelUnbind = () => el.removeEventListener(event, handler);
7444
7514
  }
7445
7515
  });
7446
7516
  }
@@ -7665,19 +7735,20 @@ class Component {
7665
7735
  }
7666
7736
  });
7667
7737
 
7668
- // -- z-bind:attr / :attr (dynamic attribute binding) -----------
7669
- // Use TreeWalker instead of querySelectorAll('*') - avoids
7670
- // creating a flat array of every single descendant element.
7671
- // TreeWalker visits nodes lazily; FILTER_REJECT skips z-pre subtrees
7672
- // at the walker level (faster than per-node closest('[z-pre]') checks).
7738
+ // -- Single TreeWalker pass for z-bind/:attr, z-class, z-style,
7739
+ // z-stream, z-cloak. One walk amortises the cost across five
7740
+ // directives that used to issue separate querySelectorAll calls.
7741
+ // FILTER_REJECT on z-pre subtrees skips per-node closest() checks.
7673
7742
  const walker = document.createTreeWalker(this._el, NodeFilter.SHOW_ELEMENT, {
7674
7743
  acceptNode(n) {
7675
7744
  return n.hasAttribute('z-pre') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
7676
7745
  }
7677
7746
  });
7747
+ const hasMediaStream = typeof MediaStream !== 'undefined';
7678
7748
  let node;
7679
7749
  while ((node = walker.nextNode())) {
7680
7750
  const attrs = node.attributes;
7751
+ // z-bind:* / :* — iterate attrs once (reverse to allow remove)
7681
7752
  for (let i = attrs.length - 1; i >= 0; i--) {
7682
7753
  const attr = attrs[i];
7683
7754
  let attrName;
@@ -7688,71 +7759,67 @@ class Component {
7688
7759
  const val = this._evalExpr(attr.value);
7689
7760
  node.removeAttribute(attr.name);
7690
7761
  if (val === false || val === null || val === undefined) {
7691
- node.removeAttribute(attrName);
7762
+ node.toggleAttribute(attrName, false);
7692
7763
  } else if (val === true) {
7693
- node.setAttribute(attrName, '');
7764
+ node.toggleAttribute(attrName, true);
7694
7765
  } else {
7695
7766
  node.setAttribute(attrName, String(val));
7696
7767
  }
7697
7768
  }
7698
- }
7699
7769
 
7700
- // -- z-class (dynamic class binding) ---------------------------
7701
- this._el.querySelectorAll('[z-class]').forEach(el => {
7702
- if (el.closest('[z-pre]')) return;
7703
- const val = this._evalExpr(el.getAttribute('z-class'));
7704
- if (typeof val === 'string') {
7705
- val.split(/\s+/).filter(Boolean).forEach(c => el.classList.add(c));
7706
- } else if (Array.isArray(val)) {
7707
- val.filter(Boolean).forEach(c => el.classList.add(String(c)));
7708
- } else if (val && typeof val === 'object') {
7709
- for (const [cls, active] of Object.entries(val)) {
7710
- el.classList.toggle(cls, !!active);
7770
+ // z-class
7771
+ if (node.hasAttribute('z-class')) {
7772
+ const val = this._evalExpr(node.getAttribute('z-class'));
7773
+ if (typeof val === 'string') {
7774
+ val.split(/\s+/).filter(Boolean).forEach(c => node.classList.add(c));
7775
+ } else if (Array.isArray(val)) {
7776
+ val.filter(Boolean).forEach(c => node.classList.add(String(c)));
7777
+ } else if (val && typeof val === 'object') {
7778
+ for (const [cls, active] of Object.entries(val)) {
7779
+ node.classList.toggle(cls, !!active);
7780
+ }
7711
7781
  }
7782
+ node.removeAttribute('z-class');
7712
7783
  }
7713
- el.removeAttribute('z-class');
7714
- });
7715
7784
 
7716
- // -- z-style (dynamic inline styles) ---------------------------
7717
- this._el.querySelectorAll('[z-style]').forEach(el => {
7718
- if (el.closest('[z-pre]')) return;
7719
- const val = this._evalExpr(el.getAttribute('z-style'));
7720
- if (typeof val === 'string') {
7721
- el.style.cssText += ';' + val;
7722
- } else if (val && typeof val === 'object') {
7723
- for (const [prop, v] of Object.entries(val)) {
7724
- el.style[prop] = v;
7785
+ // z-style
7786
+ if (node.hasAttribute('z-style')) {
7787
+ const val = this._evalExpr(node.getAttribute('z-style'));
7788
+ if (typeof val === 'string') {
7789
+ node.style.cssText += ';' + val;
7790
+ } else if (val && typeof val === 'object') {
7791
+ for (const [prop, v] of Object.entries(val)) {
7792
+ node.style[prop] = v;
7793
+ }
7725
7794
  }
7795
+ node.removeAttribute('z-style');
7726
7796
  }
7727
- el.removeAttribute('z-style');
7728
- });
7729
7797
 
7730
- // -- z-stream (assign MediaStream to <video>/<audio>.srcObject) -
7731
- this._el.querySelectorAll('[z-stream]').forEach(el => {
7732
- if (el.closest('[z-pre]')) return;
7733
- const val = this._evalExpr(el.getAttribute('z-stream'));
7734
- const hasMediaStream = typeof MediaStream !== 'undefined';
7735
- if (val == null) {
7736
- el.srcObject = null;
7737
- } else if (hasMediaStream && val instanceof MediaStream) {
7738
- el.srcObject = val;
7739
- } else if (val && typeof val.getTracks === 'function') {
7740
- // Accept duck-typed stream objects (test fakes, polyfills).
7741
- el.srcObject = val;
7742
- } else {
7743
- el.srcObject = null;
7798
+ // z-stream MediaStream <video>/<audio>.srcObject
7799
+ if (node.hasAttribute('z-stream')) {
7800
+ const val = this._evalExpr(node.getAttribute('z-stream'));
7801
+ if (val == null) {
7802
+ node.srcObject = null;
7803
+ } else if (hasMediaStream && val instanceof MediaStream) {
7804
+ node.srcObject = val;
7805
+ } else if (val && typeof val.getTracks === 'function') {
7806
+ // Accept duck-typed stream objects (test fakes, polyfills).
7807
+ node.srcObject = val;
7808
+ } else {
7809
+ node.srcObject = null;
7810
+ }
7811
+ node.removeAttribute('z-stream');
7744
7812
  }
7745
- el.removeAttribute('z-stream');
7746
- });
7813
+
7814
+ // z-cloak (just strip)
7815
+ if (node.hasAttribute('z-cloak')) {
7816
+ node.removeAttribute('z-cloak');
7817
+ }
7818
+ }
7747
7819
 
7748
7820
  // z-html and z-text are now pre-expanded at string level (before
7749
7821
  // morph) via _expandContentDirectives(), so the diff engine can
7750
7822
  // properly diff their content instead of clearing + re-injecting.
7751
-
7752
- // -- z-cloak (remove after render) -----------------------------
7753
- this._el.querySelectorAll('[z-cloak]').forEach(el => {
7754
- el.removeAttribute('z-cloak');
7755
- });
7756
7823
  }
7757
7824
 
7758
7825
  // ---------------------------------------------------------------------------
@@ -7884,21 +7951,34 @@ class Component {
7884
7951
  this._delegatedEvents = null;
7885
7952
  this._eventBindings = null;
7886
7953
  // Clear any pending debounce/throttle timers to prevent stale closures.
7887
- // Timers are keyed by individual child elements, so iterate all descendants.
7888
- const allEls = this._el.querySelectorAll('*');
7889
- allEls.forEach(child => {
7890
- const dTimers = _debounceTimers.get(child);
7891
- if (dTimers) {
7892
- for (const key in dTimers) clearTimeout(dTimers[key]);
7893
- _debounceTimers.delete(child);
7894
- }
7895
- const tTimers = _throttleTimers.get(child);
7896
- if (tTimers) {
7897
- for (const key in tTimers) clearTimeout(tTimers[key]);
7898
- _throttleTimers.delete(child);
7954
+ // Use the per-instance _timerEls set so detached elements (removed by
7955
+ // z-if / z-for / keyed reconciliation before destroy) are still cleaned.
7956
+ if (this._timerEls) {
7957
+ this._timerEls.forEach(child => {
7958
+ const dTimers = _debounceTimers.get(child);
7959
+ if (dTimers) {
7960
+ for (const key in dTimers) clearTimeout(dTimers[key]);
7961
+ _debounceTimers.delete(child);
7962
+ }
7963
+ const tTimers = _throttleTimers.get(child);
7964
+ if (tTimers) {
7965
+ for (const key in tTimers) clearTimeout(tTimers[key]);
7966
+ _throttleTimers.delete(child);
7967
+ }
7968
+ });
7969
+ this._timerEls.clear();
7970
+ }
7971
+ if (this._scopeAttr) {
7972
+ const def = this._def;
7973
+ if (def && def._styleEl && def._scopeAttr === this._scopeAttr) {
7974
+ def._styleRefCount = (def._styleRefCount || 1) - 1;
7975
+ if (def._styleRefCount <= 0) {
7976
+ def._styleEl.remove();
7977
+ def._styleEl = null;
7978
+ def._styleRefCount = 0;
7979
+ }
7899
7980
  }
7900
- });
7901
- if (this._styleEl) this._styleEl.remove();
7981
+ }
7902
7982
  _instances.delete(this._el);
7903
7983
  this._el.innerHTML = '';
7904
7984
  }
@@ -8217,7 +8297,12 @@ class Router {
8217
8297
  this._mode = isFile ? 'hash' : (config.mode || 'history');
8218
8298
 
8219
8299
  // Keep-alive cache: component name → { container, instance }
8300
+ // Map iteration order = insertion order, used for LRU eviction.
8220
8301
  this._keepAliveCache = new Map();
8302
+ // Optional cap on cached keep-alive instances. null = unbounded (default).
8303
+ this._keepAliveMax = (typeof config.keepAliveMax === 'number' && config.keepAliveMax > 0)
8304
+ ? config.keepAliveMax
8305
+ : null;
8221
8306
 
8222
8307
  // Base path for sub-path deployments
8223
8308
  // Priority: explicit config.base → window.__ZQ_BASE → <base href> tag
@@ -8342,7 +8427,13 @@ class Router {
8342
8427
  if (paramsAttr) {
8343
8428
  try {
8344
8429
  const params = JSON.parse(paramsAttr);
8345
- href = this._interpolateParams(href, params);
8430
+ // Reject arrays, null, primitives - z-link-params must be a plain
8431
+ // key/value object since we use it to interpolate :param placeholders.
8432
+ if (typeof params !== 'object' || params === null || Array.isArray(params)) {
8433
+ reportError(ErrorCode.ROUTER_RESOLVE, 'z-link-params must be a JSON object', { href, paramsAttr });
8434
+ } else {
8435
+ href = this._interpolateParams(href, params);
8436
+ }
8346
8437
  } catch (err) {
8347
8438
  reportError(ErrorCode.ROUTER_RESOLVE, 'Malformed JSON in z-link-params', { href, paramsAttr }, err);
8348
8439
  }
@@ -8564,7 +8655,6 @@ class Router {
8564
8655
  if (this._mode === 'hash') {
8565
8656
  // Hash mode: stash the substate in a global - hashchange will check.
8566
8657
  // We still push a history entry via a sentinel hash suffix.
8567
- const current = window.location.hash || '#/';
8568
8658
  window.history.pushState(
8569
8659
  { [_ZQ_STATE_KEY]: 'substate', key, data },
8570
8660
  '',
@@ -8805,6 +8895,9 @@ class Router {
8805
8895
  // Keep-alive: reuse cached instance
8806
8896
  if (isKeepAlive && componentName && this._keepAliveCache.has(componentName)) {
8807
8897
  const cached = this._keepAliveCache.get(componentName);
8898
+ // Refresh LRU order: move to most-recently-used position
8899
+ this._keepAliveCache.delete(componentName);
8900
+ this._keepAliveCache.set(componentName, cached);
8808
8901
  // Hide all children, show the cached one
8809
8902
  [...this._el.children].forEach(c => { c.style.display = 'none'; });
8810
8903
  cached.container.style.display = '';
@@ -8843,6 +8936,7 @@ class Router {
8843
8936
 
8844
8937
  if (isKeepAlive) {
8845
8938
  this._keepAliveCache.set(componentName, { container, instance: this._instance });
8939
+ this._evictKeepAliveLRU();
8846
8940
  // Call activated() on first mount
8847
8941
  if (this._instance._def.activated) {
8848
8942
  try { this._instance._def.activated.call(this._instance); }
@@ -8910,6 +9004,27 @@ class Router {
8910
9004
 
8911
9005
  // --- Destroy -------------------------------------------------------------
8912
9006
 
9007
+ /**
9008
+ * @private Evict least-recently-used keep-alive entries beyond _keepAliveMax.
9009
+ * The current route's component is preserved even if it would otherwise be
9010
+ * the oldest entry, to avoid destroying the active view.
9011
+ */
9012
+ _evictKeepAliveLRU() {
9013
+ if (this._keepAliveMax == null) return;
9014
+ while (this._keepAliveCache.size > this._keepAliveMax) {
9015
+ // Find oldest entry that isn't the currently-active component
9016
+ let evictKey = null;
9017
+ for (const key of this._keepAliveCache.keys()) {
9018
+ if (key !== this._currentComponentName) { evictKey = key; break; }
9019
+ }
9020
+ if (evictKey == null) break;
9021
+ const cached = this._keepAliveCache.get(evictKey);
9022
+ this._keepAliveCache.delete(evictKey);
9023
+ try { cached.instance.destroy(); } catch { /* swallow */ }
9024
+ if (cached.container && cached.container.parentNode) cached.container.remove();
9025
+ }
9026
+ }
9027
+
8913
9028
  destroy() {
8914
9029
  // Remove window/document event listeners to prevent memory leaks
8915
9030
  if (this._onNavEvent) {
@@ -9041,6 +9156,7 @@ function getRouter() {
9041
9156
 
9042
9157
 
9043
9158
 
9159
+
9044
9160
  class Store {
9045
9161
  constructor(config = {}) {
9046
9162
  this._subscribers = new Map(); // key → Set<fn>
@@ -9059,7 +9175,7 @@ class Store {
9059
9175
 
9060
9176
  // Store initial state for reset
9061
9177
  const initial = typeof config.state === 'function' ? config.state() : { ...(config.state || {}) };
9062
- this._initialState = JSON.parse(JSON.stringify(initial));
9178
+ this._initialState = deepClone(initial);
9063
9179
 
9064
9180
  this.state = reactive(initial, (key, value, old) => {
9065
9181
  if (this._batching) {
@@ -9121,7 +9237,7 @@ class Store {
9121
9237
  * Save a snapshot for undo. Call before making changes you want to be undoable.
9122
9238
  */
9123
9239
  checkpoint() {
9124
- const snap = JSON.parse(JSON.stringify(this.state.__raw || this.state));
9240
+ const snap = deepClone(this.state.__raw || this.state);
9125
9241
  this._undoStack.push(snap);
9126
9242
  if (this._undoStack.length > this._maxUndo) {
9127
9243
  this._undoStack.splice(0, this._undoStack.length - this._maxUndo);
@@ -9135,7 +9251,7 @@ class Store {
9135
9251
  */
9136
9252
  undo() {
9137
9253
  if (this._undoStack.length === 0) return false;
9138
- const current = JSON.parse(JSON.stringify(this.state.__raw || this.state));
9254
+ const current = deepClone(this.state.__raw || this.state);
9139
9255
  this._redoStack.push(current);
9140
9256
  const prev = this._undoStack.pop();
9141
9257
  this.replaceState(prev);
@@ -9148,7 +9264,7 @@ class Store {
9148
9264
  */
9149
9265
  redo() {
9150
9266
  if (this._redoStack.length === 0) return false;
9151
- const current = JSON.parse(JSON.stringify(this.state.__raw || this.state));
9267
+ const current = deepClone(this.state.__raw || this.state);
9152
9268
  this._undoStack.push(current);
9153
9269
  const next = this._redoStack.pop();
9154
9270
  this.replaceState(next);
@@ -9238,10 +9354,16 @@ class Store {
9238
9354
  }
9239
9355
 
9240
9356
  /**
9241
- * Get current state snapshot (plain object)
9357
+ * Get current state snapshot (plain object).
9358
+ *
9359
+ * Pass `{ clone: false }` to skip the structuredClone pass when the caller
9360
+ * treats state as read-only. Big perf win for serialise/inspect paths that
9361
+ * never mutate the returned value. Default remains a defensive deep copy.
9242
9362
  */
9243
- snapshot() {
9244
- return JSON.parse(JSON.stringify(this.state.__raw || this.state));
9363
+ snapshot(opts) {
9364
+ const raw = this.state.__raw || this.state;
9365
+ if (opts && opts.clone === false) return raw;
9366
+ return deepClone(raw);
9245
9367
  }
9246
9368
 
9247
9369
  /**
@@ -9274,7 +9396,7 @@ class Store {
9274
9396
  * Reset state to initial values. If no argument, resets to the original state.
9275
9397
  */
9276
9398
  reset(initialState) {
9277
- this.replaceState(initialState || JSON.parse(JSON.stringify(this._initialState)));
9399
+ this.replaceState(initialState || deepClone(this._initialState));
9278
9400
  this._history = [];
9279
9401
  this._undoStack = [];
9280
9402
  this._redoStack = [];
@@ -9587,25 +9709,35 @@ const http = {
9587
9709
  // ---------------------------------------------------------------------------
9588
9710
 
9589
9711
  /**
9590
- * Debounce - delays execution until after `ms` of inactivity
9712
+ * Debounce - delays execution until after `ms` of inactivity.
9713
+ * Optional `{ signal }` aborts any pending invocation and prevents future ones.
9591
9714
  */
9592
- function debounce(fn, ms = 250) {
9715
+ function debounce(fn, ms = 250, opts = {}) {
9593
9716
  let timer;
9717
+ const signal = opts.signal;
9594
9718
  const debounced = (...args) => {
9719
+ if (signal && signal.aborted) return;
9595
9720
  clearTimeout(timer);
9596
9721
  timer = setTimeout(() => fn(...args), ms);
9597
9722
  };
9598
9723
  debounced.cancel = () => clearTimeout(timer);
9724
+ if (signal) {
9725
+ if (signal.aborted) debounced.cancel();
9726
+ else signal.addEventListener('abort', debounced.cancel, { once: true });
9727
+ }
9599
9728
  return debounced;
9600
9729
  }
9601
9730
 
9602
9731
  /**
9603
- * Throttle - limits execution to once per `ms`
9732
+ * Throttle - limits execution to once per `ms`.
9733
+ * Optional `{ signal }` aborts any pending trailing call and prevents future ones.
9604
9734
  */
9605
- function throttle(fn, ms = 250) {
9735
+ function throttle(fn, ms = 250, opts = {}) {
9606
9736
  let last = 0;
9607
9737
  let timer;
9608
- return (...args) => {
9738
+ const signal = opts.signal;
9739
+ const throttled = (...args) => {
9740
+ if (signal && signal.aborted) return;
9609
9741
  const now = Date.now();
9610
9742
  const remaining = ms - (now - last);
9611
9743
  clearTimeout(timer);
@@ -9616,6 +9748,12 @@ function throttle(fn, ms = 250) {
9616
9748
  timer = setTimeout(() => { last = Date.now(); fn(...args); }, remaining);
9617
9749
  }
9618
9750
  };
9751
+ throttled.cancel = () => clearTimeout(timer);
9752
+ if (signal) {
9753
+ if (signal.aborted) throttled.cancel();
9754
+ else signal.addEventListener('abort', throttled.cancel, { once: true });
9755
+ }
9756
+ return throttled;
9619
9757
  }
9620
9758
 
9621
9759
  /**
@@ -9940,6 +10078,9 @@ function chunk(arr, size) {
9940
10078
  }
9941
10079
 
9942
10080
  function groupBy(arr, keyFn) {
10081
+ // Prefer the native Object.groupBy (Node 21+, all evergreens) when available -
10082
+ // it returns a null-prototype object which is safer for use as a lookup map.
10083
+ if (typeof Object.groupBy === 'function') return Object.groupBy(arr, keyFn);
9943
10084
  const result = {};
9944
10085
  for (const item of arr) {
9945
10086
  const k = keyFn(item);
@@ -10308,9 +10449,9 @@ $.TurnError = TurnError;
10308
10449
  $.E2eeError = E2eeError;
10309
10450
 
10310
10451
  // --- Meta ------------------------------------------------------------------
10311
- $.version = '1.2.0';
10312
- $.libSize = '~129 KB';
10313
- $.unitTests = {"passed":2322,"failed":0,"total":2508,"suites":617,"duration":8697,"ok":true};
10452
+ $.version = '1.2.3';
10453
+ $.libSize = '~130 KB';
10454
+ $.unitTests = {"passed":2348,"failed":0,"total":2534,"suites":620,"duration":8205,"ok":true};
10314
10455
  $.meta = {}; // populated at build time by CLI bundler
10315
10456
 
10316
10457
  // --- Environment detection -------------------------------------------------