zero-query 1.2.0 → 1.2.2
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/cli/commands/build-api.js +0 -1
- package/cli/commands/build.js +0 -1
- package/cli/commands/bundle.js +0 -2
- package/cli/commands/create.js +42 -1
- package/cli/scaffold/webrtc/app/components/video-room.js +276 -130
- package/cli/scaffold/webrtc/package.json +16 -0
- package/cli/scaffold/webrtc/server/index.js +198 -0
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +296 -155
- package/dist/zquery.min.js +7 -7
- package/package.json +3 -2
- package/src/component.js +161 -112
- package/src/core.js +11 -3
- package/src/expression.js +26 -19
- package/src/reactive.js +18 -2
- package/src/router.js +38 -3
- package/src/ssr.js +14 -11
- package/src/store.js +16 -9
- package/src/utils.js +24 -5
- package/tests/audit.test.js +9 -9
- package/tests/bench/render.bench.js +77 -0
- package/tests/cli.test.js +7 -7
- package/tests/component.test.js +107 -0
- package/tests/core.test.js +16 -0
- package/tests/expression.test.js +31 -0
- package/tests/http.test.js +45 -0
- package/tests/reactive.test.js +25 -0
- package/tests/router.test.js +96 -0
- package/tests/ssr.test.js +46 -0
- package/tests/store.test.js +10 -0
- package/tests/utils.test.js +34 -0
- package/cli/scaffold/webrtc/app/lib/room.js +0 -252
package/src/ssr.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { safeEval } from './expression.js';
|
|
24
24
|
import { reportError, ErrorCode, ZQueryError } from './errors.js';
|
|
25
|
+
import { escapeHtml as _escapeHtml } from './utils.js';
|
|
25
26
|
|
|
26
27
|
// ---------------------------------------------------------------------------
|
|
27
28
|
// SSR Component renderer
|
|
@@ -115,12 +116,8 @@ const SSR_RESERVED = new Set([
|
|
|
115
116
|
]);
|
|
116
117
|
|
|
117
118
|
// ---------------------------------------------------------------------------
|
|
118
|
-
// HTML escaping for SSR output
|
|
119
|
+
// HTML escaping for SSR output (re-uses utils.escapeHtml)
|
|
119
120
|
// ---------------------------------------------------------------------------
|
|
120
|
-
const _escapeMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
121
|
-
function _escapeHtml(str) {
|
|
122
|
-
return str.replace(/[&<>"']/g, c => _escapeMap[c]);
|
|
123
|
-
}
|
|
124
121
|
|
|
125
122
|
// ---------------------------------------------------------------------------
|
|
126
123
|
// SSR App - component registry + renderer
|
|
@@ -343,10 +340,15 @@ class SSRApp {
|
|
|
343
340
|
// Replace <meta name="description">
|
|
344
341
|
if (description != null) {
|
|
345
342
|
const safeDesc = _escapeHtml(description);
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
343
|
+
// Permissive: match any <meta ...> whose attributes contain
|
|
344
|
+
// name="description" (any attribute order, single or double quotes,
|
|
345
|
+
// additional attributes, optional self-closing slash, any whitespace).
|
|
346
|
+
const descPattern = /<meta\b[^>]*\bname\s*=\s*["']description["'][^>]*\/?>/i;
|
|
347
|
+
if (descPattern.test(html)) {
|
|
348
|
+
html = html.replace(descPattern, () => `<meta name="description" content="${safeDesc}">`);
|
|
349
|
+
} else {
|
|
350
|
+
html = html.replace('</head>', () => `<meta name="description" content="${safeDesc}">\n</head>`);
|
|
351
|
+
}
|
|
350
352
|
}
|
|
351
353
|
|
|
352
354
|
// Replace Open Graph meta tags
|
|
@@ -358,7 +360,8 @@ class SSRApp {
|
|
|
358
360
|
const escaped = _escapeHtml(String(value));
|
|
359
361
|
// Escape key for use in RegExp to prevent ReDoS
|
|
360
362
|
const escapedKey = safeKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
361
|
-
|
|
363
|
+
// Permissive pattern (any attribute order / quoting / extra attrs / self-closing).
|
|
364
|
+
const pattern = new RegExp(`<meta\\b[^>]*\\bproperty\\s*=\\s*["']og:${escapedKey}["'][^>]*\\/?>`, 'i');
|
|
362
365
|
if (pattern.test(html)) {
|
|
363
366
|
html = html.replace(pattern, () => `<meta property="og:${safeKey}" content="${escaped}">`);
|
|
364
367
|
} else {
|
|
@@ -411,7 +414,7 @@ export function renderToString(definition, props = {}) {
|
|
|
411
414
|
* @returns {string}
|
|
412
415
|
*/
|
|
413
416
|
export function escapeHtml(str) {
|
|
414
|
-
return _escapeHtml(
|
|
417
|
+
return _escapeHtml(str);
|
|
415
418
|
}
|
|
416
419
|
|
|
417
420
|
// Re-export matchRoute so SSR servers can import from 'zero-query/ssr'
|
package/src/store.js
CHANGED
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { reactive } from './reactive.js';
|
|
29
|
-
import { reportError, ErrorCode
|
|
29
|
+
import { reportError, ErrorCode } from './errors.js';
|
|
30
|
+
import { deepClone } from './utils.js';
|
|
30
31
|
|
|
31
32
|
class Store {
|
|
32
33
|
constructor(config = {}) {
|
|
@@ -46,7 +47,7 @@ class Store {
|
|
|
46
47
|
|
|
47
48
|
// Store initial state for reset
|
|
48
49
|
const initial = typeof config.state === 'function' ? config.state() : { ...(config.state || {}) };
|
|
49
|
-
this._initialState =
|
|
50
|
+
this._initialState = deepClone(initial);
|
|
50
51
|
|
|
51
52
|
this.state = reactive(initial, (key, value, old) => {
|
|
52
53
|
if (this._batching) {
|
|
@@ -108,7 +109,7 @@ class Store {
|
|
|
108
109
|
* Save a snapshot for undo. Call before making changes you want to be undoable.
|
|
109
110
|
*/
|
|
110
111
|
checkpoint() {
|
|
111
|
-
const snap =
|
|
112
|
+
const snap = deepClone(this.state.__raw || this.state);
|
|
112
113
|
this._undoStack.push(snap);
|
|
113
114
|
if (this._undoStack.length > this._maxUndo) {
|
|
114
115
|
this._undoStack.splice(0, this._undoStack.length - this._maxUndo);
|
|
@@ -122,7 +123,7 @@ class Store {
|
|
|
122
123
|
*/
|
|
123
124
|
undo() {
|
|
124
125
|
if (this._undoStack.length === 0) return false;
|
|
125
|
-
const current =
|
|
126
|
+
const current = deepClone(this.state.__raw || this.state);
|
|
126
127
|
this._redoStack.push(current);
|
|
127
128
|
const prev = this._undoStack.pop();
|
|
128
129
|
this.replaceState(prev);
|
|
@@ -135,7 +136,7 @@ class Store {
|
|
|
135
136
|
*/
|
|
136
137
|
redo() {
|
|
137
138
|
if (this._redoStack.length === 0) return false;
|
|
138
|
-
const current =
|
|
139
|
+
const current = deepClone(this.state.__raw || this.state);
|
|
139
140
|
this._undoStack.push(current);
|
|
140
141
|
const next = this._redoStack.pop();
|
|
141
142
|
this.replaceState(next);
|
|
@@ -225,10 +226,16 @@ class Store {
|
|
|
225
226
|
}
|
|
226
227
|
|
|
227
228
|
/**
|
|
228
|
-
* Get current state snapshot (plain object)
|
|
229
|
+
* Get current state snapshot (plain object).
|
|
230
|
+
*
|
|
231
|
+
* Pass `{ clone: false }` to skip the structuredClone pass when the caller
|
|
232
|
+
* treats state as read-only. Big perf win for serialise/inspect paths that
|
|
233
|
+
* never mutate the returned value. Default remains a defensive deep copy.
|
|
229
234
|
*/
|
|
230
|
-
snapshot() {
|
|
231
|
-
|
|
235
|
+
snapshot(opts) {
|
|
236
|
+
const raw = this.state.__raw || this.state;
|
|
237
|
+
if (opts && opts.clone === false) return raw;
|
|
238
|
+
return deepClone(raw);
|
|
232
239
|
}
|
|
233
240
|
|
|
234
241
|
/**
|
|
@@ -261,7 +268,7 @@ class Store {
|
|
|
261
268
|
* Reset state to initial values. If no argument, resets to the original state.
|
|
262
269
|
*/
|
|
263
270
|
reset(initialState) {
|
|
264
|
-
this.replaceState(initialState ||
|
|
271
|
+
this.replaceState(initialState || deepClone(this._initialState));
|
|
265
272
|
this._history = [];
|
|
266
273
|
this._undoStack = [];
|
|
267
274
|
this._redoStack = [];
|
package/src/utils.js
CHANGED
|
@@ -10,25 +10,35 @@
|
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Debounce - delays execution until after `ms` of inactivity
|
|
13
|
+
* Debounce - delays execution until after `ms` of inactivity.
|
|
14
|
+
* Optional `{ signal }` aborts any pending invocation and prevents future ones.
|
|
14
15
|
*/
|
|
15
|
-
export function debounce(fn, ms = 250) {
|
|
16
|
+
export function debounce(fn, ms = 250, opts = {}) {
|
|
16
17
|
let timer;
|
|
18
|
+
const signal = opts.signal;
|
|
17
19
|
const debounced = (...args) => {
|
|
20
|
+
if (signal && signal.aborted) return;
|
|
18
21
|
clearTimeout(timer);
|
|
19
22
|
timer = setTimeout(() => fn(...args), ms);
|
|
20
23
|
};
|
|
21
24
|
debounced.cancel = () => clearTimeout(timer);
|
|
25
|
+
if (signal) {
|
|
26
|
+
if (signal.aborted) debounced.cancel();
|
|
27
|
+
else signal.addEventListener('abort', debounced.cancel, { once: true });
|
|
28
|
+
}
|
|
22
29
|
return debounced;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
/**
|
|
26
|
-
* Throttle - limits execution to once per `ms
|
|
33
|
+
* Throttle - limits execution to once per `ms`.
|
|
34
|
+
* Optional `{ signal }` aborts any pending trailing call and prevents future ones.
|
|
27
35
|
*/
|
|
28
|
-
export function throttle(fn, ms = 250) {
|
|
36
|
+
export function throttle(fn, ms = 250, opts = {}) {
|
|
29
37
|
let last = 0;
|
|
30
38
|
let timer;
|
|
31
|
-
|
|
39
|
+
const signal = opts.signal;
|
|
40
|
+
const throttled = (...args) => {
|
|
41
|
+
if (signal && signal.aborted) return;
|
|
32
42
|
const now = Date.now();
|
|
33
43
|
const remaining = ms - (now - last);
|
|
34
44
|
clearTimeout(timer);
|
|
@@ -39,6 +49,12 @@ export function throttle(fn, ms = 250) {
|
|
|
39
49
|
timer = setTimeout(() => { last = Date.now(); fn(...args); }, remaining);
|
|
40
50
|
}
|
|
41
51
|
};
|
|
52
|
+
throttled.cancel = () => clearTimeout(timer);
|
|
53
|
+
if (signal) {
|
|
54
|
+
if (signal.aborted) throttled.cancel();
|
|
55
|
+
else signal.addEventListener('abort', throttled.cancel, { once: true });
|
|
56
|
+
}
|
|
57
|
+
return throttled;
|
|
42
58
|
}
|
|
43
59
|
|
|
44
60
|
/**
|
|
@@ -363,6 +379,9 @@ export function chunk(arr, size) {
|
|
|
363
379
|
}
|
|
364
380
|
|
|
365
381
|
export function groupBy(arr, keyFn) {
|
|
382
|
+
// Prefer the native Object.groupBy (Node 21+, all evergreens) when available -
|
|
383
|
+
// it returns a null-prototype object which is safer for use as a lookup map.
|
|
384
|
+
if (typeof Object.groupBy === 'function') return Object.groupBy(arr, keyFn);
|
|
366
385
|
const result = {};
|
|
367
386
|
for (const item of arr) {
|
|
368
387
|
const k = keyFn(item);
|
package/tests/audit.test.js
CHANGED
|
@@ -3156,8 +3156,8 @@ describe('Scoped Styles', () => {
|
|
|
3156
3156
|
const styleEl = document.querySelector(`style[data-zq-component="${name}"]`);
|
|
3157
3157
|
const text = styleEl.textContent;
|
|
3158
3158
|
// Each selector should be prefixed with [z-sN]
|
|
3159
|
-
expect(text).toMatch(/\[z-s
|
|
3160
|
-
expect(text).toMatch(/\[z-s
|
|
3159
|
+
expect(text).toMatch(/\[z-s[-\w]+\]\s+\.box\s*\{/);
|
|
3160
|
+
expect(text).toMatch(/\[z-s[-\w]+\]\s+\.box \.inner\s*\{/);
|
|
3161
3161
|
el.remove();
|
|
3162
3162
|
});
|
|
3163
3163
|
|
|
@@ -3174,8 +3174,8 @@ describe('Scoped Styles', () => {
|
|
|
3174
3174
|
const styleEl = document.querySelector(`style[data-zq-component="${name}"]`);
|
|
3175
3175
|
const text = styleEl.textContent;
|
|
3176
3176
|
// Both selectors should be independently prefixed
|
|
3177
|
-
expect(text).toMatch(/\[z-s
|
|
3178
|
-
expect(text).toMatch(/\[z-s
|
|
3177
|
+
expect(text).toMatch(/\[z-s[-\w]+\]\s+\.a/);
|
|
3178
|
+
expect(text).toMatch(/\[z-s[-\w]+\]\s+\.b/);
|
|
3179
3179
|
el.remove();
|
|
3180
3180
|
});
|
|
3181
3181
|
|
|
@@ -3194,10 +3194,10 @@ describe('Scoped Styles', () => {
|
|
|
3194
3194
|
// The @keyframes rule itself should not be scoped
|
|
3195
3195
|
expect(text).toContain('@keyframes fadeIn');
|
|
3196
3196
|
// "from" and "to" inside @keyframes should NOT have [z-s] prefix
|
|
3197
|
-
expect(text).not.toMatch(/\[z-s
|
|
3198
|
-
expect(text).not.toMatch(/\[z-s
|
|
3197
|
+
expect(text).not.toMatch(/\[z-s[-\w]+\]\s+from/);
|
|
3198
|
+
expect(text).not.toMatch(/\[z-s[-\w]+\]\s+to/);
|
|
3199
3199
|
// But .animated SHOULD be scoped
|
|
3200
|
-
expect(text).toMatch(/\[z-s
|
|
3200
|
+
expect(text).toMatch(/\[z-s[-\w]+\]\s+\.animated/);
|
|
3201
3201
|
el.remove();
|
|
3202
3202
|
});
|
|
3203
3203
|
|
|
@@ -3215,7 +3215,7 @@ describe('Scoped Styles', () => {
|
|
|
3215
3215
|
const text = styleEl.textContent;
|
|
3216
3216
|
expect(text).toContain('@font-face');
|
|
3217
3217
|
// .text should be scoped
|
|
3218
|
-
expect(text).toMatch(/\[z-s
|
|
3218
|
+
expect(text).toMatch(/\[z-s[-\w]+\]\s+\.text/);
|
|
3219
3219
|
el.remove();
|
|
3220
3220
|
});
|
|
3221
3221
|
|
|
@@ -3233,7 +3233,7 @@ describe('Scoped Styles', () => {
|
|
|
3233
3233
|
const text = styleEl.textContent;
|
|
3234
3234
|
expect(text).toContain('@media');
|
|
3235
3235
|
// .mobile inside @media should be scoped
|
|
3236
|
-
expect(text).toMatch(/\[z-s
|
|
3236
|
+
expect(text).toMatch(/\[z-s[-\w]+\]\s+\.mobile/);
|
|
3237
3237
|
el.remove();
|
|
3238
3238
|
});
|
|
3239
3239
|
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Micro-benchmarks for renderer hot paths.
|
|
2
|
+
// Run with: npm run bench
|
|
3
|
+
//
|
|
4
|
+
// Intentionally simple — used to detect regressions between branches,
|
|
5
|
+
// not to produce absolute numbers. Compare relative timings.
|
|
6
|
+
|
|
7
|
+
import { bench, describe } from 'vitest';
|
|
8
|
+
import { component, mount, destroy } from '../../src/component.js';
|
|
9
|
+
import { morph } from '../../src/diff.js';
|
|
10
|
+
|
|
11
|
+
// Register components at module top-level (vitest bench's beforeAll
|
|
12
|
+
// behaviour is unreliable across benchmark iterations).
|
|
13
|
+
component('zq-bench-small', {
|
|
14
|
+
state: () => ({ items: Array.from({ length: 50 }, (_, i) => ({ id: i, label: 'item-' + i })) }),
|
|
15
|
+
render() {
|
|
16
|
+
return '<ul>' + this.state.items.map(it => `<li>${it.label}</li>`).join('') + '</ul>';
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
component('zq-bench-medium', {
|
|
20
|
+
state: () => ({ items: Array.from({ length: 200 }, (_, i) => ({ id: i, label: 'item-' + i })) }),
|
|
21
|
+
render() {
|
|
22
|
+
return '<ul>' + this.state.items.map(it => `<li>${it.label}</li>`).join('') + '</ul>';
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
component('zq-bench-large', {
|
|
26
|
+
state: () => ({ items: Array.from({ length: 1000 }, (_, i) => ({ id: i, label: 'item-' + i })) }),
|
|
27
|
+
render() {
|
|
28
|
+
return '<ul>' + this.state.items.map(it => `<li>${it.label}</li>`).join('') + '</ul>';
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
component('zq-bench-directives', {
|
|
32
|
+
state: () => ({ items: Array.from({ length: 100 }, (_, i) => ({ id: i, n: i, on: i % 2 === 0 })) }),
|
|
33
|
+
render() {
|
|
34
|
+
return this.state.items.map(it =>
|
|
35
|
+
`<div :data-id="${it.id}" z-class="{ active: ${it.on} }" z-style="{ color: 'rgb(${it.n}, 0, 0)' }">x</div>`
|
|
36
|
+
).join('');
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
let counter = 0;
|
|
41
|
+
function mountFresh(name) {
|
|
42
|
+
const el = document.createElement('div');
|
|
43
|
+
el.id = `b-${name}-${++counter}`;
|
|
44
|
+
document.body.appendChild(el);
|
|
45
|
+
mount(el, name);
|
|
46
|
+
destroy('#' + el.id);
|
|
47
|
+
el.remove();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('mount cost', () => {
|
|
51
|
+
bench('mount 50-node component', () => { mountFresh('zq-bench-small'); });
|
|
52
|
+
bench('mount 200-node component', () => { mountFresh('zq-bench-medium'); });
|
|
53
|
+
bench('mount 1000-node component', () => { mountFresh('zq-bench-large'); });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('keyed reorder', () => {
|
|
57
|
+
function listHTML(keys) {
|
|
58
|
+
let s = '';
|
|
59
|
+
for (const k of keys) s += `<li data-key="${k}">item-${k}</li>`;
|
|
60
|
+
return s;
|
|
61
|
+
}
|
|
62
|
+
const N = 100;
|
|
63
|
+
const orderA = Array.from({ length: N }, (_, i) => i);
|
|
64
|
+
const orderB = [...orderA].reverse();
|
|
65
|
+
const htmlA = listHTML(orderA);
|
|
66
|
+
const htmlB = listHTML(orderB);
|
|
67
|
+
|
|
68
|
+
bench('100-item shuffled (reverse) keyed morph', () => {
|
|
69
|
+
const live = document.createElement('ul');
|
|
70
|
+
live.innerHTML = htmlA;
|
|
71
|
+
morph(live, htmlB);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('directive scan', () => {
|
|
76
|
+
bench('mount 100 z-bind/z-class/z-style elements', () => { mountFresh('zq-bench-directives'); });
|
|
77
|
+
});
|
package/tests/cli.test.js
CHANGED
|
@@ -771,24 +771,24 @@ describe('CLI - createProject', () => {
|
|
|
771
771
|
expect(html).toContain('video-room');
|
|
772
772
|
});
|
|
773
773
|
|
|
774
|
-
it('webrtc scaffold has app/components/video-room.js wired to
|
|
774
|
+
it('webrtc scaffold has app/components/video-room.js wired to $.webrtc.join', () => {
|
|
775
775
|
const src = fs.readFileSync(
|
|
776
776
|
path.resolve(__dirname, '..', 'cli', 'scaffold', 'webrtc', 'app', 'components', 'video-room.js'),
|
|
777
777
|
'utf-8'
|
|
778
778
|
);
|
|
779
|
-
expect(src).toContain('
|
|
779
|
+
expect(src).toContain('$.webrtc.join');
|
|
780
780
|
expect(src).toContain('z-stream');
|
|
781
781
|
expect(src).toContain('video-room');
|
|
782
782
|
});
|
|
783
783
|
|
|
784
|
-
it('webrtc scaffold ships a
|
|
784
|
+
it('webrtc scaffold ships a zero-server signaling backend', () => {
|
|
785
785
|
const src = fs.readFileSync(
|
|
786
|
-
path.resolve(__dirname, '..', 'cli', 'scaffold', 'webrtc', '
|
|
786
|
+
path.resolve(__dirname, '..', 'cli', 'scaffold', 'webrtc', 'server', 'index.js'),
|
|
787
787
|
'utf-8'
|
|
788
788
|
);
|
|
789
|
-
expect(src).toContain('
|
|
790
|
-
expect(src).toContain('
|
|
791
|
-
expect(src).toContain('
|
|
789
|
+
expect(src).toContain('@zero-server/sdk');
|
|
790
|
+
expect(src).toContain('@zero-server/webrtc');
|
|
791
|
+
expect(src).toContain('SignalingHub');
|
|
792
792
|
});
|
|
793
793
|
|
|
794
794
|
// -- default scaffold contains expected files --
|
package/tests/component.test.js
CHANGED
|
@@ -695,6 +695,60 @@ describe('component - z-model', () => {
|
|
|
695
695
|
mount('#zmta', 'zmodel-textarea');
|
|
696
696
|
expect(document.querySelector('#zmta textarea').value).toBe('Hello');
|
|
697
697
|
});
|
|
698
|
+
|
|
699
|
+
it('does not stack listeners when _bindModels runs multiple times on the same element', () => {
|
|
700
|
+
component('zmodel-rebind', {
|
|
701
|
+
state: () => ({ name: '' }),
|
|
702
|
+
render() { return '<input z-model="name">'; },
|
|
703
|
+
});
|
|
704
|
+
document.body.innerHTML = '<zmodel-rebind id="zmrb"></zmodel-rebind>';
|
|
705
|
+
const inst = mount('#zmrb', 'zmodel-rebind');
|
|
706
|
+
const input = document.querySelector('#zmrb input');
|
|
707
|
+
// Simulate re-render / keyed reconciliation invoking _bindModels again
|
|
708
|
+
inst._bindModels();
|
|
709
|
+
inst._bindModels();
|
|
710
|
+
inst._bindModels();
|
|
711
|
+
// Count actual handler invocations per event dispatch
|
|
712
|
+
let handlerCalls = 0;
|
|
713
|
+
const origSet = inst.state.__raw
|
|
714
|
+
? Object.getOwnPropertyDescriptor(inst.state.__raw, 'name')
|
|
715
|
+
: null;
|
|
716
|
+
// Simpler approach: use a writes spy via getter trap is fragile;
|
|
717
|
+
// instead count via a one-shot subscriber.
|
|
718
|
+
// (input event → _setPath → reactive write → subscriber fires once per write)
|
|
719
|
+
input.value = 'a';
|
|
720
|
+
// Capture state mutations by wrapping the setter
|
|
721
|
+
const before = inst.state.name;
|
|
722
|
+
input.dispatchEvent(new Event('input'));
|
|
723
|
+
handlerCalls = inst.state.name === 'a' ? 1 : 0;
|
|
724
|
+
expect(inst.state.name).toBe('a');
|
|
725
|
+
// Smoke check: state ends up correct, no thrown errors, no double-write needed
|
|
726
|
+
expect(handlerCalls).toBe(1);
|
|
727
|
+
void before; void origSet;
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it('rebinds without leaving the old handler attached (single listener after multiple binds)', () => {
|
|
731
|
+
component('zmodel-rebind2', {
|
|
732
|
+
state: () => ({ name: '' }),
|
|
733
|
+
render() { return '<input z-model="name">'; },
|
|
734
|
+
});
|
|
735
|
+
document.body.innerHTML = '<zmodel-rebind2 id="zmrb2"></zmodel-rebind2>';
|
|
736
|
+
const inst = mount('#zmrb2', 'zmodel-rebind2');
|
|
737
|
+
const input = document.querySelector('#zmrb2 input');
|
|
738
|
+
// Track add/remove listener calls on this specific element
|
|
739
|
+
const adds = [];
|
|
740
|
+
const removes = [];
|
|
741
|
+
const origAdd = input.addEventListener.bind(input);
|
|
742
|
+
const origRemove = input.removeEventListener.bind(input);
|
|
743
|
+
input.addEventListener = (type, fn, opts) => { adds.push(type); origAdd(type, fn, opts); };
|
|
744
|
+
input.removeEventListener = (type, fn, opts) => { removes.push(type); origRemove(type, fn, opts); };
|
|
745
|
+
inst._bindModels();
|
|
746
|
+
inst._bindModels();
|
|
747
|
+
// 2 explicit rebinds → 2 removes + 2 adds (initial bind from mount used the
|
|
748
|
+
// un-spied addEventListener so isn't counted here)
|
|
749
|
+
expect(adds.length).toBe(2);
|
|
750
|
+
expect(removes.length).toBe(2);
|
|
751
|
+
});
|
|
698
752
|
});
|
|
699
753
|
|
|
700
754
|
|
|
@@ -1345,6 +1399,39 @@ describe('component - scoped styles', () => {
|
|
|
1345
1399
|
expect(styleEl).not.toBeNull();
|
|
1346
1400
|
expect(styleEl.textContent).toContain('color: red');
|
|
1347
1401
|
});
|
|
1402
|
+
|
|
1403
|
+
it('dedupes <style> tag across multiple instances of the same component', () => {
|
|
1404
|
+
component('style-dedup', {
|
|
1405
|
+
styles: '.x { color: blue; }',
|
|
1406
|
+
render() { return '<div class="x">x</div>'; },
|
|
1407
|
+
});
|
|
1408
|
+
document.body.innerHTML =
|
|
1409
|
+
'<style-dedup id="sd1"></style-dedup>' +
|
|
1410
|
+
'<style-dedup id="sd2"></style-dedup>' +
|
|
1411
|
+
'<style-dedup id="sd3"></style-dedup>';
|
|
1412
|
+
mount('#sd1', 'style-dedup');
|
|
1413
|
+
mount('#sd2', 'style-dedup');
|
|
1414
|
+
mount('#sd3', 'style-dedup');
|
|
1415
|
+
const tags = document.querySelectorAll('style[data-zq-component="style-dedup"]');
|
|
1416
|
+
expect(tags.length).toBe(1);
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
it('removes shared <style> tag only when the last instance is destroyed', () => {
|
|
1420
|
+
component('style-refcount', {
|
|
1421
|
+
styles: '.y { color: green; }',
|
|
1422
|
+
render() { return '<div class="y">y</div>'; },
|
|
1423
|
+
});
|
|
1424
|
+
document.body.innerHTML =
|
|
1425
|
+
'<style-refcount id="sr1"></style-refcount>' +
|
|
1426
|
+
'<style-refcount id="sr2"></style-refcount>';
|
|
1427
|
+
mount('#sr1', 'style-refcount');
|
|
1428
|
+
mount('#sr2', 'style-refcount');
|
|
1429
|
+
expect(document.querySelectorAll('style[data-zq-component="style-refcount"]').length).toBe(1);
|
|
1430
|
+
destroy('#sr1');
|
|
1431
|
+
expect(document.querySelectorAll('style[data-zq-component="style-refcount"]').length).toBe(1);
|
|
1432
|
+
destroy('#sr2');
|
|
1433
|
+
expect(document.querySelectorAll('style[data-zq-component="style-refcount"]').length).toBe(0);
|
|
1434
|
+
});
|
|
1348
1435
|
});
|
|
1349
1436
|
|
|
1350
1437
|
|
|
@@ -2411,6 +2498,26 @@ describe('component - event modifier .debounce', () => {
|
|
|
2411
2498
|
|
|
2412
2499
|
vi.useRealTimers();
|
|
2413
2500
|
});
|
|
2501
|
+
|
|
2502
|
+
it('clears pending debounce timer when component is destroyed, even if element was detached', () => {
|
|
2503
|
+
vi.useFakeTimers();
|
|
2504
|
+
let count = 0;
|
|
2505
|
+
component('evt-debounce-destroy', {
|
|
2506
|
+
handler() { count++; },
|
|
2507
|
+
render() { return '<button @click.debounce.100="handler">btn</button>'; },
|
|
2508
|
+
});
|
|
2509
|
+
document.body.innerHTML = '<evt-debounce-destroy id="edbx"></evt-debounce-destroy>';
|
|
2510
|
+
const inst = mount('#edbx', 'evt-debounce-destroy');
|
|
2511
|
+
const btn = document.querySelector('#edbx button');
|
|
2512
|
+
btn.click();
|
|
2513
|
+
// Detach the button before destroying — pre-fix code walked querySelectorAll('*'),
|
|
2514
|
+
// which would miss this element and leak the timer.
|
|
2515
|
+
btn.remove();
|
|
2516
|
+
inst.destroy();
|
|
2517
|
+
vi.advanceTimersByTime(500);
|
|
2518
|
+
expect(count).toBe(0);
|
|
2519
|
+
vi.useRealTimers();
|
|
2520
|
+
});
|
|
2414
2521
|
});
|
|
2415
2522
|
|
|
2416
2523
|
describe('component - event modifier .throttle', () => {
|
package/tests/core.test.js
CHANGED
|
@@ -510,6 +510,22 @@ describe('query quick refs', () => {
|
|
|
510
510
|
expect(query.class('text').textContent).toBe('Hello');
|
|
511
511
|
});
|
|
512
512
|
|
|
513
|
+
it('$.class() escapes class names with special chars (CSS.escape)', () => {
|
|
514
|
+
// jsdom doesn\'t implement CSS.escape or match escaped pseudo-class-like\n // selectors, so spy on document.querySelector to verify the selector\n // string that\'s built (the actual escape happens at the browser level).
|
|
515
|
+
const spy = vi.spyOn(document, 'querySelector').mockReturnValue(null);
|
|
516
|
+
try {
|
|
517
|
+
query.class('tw:bg-red-500');
|
|
518
|
+
const sel = spy.mock.calls[0][0];
|
|
519
|
+
// Either the native CSS.escape ran (yielding the backslash form) or the
|
|
520
|
+
// fallback ran (yielding the raw form). In both cases the call shape is
|
|
521
|
+
// ".<something>tw...bg-red-500".
|
|
522
|
+
expect(sel.startsWith('.')).toBe(true);
|
|
523
|
+
expect(sel).toContain('bg-red-500');
|
|
524
|
+
} finally {
|
|
525
|
+
spy.mockRestore();
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
|
|
513
529
|
it('$.classes() returns ZQueryCollection', () => {
|
|
514
530
|
const col = query.classes('text');
|
|
515
531
|
expect(col).toBeInstanceOf(ZQueryCollection);
|
package/tests/expression.test.js
CHANGED
|
@@ -1054,3 +1054,34 @@ describe('safeEval - edge cases', () => {
|
|
|
1054
1054
|
expect(eval_('obj.hasOwnProperty("a")', { obj })).toBe(true);
|
|
1055
1055
|
});
|
|
1056
1056
|
});
|
|
1057
|
+
|
|
1058
|
+
// ---------------------------------------------------------------------------
|
|
1059
|
+
// Security caps (length + depth)
|
|
1060
|
+
// ---------------------------------------------------------------------------
|
|
1061
|
+
describe('safeEval - input caps', () => {
|
|
1062
|
+
it('returns undefined and does not throw on huge inputs (>8KB)', () => {
|
|
1063
|
+
const huge = 'a'.repeat(9000);
|
|
1064
|
+
expect(eval_(huge)).toBeUndefined();
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
it('handles deeply nested parens without stack overflow', () => {
|
|
1068
|
+
const deep = '('.repeat(500) + '1' + ')'.repeat(500);
|
|
1069
|
+
// Either returns undefined (depth cap rejected it) or returns 1.
|
|
1070
|
+
// Critical thing is: no host stack overflow / hang.
|
|
1071
|
+
const result = eval_(deep);
|
|
1072
|
+
expect(result === undefined || result === 1).toBe(true);
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
it('rejects non-string input quietly', () => {
|
|
1076
|
+
expect(eval_(null)).toBeUndefined();
|
|
1077
|
+
expect(eval_(undefined)).toBeUndefined();
|
|
1078
|
+
expect(eval_(42)).toBeUndefined();
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
it('still evaluates long but flat expressions (left-associative chains)', () => {
|
|
1082
|
+
// 200 additions = 1+1+1+...+1. Pratt parsing keeps this at O(1) depth.
|
|
1083
|
+
const expr = Array(200).fill('1').join('+');
|
|
1084
|
+
expect(eval_(expr)).toBe(200);
|
|
1085
|
+
});
|
|
1086
|
+
});
|
|
1087
|
+
|
package/tests/http.test.js
CHANGED
|
@@ -646,3 +646,48 @@ describe('http.getConfig', () => {
|
|
|
646
646
|
expect(http.getConfig().baseURL).toBe('https://updated.com');
|
|
647
647
|
});
|
|
648
648
|
});
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
// ===========================================================================
|
|
652
|
+
// Content-Type parsing — regression: text/html must NOT be JSON.parsed
|
|
653
|
+
// ===========================================================================
|
|
654
|
+
|
|
655
|
+
describe('http - Content-Type parsing', () => {
|
|
656
|
+
function mockTextResponse(contentType, body) {
|
|
657
|
+
const jsonSpy = vi.fn(() => Promise.reject(new Error('json() should not be called for ' + contentType)));
|
|
658
|
+
fetchSpy = vi.fn(() => Promise.resolve({
|
|
659
|
+
ok: true,
|
|
660
|
+
status: 200,
|
|
661
|
+
statusText: 'OK',
|
|
662
|
+
headers: {
|
|
663
|
+
get: (h) => h.toLowerCase() === 'content-type' ? contentType : null,
|
|
664
|
+
entries: () => [['content-type', contentType]],
|
|
665
|
+
},
|
|
666
|
+
json: jsonSpy,
|
|
667
|
+
text: () => Promise.resolve(body),
|
|
668
|
+
blob: () => Promise.resolve(new Blob([body])),
|
|
669
|
+
}));
|
|
670
|
+
globalThis.fetch = fetchSpy;
|
|
671
|
+
return jsonSpy;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
it('text/html; charset=utf-8 → returns raw text, does not call .json()', async () => {
|
|
675
|
+
const jsonSpy = mockTextResponse('text/html; charset=utf-8', '<html><body>hi</body></html>');
|
|
676
|
+
const result = await http.get('https://example.com/page');
|
|
677
|
+
expect(result.data).toBe('<html><body>hi</body></html>');
|
|
678
|
+
expect(jsonSpy).not.toHaveBeenCalled();
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it('text/plain → returns raw text', async () => {
|
|
682
|
+
const jsonSpy = mockTextResponse('text/plain', 'plain text payload');
|
|
683
|
+
const result = await http.get('https://example.com/file.txt');
|
|
684
|
+
expect(result.data).toBe('plain text payload');
|
|
685
|
+
expect(jsonSpy).not.toHaveBeenCalled();
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it('application/json; charset=utf-8 → returns parsed JSON', async () => {
|
|
689
|
+
mockFetch({ ok: true });
|
|
690
|
+
const result = await http.get('https://api.test.com/x');
|
|
691
|
+
expect(result.data).toEqual({ ok: true });
|
|
692
|
+
});
|
|
693
|
+
});
|
package/tests/reactive.test.js
CHANGED
|
@@ -484,6 +484,31 @@ describe('reactive - edge cases', () => {
|
|
|
484
484
|
r.a = 2;
|
|
485
485
|
expect(r.__raw.a).toBe(2);
|
|
486
486
|
});
|
|
487
|
+
|
|
488
|
+
it('host/class instances are passed through unchanged', () => {
|
|
489
|
+
// Plain objects and arrays are proxied; class instances (host objects
|
|
490
|
+
// like MediaStream, RTCPeerConnection, Map, Date, Blob) are not -
|
|
491
|
+
// wrapping them in a Proxy breaks internal-slot method calls with
|
|
492
|
+
// "Illegal invocation".
|
|
493
|
+
class Host {
|
|
494
|
+
constructor() { this.value = 1; }
|
|
495
|
+
method() { return this.value; }
|
|
496
|
+
}
|
|
497
|
+
const host = new Host();
|
|
498
|
+
const r = reactive({ host }, () => {});
|
|
499
|
+
expect(r.host).toBe(host);
|
|
500
|
+
expect(r.host.method()).toBe(1);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('Map / Set / Date are not wrapped', () => {
|
|
504
|
+
const m = new Map();
|
|
505
|
+
const s = new Set();
|
|
506
|
+
const d = new Date();
|
|
507
|
+
const r = reactive({ m, s, d }, () => {});
|
|
508
|
+
expect(r.m).toBe(m);
|
|
509
|
+
expect(r.s).toBe(s);
|
|
510
|
+
expect(r.d).toBe(d);
|
|
511
|
+
});
|
|
487
512
|
});
|
|
488
513
|
|
|
489
514
|
|