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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zero-query",
3
- "version": "1.2.0",
3
+ "version": "1.2.3",
4
4
  "description": "Lightweight modern frontend library - jQuery-like selectors, reactive components, SPA router, and state management with zero dependencies.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -35,7 +35,8 @@
35
35
  "lint:fix": "eslint . --fix",
36
36
  "test": "vitest run",
37
37
  "test:watch": "vitest",
38
- "test:ssr": "node tests/test-ssr.js"
38
+ "test:ssr": "node tests/test-ssr.js",
39
+ "bench": "vitest bench --run"
39
40
  },
40
41
  "keywords": [
41
42
  "dom",
package/src/component.js CHANGED
@@ -190,6 +190,10 @@ class Component {
190
190
  this._updateQueued = false;
191
191
  this._listeners = [];
192
192
  this._watchCleanups = [];
193
+ // Elements that have scheduled debounce/throttle timers under this
194
+ // component instance. Tracked so destroy() can clear them even if the
195
+ // element is detached from the subtree before teardown.
196
+ this._timerEls = new Set();
193
197
 
194
198
  // Refs map
195
199
  this.refs = {};
@@ -214,15 +218,21 @@ class Component {
214
218
  if (definition.props && typeof definition.props === 'object' && !Array.isArray(definition.props)) {
215
219
  // Reactive props with type coercion and defaults
216
220
  this.props = this._resolveReactiveProps(definition.props, props);
217
- // MutationObserver to re-read props when parent re-renders and changes attributes
221
+ // MutationObserver to re-read props when parent re-renders and changes attributes.
222
+ // attributeFilter limits callbacks to the declared prop names (plus their
223
+ // dynamic `:name` aliases) — unrelated attribute churn no longer fires.
224
+ const propNames = Object.keys(definition.props);
225
+ const filter = [];
226
+ for (const n of propNames) {
227
+ filter.push(n.toLowerCase());
228
+ filter.push(':' + n.toLowerCase());
229
+ }
218
230
  this._propObserver = new MutationObserver((mutations) => {
219
231
  if (this._destroyed) return;
220
232
  let changed = false;
221
233
  for (const mut of mutations) {
222
234
  if (mut.type === 'attributes') {
223
235
  const attrName = mut.attributeName;
224
- // Skip internal attributes
225
- if (attrName.startsWith('z-') || attrName.startsWith('@') || attrName.startsWith(':') || attrName.startsWith('data-zq')) continue;
226
236
  // Check if this is a defined prop (attribute names are lowercase)
227
237
  const propName = attrName.startsWith(':') ? attrName.slice(1) : attrName;
228
238
  if (propName in definition.props) {
@@ -235,7 +245,7 @@ class Component {
235
245
  this._scheduleUpdate();
236
246
  }
237
247
  });
238
- this._propObserver.observe(el, { attributes: true });
248
+ this._propObserver.observe(el, { attributes: true, attributeFilter: filter });
239
249
  } else {
240
250
  // Legacy: frozen props from parent
241
251
  this.props = Object.freeze({ ...props });
@@ -517,44 +527,60 @@ class Component {
517
527
 
518
528
  // Apply scoped styles on first render
519
529
  if (!this._mounted && combinedStyles) {
520
- const scopeAttr = `z-s${this._uid}`;
530
+ const def = this._def;
531
+ // Use a stable per-definition scope attr so all instances share one
532
+ // <style> tag (ref-counted). Anonymous defs fall back to per-instance
533
+ // scoping for safety.
534
+ let scopeAttr = def._scopeAttr;
535
+ if (!scopeAttr) {
536
+ scopeAttr = def._name ? `z-s-${def._name}` : `z-s${this._uid}`;
537
+ def._scopeAttr = scopeAttr;
538
+ }
521
539
  this._el.setAttribute(scopeAttr, '');
522
- let noScopeDepth = 0; // brace depth at which a no-scope @-rule started (0 = none active)
523
- let braceDepth = 0; // overall brace depth
524
- const scoped = combinedStyles.replace(/([^{}]+)\{|\}/g, (match, selector) => {
525
- if (match === '}') {
526
- if (noScopeDepth > 0 && braceDepth <= noScopeDepth) noScopeDepth = 0;
527
- braceDepth--;
528
- return match;
529
- }
530
- braceDepth++;
531
- const trimmed = selector.trim();
532
- // Don't scope @-rules themselves
533
- if (trimmed.startsWith('@')) {
534
- // @keyframes and @font-face contain non-selector content - skip scoping inside them
535
- if (/^@(keyframes|font-face)\b/.test(trimmed)) {
536
- noScopeDepth = braceDepth;
540
+ this._scopeAttr = scopeAttr;
541
+
542
+ if (def._styleEl && def._styleEl.isConnected) {
543
+ // Already injected by a previous instance — just bump refcount.
544
+ def._styleRefCount = (def._styleRefCount || 0) + 1;
545
+ } else {
546
+ let noScopeDepth = 0; // brace depth at which a no-scope @-rule started (0 = none active)
547
+ let braceDepth = 0; // overall brace depth
548
+ const scoped = combinedStyles.replace(/([^{}]+)\{|\}/g, (match, selector) => {
549
+ if (match === '}') {
550
+ if (noScopeDepth > 0 && braceDepth <= noScopeDepth) noScopeDepth = 0;
551
+ braceDepth--;
552
+ return match;
553
+ }
554
+ braceDepth++;
555
+ const trimmed = selector.trim();
556
+ // Don't scope @-rules themselves
557
+ if (trimmed.startsWith('@')) {
558
+ // @keyframes and @font-face contain non-selector content - skip scoping inside them
559
+ if (/^@(keyframes|font-face)\b/.test(trimmed)) {
560
+ noScopeDepth = braceDepth;
561
+ }
562
+ return match;
563
+ }
564
+ // Inside @keyframes or @font-face - don't scope inner rules
565
+ if (noScopeDepth > 0 && braceDepth > noScopeDepth) {
566
+ return match;
567
+ }
568
+ return selector.split(',').map(s => `[${scopeAttr}] ${s.trim()}`).join(', ') + ' {';
569
+ });
570
+ const styleEl = document.createElement('style');
571
+ styleEl.textContent = scoped;
572
+ styleEl.setAttribute('data-zq-component', def._name || '');
573
+ styleEl.setAttribute('data-zq-scope', scopeAttr);
574
+ if (def._resolvedStyleUrls) {
575
+ styleEl.setAttribute('data-zq-style-urls', def._resolvedStyleUrls.join(' '));
576
+ if (def.styles) {
577
+ styleEl.setAttribute('data-zq-inline', def.styles);
537
578
  }
538
- return match;
539
- }
540
- // Inside @keyframes or @font-face - don't scope inner rules
541
- if (noScopeDepth > 0 && braceDepth > noScopeDepth) {
542
- return match;
543
- }
544
- return selector.split(',').map(s => `[${scopeAttr}] ${s.trim()}`).join(', ') + ' {';
545
- });
546
- const styleEl = document.createElement('style');
547
- styleEl.textContent = scoped;
548
- styleEl.setAttribute('data-zq-component', this._def._name || '');
549
- styleEl.setAttribute('data-zq-scope', scopeAttr);
550
- if (this._def._resolvedStyleUrls) {
551
- styleEl.setAttribute('data-zq-style-urls', this._def._resolvedStyleUrls.join(' '));
552
- if (this._def.styles) {
553
- styleEl.setAttribute('data-zq-inline', this._def.styles);
554
579
  }
580
+ document.head.appendChild(styleEl);
581
+ def._styleEl = styleEl;
582
+ def._styleRefCount = 1;
555
583
  }
556
- document.head.appendChild(styleEl);
557
- this._styleEl = styleEl;
558
584
  }
559
585
 
560
586
  // -- Focus preservation ----------------------------------------
@@ -763,7 +789,7 @@ class Component {
763
789
  });
764
790
 
765
791
  let stoppedAt = null; // Track elements that called .stop
766
- for (const { selector, methodExpr, modifiers, el, matched } of hits) {
792
+ for (const { methodExpr, modifiers, el, matched } of hits) {
767
793
 
768
794
  // In delegated events, .stop should prevent ancestor bindings from
769
795
  // firing - stopPropagation alone only stops real DOM bubbling.
@@ -857,6 +883,7 @@ class Component {
857
883
  clearTimeout(timers[event]);
858
884
  timers[event] = setTimeout(() => invoke(e), ms);
859
885
  _debounceTimers.set(el, timers);
886
+ this._timerEls.add(el);
860
887
  continue;
861
888
  }
862
889
 
@@ -869,6 +896,7 @@ class Component {
869
896
  invoke(e);
870
897
  timers[event] = setTimeout(() => { timers[event] = null; }, ms);
871
898
  _throttleTimers.set(el, timers);
899
+ this._timerEls.add(el);
872
900
  continue;
873
901
  }
874
902
 
@@ -951,10 +979,15 @@ class Component {
951
979
  : isEditable ? 'input' : 'input';
952
980
 
953
981
  // -- Handler: read DOM → write to reactive state -------------
954
- // Skip if already bound (morph preserves existing elements,
955
- // so re-binding would stack duplicate listeners)
956
- if (el._zqModelBound) return;
957
- el._zqModelBound = true;
982
+ // Remove any previously-bound z-model listener on this element so
983
+ // re-runs (re-render, keyed reconciliation, modifier change) replace
984
+ // instead of stacking. A boolean "already bound" flag is not enough:
985
+ // if the bound state key / modifiers ever change, the stale handler
986
+ // would keep writing to the old key.
987
+ if (el._zqModelUnbind) {
988
+ try { el._zqModelUnbind(); } catch { /* ignore */ }
989
+ el._zqModelUnbind = null;
990
+ }
958
991
 
959
992
  const handler = () => {
960
993
  let val;
@@ -976,12 +1009,18 @@ class Component {
976
1009
 
977
1010
  if (hasDebounce) {
978
1011
  let timer = null;
979
- el.addEventListener(event, () => {
1012
+ const debounced = () => {
980
1013
  clearTimeout(timer);
981
1014
  timer = setTimeout(handler, debounceMs);
982
- });
1015
+ };
1016
+ el.addEventListener(event, debounced);
1017
+ el._zqModelUnbind = () => {
1018
+ el.removeEventListener(event, debounced);
1019
+ clearTimeout(timer);
1020
+ };
983
1021
  } else {
984
1022
  el.addEventListener(event, handler);
1023
+ el._zqModelUnbind = () => el.removeEventListener(event, handler);
985
1024
  }
986
1025
  });
987
1026
  }
@@ -1206,19 +1245,20 @@ class Component {
1206
1245
  }
1207
1246
  });
1208
1247
 
1209
- // -- z-bind:attr / :attr (dynamic attribute binding) -----------
1210
- // Use TreeWalker instead of querySelectorAll('*') - avoids
1211
- // creating a flat array of every single descendant element.
1212
- // TreeWalker visits nodes lazily; FILTER_REJECT skips z-pre subtrees
1213
- // at the walker level (faster than per-node closest('[z-pre]') checks).
1248
+ // -- Single TreeWalker pass for z-bind/:attr, z-class, z-style,
1249
+ // z-stream, z-cloak. One walk amortises the cost across five
1250
+ // directives that used to issue separate querySelectorAll calls.
1251
+ // FILTER_REJECT on z-pre subtrees skips per-node closest() checks.
1214
1252
  const walker = document.createTreeWalker(this._el, NodeFilter.SHOW_ELEMENT, {
1215
1253
  acceptNode(n) {
1216
1254
  return n.hasAttribute('z-pre') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
1217
1255
  }
1218
1256
  });
1257
+ const hasMediaStream = typeof MediaStream !== 'undefined';
1219
1258
  let node;
1220
1259
  while ((node = walker.nextNode())) {
1221
1260
  const attrs = node.attributes;
1261
+ // z-bind:* / :* — iterate attrs once (reverse to allow remove)
1222
1262
  for (let i = attrs.length - 1; i >= 0; i--) {
1223
1263
  const attr = attrs[i];
1224
1264
  let attrName;
@@ -1229,71 +1269,67 @@ class Component {
1229
1269
  const val = this._evalExpr(attr.value);
1230
1270
  node.removeAttribute(attr.name);
1231
1271
  if (val === false || val === null || val === undefined) {
1232
- node.removeAttribute(attrName);
1272
+ node.toggleAttribute(attrName, false);
1233
1273
  } else if (val === true) {
1234
- node.setAttribute(attrName, '');
1274
+ node.toggleAttribute(attrName, true);
1235
1275
  } else {
1236
1276
  node.setAttribute(attrName, String(val));
1237
1277
  }
1238
1278
  }
1239
- }
1240
1279
 
1241
- // -- z-class (dynamic class binding) ---------------------------
1242
- this._el.querySelectorAll('[z-class]').forEach(el => {
1243
- if (el.closest('[z-pre]')) return;
1244
- const val = this._evalExpr(el.getAttribute('z-class'));
1245
- if (typeof val === 'string') {
1246
- val.split(/\s+/).filter(Boolean).forEach(c => el.classList.add(c));
1247
- } else if (Array.isArray(val)) {
1248
- val.filter(Boolean).forEach(c => el.classList.add(String(c)));
1249
- } else if (val && typeof val === 'object') {
1250
- for (const [cls, active] of Object.entries(val)) {
1251
- el.classList.toggle(cls, !!active);
1280
+ // z-class
1281
+ if (node.hasAttribute('z-class')) {
1282
+ const val = this._evalExpr(node.getAttribute('z-class'));
1283
+ if (typeof val === 'string') {
1284
+ val.split(/\s+/).filter(Boolean).forEach(c => node.classList.add(c));
1285
+ } else if (Array.isArray(val)) {
1286
+ val.filter(Boolean).forEach(c => node.classList.add(String(c)));
1287
+ } else if (val && typeof val === 'object') {
1288
+ for (const [cls, active] of Object.entries(val)) {
1289
+ node.classList.toggle(cls, !!active);
1290
+ }
1252
1291
  }
1292
+ node.removeAttribute('z-class');
1253
1293
  }
1254
- el.removeAttribute('z-class');
1255
- });
1256
1294
 
1257
- // -- z-style (dynamic inline styles) ---------------------------
1258
- this._el.querySelectorAll('[z-style]').forEach(el => {
1259
- if (el.closest('[z-pre]')) return;
1260
- const val = this._evalExpr(el.getAttribute('z-style'));
1261
- if (typeof val === 'string') {
1262
- el.style.cssText += ';' + val;
1263
- } else if (val && typeof val === 'object') {
1264
- for (const [prop, v] of Object.entries(val)) {
1265
- el.style[prop] = v;
1295
+ // z-style
1296
+ if (node.hasAttribute('z-style')) {
1297
+ const val = this._evalExpr(node.getAttribute('z-style'));
1298
+ if (typeof val === 'string') {
1299
+ node.style.cssText += ';' + val;
1300
+ } else if (val && typeof val === 'object') {
1301
+ for (const [prop, v] of Object.entries(val)) {
1302
+ node.style[prop] = v;
1303
+ }
1266
1304
  }
1305
+ node.removeAttribute('z-style');
1267
1306
  }
1268
- el.removeAttribute('z-style');
1269
- });
1270
1307
 
1271
- // -- z-stream (assign MediaStream to <video>/<audio>.srcObject) -
1272
- this._el.querySelectorAll('[z-stream]').forEach(el => {
1273
- if (el.closest('[z-pre]')) return;
1274
- const val = this._evalExpr(el.getAttribute('z-stream'));
1275
- const hasMediaStream = typeof MediaStream !== 'undefined';
1276
- if (val == null) {
1277
- el.srcObject = null;
1278
- } else if (hasMediaStream && val instanceof MediaStream) {
1279
- el.srcObject = val;
1280
- } else if (val && typeof val.getTracks === 'function') {
1281
- // Accept duck-typed stream objects (test fakes, polyfills).
1282
- el.srcObject = val;
1283
- } else {
1284
- el.srcObject = null;
1308
+ // z-stream MediaStream <video>/<audio>.srcObject
1309
+ if (node.hasAttribute('z-stream')) {
1310
+ const val = this._evalExpr(node.getAttribute('z-stream'));
1311
+ if (val == null) {
1312
+ node.srcObject = null;
1313
+ } else if (hasMediaStream && val instanceof MediaStream) {
1314
+ node.srcObject = val;
1315
+ } else if (val && typeof val.getTracks === 'function') {
1316
+ // Accept duck-typed stream objects (test fakes, polyfills).
1317
+ node.srcObject = val;
1318
+ } else {
1319
+ node.srcObject = null;
1320
+ }
1321
+ node.removeAttribute('z-stream');
1285
1322
  }
1286
- el.removeAttribute('z-stream');
1287
- });
1323
+
1324
+ // z-cloak (just strip)
1325
+ if (node.hasAttribute('z-cloak')) {
1326
+ node.removeAttribute('z-cloak');
1327
+ }
1328
+ }
1288
1329
 
1289
1330
  // z-html and z-text are now pre-expanded at string level (before
1290
1331
  // morph) via _expandContentDirectives(), so the diff engine can
1291
1332
  // properly diff their content instead of clearing + re-injecting.
1292
-
1293
- // -- z-cloak (remove after render) -----------------------------
1294
- this._el.querySelectorAll('[z-cloak]').forEach(el => {
1295
- el.removeAttribute('z-cloak');
1296
- });
1297
1333
  }
1298
1334
 
1299
1335
  // ---------------------------------------------------------------------------
@@ -1425,21 +1461,34 @@ class Component {
1425
1461
  this._delegatedEvents = null;
1426
1462
  this._eventBindings = null;
1427
1463
  // Clear any pending debounce/throttle timers to prevent stale closures.
1428
- // Timers are keyed by individual child elements, so iterate all descendants.
1429
- const allEls = this._el.querySelectorAll('*');
1430
- allEls.forEach(child => {
1431
- const dTimers = _debounceTimers.get(child);
1432
- if (dTimers) {
1433
- for (const key in dTimers) clearTimeout(dTimers[key]);
1434
- _debounceTimers.delete(child);
1435
- }
1436
- const tTimers = _throttleTimers.get(child);
1437
- if (tTimers) {
1438
- for (const key in tTimers) clearTimeout(tTimers[key]);
1439
- _throttleTimers.delete(child);
1464
+ // Use the per-instance _timerEls set so detached elements (removed by
1465
+ // z-if / z-for / keyed reconciliation before destroy) are still cleaned.
1466
+ if (this._timerEls) {
1467
+ this._timerEls.forEach(child => {
1468
+ const dTimers = _debounceTimers.get(child);
1469
+ if (dTimers) {
1470
+ for (const key in dTimers) clearTimeout(dTimers[key]);
1471
+ _debounceTimers.delete(child);
1472
+ }
1473
+ const tTimers = _throttleTimers.get(child);
1474
+ if (tTimers) {
1475
+ for (const key in tTimers) clearTimeout(tTimers[key]);
1476
+ _throttleTimers.delete(child);
1477
+ }
1478
+ });
1479
+ this._timerEls.clear();
1480
+ }
1481
+ if (this._scopeAttr) {
1482
+ const def = this._def;
1483
+ if (def && def._styleEl && def._scopeAttr === this._scopeAttr) {
1484
+ def._styleRefCount = (def._styleRefCount || 1) - 1;
1485
+ if (def._styleRefCount <= 0) {
1486
+ def._styleEl.remove();
1487
+ def._styleEl = null;
1488
+ def._styleRefCount = 0;
1489
+ }
1440
1490
  }
1441
- });
1442
- if (this._styleEl) this._styleEl.remove();
1491
+ }
1443
1492
  _instances.delete(this._el);
1444
1493
  this._el.innerHTML = '';
1445
1494
  }
package/src/core.js CHANGED
@@ -283,11 +283,17 @@ export class ZQueryCollection {
283
283
  });
284
284
  }
285
285
  if (value === undefined) return this.first()?.getAttribute(name);
286
- return this.each((_, el) => el.setAttribute(name, value));
286
+ // Fast path: tight loop, no closure allocation, no `each` overhead.
287
+ // Mirrors the addClass/removeClass single-name fast path.
288
+ const els = this.elements;
289
+ for (let i = 0; i < els.length; i++) els[i].setAttribute(name, value);
290
+ return this;
287
291
  }
288
292
 
289
293
  removeAttr(name) {
290
- return this.each((_, el) => el.removeAttribute(name));
294
+ const els = this.elements;
295
+ for (let i = 0; i < els.length; i++) els[i].removeAttribute(name);
296
+ return this;
291
297
  }
292
298
 
293
299
  prop(name, value) {
@@ -852,7 +858,9 @@ export function queryAll(selector, context) {
852
858
  // Quick-ref shortcuts, on $ namespace)
853
859
  // ---------------------------------------------------------------------------
854
860
  query.id = (id) => document.getElementById(id);
855
- query.class = (name) => document.querySelector(`.${name}`);
861
+ // Escape the class name so callers can safely pass identifiers containing
862
+ // dots, colons, leading digits, etc. without breaking the selector.
863
+ query.class = (name) => document.querySelector(`.${typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(name) : name}`);
856
864
  query.classes = (name) => new ZQueryCollection(Array.from(document.getElementsByClassName(name)));
857
865
  query.tag = (name) => new ZQueryCollection(Array.from(document.getElementsByTagName(name)));
858
866
  Object.defineProperty(query, 'name', {
package/src/expression.js CHANGED
@@ -218,12 +218,28 @@ class Parser {
218
218
 
219
219
  // Main entry
220
220
  parse() {
221
+ this.depth = 0;
221
222
  const result = this.parseExpression(0);
222
223
  return result;
223
224
  }
224
225
 
225
226
  // Precedence climbing
226
227
  parseExpression(minPrec) {
228
+ // Cap nesting depth so a deeply parenthesised or recursive expression
229
+ // can’t stack-overflow the host. Threshold (96) is generous - real
230
+ // template expressions are flat - but stops pathological inputs cold.
231
+ if (++this.depth > 96) {
232
+ this.depth--;
233
+ throw new Error('Expression nesting depth exceeded (max 96)');
234
+ }
235
+ try {
236
+ return this._parseExpressionImpl(minPrec);
237
+ } finally {
238
+ this.depth--;
239
+ }
240
+ }
241
+
242
+ _parseExpressionImpl(minPrec) {
227
243
  let left = this.parseUnary();
228
244
 
229
245
  while (true) {
@@ -550,13 +566,6 @@ class Parser {
550
566
  // ---------------------------------------------------------------------------
551
567
 
552
568
  /** Safe property access whitelist for built-in prototypes */
553
- const SAFE_ARRAY_METHODS = new Set([
554
- 'length', 'map', 'filter', 'find', 'findIndex', 'some', 'every',
555
- 'reduce', 'reduceRight', 'forEach', 'includes', 'indexOf', 'lastIndexOf',
556
- 'join', 'slice', 'concat', 'flat', 'flatMap', 'reverse', 'sort',
557
- 'fill', 'keys', 'values', 'entries', 'at', 'toString',
558
- ]);
559
-
560
569
  const SAFE_STRING_METHODS = new Set([
561
570
  'length', 'charAt', 'charCodeAt', 'includes', 'indexOf', 'lastIndexOf',
562
571
  'slice', 'substring', 'trim', 'trimStart', 'trimEnd', 'toLowerCase',
@@ -569,18 +578,6 @@ const SAFE_NUMBER_METHODS = new Set([
569
578
  'toFixed', 'toPrecision', 'toString', 'valueOf',
570
579
  ]);
571
580
 
572
- const SAFE_OBJECT_METHODS = new Set([
573
- 'hasOwnProperty', 'toString', 'valueOf',
574
- ]);
575
-
576
- const SAFE_MATH_PROPS = new Set([
577
- 'PI', 'E', 'LN2', 'LN10', 'LOG2E', 'LOG10E', 'SQRT2', 'SQRT1_2',
578
- 'abs', 'ceil', 'floor', 'round', 'trunc', 'max', 'min', 'pow',
579
- 'sqrt', 'sign', 'random', 'log', 'log2', 'log10',
580
- ]);
581
-
582
- const SAFE_JSON_PROPS = new Set(['parse', 'stringify']);
583
-
584
581
  /**
585
582
  * Check if property access is safe
586
583
  */
@@ -878,8 +875,18 @@ function _evalBinary(node, scope) {
878
875
  const _astCache = new Map();
879
876
  const _AST_CACHE_MAX = 512;
880
877
 
878
+ // Maximum source length accepted by safeEval. Real template expressions
879
+ // are tens of bytes; anything beyond 8 KB is almost certainly an attack
880
+ // payload or accidental dump. Reject up front to avoid feeding the
881
+ // tokenizer and parser unbounded input.
882
+ const _EXPR_MAX_LEN = 8192;
883
+
881
884
  export function safeEval(expr, scope) {
882
885
  try {
886
+ if (typeof expr !== 'string') return undefined;
887
+ if (expr.length > _EXPR_MAX_LEN) {
888
+ throw new Error(`Expression exceeds max length (${_EXPR_MAX_LEN} bytes)`);
889
+ }
883
890
  const trimmed = expr.trim();
884
891
  if (!trimmed) return undefined;
885
892
 
package/src/reactive.js CHANGED
@@ -7,11 +7,27 @@
7
7
 
8
8
  import { reportError, ErrorCode } from './errors.js';
9
9
 
10
+ // ---------------------------------------------------------------------------
11
+ // Host-object passthrough
12
+ // ---------------------------------------------------------------------------
13
+ // The reactive proxy is meant for plain data: state bags, arrays, nested
14
+ // records. Wrapping host/native objects (MediaStream, RTCPeerConnection,
15
+ // Blob, Map, Date, etc.) breaks them - the proxy receiver is not the real
16
+ // underlying object, so any internal-slot method call throws
17
+ // "Illegal invocation" in the browser. Only proxy values whose prototype is
18
+ // Object.prototype/null (plain objects) or that are Arrays.
19
+ function _isProxyable(value) {
20
+ if (value === null || typeof value !== 'object') return false;
21
+ if (Array.isArray(value)) return true;
22
+ const proto = Object.getPrototypeOf(value);
23
+ return proto === Object.prototype || proto === null;
24
+ }
25
+
10
26
  // ---------------------------------------------------------------------------
11
27
  // Deep reactive proxy
12
28
  // ---------------------------------------------------------------------------
13
29
  export function reactive(target, onChange, _path = '') {
14
- if (typeof target !== 'object' || target === null) return target;
30
+ if (!_isProxyable(target)) return target;
15
31
  if (typeof onChange !== 'function') {
16
32
  reportError(ErrorCode.REACTIVE_CALLBACK, 'reactive() onChange must be a function', { received: typeof onChange });
17
33
  onChange = () => {};
@@ -25,7 +41,7 @@ export function reactive(target, onChange, _path = '') {
25
41
  if (key === '__raw') return obj;
26
42
 
27
43
  const value = obj[key];
28
- if (typeof value === 'object' && value !== null) {
44
+ if (_isProxyable(value)) {
29
45
  // Return cached proxy or create new one
30
46
  if (proxyCache.has(value)) return proxyCache.get(value);
31
47
  const childProxy = new Proxy(value, handler);
package/src/router.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * });
18
18
  */
19
19
 
20
- import { mount, destroy, prefetch } from './component.js';
20
+ import { mount, prefetch } from './component.js';
21
21
  import { reportError, ErrorCode } from './errors.js';
22
22
 
23
23
  // Unique marker on history.state to identify zQuery-managed entries
@@ -48,7 +48,12 @@ class Router {
48
48
  this._mode = isFile ? 'hash' : (config.mode || 'history');
49
49
 
50
50
  // Keep-alive cache: component name → { container, instance }
51
+ // Map iteration order = insertion order, used for LRU eviction.
51
52
  this._keepAliveCache = new Map();
53
+ // Optional cap on cached keep-alive instances. null = unbounded (default).
54
+ this._keepAliveMax = (typeof config.keepAliveMax === 'number' && config.keepAliveMax > 0)
55
+ ? config.keepAliveMax
56
+ : null;
52
57
 
53
58
  // Base path for sub-path deployments
54
59
  // Priority: explicit config.base → window.__ZQ_BASE → <base href> tag
@@ -173,7 +178,13 @@ class Router {
173
178
  if (paramsAttr) {
174
179
  try {
175
180
  const params = JSON.parse(paramsAttr);
176
- href = this._interpolateParams(href, params);
181
+ // Reject arrays, null, primitives - z-link-params must be a plain
182
+ // key/value object since we use it to interpolate :param placeholders.
183
+ if (typeof params !== 'object' || params === null || Array.isArray(params)) {
184
+ reportError(ErrorCode.ROUTER_RESOLVE, 'z-link-params must be a JSON object', { href, paramsAttr });
185
+ } else {
186
+ href = this._interpolateParams(href, params);
187
+ }
177
188
  } catch (err) {
178
189
  reportError(ErrorCode.ROUTER_RESOLVE, 'Malformed JSON in z-link-params', { href, paramsAttr }, err);
179
190
  }
@@ -395,7 +406,6 @@ class Router {
395
406
  if (this._mode === 'hash') {
396
407
  // Hash mode: stash the substate in a global - hashchange will check.
397
408
  // We still push a history entry via a sentinel hash suffix.
398
- const current = window.location.hash || '#/';
399
409
  window.history.pushState(
400
410
  { [_ZQ_STATE_KEY]: 'substate', key, data },
401
411
  '',
@@ -636,6 +646,9 @@ class Router {
636
646
  // Keep-alive: reuse cached instance
637
647
  if (isKeepAlive && componentName && this._keepAliveCache.has(componentName)) {
638
648
  const cached = this._keepAliveCache.get(componentName);
649
+ // Refresh LRU order: move to most-recently-used position
650
+ this._keepAliveCache.delete(componentName);
651
+ this._keepAliveCache.set(componentName, cached);
639
652
  // Hide all children, show the cached one
640
653
  [...this._el.children].forEach(c => { c.style.display = 'none'; });
641
654
  cached.container.style.display = '';
@@ -674,6 +687,7 @@ class Router {
674
687
 
675
688
  if (isKeepAlive) {
676
689
  this._keepAliveCache.set(componentName, { container, instance: this._instance });
690
+ this._evictKeepAliveLRU();
677
691
  // Call activated() on first mount
678
692
  if (this._instance._def.activated) {
679
693
  try { this._instance._def.activated.call(this._instance); }
@@ -741,6 +755,27 @@ class Router {
741
755
 
742
756
  // --- Destroy -------------------------------------------------------------
743
757
 
758
+ /**
759
+ * @private Evict least-recently-used keep-alive entries beyond _keepAliveMax.
760
+ * The current route's component is preserved even if it would otherwise be
761
+ * the oldest entry, to avoid destroying the active view.
762
+ */
763
+ _evictKeepAliveLRU() {
764
+ if (this._keepAliveMax == null) return;
765
+ while (this._keepAliveCache.size > this._keepAliveMax) {
766
+ // Find oldest entry that isn't the currently-active component
767
+ let evictKey = null;
768
+ for (const key of this._keepAliveCache.keys()) {
769
+ if (key !== this._currentComponentName) { evictKey = key; break; }
770
+ }
771
+ if (evictKey == null) break;
772
+ const cached = this._keepAliveCache.get(evictKey);
773
+ this._keepAliveCache.delete(evictKey);
774
+ try { cached.instance.destroy(); } catch { /* swallow */ }
775
+ if (cached.container && cached.container.parentNode) cached.container.remove();
776
+ }
777
+ }
778
+
744
779
  destroy() {
745
780
  // Remove window/document event listeners to prevent memory leaks
746
781
  if (this._onNavEvent) {