zero-query 0.8.9 → 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.
@@ -0,0 +1,261 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createSSRApp, renderToString } from '../src/ssr.js';
3
+
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // createSSRApp
7
+ // ---------------------------------------------------------------------------
8
+
9
+ describe('createSSRApp', () => {
10
+ it('returns an SSRApp instance', () => {
11
+ const app = createSSRApp();
12
+ expect(app).toBeDefined();
13
+ expect(typeof app.component).toBe('function');
14
+ expect(typeof app.renderToString).toBe('function');
15
+ expect(typeof app.renderPage).toBe('function');
16
+ });
17
+ });
18
+
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // component registration
22
+ // ---------------------------------------------------------------------------
23
+
24
+ describe('SSRApp — component', () => {
25
+ it('registers a component and returns self for chaining', () => {
26
+ const app = createSSRApp();
27
+ const result = app.component('my-comp', {
28
+ render() { return '<div>hello</div>'; }
29
+ });
30
+ expect(result).toBe(app);
31
+ });
32
+
33
+ it('throws when rendering unregistered component', async () => {
34
+ const app = createSSRApp();
35
+ await expect(app.renderToString('nonexistent')).rejects.toThrow('not registered');
36
+ });
37
+ });
38
+
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // renderToString (app method)
42
+ // ---------------------------------------------------------------------------
43
+
44
+ describe('SSRApp — renderToString', () => {
45
+ it('renders basic component', async () => {
46
+ const app = createSSRApp();
47
+ app.component('my-page', {
48
+ state: () => ({ title: 'Hello' }),
49
+ render() { return `<h1>${this.state.title}</h1>`; }
50
+ });
51
+ const html = await app.renderToString('my-page');
52
+ expect(html).toContain('<h1>Hello</h1>');
53
+ });
54
+
55
+ it('adds hydration marker by default', async () => {
56
+ const app = createSSRApp();
57
+ app.component('my-comp', { render() { return '<p>test</p>'; } });
58
+ const html = await app.renderToString('my-comp');
59
+ expect(html).toContain('data-zq-ssr');
60
+ });
61
+
62
+ it('omits hydration marker when hydrate=false', async () => {
63
+ const app = createSSRApp();
64
+ app.component('my-comp', { render() { return '<p>test</p>'; } });
65
+ const html = await app.renderToString('my-comp', {}, { hydrate: false });
66
+ expect(html).not.toContain('data-zq-ssr');
67
+ });
68
+
69
+ it('wraps output in component tag', async () => {
70
+ const app = createSSRApp();
71
+ app.component('custom-tag', { render() { return '<span>ok</span>'; } });
72
+ const html = await app.renderToString('custom-tag');
73
+ expect(html).toMatch(/^<custom-tag[^>]*>.*<\/custom-tag>$/);
74
+ });
75
+
76
+ it('passes props to component', async () => {
77
+ const app = createSSRApp();
78
+ app.component('greet', {
79
+ render() { return `<span>${this.props.name}</span>`; }
80
+ });
81
+ const html = await app.renderToString('greet', { name: 'World' });
82
+ expect(html).toContain('World');
83
+ });
84
+
85
+ it('strips z-cloak attributes', async () => {
86
+ const app = createSSRApp();
87
+ app.component('my-comp', {
88
+ render() { return '<div z-cloak>content</div>'; }
89
+ });
90
+ const html = await app.renderToString('my-comp');
91
+ expect(html).not.toContain('z-cloak');
92
+ expect(html).toContain('content');
93
+ });
94
+
95
+ it('strips event bindings (@click, z-on:click)', async () => {
96
+ const app = createSSRApp();
97
+ app.component('my-comp', {
98
+ render() { return '<button @click="handle">Click</button>'; }
99
+ });
100
+ const html = await app.renderToString('my-comp');
101
+ expect(html).not.toContain('@click');
102
+ });
103
+ });
104
+
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // renderToString (standalone function)
108
+ // ---------------------------------------------------------------------------
109
+
110
+ describe('renderToString (standalone)', () => {
111
+ it('renders a definition directly', () => {
112
+ const html = renderToString({
113
+ state: () => ({ msg: 'hi' }),
114
+ render() { return `<p>${this.state.msg}</p>`; }
115
+ });
116
+ expect(html).toContain('<p>hi</p>');
117
+ });
118
+
119
+ it('returns empty string when no render function', () => {
120
+ const html = renderToString({ state: {} });
121
+ expect(html).toBe('');
122
+ });
123
+ });
124
+
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // State factory
128
+ // ---------------------------------------------------------------------------
129
+
130
+ describe('SSR — state factory', () => {
131
+ it('calls state function for initial state', async () => {
132
+ const app = createSSRApp();
133
+ let callCount = 0;
134
+ app.component('comp', {
135
+ state: () => { callCount++; return { x: 1 }; },
136
+ render() { return `<div>${this.state.x}</div>`; }
137
+ });
138
+ await app.renderToString('comp');
139
+ expect(callCount).toBe(1);
140
+ });
141
+
142
+ it('supports state as object (copied)', async () => {
143
+ const app = createSSRApp();
144
+ const shared = { x: 1 };
145
+ app.component('comp', {
146
+ state: shared,
147
+ render() { return `<div>${this.state.x}</div>`; }
148
+ });
149
+ const html = await app.renderToString('comp');
150
+ expect(html).toContain('1');
151
+ });
152
+ });
153
+
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Computed properties
157
+ // ---------------------------------------------------------------------------
158
+
159
+ describe('SSR — computed', () => {
160
+ it('computes derived values', async () => {
161
+ const app = createSSRApp();
162
+ app.component('comp', {
163
+ state: () => ({ first: 'Jane', last: 'Doe' }),
164
+ computed: {
165
+ full(state) { return `${state.first} ${state.last}`; }
166
+ },
167
+ render() { return `<span>${this.computed.full}</span>`; }
168
+ });
169
+ const html = await app.renderToString('comp');
170
+ expect(html).toContain('Jane Doe');
171
+ });
172
+ });
173
+
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Init lifecycle
177
+ // ---------------------------------------------------------------------------
178
+
179
+ describe('SSR — init lifecycle', () => {
180
+ it('calls init() during construction', () => {
181
+ let initCalled = false;
182
+ renderToString({
183
+ init() { initCalled = true; },
184
+ render() { return '<div></div>'; }
185
+ });
186
+ expect(initCalled).toBe(true);
187
+ });
188
+ });
189
+
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // XSS sanitization via _escapeHtml
193
+ // ---------------------------------------------------------------------------
194
+
195
+ describe('SSR — XSS sanitization', () => {
196
+ it('escapes HTML in renderPage title', async () => {
197
+ const app = createSSRApp();
198
+ const html = await app.renderPage({ title: '<script>alert("xss")</script>' });
199
+ expect(html).not.toContain('<script>alert("xss")</script>');
200
+ expect(html).toContain('&lt;script&gt;');
201
+ });
202
+
203
+ it('escapes script paths in renderPage', async () => {
204
+ const app = createSSRApp();
205
+ const html = await app.renderPage({ scripts: ['"><script>alert(1)</script>'] });
206
+ expect(html).toContain('&quot;');
207
+ });
208
+
209
+ it('strips onXxx attributes from bodyAttrs', async () => {
210
+ const app = createSSRApp();
211
+ const html = await app.renderPage({ bodyAttrs: 'onclick="alert(1)"' });
212
+ expect(html).not.toContain('onclick');
213
+ });
214
+
215
+ it('strips javascript: from bodyAttrs', async () => {
216
+ const app = createSSRApp();
217
+ const html = await app.renderPage({ bodyAttrs: 'data-x="javascript:void(0)"' });
218
+ expect(html).not.toContain('javascript:');
219
+ });
220
+ });
221
+
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // renderPage
225
+ // ---------------------------------------------------------------------------
226
+
227
+ describe('SSRApp — renderPage', () => {
228
+ it('renders a full HTML page', async () => {
229
+ const app = createSSRApp();
230
+ app.component('page', { render() { return '<h1>Home</h1>'; } });
231
+ const html = await app.renderPage({
232
+ component: 'page',
233
+ title: 'My App',
234
+ lang: 'en'
235
+ });
236
+ expect(html).toContain('<!DOCTYPE html>');
237
+ expect(html).toContain('<title>My App</title>');
238
+ expect(html).toContain('<h1>Home</h1>');
239
+ expect(html).toContain('lang="en"');
240
+ });
241
+
242
+ it('renders without component', async () => {
243
+ const app = createSSRApp();
244
+ const html = await app.renderPage({ title: 'Empty' });
245
+ expect(html).toContain('<title>Empty</title>');
246
+ expect(html).toContain('<div id="app"></div>');
247
+ });
248
+
249
+ it('includes style links', async () => {
250
+ const app = createSSRApp();
251
+ const html = await app.renderPage({ styles: ['style.css', 'theme.css'] });
252
+ expect(html).toContain('href="style.css"');
253
+ expect(html).toContain('href="theme.css"');
254
+ });
255
+
256
+ it('includes script tags', async () => {
257
+ const app = createSSRApp();
258
+ const html = await app.renderPage({ scripts: ['app.js'] });
259
+ expect(html).toContain('src="app.js"');
260
+ });
261
+ });
@@ -377,3 +377,213 @@ describe('Store — complex getters', () => {
377
377
  expect(store.getters.doubled).toBe(20);
378
378
  });
