zero-query 0.9.9 → 1.0.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 +34 -33
- package/cli/args.js +1 -1
- package/cli/commands/build.js +2 -2
- package/cli/commands/bundle.js +21 -18
- package/cli/commands/create.js +9 -2
- package/cli/commands/dev/devtools/index.js +1 -1
- package/cli/commands/dev/devtools/js/core.js +14 -14
- package/cli/commands/dev/devtools/js/elements.js +4 -4
- package/cli/commands/dev/devtools/js/stats.js +1 -1
- package/cli/commands/dev/devtools/styles.css +2 -2
- package/cli/commands/dev/index.js +2 -2
- package/cli/commands/dev/logger.js +1 -1
- package/cli/commands/dev/overlay.js +21 -14
- package/cli/commands/dev/server.js +5 -5
- package/cli/commands/dev/validator.js +7 -7
- package/cli/commands/dev/watcher.js +6 -6
- package/cli/help.js +3 -3
- package/cli/index.js +1 -1
- package/cli/scaffold/default/app/app.js +17 -18
- package/cli/scaffold/default/app/components/about.js +9 -9
- package/cli/scaffold/default/app/components/api-demo.js +6 -6
- package/cli/scaffold/default/app/components/contact-card.js +4 -4
- package/cli/scaffold/default/app/components/contacts/contacts.css +2 -2
- package/cli/scaffold/default/app/components/contacts/contacts.html +3 -3
- package/cli/scaffold/default/app/components/contacts/contacts.js +11 -11
- package/cli/scaffold/default/app/components/counter.js +8 -8
- package/cli/scaffold/default/app/components/home.js +13 -13
- package/cli/scaffold/default/app/components/not-found.js +1 -1
- package/cli/scaffold/default/app/components/playground/playground.css +1 -1
- package/cli/scaffold/default/app/components/playground/playground.html +11 -11
- package/cli/scaffold/default/app/components/playground/playground.js +11 -11
- package/cli/scaffold/default/app/components/todos.js +8 -8
- package/cli/scaffold/default/app/components/toolkit/toolkit.css +1 -1
- package/cli/scaffold/default/app/components/toolkit/toolkit.html +4 -4
- package/cli/scaffold/default/app/components/toolkit/toolkit.js +7 -7
- package/cli/scaffold/default/app/routes.js +1 -1
- package/cli/scaffold/default/app/store.js +1 -1
- package/cli/scaffold/default/global.css +2 -2
- package/cli/scaffold/default/index.html +2 -2
- package/cli/scaffold/minimal/app/app.js +6 -7
- package/cli/scaffold/minimal/app/components/about.js +5 -5
- package/cli/scaffold/minimal/app/components/counter.js +6 -6
- package/cli/scaffold/minimal/app/components/home.js +8 -8
- package/cli/scaffold/minimal/app/components/not-found.js +1 -1
- package/cli/scaffold/minimal/app/routes.js +1 -1
- package/cli/scaffold/minimal/app/store.js +1 -1
- package/cli/scaffold/minimal/global.css +2 -2
- package/cli/scaffold/minimal/index.html +1 -1
- package/cli/scaffold/ssr/app/app.js +1 -2
- package/cli/scaffold/ssr/app/components/about.js +5 -5
- package/cli/scaffold/ssr/app/components/home.js +2 -2
- package/cli/scaffold/ssr/app/components/not-found.js +2 -2
- package/cli/scaffold/ssr/app/routes.js +1 -1
- package/cli/scaffold/ssr/global.css +3 -4
- package/cli/scaffold/ssr/index.html +2 -2
- package/cli/scaffold/ssr/server/index.js +26 -25
- package/cli/utils.js +6 -6
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +508 -227
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +16 -13
- package/index.js +7 -5
- package/package.json +3 -3
- package/src/component.js +64 -63
- package/src/core.js +15 -15
- package/src/diff.js +38 -38
- package/src/errors.js +17 -17
- package/src/expression.js +15 -17
- package/src/http.js +4 -4
- package/src/reactive.js +75 -9
- package/src/router.js +104 -24
- package/src/ssr.js +28 -28
- package/src/store.js +103 -21
- package/src/utils.js +64 -12
- package/tests/audit.test.js +143 -15
- package/tests/cli.test.js +20 -20
- package/tests/component.test.js +121 -121
- package/tests/core.test.js +56 -56
- package/tests/diff.test.js +42 -42
- package/tests/errors.test.js +5 -5
- package/tests/expression.test.js +58 -53
- package/tests/http.test.js +20 -20
- package/tests/reactive.test.js +185 -24
- package/tests/router.test.js +501 -74
- package/tests/ssr.test.js +15 -13
- package/tests/store.test.js +264 -23
- package/tests/test-minifier.js +153 -0
- package/tests/test-ssr.js +27 -0
- package/tests/utils.test.js +163 -26
- package/types/collection.d.ts +2 -2
- package/types/component.d.ts +5 -5
- package/types/errors.d.ts +3 -3
- package/types/http.d.ts +3 -3
- package/types/misc.d.ts +9 -9
- package/types/reactive.d.ts +25 -3
- package/types/router.d.ts +10 -6
- package/types/ssr.d.ts +2 -2
- package/types/store.d.ts +40 -5
- package/types/utils.d.ts +1 -1
package/src/store.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* zQuery Store
|
|
2
|
+
* zQuery Store - Global reactive state management
|
|
3
3
|
*
|
|
4
4
|
* A lightweight Redux/Vuex-inspired store with:
|
|
5
5
|
* - Reactive state via Proxy
|
|
@@ -38,22 +38,22 @@ class Store {
|
|
|
38
38
|
this._history = []; // action log
|
|
39
39
|
this._maxHistory = config.maxHistory || 1000;
|
|
40
40
|
this._debug = config.debug || false;
|
|
41
|
+
this._batching = false;
|
|
42
|
+
this._batchQueue = []; // pending notifications during batch
|
|
43
|
+
this._undoStack = [];
|
|
44
|
+
this._redoStack = [];
|
|
45
|
+
this._maxUndo = config.maxUndo || 50;
|
|
41
46
|
|
|
42
|
-
//
|
|
47
|
+
// Store initial state for reset
|
|
43
48
|
const initial = typeof config.state === 'function' ? config.state() : { ...(config.state || {}) };
|
|
49
|
+
this._initialState = JSON.parse(JSON.stringify(initial));
|
|
44
50
|
|
|
45
51
|
this.state = reactive(initial, (key, value, old) => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
});
|
|
52
|
-
// Notify wildcard subscribers
|
|
53
|
-
this._wildcards.forEach(fn => {
|
|
54
|
-
try { fn(key, value, old); }
|
|
55
|
-
catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, 'Wildcard subscriber threw', { key }, err); }
|
|
56
|
-
});
|
|
52
|
+
if (this._batching) {
|
|
53
|
+
this._batchQueue.push({ key, value, old });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
this._notifySubscribers(key, value, old);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
// Build getters as computed properties
|
|
@@ -66,10 +66,90 @@ class Store {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/** @private Notify key-specific and wildcard subscribers */
|
|
70
|
+
_notifySubscribers(key, value, old) {
|
|
71
|
+
const subs = this._subscribers.get(key);
|
|
72
|
+
if (subs) subs.forEach(fn => {
|
|
73
|
+
try { fn(key, value, old); }
|
|
74
|
+
catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, `Subscriber for "${key}" threw`, { key }, err); }
|
|
75
|
+
});
|
|
76
|
+
this._wildcards.forEach(fn => {
|
|
77
|
+
try { fn(key, value, old); }
|
|
78
|
+
catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, 'Wildcard subscriber threw', { key }, err); }
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Batch multiple state changes - subscribers fire once at the end
|
|
84
|
+
* with only the latest value per key.
|
|
85
|
+
*/
|
|
86
|
+
batch(fn) {
|
|
87
|
+
this._batching = true;
|
|
88
|
+
this._batchQueue = [];
|
|
89
|
+
try {
|
|
90
|
+
fn(this.state);
|
|
91
|
+
} finally {
|
|
92
|
+
this._batching = false;
|
|
93
|
+
// Deduplicate: keep only the last change per key
|
|
94
|
+
const last = new Map();
|
|
95
|
+
for (const entry of this._batchQueue) {
|
|
96
|
+
last.set(entry.key, entry);
|
|
97
|
+
}
|
|
98
|
+
this._batchQueue = [];
|
|
99
|
+
for (const { key, value, old } of last.values()) {
|
|
100
|
+
this._notifySubscribers(key, value, old);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Save a snapshot for undo. Call before making changes you want to be undoable.
|
|
107
|
+
*/
|
|
108
|
+
checkpoint() {
|
|
109
|
+
const snap = JSON.parse(JSON.stringify(this.state.__raw || this.state));
|
|
110
|
+
this._undoStack.push(snap);
|
|
111
|
+
if (this._undoStack.length > this._maxUndo) {
|
|
112
|
+
this._undoStack.splice(0, this._undoStack.length - this._maxUndo);
|
|
113
|
+
}
|
|
114
|
+
this._redoStack = [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Undo to the last checkpoint
|
|
119
|
+
* @returns {boolean} true if undo was performed
|
|
120
|
+
*/
|
|
121
|
+
undo() {
|
|
122
|
+
if (this._undoStack.length === 0) return false;
|
|
123
|
+
const current = JSON.parse(JSON.stringify(this.state.__raw || this.state));
|
|
124
|
+
this._redoStack.push(current);
|
|
125
|
+
const prev = this._undoStack.pop();
|
|
126
|
+
this.replaceState(prev);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Redo the last undone state change
|
|
132
|
+
* @returns {boolean} true if redo was performed
|
|
133
|
+
*/
|
|
134
|
+
redo() {
|
|
135
|
+
if (this._redoStack.length === 0) return false;
|
|
136
|
+
const current = JSON.parse(JSON.stringify(this.state.__raw || this.state));
|
|
137
|
+
this._undoStack.push(current);
|
|
138
|
+
const next = this._redoStack.pop();
|
|
139
|
+
this.replaceState(next);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Check if undo is available */
|
|
144
|
+
get canUndo() { return this._undoStack.length > 0; }
|
|
145
|
+
|
|
146
|
+
/** Check if redo is available */
|
|
147
|
+
get canRedo() { return this._redoStack.length > 0; }
|
|
148
|
+
|
|
69
149
|
/**
|
|
70
150
|
* Dispatch a named action
|
|
71
|
-
* @param {string} name
|
|
72
|
-
* @param {...any} args
|
|
151
|
+
* @param {string} name - action name
|
|
152
|
+
* @param {...any} args - payload
|
|
73
153
|
*/
|
|
74
154
|
dispatch(name, ...args) {
|
|
75
155
|
const action = this._actions[name];
|
|
@@ -108,13 +188,13 @@ class Store {
|
|
|
108
188
|
|
|
109
189
|
/**
|
|
110
190
|
* Subscribe to changes on a specific state key
|
|
111
|
-
* @param {string|Function} keyOrFn
|
|
112
|
-
* @param {Function} [fn]
|
|
113
|
-
* @returns {Function}
|
|
191
|
+
* @param {string|Function} keyOrFn - state key, or function for all changes
|
|
192
|
+
* @param {Function} [fn] - callback (key, value, oldValue)
|
|
193
|
+
* @returns {Function} - unsubscribe
|
|
114
194
|
*/
|
|
115
195
|
subscribe(keyOrFn, fn) {
|
|
116
196
|
if (typeof keyOrFn === 'function') {
|
|
117
|
-
// Wildcard
|
|
197
|
+
// Wildcard - listen to all changes
|
|
118
198
|
this._wildcards.add(keyOrFn);
|
|
119
199
|
return () => this._wildcards.delete(keyOrFn);
|
|
120
200
|
}
|
|
@@ -160,11 +240,13 @@ class Store {
|
|
|
160
240
|
}
|
|
161
241
|
|
|
162
242
|
/**
|
|
163
|
-
* Reset state to initial values
|
|
243
|
+
* Reset state to initial values. If no argument, resets to the original state.
|
|
164
244
|
*/
|
|
165
245
|
reset(initialState) {
|
|
166
|
-
this.replaceState(initialState);
|
|
246
|
+
this.replaceState(initialState || JSON.parse(JSON.stringify(this._initialState)));
|
|
167
247
|
this._history = [];
|
|
248
|
+
this._undoStack = [];
|
|
249
|
+
this._redoStack = [];
|
|
168
250
|
}
|
|
169
251
|
}
|
|
170
252
|
|
package/src/utils.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* zQuery Utils
|
|
2
|
+
* zQuery Utils - Common utility functions
|
|
3
3
|
*
|
|
4
4
|
* Quality-of-life helpers that every frontend project needs.
|
|
5
5
|
* Attached to $ namespace for convenience.
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Debounce
|
|
13
|
+
* Debounce - delays execution until after `ms` of inactivity
|
|
14
14
|
*/
|
|
15
15
|
export function debounce(fn, ms = 250) {
|
|
16
16
|
let timer;
|
|
@@ -23,7 +23,7 @@ export function debounce(fn, ms = 250) {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* Throttle
|
|
26
|
+
* Throttle - limits execution to once per `ms`
|
|
27
27
|
*/
|
|
28
28
|
export function throttle(fn, ms = 250) {
|
|
29
29
|
let last = 0;
|
|
@@ -42,14 +42,14 @@ export function throttle(fn, ms = 250) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
* Pipe
|
|
45
|
+
* Pipe - compose functions left-to-right
|
|
46
46
|
*/
|
|
47
47
|
export function pipe(...fns) {
|
|
48
48
|
return (input) => fns.reduce((val, fn) => fn(val), input);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
* Once
|
|
52
|
+
* Once - function that only runs once
|
|
53
53
|
*/
|
|
54
54
|
export function once(fn) {
|
|
55
55
|
let called = false, result;
|
|
@@ -60,7 +60,7 @@ export function once(fn) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
* Sleep
|
|
63
|
+
* Sleep - promise-based delay
|
|
64
64
|
*/
|
|
65
65
|
export function sleep(ms) {
|
|
66
66
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -111,8 +111,12 @@ export function trust(htmlStr) {
|
|
|
111
111
|
* Generate UUID v4
|
|
112
112
|
*/
|
|
113
113
|
export function uuid() {
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
if (crypto?.randomUUID) return crypto.randomUUID();
|
|
115
|
+
// Fallback using crypto.getRandomValues (wider support than randomUUID)
|
|
116
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
117
|
+
const buf = new Uint8Array(1);
|
|
118
|
+
crypto.getRandomValues(buf);
|
|
119
|
+
const r = buf[0] & 15;
|
|
116
120
|
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
117
121
|
});
|
|
118
122
|
}
|
|
@@ -140,13 +144,50 @@ export function kebabCase(str) {
|
|
|
140
144
|
// ---------------------------------------------------------------------------
|
|
141
145
|
|
|
142
146
|
/**
|
|
143
|
-
* Deep clone
|
|
147
|
+
* Deep clone via structuredClone (handles circular refs, Dates, etc.).
|
|
148
|
+
* Falls back to a manual deep clone that preserves Date, RegExp, Map, Set,
|
|
149
|
+
* ArrayBuffer, TypedArrays, undefined values, and circular references.
|
|
144
150
|
*/
|
|
145
151
|
export function deepClone(obj) {
|
|
146
152
|
if (typeof structuredClone === 'function') return structuredClone(obj);
|
|
147
|
-
|
|
153
|
+
|
|
154
|
+
const seen = new Map();
|
|
155
|
+
function clone(val) {
|
|
156
|
+
if (val === null || typeof val !== 'object') return val;
|
|
157
|
+
if (seen.has(val)) return seen.get(val);
|
|
158
|
+
if (val instanceof Date) return new Date(val.getTime());
|
|
159
|
+
if (val instanceof RegExp) return new RegExp(val.source, val.flags);
|
|
160
|
+
if (val instanceof Map) {
|
|
161
|
+
const m = new Map();
|
|
162
|
+
seen.set(val, m);
|
|
163
|
+
val.forEach((v, k) => m.set(clone(k), clone(v)));
|
|
164
|
+
return m;
|
|
165
|
+
}
|
|
166
|
+
if (val instanceof Set) {
|
|
167
|
+
const s = new Set();
|
|
168
|
+
seen.set(val, s);
|
|
169
|
+
val.forEach(v => s.add(clone(v)));
|
|
170
|
+
return s;
|
|
171
|
+
}
|
|
172
|
+
if (ArrayBuffer.isView(val)) return new val.constructor(val.buffer.slice(0));
|
|
173
|
+
if (val instanceof ArrayBuffer) return val.slice(0);
|
|
174
|
+
if (Array.isArray(val)) {
|
|
175
|
+
const arr = [];
|
|
176
|
+
seen.set(val, arr);
|
|
177
|
+
for (let i = 0; i < val.length; i++) arr[i] = clone(val[i]);
|
|
178
|
+
return arr;
|
|
179
|
+
}
|
|
180
|
+
const result = Object.create(Object.getPrototypeOf(val));
|
|
181
|
+
seen.set(val, result);
|
|
182
|
+
for (const key of Object.keys(val)) result[key] = clone(val[key]);
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
return clone(obj);
|
|
148
186
|
}
|
|
149
187
|
|
|
188
|
+
// Keys that must never be written through data-merge or path-set operations
|
|
189
|
+
const _UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
190
|
+
|
|
150
191
|
/**
|
|
151
192
|
* Deep merge objects
|
|
152
193
|
*/
|
|
@@ -156,6 +197,7 @@ export function deepMerge(target, ...sources) {
|
|
|
156
197
|
if (seen.has(src)) return tgt;
|
|
157
198
|
seen.add(src);
|
|
158
199
|
for (const key of Object.keys(src)) {
|
|
200
|
+
if (_UNSAFE_KEYS.has(key)) continue;
|
|
159
201
|
if (src[key] && typeof src[key] === 'object' && !Array.isArray(src[key])) {
|
|
160
202
|
if (!tgt[key] || typeof tgt[key] !== 'object') tgt[key] = {};
|
|
161
203
|
merge(tgt[key], src[key]);
|
|
@@ -362,10 +404,13 @@ export function setPath(obj, path, value) {
|
|
|
362
404
|
let cur = obj;
|
|
363
405
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
364
406
|
const k = keys[i];
|
|
407
|
+
if (_UNSAFE_KEYS.has(k)) return obj;
|
|
365
408
|
if (cur[k] == null || typeof cur[k] !== 'object') cur[k] = {};
|
|
366
409
|
cur = cur[k];
|
|
367
410
|
}
|
|
368
|
-
|
|
411
|
+
const lastKey = keys[keys.length - 1];
|
|
412
|
+
if (_UNSAFE_KEYS.has(lastKey)) return obj;
|
|
413
|
+
cur[lastKey] = value;
|
|
369
414
|
return obj;
|
|
370
415
|
}
|
|
371
416
|
|
|
@@ -416,9 +461,16 @@ export function memoize(fn, keyFnOrOpts) {
|
|
|
416
461
|
|
|
417
462
|
const memoized = (...args) => {
|
|
418
463
|
const key = keyFn ? keyFn(...args) : args[0];
|
|
419
|
-
if (cache.has(key))
|
|
464
|
+
if (cache.has(key)) {
|
|
465
|
+
// LRU: promote to newest by re-inserting
|
|
466
|
+
const value = cache.get(key);
|
|
467
|
+
cache.delete(key);
|
|
468
|
+
cache.set(key, value);
|
|
469
|
+
return value;
|
|
470
|
+
}
|
|
420
471
|
const result = fn(...args);
|
|
421
472
|
cache.set(key, result);
|
|
473
|
+
// LRU eviction: drop the least-recently-used entry
|
|
422
474
|
if (maxSize > 0 && cache.size > maxSize) {
|
|
423
475
|
cache.delete(cache.keys().next().value);
|
|
424
476
|
}
|
package/tests/audit.test.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
escapeHtml, html, trust, TrustedHTML, uuid, camelCase, kebabCase,
|
|
13
13
|
deepClone, deepMerge, isEqual, param, parseQuery,
|
|
14
14
|
storage, session, EventBus, bus,
|
|
15
|
+
setPath,
|
|
15
16
|
} from '../src/utils.js';
|
|
16
17
|
import { createRouter, getRouter } from '../src/router.js';
|
|
17
18
|
import { component, mount, mountAll, destroy, prefetch, getInstance } from '../src/component.js';
|
|
@@ -2861,7 +2862,7 @@ describe('Store', () => {
|
|
|
2861
2862
|
actions: { inc(state) { state.count++; } }
|
|
2862
2863
|
});
|
|
2863
2864
|
const received = [];
|
|
2864
|
-
store.subscribe('count', (val, old) => received.push({ val, old }));
|
|
2865
|
+
store.subscribe('count', (key, val, old) => received.push({ val, old }));
|
|
2865
2866
|
store.dispatch('inc');
|
|
2866
2867
|
store.dispatch('inc');
|
|
2867
2868
|
expect(received).toEqual([
|
|
@@ -2876,7 +2877,7 @@ describe('Store', () => {
|
|
|
2876
2877
|
actions: { bump(state) { state.x++; } }
|
|
2877
2878
|
});
|
|
2878
2879
|
const received = [];
|
|
2879
|
-
const unsub = store.subscribe('x', (val) => received.push(val));
|
|
2880
|
+
const unsub = store.subscribe('x', (key, val) => received.push(val));
|
|
2880
2881
|
store.dispatch('bump');
|
|
2881
2882
|
unsub();
|
|
2882
2883
|
store.dispatch('bump');
|
|
@@ -3062,7 +3063,7 @@ describe('Store', () => {
|
|
|
3062
3063
|
});
|
|
3063
3064
|
const received = [];
|
|
3064
3065
|
store.subscribe('x', () => { throw new Error('sub error'); });
|
|
3065
|
-
store.subscribe('x', (val) => received.push(val));
|
|
3066
|
+
store.subscribe('x', (key, val) => received.push(val));
|
|
3066
3067
|
store.dispatch('bump');
|
|
3067
3068
|
// The second subscriber still gets called because reportError is used (not re-throw)
|
|
3068
3069
|
expect(received).toEqual([1]);
|
|
@@ -3093,7 +3094,7 @@ describe('Store', () => {
|
|
|
3093
3094
|
state: { x: 0 }
|
|
3094
3095
|
});
|
|
3095
3096
|
const received = [];
|
|
3096
|
-
store.subscribe('x', (val) => received.push(val));
|
|
3097
|
+
store.subscribe('x', (key, val) => received.push(val));
|
|
3097
3098
|
store.state.x = 99;
|
|
3098
3099
|
expect(received).toEqual([99]);
|
|
3099
3100
|
});
|
|
@@ -3895,7 +3896,7 @@ describe('TrustedHTML and html template tag', () => {
|
|
|
3895
3896
|
|
|
3896
3897
|
|
|
3897
3898
|
// ===========================================================================
|
|
3898
|
-
// 24. Bug 9
|
|
3899
|
+
// 24. Bug 9 - `new` constructor globals reachable
|
|
3899
3900
|
// ===========================================================================
|
|
3900
3901
|
describe('new constructor globals (Bug 9)', () => {
|
|
3901
3902
|
const eval_ = (expr, ctx = {}) => safeEval(expr, [ctx]);
|
|
@@ -3912,10 +3913,9 @@ describe('new constructor globals (Bug 9)', () => {
|
|
|
3912
3913
|
expect(result.has(2)).toBe(true);
|
|
3913
3914
|
});
|
|
3914
3915
|
|
|
3915
|
-
it('new RegExp
|
|
3916
|
+
it('new RegExp is blocked (ReDoS prevention)', () => {
|
|
3916
3917
|
const result = eval_('new RegExp(pat, flags)', { pat: '^hello', flags: 'i' });
|
|
3917
|
-
expect(result).
|
|
3918
|
-
expect(result.test('Hello world')).toBe(true);
|
|
3918
|
+
expect(result).toBeUndefined();
|
|
3919
3919
|
});
|
|
3920
3920
|
|
|
3921
3921
|
it('new URL creates a URL', () => {
|
|
@@ -3931,10 +3931,9 @@ describe('new constructor globals (Bug 9)', () => {
|
|
|
3931
3931
|
expect(result.get('b')).toBe('2');
|
|
3932
3932
|
});
|
|
3933
3933
|
|
|
3934
|
-
it('new Error
|
|
3934
|
+
it('new Error is blocked (info disclosure prevention)', () => {
|
|
3935
3935
|
const result = eval_('new Error(msg)', { msg: 'test error' });
|
|
3936
|
-
expect(result).
|
|
3937
|
-
expect(result.message).toBe('test error');
|
|
3936
|
+
expect(result).toBeUndefined();
|
|
3938
3937
|
});
|
|
3939
3938
|
|
|
3940
3939
|
it('Map and Set are accessible as identifiers for instanceof', () => {
|
|
@@ -3945,7 +3944,7 @@ describe('new constructor globals (Bug 9)', () => {
|
|
|
3945
3944
|
|
|
3946
3945
|
|
|
3947
3946
|
// ===========================================================================
|
|
3948
|
-
// 25. Bug 10
|
|
3947
|
+
// 25. Bug 10 - optional_call preserves `this` binding
|
|
3949
3948
|
// ===========================================================================
|
|
3950
3949
|
describe('optional_call this binding (Bug 10)', () => {
|
|
3951
3950
|
const eval_ = (expr, ctx = {}) => safeEval(expr, [ctx]);
|
|
@@ -3974,7 +3973,7 @@ describe('optional_call this binding (Bug 10)', () => {
|
|
|
3974
3973
|
|
|
3975
3974
|
|
|
3976
3975
|
// ===========================================================================
|
|
3977
|
-
// 26. Bug 11
|
|
3976
|
+
// 26. Bug 11 - HTTP abort vs timeout distinction
|
|
3978
3977
|
// ===========================================================================
|
|
3979
3978
|
describe('HTTP abort vs timeout message (Bug 11)', () => {
|
|
3980
3979
|
it('user abort says "aborted" not "timeout"', async () => {
|
|
@@ -4000,7 +3999,7 @@ describe('HTTP abort vs timeout message (Bug 11)', () => {
|
|
|
4000
3999
|
|
|
4001
4000
|
|
|
4002
4001
|
// ===========================================================================
|
|
4003
|
-
// 27. Bug 12
|
|
4002
|
+
// 27. Bug 12 - isEqual circular reference protection
|
|
4004
4003
|
// ===========================================================================
|
|
4005
4004
|
describe('isEqual circular reference protection (Bug 12)', () => {
|
|
4006
4005
|
it('does not stack overflow on circular objects', () => {
|
|
@@ -4008,7 +4007,7 @@ describe('isEqual circular reference protection (Bug 12)', () => {
|
|
|
4008
4007
|
a.self = a;
|
|
4009
4008
|
const b = { x: 1 };
|
|
4010
4009
|
b.self = b;
|
|
4011
|
-
// Should not throw
|
|
4010
|
+
// Should not throw - just return true (both are circular in the same shape)
|
|
4012
4011
|
expect(() => isEqual(a, b)).not.toThrow();
|
|
4013
4012
|
expect(isEqual(a, b)).toBe(true);
|
|
4014
4013
|
});
|
|
@@ -4028,3 +4027,132 @@ describe('isEqual circular reference protection (Bug 12)', () => {
|
|
|
4028
4027
|
expect(isEqual([1, 2], [1, 3])).toBe(false);
|
|
4029
4028
|
});
|
|
4030
4029
|
});
|
|
4030
|
+
|
|
4031
|
+
|
|
4032
|
+
// ===========================================================================
|
|
4033
|
+
// SECURITY AUDIT v2 - Prototype Pollution, ReDoS, Expression Safety
|
|
4034
|
+
// ===========================================================================
|
|
4035
|
+
|
|
4036
|
+
describe('Security: deepMerge prototype pollution prevention', () => {
|
|
4037
|
+
it('blocks __proto__ key from being merged', () => {
|
|
4038
|
+
const target = {};
|
|
4039
|
+
const malicious = JSON.parse('{"__proto__": {"polluted": true}}');
|
|
4040
|
+
deepMerge(target, malicious);
|
|
4041
|
+
expect(target.polluted).toBeUndefined();
|
|
4042
|
+
expect(({}).polluted).toBeUndefined();
|
|
4043
|
+
});
|
|
4044
|
+
|
|
4045
|
+
it('blocks constructor key from being merged', () => {
|
|
4046
|
+
const target = {};
|
|
4047
|
+
deepMerge(target, { constructor: { prototype: { polluted: true } } });
|
|
4048
|
+
expect(({}).polluted).toBeUndefined();
|
|
4049
|
+
});
|
|
4050
|
+
|
|
4051
|
+
it('blocks prototype key from being merged', () => {
|
|
4052
|
+
const target = {};
|
|
4053
|
+
deepMerge(target, { prototype: { polluted: true } });
|
|
4054
|
+
expect(target.prototype).toBeUndefined();
|
|
4055
|
+
});
|
|
4056
|
+
|
|
4057
|
+
it('still merges safe keys normally', () => {
|
|
4058
|
+
const result = deepMerge({}, { a: 1, b: { c: 2 } });
|
|
4059
|
+
expect(result).toEqual({ a: 1, b: { c: 2 } });
|
|
4060
|
+
});
|
|
4061
|
+
|
|
4062
|
+
it('blocks nested __proto__ pollution attempt', () => {
|
|
4063
|
+
const target = { nested: {} };
|
|
4064
|
+
const malicious = JSON.parse('{"nested": {"__proto__": {"deep": true}}}');
|
|
4065
|
+
deepMerge(target, malicious);
|
|
4066
|
+
expect(({}).deep).toBeUndefined();
|
|
4067
|
+
expect(target.nested.deep).toBeUndefined();
|
|
4068
|
+
});
|
|
4069
|
+
});
|
|
4070
|
+
|
|
4071
|
+
describe('Security: setPath prototype pollution prevention', () => {
|
|
4072
|
+
it('blocks __proto__ in path segments', () => {
|
|
4073
|
+
const obj = {};
|
|
4074
|
+
setPath(obj, '__proto__.polluted', true);
|
|
4075
|
+
expect(({}).polluted).toBeUndefined();
|
|
4076
|
+
});
|
|
4077
|
+
|
|
4078
|
+
it('blocks constructor in path segments', () => {
|
|
4079
|
+
const obj = {};
|
|
4080
|
+
setPath(obj, 'constructor.prototype.polluted', true);
|
|
4081
|
+
expect(({}).polluted).toBeUndefined();
|
|
4082
|
+
});
|
|
4083
|
+
|
|
4084
|
+
it('blocks prototype as final key', () => {
|
|
4085
|
+
const obj = {};
|
|
4086
|
+
setPath(obj, 'prototype', { evil: true });
|
|
4087
|
+
expect(obj.prototype).toBeUndefined();
|
|
4088
|
+
});
|
|
4089
|
+
|
|
4090
|
+
it('still sets safe paths normally', () => {
|
|
4091
|
+
const obj = {};
|
|
4092
|
+
setPath(obj, 'a.b.c', 42);
|
|
4093
|
+
expect(obj.a.b.c).toBe(42);
|
|
4094
|
+
});
|
|
4095
|
+
});
|
|
4096
|
+
|
|
4097
|
+
describe('Security: expression evaluator - blocked constructors', () => {
|
|
4098
|
+
it('blocks RegExp constructor (ReDoS prevention)', () => {
|
|
4099
|
+
expect(eval_('new RegExp(".*")')).toBeUndefined();
|
|
4100
|
+
expect(eval_('RegExp')).toBeUndefined();
|
|
4101
|
+
});
|
|
4102
|
+
|
|
4103
|
+
it('blocks Error constructor (info leak prevention)', () => {
|
|
4104
|
+
expect(eval_('new Error("test")')).toBeUndefined();
|
|
4105
|
+
});
|
|
4106
|
+
|
|
4107
|
+
it('blocks Function constructor', () => {
|
|
4108
|
+
expect(eval_('new Function("return 1")')).toBeUndefined();
|
|
4109
|
+
});
|
|
4110
|
+
|
|
4111
|
+
it('still allows safe constructors', () => {
|
|
4112
|
+
expect(eval_('new Date(2024, 0, 1)')).toBeInstanceOf(Date);
|
|
4113
|
+
expect(eval_('new Map')).toBeInstanceOf(Map);
|
|
4114
|
+
expect(eval_('new Set')).toBeInstanceOf(Set);
|
|
4115
|
+
expect(eval_('new Array(3)')).toBeInstanceOf(Array);
|
|
4116
|
+
expect(eval_('new URL("https://example.com")')).toBeInstanceOf(URL);
|
|
4117
|
+
});
|
|
4118
|
+
});
|
|
4119
|
+
|
|
4120
|
+
describe('Security: expression evaluator - blocked property access', () => {
|
|
4121
|
+
it('blocks __proto__ access', () => {
|
|
4122
|
+
expect(eval_('obj.__proto__', { obj: {} })).toBeUndefined();
|
|
4123
|
+
});
|
|
4124
|
+
|
|
4125
|
+
it('blocks constructor access', () => {
|
|
4126
|
+
expect(eval_('obj.constructor', { obj: {} })).toBeUndefined();
|
|
4127
|
+
});
|
|
4128
|
+
|
|
4129
|
+
it('blocks prototype access', () => {
|
|
4130
|
+
expect(eval_('obj.prototype', { obj: function(){} })).toBeUndefined();
|
|
4131
|
+
});
|
|
4132
|
+
|
|
4133
|
+
it('blocks __defineGetter__ access', () => {
|
|
4134
|
+
expect(eval_('obj.__defineGetter__', { obj: {} })).toBeUndefined();
|
|
4135
|
+
});
|
|
4136
|
+
|
|
4137
|
+
it('blocks call/apply/bind', () => {
|
|
4138
|
+
expect(eval_('fn.call', { fn: () => {} })).toBeUndefined();
|
|
4139
|
+
expect(eval_('fn.apply', { fn: () => {} })).toBeUndefined();
|
|
4140
|
+
expect(eval_('fn.bind', { fn: () => {} })).toBeUndefined();
|
|
4141
|
+
});
|
|
4142
|
+
});
|
|
4143
|
+
|
|
4144
|
+
describe('Security: template expression HTML escaping', () => {
|
|
4145
|
+
it('escapeHtml escapes script tags', () => {
|
|
4146
|
+
expect(escapeHtml('<script>alert(1)</script>')).toBe('<script>alert(1)</script>');
|
|
4147
|
+
});
|
|
4148
|
+
|
|
4149
|
+
it('escapeHtml escapes quotes and ampersands', () => {
|
|
4150
|
+
expect(escapeHtml('"&\'')).toBe('"&'');
|
|
4151
|
+
});
|
|
4152
|
+
|
|
4153
|
+
it('escapeHtml handles non-string input', () => {
|
|
4154
|
+
expect(escapeHtml(42)).toBe('42');
|
|
4155
|
+
expect(escapeHtml(null)).toBe('null');
|
|
4156
|
+
expect(escapeHtml(undefined)).toBe('undefined');
|
|
4157
|
+
});
|
|
4158
|
+
});
|