zero-query 0.9.1 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Lightweight modern frontend library — jQuery-like selectors, reactive
5
5
  * components, SPA router, state management, HTTP client & utilities.
6
6
  *
7
- * @version 0.9.1
7
+ * @version 0.9.6
8
8
  * @license MIT
9
9
  * @see https://z-query.com/docs
10
10
  */
@@ -74,6 +74,7 @@ export {
74
74
  once,
75
75
  sleep,
76
76
  escapeHtml,
77
+ stripHtml,
77
78
  html,
78
79
  TrustedHTML,
79
80
  trust,
@@ -90,6 +91,23 @@ export {
90
91
  session,
91
92
  EventBus,
92
93
  bus,
94
+ range,
95
+ unique,
96
+ chunk,
97
+ groupBy,
98
+ pick,
99
+ omit,
100
+ getPath,
101
+ setPath,
102
+ isEmpty,
103
+ capitalize,
104
+ truncate,
105
+ clamp,
106
+ MemoizedFunction,
107
+ memoize,
108
+ RetryOptions,
109
+ retry,
110
+ timeout,
93
111
  } from './types/utils';
94
112
 
95
113
  export {
@@ -122,9 +140,13 @@ import type { createStore, getStore } from './types/store';
122
140
  import type { HttpClient } from './types/http';
123
141
  import type {
124
142
  debounce, throttle, pipe, once, sleep,
125
- escapeHtml, html, trust, uuid, camelCase, kebabCase,
143
+ escapeHtml, stripHtml, html, trust, uuid, camelCase, kebabCase,
126
144
  deepClone, deepMerge, isEqual, param, parseQuery,
127
145
  StorageWrapper, EventBus,
146
+ range, unique, chunk, groupBy,
147
+ pick, omit, getPath, setPath, isEmpty,
148
+ capitalize, truncate, clamp,
149
+ MemoizedFunction, memoize, RetryOptions, retry, timeout,
128
150
  } from './types/utils';
129
151
  import type { onError, ZQueryError, ErrorCode, guardCallback, validate } from './types/errors';
130
152
  import type { morph, morphElement, safeEval } from './types/misc';
@@ -169,6 +191,10 @@ interface ZQueryStatic {
169
191
  name(name: string): ZQueryCollection;
170
192
  /** Children of `#parentId` as `ZQueryCollection`. */
171
193
  children(parentId: string): ZQueryCollection;
194
+ /** `document.querySelector(selector)` — raw Element or null. */
195
+ qs(selector: string, context?: Element | Document): Element | null;
196
+ /** `document.querySelectorAll(selector)` — as a real `Array<Element>`. */
197
+ qsa(selector: string, context?: Element | Document): Element[];
172
198
 
173
199
  // -- Static helpers ------------------------------------------------------
174
200
  /**
@@ -258,6 +284,7 @@ interface ZQueryStatic {
258
284
  sleep: typeof sleep;
259
285
 
260
286
  escapeHtml: typeof escapeHtml;
287
+ stripHtml: typeof stripHtml;
261
288
  html: typeof html;
262
289
  trust: typeof trust;
263
290
  uuid: typeof uuid;
@@ -273,8 +300,25 @@ interface ZQueryStatic {
273
300
 
274
301
  storage: StorageWrapper;
275
302
  session: StorageWrapper;
303
+ EventBus: typeof EventBus;
276
304
  bus: EventBus;
277
305
 
306
+ range: typeof range;
307
+ unique: typeof unique;
308
+ chunk: typeof chunk;
309
+ groupBy: typeof groupBy;
310
+ pick: typeof pick;
311
+ omit: typeof omit;
312
+ getPath: typeof getPath;
313
+ setPath: typeof setPath;
314
+ isEmpty: typeof isEmpty;
315
+ capitalize: typeof capitalize;
316
+ truncate: typeof truncate;
317
+ clamp: typeof clamp;
318
+ memoize: typeof memoize;
319
+ retry: typeof retry;
320
+ timeout: typeof timeout;
321
+
278
322
  // -- Meta ----------------------------------------------------------------
279
323
  /** Library version string. */
280
324
  version: string;
package/index.js CHANGED
@@ -19,9 +19,13 @@ import { morph, morphElement } from './src/diff.js';
19
19
  import { safeEval } from './src/expression.js';
20
20
  import {
21
21
  debounce, throttle, pipe, once, sleep,
22
- escapeHtml, html, trust, uuid, camelCase, kebabCase,
22
+ escapeHtml, stripHtml, html, trust, TrustedHTML, uuid, camelCase, kebabCase,
23
23
  deepClone, deepMerge, isEqual, param, parseQuery,
24
- storage, session, bus,
24
+ storage, session, EventBus, bus,
25
+ range, unique, chunk, groupBy,
26
+ pick, omit, getPath, setPath, isEmpty,
27
+ capitalize, truncate, clamp,
28
+ memoize, retry, timeout,
25
29
  } from './src/utils.js';
26
30
  import { ZQueryError, ErrorCode, onError, reportError, guardCallback, validate } from './src/errors.js';
27
31
 
@@ -61,6 +65,8 @@ Object.defineProperty($, 'name', {
61
65
  value: query.name, writable: true, configurable: true
62
66
  });
63
67
  $.children = query.children;
68
+ $.qs = query.qs;
69
+ $.qsa = query.qsa;
64
70
 
65
71
  // --- Collection selector ---------------------------------------------------
66
72
  /**
@@ -129,8 +135,10 @@ $.pipe = pipe;
129
135
  $.once = once;
130
136
  $.sleep = sleep;
131
137
  $.escapeHtml = escapeHtml;
138
+ $.stripHtml = stripHtml;
132
139
  $.html = html;
133
140
  $.trust = trust;
141
+ $.TrustedHTML = TrustedHTML;
134
142
  $.uuid = uuid;
135
143
  $.camelCase = camelCase;
136
144
  $.kebabCase = kebabCase;
@@ -141,7 +149,23 @@ $.param = param;
141
149
  $.parseQuery = parseQuery;
142
150
  $.storage = storage;
143
151
  $.session = session;
152
+ $.EventBus = EventBus;
144
153
  $.bus = bus;
154
+ $.range = range;
155
+ $.unique = unique;
156
+ $.chunk = chunk;
157
+ $.groupBy = groupBy;
158
+ $.pick = pick;
159
+ $.omit = omit;
160
+ $.getPath = getPath;
161
+ $.setPath = setPath;
162
+ $.isEmpty = isEmpty;
163
+ $.capitalize = capitalize;
164
+ $.truncate = truncate;
165
+ $.clamp = clamp;
166
+ $.memoize = memoize;
167
+ $.retry = retry;
168
+ $.timeout = timeout;
145
169
 
146
170
  // --- Error handling --------------------------------------------------------
147
171
  $.onError = onError;
@@ -189,9 +213,13 @@ export {
189
213
  http,
190
214
  ZQueryError, ErrorCode, onError, reportError, guardCallback, validate,
191
215
  debounce, throttle, pipe, once, sleep,
192
- escapeHtml, html, trust, uuid, camelCase, kebabCase,
216
+ escapeHtml, stripHtml, html, trust, TrustedHTML, uuid, camelCase, kebabCase,
193
217
  deepClone, deepMerge, isEqual, param, parseQuery,
194
- storage, session, bus,
218
+ storage, session, EventBus, bus,
219
+ range, unique, chunk, groupBy,
220
+ pick, omit, getPath, setPath, isEmpty,
221
+ capitalize, truncate, clamp,
222
+ memoize, retry, timeout,
195
223
  };
196
224
 
197
225
  export default $;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zero-query",
3
- "version": "0.9.1",
3
+ "version": "0.9.6",
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",
package/src/component.js CHANGED
@@ -198,7 +198,7 @@ class Component {
198
198
  const defaultSlotNodes = [];
199
199
  [...el.childNodes].forEach(node => {
200
200
  if (node.nodeType === 1 && node.hasAttribute('slot')) {
201
- const slotName = node.getAttribute('slot');
201
+ const slotName = node.getAttribute('slot') || 'default';
202
202
  if (!this._slotContent[slotName]) this._slotContent[slotName] = '';
203
203
  this._slotContent[slotName] += node.outerHTML;
204
204
  } else if (node.nodeType === 1 || (node.nodeType === 3 && node.textContent.trim())) {
@@ -607,6 +607,24 @@ class Component {
607
607
  for (const [event, bindings] of eventMap) {
608
608
  this._attachDelegatedEvent(event, bindings);
609
609
  }
610
+
611
+ // .outside — attach a document-level listener for bindings that need
612
+ // to detect clicks/events outside their element.
613
+ this._outsideListeners = this._outsideListeners || [];
614
+ for (const [event, bindings] of eventMap) {
615
+ for (const binding of bindings) {
616
+ if (!binding.modifiers.includes('outside')) continue;
617
+ const outsideHandler = (e) => {
618
+ if (binding.el.contains(e.target)) return;
619
+ const match = binding.methodExpr.match(/^(\w+)(?:\(([^)]*)\))?$/);
620
+ if (!match) return;
621
+ const fn = this[match[1]];
622
+ if (typeof fn === 'function') fn.call(this, e);
623
+ };
624
+ document.addEventListener(event, outsideHandler, true);
625
+ this._outsideListeners.push({ event, handler: outsideHandler });
626
+ }
627
+ }
610
628
  }
611
629
 
612
630
  // Attach a single delegated listener for an event type
@@ -620,15 +638,66 @@ class Component {
620
638
  const handler = (e) => {
621
639
  // Read bindings from live map — always up to date after re-renders
622
640
  const currentBindings = this._eventBindings?.get(event) || [];
623
- for (const { selector, methodExpr, modifiers, el } of currentBindings) {
624
- if (!e.target.closest(selector)) continue;
641
+
642
+ // Collect matching bindings with their matched elements, then sort
643
+ // deepest-first so .stop correctly prevents ancestor handlers
644
+ // (mimics real DOM bubbling order within delegated events).
645
+ const hits = [];
646
+ for (const binding of currentBindings) {
647
+ const matched = e.target.closest(binding.selector);
648
+ if (!matched) continue;
649
+ hits.push({ ...binding, matched });
650
+ }
651
+ hits.sort((a, b) => {
652
+ if (a.matched === b.matched) return 0;
653
+ return a.matched.contains(b.matched) ? 1 : -1;
654
+ });
655
+
656
+ let stoppedAt = null; // Track elements that called .stop
657
+ for (const { selector, methodExpr, modifiers, el, matched } of hits) {
658
+
659
+ // In delegated events, .stop should prevent ancestor bindings from
660
+ // firing — stopPropagation alone only stops real DOM bubbling.
661
+ if (stoppedAt) {
662
+ let blocked = false;
663
+ for (const stopped of stoppedAt) {
664
+ if (matched.contains(stopped) && matched !== stopped) { blocked = true; break; }
665
+ }
666
+ if (blocked) continue;
667
+ }
625
668
 
626
669
  // .self — only fire if target is the element itself
627
670
  if (modifiers.includes('self') && e.target !== el) continue;
628
671
 
672
+ // .outside — only fire if event target is OUTSIDE the element
673
+ if (modifiers.includes('outside')) {
674
+ if (el.contains(e.target)) continue;
675
+ }
676
+
677
+ // Key modifiers — filter keyboard events by key
678
+ const _keyMap = { enter: 'Enter', escape: 'Escape', tab: 'Tab', space: ' ', delete: 'Delete|Backspace', up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight' };
679
+ let keyFiltered = false;
680
+ for (const mod of modifiers) {
681
+ if (_keyMap[mod]) {
682
+ const keys = _keyMap[mod].split('|');
683
+ if (!e.key || !keys.includes(e.key)) { keyFiltered = true; break; }
684
+ }
685
+ }
686
+ if (keyFiltered) continue;
687
+
688
+ // System key modifiers — require modifier keys to be held
689
+ if (modifiers.includes('ctrl') && !e.ctrlKey) continue;
690
+ if (modifiers.includes('shift') && !e.shiftKey) continue;
691
+ if (modifiers.includes('alt') && !e.altKey) continue;
692
+ if (modifiers.includes('meta') && !e.metaKey) continue;
693
+
629
694
  // Handle modifiers
630
695
  if (modifiers.includes('prevent')) e.preventDefault();
631
- if (modifiers.includes('stop')) e.stopPropagation();
696
+ if (modifiers.includes('stop')) {
697
+ e.stopPropagation();
698
+ if (!stoppedAt) stoppedAt = [];
699
+ stoppedAt.push(matched);
700
+ }
632
701
 
633
702
  // Build the invocation function
634
703
  const invoke = (evt) => {
@@ -707,9 +776,12 @@ class Component {
707
776
  // textarea, select (single & multiple), contenteditable
708
777
  // Nested state keys: z-model="user.name" → this.state.user.name
709
778
  // Modifiers (boolean attributes on the same element):
710
- // z-lazy — listen on 'change' instead of 'input' (update on blur / commit)
711
- // z-trim — trim whitespace before writing to state
712
- // z-number — force Number() conversion regardless of input type
779
+ // z-lazy — listen on 'change' instead of 'input' (update on blur / commit)
780
+ // z-trim — trim whitespace before writing to state
781
+ // z-number — force Number() conversion regardless of input type
782
+ // z-debounce — debounce state writes (default 250ms, or z-debounce="300")
783
+ // z-uppercase — convert string to uppercase before writing to state
784
+ // z-lowercase — convert string to lowercase before writing to state
713
785
  //
714
786
  // Writes to reactive state so the rest of the UI stays in sync.
715
787
  // Focus and cursor position are preserved in _render() via focusInfo.
@@ -725,6 +797,10 @@ class Component {
725
797
  const isLazy = el.hasAttribute('z-lazy');
726
798
  const isTrim = el.hasAttribute('z-trim');
727
799
  const isNum = el.hasAttribute('z-number');
800
+ const isUpper = el.hasAttribute('z-uppercase');
801
+ const isLower = el.hasAttribute('z-lowercase');
802
+ const hasDebounce = el.hasAttribute('z-debounce');
803
+ const debounceMs = hasDebounce ? (parseInt(el.getAttribute('z-debounce'), 10) || 250) : 0;
728
804
 
729
805
  // Read current state value (supports dot-path keys)
730
806
  const currentVal = _getPath(this.state, key);
@@ -765,6 +841,8 @@ class Component {
765
841
 
766
842
  // Apply modifiers
767
843
  if (isTrim && typeof val === 'string') val = val.trim();
844
+ if (isUpper && typeof val === 'string') val = val.toUpperCase();
845
+ if (isLower && typeof val === 'string') val = val.toLowerCase();
768
846
  if (isNum || type === 'number' || type === 'range') val = Number(val);
769
847
 
770
848
  // Write through the reactive proxy (triggers re-render).
@@ -772,7 +850,15 @@ class Component {
772
850
  _setPath(this.state, key, val);
773
851
  };
774
852
 
775
- el.addEventListener(event, handler);
853
+ if (hasDebounce) {
854
+ let timer = null;
855
+ el.addEventListener(event, () => {
856
+ clearTimeout(timer);
857
+ timer = setTimeout(handler, debounceMs);
858
+ });
859
+ } else {
860
+ el.addEventListener(event, handler);
861
+ }
776
862
  });
777
863
  }
778
864
 
@@ -1058,6 +1144,10 @@ class Component {
1058
1144
  }
1059
1145
  this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
1060
1146
  this._listeners = [];
1147
+ if (this._outsideListeners) {
1148
+ this._outsideListeners.forEach(({ event, handler }) => document.removeEventListener(event, handler, true));
1149
+ this._outsideListeners = [];
1150
+ }
1061
1151
  this._delegatedEvents = null;
1062
1152
  this._eventBindings = null;
1063
1153
  // Clear any pending debounce/throttle timers to prevent stale closures.
package/src/core.js CHANGED
@@ -863,6 +863,8 @@ query.children = (parentId) => {
863
863
  const p = document.getElementById(parentId);
864
864
  return new ZQueryCollection(p ? Array.from(p.children) : []);
865
865
  };
866
+ query.qs = (sel, ctx = document) => ctx.querySelector(sel);
867
+ query.qsa = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel));
866
868
 
867
869
  // Create element shorthand — returns ZQueryCollection for chaining
868
870
  query.create = (tag, attrs = {}, ...children) => {
@@ -870,7 +872,7 @@ query.create = (tag, attrs = {}, ...children) => {
870
872
  for (const [k, v] of Object.entries(attrs)) {
871
873
  if (k === 'class') el.className = v;
872
874
  else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
873
- else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);
875
+ else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v);
874
876
  else if (k === 'data' && typeof v === 'object') Object.entries(v).forEach(([dk, dv]) => { el.dataset[dk] = dv; });
875
877
  else el.setAttribute(k, v);
876
878
  }
package/src/expression.js CHANGED
@@ -169,6 +169,11 @@ function tokenize(expr) {
169
169
  tokens.push({ t: T.OP, v: ch });
170
170
  i++; continue;
171
171
  }
172
+ // Spread operator: ...
173
+ if (ch === '.' && i + 2 < len && expr[i + 1] === '.' && expr[i + 2] === '.') {
174
+ tokens.push({ t: T.OP, v: '...' });
175
+ i += 3; continue;
176
+ }
172
177
  if ('()[]{},.?:'.includes(ch)) {
173
178
  tokens.push({ t: T.PUNC, v: ch });
174
179
  i++; continue;
@@ -232,7 +237,7 @@ class Parser {
232
237
  this.next(); // consume ?
233
238
  const truthy = this.parseExpression(0);
234
239
  this.expect(T.PUNC, ':');
235
- const falsy = this.parseExpression(1);
240
+ const falsy = this.parseExpression(0);
236
241
  left = { type: 'ternary', cond: left, truthy, falsy };
237
242
  continue;
238
243
  }
@@ -373,7 +378,12 @@ class Parser {
373
378
  this.expect(T.PUNC, '(');
374
379
  const args = [];
375
380
  while (!(this.peek().t === T.PUNC && this.peek().v === ')') && this.peek().t !== T.EOF) {
376
- args.push(this.parseExpression(0));
381
+ if (this.peek().t === T.OP && this.peek().v === '...') {
382
+ this.next();
383
+ args.push({ type: 'spread', arg: this.parseExpression(0) });
384
+ } else {
385
+ args.push(this.parseExpression(0));
386
+ }
377
387
  if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
378
388
  }
379
389
  this.expect(T.PUNC, ')');
@@ -449,7 +459,12 @@ class Parser {
449
459
  this.next();
450
460
  const elements = [];
451
461
  while (!(this.peek().t === T.PUNC && this.peek().v === ']') && this.peek().t !== T.EOF) {
452
- elements.push(this.parseExpression(0));
462
+ if (this.peek().t === T.OP && this.peek().v === '...') {
463
+ this.next();
464
+ elements.push({ type: 'spread', arg: this.parseExpression(0) });
465
+ } else {
466
+ elements.push(this.parseExpression(0));
467
+ }
453
468
  if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
454
469
  }
455
470
  this.expect(T.PUNC, ']');
@@ -461,6 +476,14 @@ class Parser {
461
476
  this.next();
462
477
  const properties = [];
463
478
  while (!(this.peek().t === T.PUNC && this.peek().v === '}') && this.peek().t !== T.EOF) {
479
+ // Spread in object: { ...obj }
480
+ if (this.peek().t === T.OP && this.peek().v === '...') {
481
+ this.next();
482
+ properties.push({ spread: true, value: this.parseExpression(0) });
483
+ if (this.peek().t === T.PUNC && this.peek().v === ',') this.next();
484
+ continue;
485
+ }
486
+
464
487
  const keyTok = this.next();
465
488
  let key;
466
489
  if (keyTok.t === T.IDENT || keyTok.t === T.STR) key = keyTok.v;
@@ -492,7 +515,13 @@ class Parser {
492
515
 
493
516
  // new keyword
494
517
  if (tok.v === 'new') {
495
- const classExpr = this.parsePostfix();
518
+ let classExpr = this.parsePrimary();
519
+ // Handle member access (e.g. ns.MyClass) without consuming call args
520
+ while (this.peek().t === T.PUNC && this.peek().v === '.') {
521
+ this.next();
522
+ const prop = this.next();
523
+ classExpr = { type: 'member', obj: classExpr, prop: prop.v, computed: false };
524
+ }
496
525
  let args = [];
497
526
  if (this.peek().t === T.PUNC && this.peek().v === '(') {
498
527
  args = this._parseArgs();
@@ -604,6 +633,12 @@ function evaluate(node, scope) {
604
633
  if (name === 'encodeURIComponent') return encodeURIComponent;
605
634
  if (name === 'decodeURIComponent') return decodeURIComponent;
606
635
  if (name === 'console') return console;
636
+ if (name === 'Map') return Map;
637
+ if (name === 'Set') return Set;
638
+ if (name === 'RegExp') return RegExp;
639
+ if (name === 'Error') return Error;
640
+ if (name === 'URL') return URL;
641
+ if (name === 'URLSearchParams') return URLSearchParams;
607
642
  return undefined;
608
643
  }
609
644
 
@@ -642,10 +677,21 @@ function evaluate(node, scope) {
642
677
  }
643
678
 
644
679
  case 'optional_call': {
645
- const callee = evaluate(node.callee, scope);
680
+ const calleeNode = node.callee;
681
+ const args = _evalArgs(node.args, scope);
682
+ // Method call: obj?.method() — bind `this` to obj
683
+ if (calleeNode.type === 'member' || calleeNode.type === 'optional_member') {
684
+ const obj = evaluate(calleeNode.obj, scope);
685
+ if (obj == null) return undefined;
686
+ const prop = calleeNode.computed ? evaluate(calleeNode.prop, scope) : calleeNode.prop;
687
+ if (!_isSafeAccess(obj, prop)) return undefined;
688
+ const fn = obj[prop];
689
+ if (typeof fn !== 'function') return undefined;
690
+ return fn.apply(obj, args);
691
+ }
692
+ const callee = evaluate(calleeNode, scope);
646
693
  if (callee == null) return undefined;
647
694
  if (typeof callee !== 'function') return undefined;
648
- const args = node.args.map(a => evaluate(a, scope));
649
695
  return callee(...args);
650
696
  }
651
697
 
@@ -655,7 +701,7 @@ function evaluate(node, scope) {
655
701
  // Only allow safe constructors
656
702
  if (Ctor === Date || Ctor === Array || Ctor === Map || Ctor === Set ||
657
703
  Ctor === RegExp || Ctor === Error || Ctor === URL || Ctor === URLSearchParams) {
658
- const args = node.args.map(a => evaluate(a, scope));
704
+ const args = _evalArgs(node.args, scope);
659
705
  return new Ctor(...args);
660
706
  }
661
707
  return undefined;
@@ -685,13 +731,32 @@ function evaluate(node, scope) {
685
731
  return cond ? evaluate(node.truthy, scope) : evaluate(node.falsy, scope);
686
732
  }
687
733
 
688
- case 'array':
689
- return node.elements.map(e => evaluate(e, scope));
734
+ case 'array': {
735
+ const arr = [];
736
+ for (const e of node.elements) {
737
+ if (e.type === 'spread') {
738
+ const iterable = evaluate(e.arg, scope);
739
+ if (iterable != null && typeof iterable[Symbol.iterator] === 'function') {
740
+ for (const v of iterable) arr.push(v);
741
+ }
742
+ } else {
743
+ arr.push(evaluate(e, scope));
744
+ }
745
+ }
746
+ return arr;
747
+ }
690
748
 
691
749
  case 'object': {
692
750
  const obj = {};
693
- for (const { key, value } of node.properties) {
694
- obj[key] = evaluate(value, scope);
751
+ for (const prop of node.properties) {
752
+ if (prop.spread) {
753
+ const source = evaluate(prop.value, scope);
754
+ if (source != null && typeof source === 'object') {
755
+ Object.assign(obj, source);
756
+ }
757
+ } else {
758
+ obj[prop.key] = evaluate(prop.value, scope);
759
+ }
695
760
  }
696
761
  return obj;
697
762
  }
@@ -712,12 +777,30 @@ function evaluate(node, scope) {
712
777
  }
713
778
  }
714
779
 
780
+ /**
781
+ * Evaluate a list of argument AST nodes, flattening any spread elements.
782
+ */
783
+ function _evalArgs(argNodes, scope) {
784
+ const result = [];
785
+ for (const a of argNodes) {
786
+ if (a.type === 'spread') {
787
+ const iterable = evaluate(a.arg, scope);
788
+ if (iterable != null && typeof iterable[Symbol.iterator] === 'function') {
789
+ for (const v of iterable) result.push(v);
790
+ }
791
+ } else {
792
+ result.push(evaluate(a, scope));
793
+ }
794
+ }
795
+ return result;
796
+ }
797
+
715
798
  /**
716
799
  * Resolve and execute a function call safely.
717
800
  */
718
801
  function _resolveCall(node, scope) {
719
802
  const callee = node.callee;
720
- const args = node.args.map(a => evaluate(a, scope));
803
+ const args = _evalArgs(node.args, scope);
721
804
 
722
805
  // Method call: obj.method() — bind `this` to obj
723
806
  if (callee.type === 'member' || callee.type === 'optional_member') {
@@ -791,8 +874,9 @@ function _evalBinary(node, scope) {
791
874
  * @returns {*} — evaluation result, or undefined on error
792
875
  */
793
876
 
794
- // AST cache — avoids re-tokenizing and re-parsing the same expression string.
795
- // Bounded to prevent unbounded memory growth in long-lived apps.
877
+ // AST cache (LRU) — avoids re-tokenizing and re-parsing the same expression.
878
+ // Uses Map insertion-order: on hit, delete + re-set moves entry to the end.
879
+ // Eviction removes the least-recently-used (first) entry when at capacity.
796
880
  const _astCache = new Map();
797
881
  const _AST_CACHE_MAX = 512;
798
882
 
@@ -812,9 +896,12 @@ export function safeEval(expr, scope) {
812
896
  // Fall through to full parser for built-in globals (Math, JSON, etc.)
813
897
  }
814
898
 
815
- // Check AST cache
899
+ // Check AST cache (LRU: move to end on hit)
816
900
  let ast = _astCache.get(trimmed);
817
- if (!ast) {
901
+ if (ast) {
902
+ _astCache.delete(trimmed);
903
+ _astCache.set(trimmed, ast);
904
+ } else {
818
905
  const tokens = tokenize(trimmed);
819
906
  const parser = new Parser(tokens, scope);
820
907
  ast = parser.parse();
package/src/http.js CHANGED
@@ -84,8 +84,9 @@ async function request(method, url, data, options = {}) {
84
84
  } else {
85
85
  fetchOpts.signal = controller.signal;
86
86
  }
87
+ let _timedOut = false;
87
88
  if (timeout > 0) {
88
- timer = setTimeout(() => controller.abort(), timeout);
89
+ timer = setTimeout(() => { _timedOut = true; controller.abort(); }, timeout);
89
90
  }
90
91
 
91
92
  // Run request interceptors
@@ -145,7 +146,10 @@ async function request(method, url, data, options = {}) {
145
146
  } catch (err) {
146
147
  if (timer) clearTimeout(timer);
147
148
  if (err.name === 'AbortError') {
148
- throw new Error(`Request timeout after ${timeout}ms: ${method} ${fullURL}`);
149
+ if (_timedOut) {
150
+ throw new Error(`Request timeout after ${timeout}ms: ${method} ${fullURL}`);
151
+ }
152
+ throw new Error(`Request aborted: ${method} ${fullURL}`);
149
153
  }
150
154
  throw err;
151
155
  }
package/src/router.js CHANGED
@@ -583,7 +583,12 @@ class Router {
583
583
  if (typeof matched.component === 'string') {
584
584
  const container = document.createElement(matched.component);
585
585
  this._el.appendChild(container);
586
- this._instance = mount(container, matched.component, props);
586
+ try {
587
+ this._instance = mount(container, matched.component, props);
588
+ } catch (err) {
589
+ reportError(ErrorCode.COMP_NOT_FOUND, `Failed to mount component for route "${matched.path}"`, { component: matched.component, path: matched.path }, err);
590
+ return;
591
+ }
587
592
  if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', matched.component);
588
593
  }
589
594
  // If component is a render function