zero-query 0.9.0 → 0.9.5
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 +11 -9
- package/cli/commands/bundle.js +15 -2
- package/cli/commands/dev/devtools/js/elements.js +5 -0
- package/cli/scaffold/app/app.js +1 -1
- package/cli/scaffold/global.css +1 -2
- package/cli/scaffold/index.html +2 -1
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +544 -71
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +51 -3
- package/index.js +32 -4
- package/package.json +1 -1
- package/src/component.js +60 -10
- package/src/core.js +65 -13
- package/src/diff.js +11 -5
- package/src/expression.js +104 -16
- package/src/http.js +23 -3
- package/src/reactive.js +8 -2
- package/src/router.js +43 -9
- package/src/ssr.js +1 -1
- package/src/store.js +5 -0
- package/src/utils.js +203 -11
- package/tests/audit.test.js +4030 -0
- package/tests/cli.test.js +456 -0
- package/tests/component.test.js +1387 -0
- package/tests/core.test.js +934 -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 +729 -1
- package/types/store.d.ts +3 -0
- package/types/utils.d.ts +103 -0
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
|
@@ -560,6 +589,7 @@ function _isSafeAccess(obj, prop) {
|
|
|
560
589
|
const BLOCKED = new Set([
|
|
561
590
|
'constructor', '__proto__', 'prototype', '__defineGetter__',
|
|
562
591
|
'__defineSetter__', '__lookupGetter__', '__lookupSetter__',
|
|
592
|
+
'call', 'apply', 'bind',
|
|
563
593
|
]);
|
|
564
594
|
if (typeof prop === 'string' && BLOCKED.has(prop)) return false;
|
|
565
595
|
|
|
@@ -603,6 +633,12 @@ function evaluate(node, scope) {
|
|
|
603
633
|
if (name === 'encodeURIComponent') return encodeURIComponent;
|
|
604
634
|
if (name === 'decodeURIComponent') return decodeURIComponent;
|
|
605
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;
|
|
606
642
|
return undefined;
|
|
607
643
|
}
|
|
608
644
|
|
|
@@ -641,10 +677,21 @@ function evaluate(node, scope) {
|
|
|
641
677
|
}
|
|
642
678
|
|
|
643
679
|
case 'optional_call': {
|
|
644
|
-
const
|
|
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);
|
|
645
693
|
if (callee == null) return undefined;
|
|
646
694
|
if (typeof callee !== 'function') return undefined;
|
|
647
|
-
const args = node.args.map(a => evaluate(a, scope));
|
|
648
695
|
return callee(...args);
|
|
649
696
|
}
|
|
650
697
|
|
|
@@ -654,7 +701,7 @@ function evaluate(node, scope) {
|
|
|
654
701
|
// Only allow safe constructors
|
|
655
702
|
if (Ctor === Date || Ctor === Array || Ctor === Map || Ctor === Set ||
|
|
656
703
|
Ctor === RegExp || Ctor === Error || Ctor === URL || Ctor === URLSearchParams) {
|
|
657
|
-
const args = node.args
|
|
704
|
+
const args = _evalArgs(node.args, scope);
|
|
658
705
|
return new Ctor(...args);
|
|
659
706
|
}
|
|
660
707
|
return undefined;
|
|
@@ -684,13 +731,32 @@ function evaluate(node, scope) {
|
|
|
684
731
|
return cond ? evaluate(node.truthy, scope) : evaluate(node.falsy, scope);
|
|
685
732
|
}
|
|
686
733
|
|
|
687
|
-
case 'array':
|
|
688
|
-
|
|
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
|
+
}
|
|
689
748
|
|
|
690
749
|
case 'object': {
|
|
691
750
|
const obj = {};
|
|
692
|
-
for (const
|
|
693
|
-
|
|
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
|
+
}
|
|
694
760
|
}
|
|
695
761
|
return obj;
|
|
696
762
|
}
|
|
@@ -711,12 +777,30 @@ function evaluate(node, scope) {
|
|
|
711
777
|
}
|
|
712
778
|
}
|
|
713
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
|
+
|
|
714
798
|
/**
|
|
715
799
|
* Resolve and execute a function call safely.
|
|
716
800
|
*/
|
|
717
801
|
function _resolveCall(node, scope) {
|
|
718
802
|
const callee = node.callee;
|
|
719
|
-
const args = node.args
|
|
803
|
+
const args = _evalArgs(node.args, scope);
|
|
720
804
|
|
|
721
805
|
// Method call: obj.method() — bind `this` to obj
|
|
722
806
|
if (callee.type === 'member' || callee.type === 'optional_member') {
|
|
@@ -790,8 +874,9 @@ function _evalBinary(node, scope) {
|
|
|
790
874
|
* @returns {*} — evaluation result, or undefined on error
|
|
791
875
|
*/
|
|
792
876
|
|
|
793
|
-
// AST cache — avoids re-tokenizing and re-parsing the same expression
|
|
794
|
-
//
|
|
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.
|
|
795
880
|
const _astCache = new Map();
|
|
796
881
|
const _AST_CACHE_MAX = 512;
|
|
797
882
|
|
|
@@ -811,9 +896,12 @@ export function safeEval(expr, scope) {
|
|
|
811
896
|
// Fall through to full parser for built-in globals (Math, JSON, etc.)
|
|
812
897
|
}
|
|
813
898
|
|
|
814
|
-
// Check AST cache
|
|
899
|
+
// Check AST cache (LRU: move to end on hit)
|
|
815
900
|
let ast = _astCache.get(trimmed);
|
|
816
|
-
if (
|
|
901
|
+
if (ast) {
|
|
902
|
+
_astCache.delete(trimmed);
|
|
903
|
+
_astCache.set(trimmed, ast);
|
|
904
|
+
} else {
|
|
817
905
|
const tokens = tokenize(trimmed);
|
|
818
906
|
const parser = new Parser(tokens, scope);
|
|
819
907
|
ast = parser.parse();
|
package/src/http.js
CHANGED
|
@@ -65,11 +65,28 @@ 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
|
+
}
|
|
87
|
+
let _timedOut = false;
|
|
71
88
|
if (timeout > 0) {
|
|
72
|
-
timer = setTimeout(() => controller.abort(), timeout);
|
|
89
|
+
timer = setTimeout(() => { _timedOut = true; controller.abort(); }, timeout);
|
|
73
90
|
}
|
|
74
91
|
|
|
75
92
|
// Run request interceptors
|
|
@@ -129,7 +146,10 @@ async function request(method, url, data, options = {}) {
|
|
|
129
146
|
} catch (err) {
|
|
130
147
|
if (timer) clearTimeout(timer);
|
|
131
148
|
if (err.name === 'AbortError') {
|
|
132
|
-
|
|
149
|
+
if (_timedOut) {
|
|
150
|
+
throw new Error(`Request timeout after ${timeout}ms: ${method} ${fullURL}`);
|
|
151
|
+
}
|
|
152
|
+
throw new Error(`Request aborted: ${method} ${fullURL}`);
|
|
133
153
|
}
|
|
134
154
|
throw err;
|
|
135
155
|
}
|
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;
|
|
@@ -563,7 +583,12 @@ class Router {
|
|
|
563
583
|
if (typeof matched.component === 'string') {
|
|
564
584
|
const container = document.createElement(matched.component);
|
|
565
585
|
this._el.appendChild(container);
|
|
566
|
-
|
|
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
|
+
}
|
|
567
592
|
if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', matched.component);
|
|
568
593
|
}
|
|
569
594
|
// If component is a render function
|
|
@@ -585,6 +610,15 @@ class Router {
|
|
|
585
610
|
// --- Destroy -------------------------------------------------------------
|
|
586
611
|
|
|
587
612
|
destroy() {
|
|
613
|
+
// Remove window/document event listeners to prevent memory leaks
|
|
614
|
+
if (this._onNavEvent) {
|
|
615
|
+
window.removeEventListener(this._mode === 'hash' ? 'hashchange' : 'popstate', this._onNavEvent);
|
|
616
|
+
this._onNavEvent = null;
|
|
617
|
+
}
|
|
618
|
+
if (this._onLinkClick) {
|
|
619
|
+
document.removeEventListener('click', this._onLinkClick);
|
|
620
|
+
this._onLinkClick = null;
|
|
621
|
+
}
|
|
588
622
|
if (this._instance) this._instance.destroy();
|
|
589
623
|
this._listeners.clear();
|
|
590
624
|
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
|
@@ -79,6 +79,10 @@ export function escapeHtml(str) {
|
|
|
79
79
|
return String(str).replace(/[&<>"']/g, c => map[c]);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
export function stripHtml(str) {
|
|
83
|
+
return String(str).replace(/<[^>]*>/g, '');
|
|
84
|
+
}
|
|
85
|
+
|
|
82
86
|
/**
|
|
83
87
|
* Template tag for auto-escaping interpolated values
|
|
84
88
|
* Usage: $.html`<div>${userInput}</div>`
|
|
@@ -94,7 +98,7 @@ export function html(strings, ...values) {
|
|
|
94
98
|
/**
|
|
95
99
|
* Mark HTML as trusted (skip escaping in $.html template)
|
|
96
100
|
*/
|
|
97
|
-
class TrustedHTML {
|
|
101
|
+
export class TrustedHTML {
|
|
98
102
|
constructor(html) { this._html = html; }
|
|
99
103
|
toString() { return this._html; }
|
|
100
104
|
}
|
|
@@ -124,7 +128,10 @@ export function camelCase(str) {
|
|
|
124
128
|
* CamelCase to kebab-case
|
|
125
129
|
*/
|
|
126
130
|
export function kebabCase(str) {
|
|
127
|
-
return str
|
|
131
|
+
return str
|
|
132
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
|
|
133
|
+
.replace(/([a-z\d])([A-Z])/g, '$1-$2')
|
|
134
|
+
.toLowerCase();
|
|
128
135
|
}
|
|
129
136
|
|
|
130
137
|
|
|
@@ -144,30 +151,40 @@ export function deepClone(obj) {
|
|
|
144
151
|
* Deep merge objects
|
|
145
152
|
*/
|
|
146
153
|
export function deepMerge(target, ...sources) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
154
|
+
const seen = new WeakSet();
|
|
155
|
+
function merge(tgt, src) {
|
|
156
|
+
if (seen.has(src)) return tgt;
|
|
157
|
+
seen.add(src);
|
|
158
|
+
for (const key of Object.keys(src)) {
|
|
159
|
+
if (src[key] && typeof src[key] === 'object' && !Array.isArray(src[key])) {
|
|
160
|
+
if (!tgt[key] || typeof tgt[key] !== 'object') tgt[key] = {};
|
|
161
|
+
merge(tgt[key], src[key]);
|
|
152
162
|
} else {
|
|
153
|
-
|
|
163
|
+
tgt[key] = src[key];
|
|
154
164
|
}
|
|
155
165
|
}
|
|
166
|
+
return tgt;
|
|
156
167
|
}
|
|
168
|
+
for (const source of sources) merge(target, source);
|
|
157
169
|
return target;
|
|
158
170
|
}
|
|
159
171
|
|
|
160
172
|
/**
|
|
161
173
|
* Simple object equality check
|
|
162
174
|
*/
|
|
163
|
-
export function isEqual(a, b) {
|
|
175
|
+
export function isEqual(a, b, _seen) {
|
|
164
176
|
if (a === b) return true;
|
|
165
177
|
if (typeof a !== typeof b) return false;
|
|
166
178
|
if (typeof a !== 'object' || a === null || b === null) return false;
|
|
179
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
180
|
+
// Guard against circular references
|
|
181
|
+
if (!_seen) _seen = new Set();
|
|
182
|
+
if (_seen.has(a)) return true;
|
|
183
|
+
_seen.add(a);
|
|
167
184
|
const keysA = Object.keys(a);
|
|
168
185
|
const keysB = Object.keys(b);
|
|
169
186
|
if (keysA.length !== keysB.length) return false;
|
|
170
|
-
return keysA.every(k => isEqual(a[k], b[k]));
|
|
187
|
+
return keysA.every(k => isEqual(a[k], b[k], _seen));
|
|
171
188
|
}
|
|
172
189
|
|
|
173
190
|
|
|
@@ -243,7 +260,7 @@ export const session = {
|
|
|
243
260
|
// ---------------------------------------------------------------------------
|
|
244
261
|
// Event bus (pub/sub)
|
|
245
262
|
// ---------------------------------------------------------------------------
|
|
246
|
-
class EventBus {
|
|
263
|
+
export class EventBus {
|
|
247
264
|
constructor() { this._handlers = new Map(); }
|
|
248
265
|
|
|
249
266
|
on(event, fn) {
|
|
@@ -269,3 +286,178 @@ class EventBus {
|
|
|
269
286
|
}
|
|
270
287
|
|
|
271
288
|
export const bus = new EventBus();
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// Array utilities
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
export function range(startOrEnd, end, step) {
|
|
296
|
+
let s, e, st;
|
|
297
|
+
if (end === undefined) { s = 0; e = startOrEnd; st = 1; }
|
|
298
|
+
else { s = startOrEnd; e = end; st = step !== undefined ? step : 1; }
|
|
299
|
+
if (st === 0) return [];
|
|
300
|
+
const result = [];
|
|
301
|
+
if (st > 0) { for (let i = s; i < e; i += st) result.push(i); }
|
|
302
|
+
else { for (let i = s; i > e; i += st) result.push(i); }
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function unique(arr, keyFn) {
|
|
307
|
+
if (!keyFn) return [...new Set(arr)];
|
|
308
|
+
const seen = new Set();
|
|
309
|
+
return arr.filter(item => {
|
|
310
|
+
const k = keyFn(item);
|
|
311
|
+
if (seen.has(k)) return false;
|
|
312
|
+
seen.add(k);
|
|
313
|
+
return true;
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function chunk(arr, size) {
|
|
318
|
+
const result = [];
|
|
319
|
+
for (let i = 0; i < arr.length; i += size) result.push(arr.slice(i, i + size));
|
|
320
|
+
return result;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function groupBy(arr, keyFn) {
|
|
324
|
+
const result = {};
|
|
325
|
+
for (const item of arr) {
|
|
326
|
+
const k = keyFn(item);
|
|
327
|
+
(result[k] ??= []).push(item);
|
|
328
|
+
}
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// Object utilities
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
export function pick(obj, keys) {
|
|
338
|
+
const result = {};
|
|
339
|
+
for (const k of keys) { if (k in obj) result[k] = obj[k]; }
|
|
340
|
+
return result;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function omit(obj, keys) {
|
|
344
|
+
const exclude = new Set(keys);
|
|
345
|
+
const result = {};
|
|
346
|
+
for (const k of Object.keys(obj)) { if (!exclude.has(k)) result[k] = obj[k]; }
|
|
347
|
+
return result;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function getPath(obj, path, fallback) {
|
|
351
|
+
const keys = path.split('.');
|
|
352
|
+
let cur = obj;
|
|
353
|
+
for (const k of keys) {
|
|
354
|
+
if (cur == null || typeof cur !== 'object') return fallback;
|
|
355
|
+
cur = cur[k];
|
|
356
|
+
}
|
|
357
|
+
return cur === undefined ? fallback : cur;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function setPath(obj, path, value) {
|
|
361
|
+
const keys = path.split('.');
|
|
362
|
+
let cur = obj;
|
|
363
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
364
|
+
const k = keys[i];
|
|
365
|
+
if (cur[k] == null || typeof cur[k] !== 'object') cur[k] = {};
|
|
366
|
+
cur = cur[k];
|
|
367
|
+
}
|
|
368
|
+
cur[keys[keys.length - 1]] = value;
|
|
369
|
+
return obj;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function isEmpty(val) {
|
|
373
|
+
if (val == null) return true;
|
|
374
|
+
if (typeof val === 'string' || Array.isArray(val)) return val.length === 0;
|
|
375
|
+
if (val instanceof Map || val instanceof Set) return val.size === 0;
|
|
376
|
+
if (typeof val === 'object') return Object.keys(val).length === 0;
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
// String utilities
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
export function capitalize(str) {
|
|
386
|
+
if (!str) return '';
|
|
387
|
+
return str[0].toUpperCase() + str.slice(1).toLowerCase();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function truncate(str, maxLen, suffix = '…') {
|
|
391
|
+
if (str.length <= maxLen) return str;
|
|
392
|
+
const end = Math.max(0, maxLen - suffix.length);
|
|
393
|
+
return str.slice(0, end) + suffix;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
// Number utilities
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
export function clamp(val, min, max) {
|
|
402
|
+
return val < min ? min : val > max ? max : val;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// Function utilities
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
export function memoize(fn, keyFnOrOpts) {
|
|
411
|
+
let keyFn, maxSize = 0;
|
|
412
|
+
if (typeof keyFnOrOpts === 'function') keyFn = keyFnOrOpts;
|
|
413
|
+
else if (keyFnOrOpts && typeof keyFnOrOpts === 'object') maxSize = keyFnOrOpts.maxSize || 0;
|
|
414
|
+
|
|
415
|
+
const cache = new Map();
|
|
416
|
+
|
|
417
|
+
const memoized = (...args) => {
|
|
418
|
+
const key = keyFn ? keyFn(...args) : args[0];
|
|
419
|
+
if (cache.has(key)) return cache.get(key);
|
|
420
|
+
const result = fn(...args);
|
|
421
|
+
cache.set(key, result);
|
|
422
|
+
if (maxSize > 0 && cache.size > maxSize) {
|
|
423
|
+
cache.delete(cache.keys().next().value);
|
|
424
|
+
}
|
|
425
|
+
return result;
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
memoized.clear = () => cache.clear();
|
|
429
|
+
return memoized;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// Async utilities
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
export function retry(fn, opts = {}) {
|
|
438
|
+
const { attempts = 3, delay = 1000, backoff = 1 } = opts;
|
|
439
|
+
return new Promise((resolve, reject) => {
|
|
440
|
+
let attempt = 0, currentDelay = delay;
|
|
441
|
+
const tryOnce = () => {
|
|
442
|
+
attempt++;
|
|
443
|
+
fn(attempt).then(resolve, (err) => {
|
|
444
|
+
if (attempt >= attempts) return reject(err);
|
|
445
|
+
const d = currentDelay;
|
|
446
|
+
currentDelay *= backoff;
|
|
447
|
+
setTimeout(tryOnce, d);
|
|
448
|
+
});
|
|
449
|
+
};
|
|
450
|
+
tryOnce();
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function timeout(promise, ms, message) {
|
|
455
|
+
let timer;
|
|
456
|
+
const race = Promise.race([
|
|
457
|
+
promise,
|
|
458
|
+
new Promise((_, reject) => {
|
|
459
|
+
timer = setTimeout(() => reject(new Error(message || `Timed out after ${ms}ms`)), ms);
|
|
460
|
+
})
|
|
461
|
+
]);
|
|
462
|
+
return race.finally(() => clearTimeout(timer));
|
|
463
|
+
}
|