379
379
  });
380
+
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // PERF: history trim uses splice (in-place) instead of slice (copy)
384
+ // ---------------------------------------------------------------------------
385
+
386
+ describe('Store — history trim in-place', () => {
387
+ it('trims history to maxHistory without exceeding', () => {
388
+ const store = createStore('hist-trim', {
389
+ state: { n: 0 },
390
+ actions: { inc(s) { s.n++; } },
391
+ maxHistory: 5,
392
+ });
393
+ for (let i = 0; i < 10; i++) store.dispatch('inc');
394
+ expect(store.history.length).toBe(5);
395
+ // Newest actions should survive
396
+ expect(store.state.n).toBe(10);
397
+ });
398
+
399
+ it('maintains same array identity (splice, not slice)', () => {
400
+ const store = createStore('hist-identity', {
401
+ state: { n: 0 },
402
+ actions: { inc(s) { s.n++; } },
403
+ maxHistory: 3,
404
+ });
405
+ const ref = store._history;
406
+ for (let i = 0; i < 10; i++) store.dispatch('inc');
407
+ // splice modifies in-place so the array reference stays the same
408
+ expect(store._history).toBe(ref);
409
+ expect(store._history.length).toBe(3);
410
+ });
411
+ });
412
+
413
+
414
+ // ===========================================================================
415
+ // use() — middleware chaining
416
+ // ===========================================================================
417
+
418
+ describe('Store — use() chaining', () => {
419
+ it('returns the store for chaining', () => {
420
+ const store = createStore({
421
+ state: { x: 0 },
422
+ actions: { inc(state) { state.x++; } }
423
+ });
424
+ const result = store.use(() => {}).use(() => {});
425
+ expect(result).toBe(store);
426
+ });
427
+
428
+ it('multiple middleware run in order', () => {
429
+ const order = [];
430
+ const store = createStore({
431
+ state: { x: 0 },
432
+ actions: { inc(state) { state.x++; } }
433
+ });
434
+ store.use(() => { order.push('a'); });
435
+ store.use(() => { order.push('b'); });
436
+ store.dispatch('inc');
437
+ expect(order).toEqual(['a', 'b']);
438
+ });
439
+
440
+ it('middleware returning false blocks action', () => {
441
+ const store = createStore({
442
+ state: { x: 0 },
443
+ actions: { inc(state) { state.x++; } }
444
+ });
445
+ store.use(() => false);
446
+ store.dispatch('inc');
447
+ expect(store.state.x).toBe(0);
448
+ });
449
+ });
450
+
451
+
452
+ // ===========================================================================
453
+ // debug mode
454
+ // ===========================================================================
455
+
456
+ describe('Store — debug mode', () => {
457
+ it('logs when debug is true', () => {
458
+ const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
459
+ const store = createStore({
460
+ state: { x: 0 },
461
+ actions: { inc(state) { state.x++; } },
462
+ debug: true
463
+ });
464
+ store.dispatch('inc');
465
+ expect(spy).toHaveBeenCalled();
466
+ const logStr = spy.mock.calls[0].join(' ');
467
+ expect(logStr).toContain('inc');
468
+ spy.mockRestore();
469
+ });
470
+ });
471
+
472
+
473
+ // ===========================================================================
474
+ // replaceState
475
+ // ===========================================================================
476
+
477
+ describe('Store — replaceState', () => {
478
+ it('replaces all keys', () => {
479
+ const store = createStore({
480
+ state: { a: 1, b: 2 }
481
+ });
482
+ store.replaceState({ c: 3 });
483
+ const snap = store.snapshot();
484
+ expect(snap).not.toHaveProperty('a');
485
+ expect(snap).not.toHaveProperty('b');
486
+ expect(snap.c).toBe(3);
487
+ });
488
+ });
489
+
490
+
491
+ // ===========================================================================
492
+ // wildcard subscription
493
+ // ===========================================================================
494
+
495
+ describe('Store — wildcard subscription', () => {
496
+ it('fires on any state change', () => {
497
+ const store = createStore({
498
+ state: { a: 1, b: 2 },
499
+ actions: {
500
+ setA(state, v) { state.a = v; },
501
+ setB(state, v) { state.b = v; }
502
+ }
503
+ });
504
+ const calls = [];
505
+ store.subscribe((key, val, old) => calls.push([key, val, old]));
506
+ store.dispatch('setA', 10);
507
+ store.dispatch('setB', 20);
508
+ expect(calls).toEqual([['a', 10, 1], ['b', 20, 2]]);
509
+ });
510
+
511
+ it('unsubscribes wildcard', () => {
512
+ const store = createStore({
513
+ state: { a: 1 },
514
+ actions: { setA(state, v) { state.a = v; } }
515
+ });
516
+ const fn = vi.fn();
517
+ const unsub = store.subscribe(fn);
518
+ store.dispatch('setA', 2);
519
+ expect(fn).toHaveBeenCalledTimes(1);
520
+ unsub();
521
+ store.dispatch('setA', 3);
522
+ expect(fn).toHaveBeenCalledTimes(1);
523
+ });
524
+ });
525
+
526
+
527
+ // ===========================================================================
528
+ // state as factory function
529
+ // ===========================================================================
530
+
531
+ describe('Store — state factory', () => {
532
+ it('calls state function for initial state', () => {
533
+ const store = createStore({
534
+ state: () => ({ count: 0 })
535
+ });
536
+ expect(store.state.count).toBe(0);
537
+ });
538
+ });
539
+
540
+
541
+ // ===========================================================================
542
+ // createStore — named stores
543
+ // ===========================================================================
544
+
545
+ describe('createStore — named stores', () => {
546
+ it('creates default store when no name given', () => {
547
+ const store = createStore({ state: { x: 1 } });
548
+ expect(store.state.x).toBe(1);
549
+ });
550
+
551
+ it('dispatch unknown action does not throw', () => {
552
+ const store = createStore({ state: { x: 1 }, actions: {} });
553
+ expect(() => store.dispatch('nope')).not.toThrow();
554
+ });
555
+ });
556
+
557
+
558
+ // ===========================================================================
559
+ // reset
560
+ // ===========================================================================
561
+
562
+ describe('Store — reset', () => {
563
+ it('resets state and clears history', () => {
564
+ const store = createStore({
565
+ state: { x: 0 },
566
+ actions: { inc(state) { state.x++; } }
567
+ });
568
+ store.dispatch('inc');
569
+ store.dispatch('inc');
570
+ expect(store.state.x).toBe(2);
571
+ expect(store.history.length).toBe(2);
572
+ store.reset({ x: 0 });
573
+ expect(store.state.x).toBe(0);
574
+ expect(store.history.length).toBe(0);
575
+ });
576
+ });
577
+
578
+
579
+ // ===========================================================================
580
+ // empty config
581
+ // ===========================================================================
582
+
583
+ describe('Store — empty config', () => {
584
+ it('creates store with no config', () => {
585
+ const store = createStore({});
586
+ expect(store.snapshot()).toEqual({});
587
+ expect(store.history).toEqual([]);
588
+ });
589
+ });
@@ -265,6 +265,35 @@ describe('isEqual', () => {
265
265
  expect(isEqual(null, {})).toBe(false);
266
266
  expect(isEqual({}, null)).toBe(false);
267
267
  });
