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/README.md +2 -3
- package/cli/commands/bundle.js +15 -2
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +184 -44
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +6 -2
- package/package.json +1 -1
- package/src/component.js +28 -7
- package/src/core.js +62 -12
- package/src/diff.js +11 -5
- package/src/expression.js +1 -0
- package/src/http.js +17 -1
- package/src/reactive.js +8 -2
- package/src/router.js +37 -8
- package/src/ssr.js +1 -1
- package/src/store.js +5 -0
- package/src/utils.js +12 -6
- package/tests/cli.test.js +456 -0
- package/tests/component.test.js +1387 -0
- package/tests/core.test.js +893 -1
- package/tests/diff.test.js +891 -0
- package/tests/errors.test.js +179 -0
- package/tests/expression.test.js +569 -0
- package/tests/http.test.js +160 -1
- package/tests/reactive.test.js +320 -0
- package/tests/router.test.js +1187 -0
- package/tests/ssr.test.js +261 -0
- package/tests/store.test.js +210 -0
- package/tests/utils.test.js +186 -0
- package/types/store.d.ts +3 -0
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
|
-
|
|
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
|
-
|
|
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 —
|
|
567
|
-
|
|
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 =>
|
|
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)
|
|
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(() =>
|
|
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
|
|
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 =
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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(() => {
|
|
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
|
-
|
|
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
|
-
|
|
93
|
+
this._onNavEvent = () => this._resolve();
|
|
94
|
+
window.addEventListener('hashchange', this._onNavEvent);
|
|
77
95
|
} else {
|
|
78
|
-
|
|
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
|
-
|
|
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 =
|
|
491
|
-
const sameQuery =
|
|
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
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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;
|