zero-query 0.9.0 → 0.9.1

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/src/core.js CHANGED
@@ -12,7 +12,7 @@ import { morph as _morph, morphElement as _morphElement } from './diff.js';
12
12
  // ---------------------------------------------------------------------------
13
13
  export class ZQueryCollection {
14
14
  constructor(elements) {
15
- this.elements = Array.isArray(elements) ? elements : [elements];
15
+ this.elements = Array.isArray(elements) ? elements : (elements ? [elements] : []);
16
16
  this.length = this.elements.length;
17
17
  this.elements.forEach((el, i) => { this[i] = el; });
18
18
  }
@@ -69,10 +69,12 @@ export class ZQueryCollection {
69
69
  return new ZQueryCollection([...kids]);
70
70
  }
71
71
 
72
- siblings() {
72
+ siblings(selector) {
73
73
  const sibs = [];
74
74
  this.elements.forEach(el => {
75
- sibs.push(...[...el.parentElement.children].filter(c => c !== el));
75
+ if (!el.parentElement) return;
76
+ const all = [...el.parentElement.children].filter(c => c !== el);
77
+ sibs.push(...(selector ? all.filter(c => c.matches(selector)) : all));
76
78
  });
77
79
  return new ZQueryCollection(sibs);
78
80
  }
@@ -214,7 +216,8 @@ export class ZQueryCollection {
214
216
  index(selector) {
215
217
  if (selector === undefined) {
216
218
  const el = this.first();
217
- return el ? Array.from(el.parentElement.children).indexOf(el) : -1;
219
+ if (!el || !el.parentElement) return -1;
220
+ return Array.from(el.parentElement.children).indexOf(el);
218
221
  }
219
222
  const target = (typeof selector === 'string')
220
223
  ? document.querySelector(selector)
@@ -274,6 +277,11 @@ export class ZQueryCollection {
274
277
  // --- Attributes ----------------------------------------------------------
275
278
 
276
279
  attr(name, value) {
280
+ if (typeof name === 'object' && name !== null) {
281
+ return this.each((_, el) => {
282
+ for (const [k, v] of Object.entries(name)) el.setAttribute(k, v);
283
+ });
284
+ }
277
285
  if (value === undefined) return this.first()?.getAttribute(name);
278
286
  return this.each((_, el) => el.setAttribute(name, value));
279
287
  }
@@ -298,7 +306,10 @@ export class ZQueryCollection {
298
306
 
299
307
  // --- CSS / Dimensions ----------------------------------------------------
300
308
 
301
- css(props) {
309
+ css(props, value) {
310
+ if (typeof props === 'string' && value !== undefined) {
311
+ return this.each((_, el) => { el.style[props] = value; });
312
+ }
302
313
  if (typeof props === 'string') {
303
314
  const el = this.first();
304
315
  return el ? getComputedStyle(el)[props] : undefined;
@@ -438,6 +449,7 @@ export class ZQueryCollection {
438
449
  wrap(wrapper) {
439
450
  return this.each((_, el) => {
440
451
  const w = typeof wrapper === 'string' ? createFragment(wrapper).firstElementChild : wrapper.cloneNode(true);
452
+ if (!w || !el.parentNode) return;
441
453
  el.parentNode.insertBefore(w, el);
442
454
  w.appendChild(el);
443
455
  });
@@ -563,12 +575,18 @@ export class ZQueryCollection {
563
575
  if (typeof selectorOrHandler === 'function') {
564
576
  el.addEventListener(evt, selectorOrHandler);
565
577
  } else if (typeof selectorOrHandler === 'string') {
566
- // Delegated event — only works on elements that support closest()
567
- el.addEventListener(evt, (e) => {
578
+ // Delegated event — store wrapper so off() can remove it
579
+ const wrapper = (e) => {
568
580
  if (!e.target || typeof e.target.closest !== 'function') return;
569
581
  const target = e.target.closest(selectorOrHandler);
570
582
  if (target && el.contains(target)) handler.call(target, e);
571
- });
583
+ };
584
+ wrapper._zqOriginal = handler;
585
+ wrapper._zqSelector = selectorOrHandler;
586
+ el.addEventListener(evt, wrapper);
587
+ // Track delegated handlers for removal
588
+ if (!el._zqDelegated) el._zqDelegated = [];
589
+ el._zqDelegated.push({ evt, wrapper });
572
590
  }
573
591
  });
574
592
  });
@@ -577,7 +595,20 @@ export class ZQueryCollection {
577
595
  off(event, handler) {
578
596
  const events = event.split(/\s+/);
579
597
  return this.each((_, el) => {
580
- events.forEach(evt => el.removeEventListener(evt, handler));
598
+ events.forEach(evt => {
599
+ // Try direct removal first
600
+ el.removeEventListener(evt, handler);
601
+ // Also check delegated handlers
602
+ if (el._zqDelegated) {
603
+ el._zqDelegated = el._zqDelegated.filter(d => {
604
+ if (d.evt === evt && d.wrapper._zqOriginal === handler) {
605
+ el.removeEventListener(evt, d.wrapper);
606
+ return false;
607
+ }
608
+ return true;
609
+ });
610
+ }
611
+ });
581
612
  });
582
613
  }
583
614
 
@@ -606,8 +637,12 @@ export class ZQueryCollection {
606
637
  // --- Animation -----------------------------------------------------------
607
638
 
608
639
  animate(props, duration = 300, easing = 'ease') {
640
+ // Empty collection — resolve immediately
641
+ if (this.length === 0) return Promise.resolve(this);
609
642
  return new Promise(resolve => {
643
+ let resolved = false;
610
644
  const count = { done: 0 };
645
+ const listeners = [];
611
646
  this.each((_, el) => {
612
647
  el.style.transition = `all ${duration}ms ${easing}`;
613
648
  requestAnimationFrame(() => {
@@ -615,13 +650,27 @@ export class ZQueryCollection {
615
650
  const onEnd = () => {
616
651
  el.removeEventListener('transitionend', onEnd);
617
652
  el.style.transition = '';
618
- if (++count.done >= this.length) resolve(this);
653
+ if (!resolved && ++count.done >= this.length) {
654
+ resolved = true;
655
+ resolve(this);
656
+ }
619
657
  };
620
658
  el.addEventListener('transitionend', onEnd);
659
+ listeners.push({ el, onEnd });
621
660
  });
622
661
  });
623
662
  // Fallback in case transitionend doesn't fire
624
- setTimeout(() => resolve(this), duration + 50);
663
+ setTimeout(() => {
664
+ if (!resolved) {
665
+ resolved = true;
666
+ // Clean up any remaining transitionend listeners
667
+ for (const { el, onEnd } of listeners) {
668
+ el.removeEventListener('transitionend', onEnd);
669
+ el.style.transition = '';
670
+ }
671
+ resolve(this);
672
+ }
673
+ }, duration + 50);
625
674
  });
626
675
  }
627
676
 
@@ -635,7 +684,8 @@ export class ZQueryCollection {
635
684
 
636
685
  fadeToggle(duration = 300) {
637
686
  return Promise.all(this.elements.map(el => {
638
- const visible = getComputedStyle(el).opacity !== '0' && getComputedStyle(el).display !== 'none';
687
+ const cs = getComputedStyle(el);
688
+ const visible = cs.opacity !== '0' && cs.display !== 'none';
639
689
  const col = new ZQueryCollection([el]);
640
690
  return visible ? col.fadeOut(duration) : col.fadeIn(duration);
641
691
  })).then(() => this);
package/src/diff.js CHANGED
@@ -239,8 +239,11 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
239
239
  if (!lisSet.has(i)) {
240
240
  oldParent.insertBefore(oldNode, cursor);
241
241
  }
242
+ // Capture next sibling BEFORE _morphNode — if _morphNode calls
243
+ // replaceChild, oldNode is removed and nextSibling becomes stale.
244
+ const nextSib = oldNode.nextSibling;
242
245
  _morphNode(oldParent, oldNode, newNode);
243
- cursor = oldNode.nextSibling;
246
+ cursor = nextSib;
244
247
  } else {
245
248
  // Insert new node
246
249
  const clone = newNode.cloneNode(true);
@@ -418,10 +421,13 @@ function _morphAttributes(oldEl, newEl) {
418
421
  }
419
422
  }
420
423
 
421
- // Remove stale attributes
422
- for (let i = oldLen - 1; i >= 0; i--) {
423
- if (!newNames.has(oldAttrs[i].name)) {
424
- oldEl.removeAttribute(oldAttrs[i].name);
424
+ // Remove stale attributes — snapshot names first because oldAttrs
425
+ // is a live NamedNodeMap that mutates on removeAttribute().
426
+ const oldNames = new Array(oldLen);
427
+ for (let i = 0; i < oldLen; i++) oldNames[i] = oldAttrs[i].name;
428
+ for (let i = oldNames.length - 1; i >= 0; i--) {
429
+ if (!newNames.has(oldNames[i])) {
430
+ oldEl.removeAttribute(oldNames[i]);
425
431
  }
426
432
  }
427
433
  }
package/src/expression.js CHANGED
@@ -560,6 +560,7 @@ function _isSafeAccess(obj, prop) {
560
560
  const BLOCKED = new Set([
561
561
  'constructor', '__proto__', 'prototype', '__defineGetter__',
562
562
  '__defineSetter__', '__lookupGetter__', '__lookupSetter__',
563
+ 'call', 'apply', 'bind',
563
564
  ]);
564
565
  if (typeof prop === 'string' && BLOCKED.has(prop)) return false;
565
566
 
package/src/http.js CHANGED
@@ -65,9 +65,25 @@ async function request(method, url, data, options = {}) {
65
65
 
66
66
  // Timeout via AbortController
67
67
  const controller = new AbortController();
68
- fetchOpts.signal = options.signal || controller.signal;
69
68
  const timeout = options.timeout ?? _config.timeout;
70
69
  let timer;
70
+ // Combine user signal with internal controller for proper timeout support
71
+ if (options.signal) {
72
+ // If AbortSignal.any is available, combine both signals
73
+ if (typeof AbortSignal.any === 'function') {
74
+ fetchOpts.signal = AbortSignal.any([options.signal, controller.signal]);
75
+ } else {
76
+ // Fallback: forward user signal's abort to our controller
77
+ fetchOpts.signal = controller.signal;
78
+ if (options.signal.aborted) {
79
+ controller.abort(options.signal.reason);
80
+ } else {
81
+ options.signal.addEventListener('abort', () => controller.abort(options.signal.reason), { once: true });
82
+ }
83
+ }
84
+ } else {
85
+ fetchOpts.signal = controller.signal;
86
+ }
71
87
  if (timeout > 0) {
72
88
  timer = setTimeout(() => controller.abort(), timeout);
73
89
  }
package/src/reactive.js CHANGED
@@ -134,7 +134,13 @@ export function signal(initial) {
134
134
  */
135
135
  export function computed(fn) {
136
136
  const s = new Signal(undefined);
137
- effect(() => { s._value = fn(); s._notify(); });
137
+ effect(() => {
138
+ const v = fn();
139
+ if (v !== s._value) {
140
+ s._value = v;
141
+ s._notify();
142
+ }
143
+ });
138
144
  return s;
139
145
  }
140
146
 
@@ -177,6 +183,6 @@ export function effect(fn) {
177
183
  }
178
184
  execute._deps.clear();
179
185
  }
180
- Signal._activeEffect = null;
186
+ // Don't clobber _activeEffect another effect may be running
181
187
  };
182
188
  }
package/src/router.js CHANGED
@@ -24,6 +24,23 @@ import { reportError, ErrorCode } from './errors.js';
24
24
  // Unique marker on history.state to identify zQuery-managed entries
25
25
  const _ZQ_STATE_KEY = '__zq';
26
26
 
27
+ /**
28
+ * Shallow-compare two flat objects (for params / query comparison).
29
+ * Avoids JSON.stringify overhead on every navigation.
30
+ */
31
+ function _shallowEqual(a, b) {
32
+ if (a === b) return true;
33
+ if (!a || !b) return false;
34
+ const keysA = Object.keys(a);
35
+ const keysB = Object.keys(b);
36
+ if (keysA.length !== keysB.length) return false;
37
+ for (let i = 0; i < keysA.length; i++) {
38
+ const k = keysA[i];
39
+ if (a[k] !== b[k]) return false;
40
+ }
41
+ return true;
42
+ }
43
+
27
44
  class Router {
28
45
  constructor(config = {}) {
29
46
  this._el = null;
@@ -71,11 +88,12 @@ class Router {
71
88
  config.routes.forEach(r => this.add(r));
72
89
  }
73
90
 
74
- // Listen for navigation
91
+ // Listen for navigation — store handler references for cleanup in destroy()
75
92
  if (this._mode === 'hash') {
76
- window.addEventListener('hashchange', () => this._resolve());
93
+ this._onNavEvent = () => this._resolve();
94
+ window.addEventListener('hashchange', this._onNavEvent);
77
95
  } else {
78
- window.addEventListener('popstate', (e) => {
96
+ this._onNavEvent = (e) => {
79
97
  // Check for substate pop first — if a listener handles it, don't route
80
98
  const st = e.state;
81
99
  if (st && st[_ZQ_STATE_KEY] === 'substate') {
@@ -89,11 +107,12 @@ class Router {
89
107
  this._fireSubstate(null, null, 'reset');
90
108
  }
91
109
  this._resolve();
92
- });
110
+ };
111
+ window.addEventListener('popstate', this._onNavEvent);
93
112
  }
94
113
 
95
114
  // Intercept link clicks for SPA navigation
96
- document.addEventListener('click', (e) => {
115
+ this._onLinkClick = (e) => {
97
116
  // Don't intercept modified clicks (Ctrl/Cmd+click = new tab)
98
117
  if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
99
118
  const link = e.target.closest('[z-link]');
@@ -115,7 +134,8 @@ class Router {
115
134
  const scrollBehavior = link.getAttribute('z-to-top') || 'instant';
116
135
  window.scrollTo({ top: 0, behavior: scrollBehavior });
117
136
  }
118
- });
137
+ };
138
+ document.addEventListener('click', this._onLinkClick);
119
139
 
120
140
  // Initial resolve
121
141
  if (this._el) {
@@ -487,8 +507,8 @@ class Router {
487
507
  // with the same params, skip the full destroy/mount cycle and just
488
508
  // update props. This prevents flashing and unnecessary DOM churn.
489
509
  if (from && this._instance && matched.component === from.route.component) {
490
- const sameParams = JSON.stringify(params) === JSON.stringify(from.params);
491
- const sameQuery = JSON.stringify(query) === JSON.stringify(from.query);
510
+ const sameParams = _shallowEqual(params, from.params);
511
+ const sameQuery = _shallowEqual(query, from.query);
492
512
  if (sameParams && sameQuery) {
493
513
  // Identical navigation — nothing to do
494
514
  return;
@@ -585,6 +605,15 @@ class Router {
585
605
  // --- Destroy -------------------------------------------------------------
586
606
 
587
607
  destroy() {
608
+ // Remove window/document event listeners to prevent memory leaks
609
+ if (this._onNavEvent) {
610
+ window.removeEventListener(this._mode === 'hash' ? 'hashchange' : 'popstate', this._onNavEvent);
611
+ this._onNavEvent = null;
612
+ }
613
+ if (this._onLinkClick) {
614
+ document.removeEventListener('click', this._onLinkClick);
615
+ this._onLinkClick = null;
616
+ }
588
617
  if (this._instance) this._instance.destroy();
589
618
  this._listeners.clear();
590
619
  this._substateListeners = [];
package/src/ssr.js CHANGED
@@ -192,7 +192,7 @@ class SSRApp {
192
192
  ${meta}
193
193
  ${styleLinks}
194
194
  </head>
195
- <body ${bodyAttrs}>
195
+ <body ${bodyAttrs.replace(/on\w+\s*=/gi, '').replace(/javascript\s*:/gi, '')}>
196
196
  <div id="app">${content}</div>
197
197
  ${scriptTags}
198
198
  </body>
package/src/store.js CHANGED
@@ -36,6 +36,7 @@ class Store {
36
36
  this._getters = config.getters || {};
37
37
  this._middleware = [];
38
38
  this._history = []; // action log
39
+ this._maxHistory = config.maxHistory || 1000;
39
40
  this._debug = config.debug || false;
40
41
 
41
42
  // Create reactive state
@@ -95,6 +96,10 @@ class Store {
95
96
  try {
96
97
  const result = action(this.state, ...args);
97
98
  this._history.push({ action: name, args, timestamp: Date.now() });
99
+ // Cap history to prevent unbounded memory growth
100
+ if (this._history.length > this._maxHistory) {
101
+ this._history.splice(0, this._history.length - this._maxHistory);
102
+ }
98
103
  return result;
99
104
  } catch (err) {
100
105
  reportError(ErrorCode.STORE_ACTION, `Action "${name}" threw`, { action: name, args }, err);
package/src/utils.js CHANGED
@@ -144,16 +144,21 @@ export function deepClone(obj) {
144
144
  * Deep merge objects
145
145
  */
146
146
  export function deepMerge(target, ...sources) {
147
- for (const source of sources) {
148
- for (const key of Object.keys(source)) {
149
- if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
150
- if (!target[key] || typeof target[key] !== 'object') target[key] = {};
151
- deepMerge(target[key], source[key]);
147
+ const seen = new WeakSet();
148
+ function merge(tgt, src) {
149
+ if (seen.has(src)) return tgt;
150
+ seen.add(src);
151
+ for (const key of Object.keys(src)) {
152
+ if (src[key] && typeof src[key] === 'object' && !Array.isArray(src[key])) {
153
+ if (!tgt[key] || typeof tgt[key] !== 'object') tgt[key] = {};
154
+ merge(tgt[key], src[key]);
152
155
  } else {
153
- target[key] = source[key];
156
+ tgt[key] = src[key];
154
157
  }
155
158
  }
159
+ return tgt;
156
160
  }
161
+ for (const source of sources) merge(target, source);
157
162
  return target;
158
163
  }
159
164
 
@@ -164,6 +169,7 @@ export function isEqual(a, b) {
164
169
  if (a === b) return true;
165
170
  if (typeof a !== typeof b) return false;
166
171
  if (typeof a !== 'object' || a === null || b === null) return false;
172
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
167
173
  const keysA = Object.keys(a);
168
174
  const keysB = Object.keys(b);
169
175
  if (keysA.length !== keysB.length) return false;