268
+
269
+ it('distinguishes arrays from plain objects with same indices', () => {
270
+ expect(isEqual([1, 2], { 0: 1, 1: 2 })).toBe(false);
271
+ expect(isEqual({ 0: 'a' }, ['a'])).toBe(false);
272
+ });
273
+ });
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // deepMerge — circular reference safety
277
+ // ---------------------------------------------------------------------------
278
+
279
+ describe('deepMerge — circular reference safety', () => {
280
+ it('does not infinite-loop on circular source', () => {
281
+ const a = { x: 1 };
282
+ const b = { y: 2 };
283
+ b.self = b; // circular
284
+ const result = deepMerge(a, b);
285
+ expect(result.x).toBe(1);
286
+ expect(result.y).toBe(2);
287
+ // circular ref is simply not followed again
288
+ });
289
+
290
+ it('does not infinite-loop on circular target', () => {
291
+ const a = { x: 1 };
292
+ a.self = a;
293
+ const b = { y: 2 };
294
+ const result = deepMerge(a, b);
295
+ expect(result.y).toBe(2);
296
+ });
268
297
  });
269
298
 
270
299
 
@@ -510,3 +539,160 @@ describe('bus (EventBus)', () => {
510
539
  expect(() => bus.emit('nonexistent')).not.toThrow();
511
540
  });
512
541
  });
542
+
543
+
544
+ // ===========================================================================
545
+ // throttle — window reset
546
+ // ===========================================================================
547
+
548
+ describe('throttle — edge cases', () => {
549
+ it('fires trailing call after wait period', async () => {
550
+ vi.useFakeTimers();
551
+ const fn = vi.fn();
552
+ const throttled = throttle(fn, 100);
553
+
554
+ throttled('a'); // immediate
555
+ throttled('b'); // queued
556
+ expect(fn).toHaveBeenCalledTimes(1);
557
+
558
+ vi.advanceTimersByTime(100);
559
+ expect(fn).toHaveBeenCalledTimes(2);
560
+ expect(fn).toHaveBeenLastCalledWith('b');
561
+ vi.useRealTimers();
562
+ });
563
+ });
564
+
565
+
566
+ // ===========================================================================
567
+ // deepClone — edge cases
568
+ // ===========================================================================
569
+
570
+ describe('deepClone — edge cases', () => {
571
+ it('clones nested arrays', () => {
572
+ const arr = [[1, 2], [3, 4]];
573
+ const clone = deepClone(arr);
574
+ expect(clone).toEqual(arr);
575
+ clone[0][0] = 99;
576
+ expect(arr[0][0]).toBe(1);
577
+ });
578
+
579
+ it('handles null values', () => {
580
+ expect(deepClone({ a: null })).toEqual({ a: null });
581
+ });
582
+ });
583
+
584
+
585
+ // ===========================================================================
586
+ // deepMerge — multiple sources
587
+ // ===========================================================================
588
+
589
+ describe('deepMerge — edge cases', () => {
590
+ it('merges from multiple sources', () => {
591
+ const result = deepMerge({}, { a: 1 }, { b: 2 }, { c: 3 });
592
+ expect(result).toEqual({ a: 1, b: 2, c: 3 });
593
+ });
594
+
595
+ it('later sources override earlier', () => {
596
+ const result = deepMerge({}, { a: 1 }, { a: 2 });
597
+ expect(result).toEqual({ a: 2 });
598
+ });
599
+
600
+ it('handles arrays (replaces, not merges)', () => {
601
+ const result = deepMerge({}, { arr: [1, 2] }, { arr: [3] });
602
+ expect(result.arr).toEqual([3]);
603
+ });
604
+ });
605
+
606
+
607
+ // ===========================================================================
608
+ // isEqual — deeply nested
609
+ // ===========================================================================
610
+
611
+ describe('isEqual — edge cases', () => {
612
+ it('deeply nested equal objects', () => {
613
+ expect(isEqual({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } })).toBe(true);
614
+ });
615
+
616
+ it('arrays of objects', () => {
617
+ expect(isEqual([{ a: 1 }], [{ a: 1 }])).toBe(true);
618
+ expect(isEqual([{ a: 1 }], [{ a: 2 }])).toBe(false);
619
+ });
620
+
621
+ it('empty arrays equal', () => {
622
+ expect(isEqual([], [])).toBe(true);
623
+ });
624
+
625
+ it('null vs object', () => {
626
+ expect(isEqual(null, { a: 1 })).toBe(false);
627
+ expect(isEqual({ a: 1 }, null)).toBe(false);
628
+ });
629
+
630
+ it('different types', () => {
631
+ expect(isEqual('1', 1)).toBe(false);
632
+ });
633
+
634
+ it('array vs object', () => {
635
+ expect(isEqual([], {})).toBe(false);
636
+ });
637
+ });
638
+
639
+
640
+ // ===========================================================================
641
+ // camelCase / kebabCase — edge cases
642
+ // ===========================================================================
643
+
644
+ describe('camelCase / kebabCase — edge cases', () => {
645
+ it('camelCase single word', () => {
646
+ expect(camelCase('hello')).toBe('hello');
647
+ });
648
+
649
+ it('camelCase already camel', () => {
650
+ expect(camelCase('helloWorld')).toBe('helloWorld');
651
+ });
652
+
653
+ it('kebabCase single word lowercase', () => {
654
+ expect(kebabCase('hello')).toBe('hello');
655
+ });
656
+
657
+ it('kebabCase multiple humps', () => {
658
+ expect(kebabCase('myComponentName')).toBe('my-component-name');
659
+ });
660
+ });
661
+
662
+
663
+ // ===========================================================================
664
+ // html tag — escaping
665
+ // ===========================================================================
666
+
667
+ describe('html tag — edge cases', () => {
668
+ it('handles null interp value', () => {
669
+ const result = html`<div>${null}</div>`;
670
+ expect(result).toBe('<div></div>');
671
+ });
672
+
673
+ it('handles undefined interp value', () => {
674
+ const result = html`<div>${undefined}</div>`;
675
+ expect(result).toBe('<div></div>');
676
+ });
677
+
678
+ it('escapes multiple interpolations', () => {
679
+ const a = '<b>';
680
+ const b = '&';
681
+ const result = html`${a} and ${b}`;
682
+ expect(result).toContain('&lt;b&gt;');
683
+ expect(result).toContain('&amp;');
684
+ });
685
+ });
686
+
687
+
688
+ // ===========================================================================
689
+ // storage — error handling
690
+ // ===========================================================================
691
+
692
+ describe('storage — parse error fallback', () => {
693
+ it('returns fallback when JSON.parse fails', () => {
694
+ localStorage.setItem('bad', '{invalid json');
695
+ expect(storage.get('bad', 'default')).toBe('default');
696
+ localStorage.removeItem('bad');
697
+ });
698
+ });
package/types/store.d.ts CHANGED
@@ -23,6 +23,9 @@ export interface StoreConfig<
23
23
 
24
24
  /** Log dispatched actions to the console. */
25
25
  debug?: boolean;
26
+
27
+ /** Maximum number of action history entries to keep (default `1000`). */
28
+ maxHistory?: number;
26
29
  }
27
30
 
28
31
  /** A store action history entry. */