zero-query 0.6.3 → 0.8.6

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.
Files changed (72) hide show
  1. package/README.md +39 -29
  2. package/cli/commands/build.js +113 -4
  3. package/cli/commands/bundle.js +392 -29
  4. package/cli/commands/create.js +1 -1
  5. package/cli/commands/dev/devtools/index.js +56 -0
  6. package/cli/commands/dev/devtools/js/components.js +49 -0
  7. package/cli/commands/dev/devtools/js/core.js +409 -0
  8. package/cli/commands/dev/devtools/js/elements.js +413 -0
  9. package/cli/commands/dev/devtools/js/network.js +166 -0
  10. package/cli/commands/dev/devtools/js/performance.js +73 -0
  11. package/cli/commands/dev/devtools/js/router.js +105 -0
  12. package/cli/commands/dev/devtools/js/source.js +132 -0
  13. package/cli/commands/dev/devtools/js/stats.js +35 -0
  14. package/cli/commands/dev/devtools/js/tabs.js +79 -0
  15. package/cli/commands/dev/devtools/panel.html +95 -0
  16. package/cli/commands/dev/devtools/styles.css +244 -0
  17. package/cli/commands/dev/index.js +29 -4
  18. package/cli/commands/dev/logger.js +6 -1
  19. package/cli/commands/dev/overlay.js +428 -2
  20. package/cli/commands/dev/server.js +42 -5
  21. package/cli/commands/dev/watcher.js +59 -1
  22. package/cli/help.js +8 -5
  23. package/cli/scaffold/{scripts → app}/app.js +16 -23
  24. package/cli/scaffold/{scripts → app}/components/about.js +4 -4
  25. package/cli/scaffold/{scripts → app}/components/api-demo.js +1 -1
  26. package/cli/scaffold/{scripts → app}/components/contacts/contacts.css +0 -7
  27. package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +3 -3
  28. package/cli/scaffold/app/components/home.js +137 -0
  29. package/cli/scaffold/{scripts → app}/routes.js +1 -1
  30. package/cli/scaffold/{scripts → app}/store.js +6 -6
  31. package/cli/scaffold/assets/.gitkeep +0 -0
  32. package/cli/scaffold/{styles/styles.css → global.css} +4 -2
  33. package/cli/scaffold/index.html +12 -11
  34. package/cli/utils.js +111 -6
  35. package/dist/zquery.dist.zip +0 -0
  36. package/dist/zquery.js +1122 -158
  37. package/dist/zquery.min.js +3 -16
  38. package/index.d.ts +129 -1290
  39. package/index.js +15 -10
  40. package/package.json +7 -6
  41. package/src/component.js +172 -49
  42. package/src/core.js +359 -18
  43. package/src/diff.js +256 -58
  44. package/src/expression.js +33 -3
  45. package/src/reactive.js +37 -5
  46. package/src/router.js +243 -7
  47. package/tests/component.test.js +886 -0
  48. package/tests/core.test.js +977 -0
  49. package/tests/diff.test.js +525 -0
  50. package/tests/errors.test.js +162 -0
  51. package/tests/expression.test.js +482 -0
  52. package/tests/http.test.js +289 -0
  53. package/tests/reactive.test.js +339 -0
  54. package/tests/router.test.js +649 -0
  55. package/tests/store.test.js +379 -0
  56. package/tests/utils.test.js +512 -0
  57. package/types/collection.d.ts +383 -0
  58. package/types/component.d.ts +217 -0
  59. package/types/errors.d.ts +103 -0
  60. package/types/http.d.ts +81 -0
  61. package/types/misc.d.ts +179 -0
  62. package/types/reactive.d.ts +76 -0
  63. package/types/router.d.ts +161 -0
  64. package/types/ssr.d.ts +49 -0
  65. package/types/store.d.ts +107 -0
  66. package/types/utils.d.ts +142 -0
  67. package/cli/commands/dev.old.js +0 -520
  68. package/cli/scaffold/scripts/components/home.js +0 -137
  69. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.js +0 -0
  70. /package/cli/scaffold/{scripts → app}/components/counter.js +0 -0
  71. /package/cli/scaffold/{scripts → app}/components/not-found.js +0 -0
  72. /package/cli/scaffold/{scripts → app}/components/todos.js +0 -0
@@ -0,0 +1,649 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { createRouter, getRouter } from '../src/router.js';
3
+ import { component } from '../src/component.js';
4
+
5
+ // Register stub components used in route definitions so mount() doesn't throw
6
+ component('home-page', { render: () => '<p>home</p>' });
7
+ component('about-page', { render: () => '<p>about</p>' });
8
+ component('user-page', { render: () => '<p>user</p>' });
9
+ component('docs-page', { render: () => '<p>docs</p>' });
10
+
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Router creation and basic API
14
+ // ---------------------------------------------------------------------------
15
+
16
+ describe('Router — creation', () => {
17
+ beforeEach(() => {
18
+ document.body.innerHTML = '<div id="app"></div>';
19
+ });
20
+
21
+ it('creates a router and retrieves it with getRouter', () => {
22
+ const router = createRouter({
23
+ el: '#app',
24
+ mode: 'hash',
25
+ routes: [
26
+ { path: '/', component: 'home-page' },
27
+ ],
28
+ });
29
+ expect(getRouter()).toBe(router);
30
+ });
31
+
32
+ it('defaults to history mode (unless file:// protocol)', () => {
33
+ const router = createRouter({
34
+ routes: [{ path: '/', component: 'home-page' }],
35
+ });
36
+ expect(router._mode).toBe('history');
37
+ });
38
+
39
+ it('resolves base path from config', () => {
40
+ const router = createRouter({
41
+ base: '/app',
42
+ routes: [],
43
+ });
44
+ expect(router.base).toBe('/app');
45
+ });
46
+
47
+ it('strips trailing slash from base', () => {
48
+ const router = createRouter({
49
+ base: '/app/',
50
+ routes: [],
51
+ });
52
+ expect(router.base).toBe('/app');
53
+ });
54
+ });
55
+
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Route matching
59
+ // ---------------------------------------------------------------------------
60
+
61
+ describe('Router — route matching', () => {
62
+ it('compiles path params', () => {
63
+ const router = createRouter({
64
+ mode: 'hash',
65
+ routes: [
66
+ { path: '/user/:id', component: 'user-page' },
67
+ ],
68
+ });
69
+ const route = router._routes[0];
70
+ expect(route._regex.test('/user/42')).toBe(true);
71
+ expect(route._keys).toEqual(['id']);
72
+ });
73
+
74
+ it('adds fallback route alias', () => {
75
+ const router = createRouter({
76
+ mode: 'hash',
77
+ routes: [
78
+ { path: '/docs/:section', fallback: '/docs', component: 'docs-page' },
79
+ ],
80
+ });
81
+ // Should have two routes: the original + fallback alias
82
+ expect(router._routes.length).toBe(2);
83
+ expect(router._routes[1]._regex.test('/docs')).toBe(true);
84
+ });
85
+ });
86
+
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Navigation
90
+ // ---------------------------------------------------------------------------
91
+
92
+ describe('Router — navigation', () => {
93
+ let router;
94
+
95
+ beforeEach(() => {
96
+ document.body.innerHTML = '<div id="app"></div>';
97
+ window.location.hash = '#/';
98
+ router = createRouter({
99
+ el: '#app',
100
+ mode: 'hash',
101
+ routes: [
102
+ { path: '/', component: 'home-page' },
103
+ { path: '/about', component: 'about-page' },
104
+ ],
105
+ });
106
+ });
107
+
108
+ it('navigate changes hash', () => {
109
+ router.navigate('/about');
110
+ expect(window.location.hash).toBe('#/about');
111
+ });
112
+
113
+ it('replace changes hash without pushing history', () => {
114
+ router.replace('/about');
115
+ expect(window.location.hash).toBe('#/about');
116
+ });
117
+
118
+ it('navigate interpolates :param placeholders from options.params', () => {
119
+ router.navigate('/user/:id', { params: { id: 42 } });
120
+ expect(window.location.hash).toBe('#/user/42');
121
+ });
122
+
123
+ it('navigate interpolates multiple :param placeholders', () => {
124
+ router.navigate('/post/:postId/comment/:cid', { params: { postId: 5, cid: 99 } });
125
+ expect(window.location.hash).toBe('#/post/5/comment/99');
126
+ });
127
+
128
+ it('navigate leaves unmatched :params as-is', () => {
129
+ router.navigate('/user/:id', { params: { name: 'Alice' } });
130
+ expect(window.location.hash).toBe('#/user/:id');
131
+ });
132
+
133
+ it('navigate URI-encodes param values', () => {
134
+ router.navigate('/search/:query', { params: { query: 'hello world' } });
135
+ expect(window.location.hash).toBe('#/search/hello%20world');
136
+ });
137
+
138
+ it('replace interpolates :param placeholders from options.params', () => {
139
+ router.replace('/user/:id', { params: { id: 7 } });
140
+ expect(window.location.hash).toBe('#/user/7');
141
+ });
142
+
143
+ it('navigate without params option works as before', () => {
144
+ router.navigate('/about');
145
+ expect(window.location.hash).toBe('#/about');
146
+ });
147
+ });
148
+
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // _interpolateParams
152
+ // ---------------------------------------------------------------------------
153
+
154
+ describe('Router — _interpolateParams', () => {
155
+ let router;
156
+
157
+ beforeEach(() => {
158
+ router = createRouter({ mode: 'hash', routes: [] });
159
+ });
160
+
161
+ it('replaces single :param', () => {
162
+ expect(router._interpolateParams('/user/:id', { id: 42 })).toBe('/user/42');
163
+ });
164
+
165
+ it('replaces multiple :params', () => {
166
+ expect(router._interpolateParams('/post/:pid/comment/:cid', { pid: 1, cid: 5 }))
167
+ .toBe('/post/1/comment/5');
168
+ });
169
+
170
+ it('leaves unmatched :params in place', () => {
171
+ expect(router._interpolateParams('/user/:id', {})).toBe('/user/:id');
172
+ });
173
+
174
+ it('URI-encodes param values', () => {
175
+ expect(router._interpolateParams('/tag/:name', { name: 'foo bar' }))
176
+ .toBe('/tag/foo%20bar');
177
+ });
178
+
179
+ it('returns path unchanged when params is null', () => {
180
+ expect(router._interpolateParams('/about', null)).toBe('/about');
181
+ });
182
+
183
+ it('converts numbers to strings', () => {
184
+ expect(router._interpolateParams('/user/:id', { id: 123 })).toBe('/user/123');
185
+ });
186
+ });
187
+
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // z-link-params
191
+ // ---------------------------------------------------------------------------
192
+
193
+ describe('Router — z-link-params', () => {
194
+ let router;
195
+
196
+ beforeEach(() => {
197
+ document.body.innerHTML = '<div id="app"></div>';
198
+ window.location.hash = '#/';
199
+ router = createRouter({
200
+ el: '#app',
201
+ mode: 'hash',
202
+ routes: [
203
+ { path: '/', component: 'home-page' },
204
+ { path: '/user/:id', component: 'user-page' },
205
+ ],
206
+ });
207
+ });
208
+
209
+ it('interpolates params from z-link-params attribute on click', () => {
210
+ document.body.innerHTML += '<a z-link="/user/:id" z-link-params=\'{"id": "42"}\'>User</a>';
211
+ const link = document.querySelector('[z-link]');
212
+ link.click();
213
+ expect(window.location.hash).toBe('#/user/42');
214
+ });
215
+
216
+ it('works without z-link-params (plain z-link)', () => {
217
+ document.body.innerHTML += '<a z-link="/about">About</a>';
218
+ const link = document.querySelector('a[z-link="/about"]');
219
+ link.click();
220
+ expect(window.location.hash).toBe('#/about');
221
+ });
222
+
223
+ it('ignores malformed z-link-params JSON gracefully', () => {
224
+ document.body.innerHTML += '<a z-link="/user/fallback" z-link-params="not json">User</a>';
225
+ const link = document.querySelector('a[z-link="/user/fallback"]');
226
+ link.click();
227
+ expect(window.location.hash).toBe('#/user/fallback');
228
+ });
229
+ });
230
+
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Guards
234
+ // ---------------------------------------------------------------------------
235
+
236
+ describe('Router — guards', () => {
237
+ it('beforeEach registers a guard', () => {
238
+ const router = createRouter({
239
+ mode: 'hash',
240
+ routes: [{ path: '/', component: 'home-page' }],
241
+ });
242
+ const guard = vi.fn();
243
+ router.beforeEach(guard);
244
+ expect(router._guards.before).toContain(guard);
245
+ });
246
+
247
+ it('afterEach registers a guard', () => {
248
+ const router = createRouter({
249
+ mode: 'hash',
250
+ routes: [{ path: '/', component: 'home-page' }],
251
+ });
252
+ const guard = vi.fn();
253
+ router.afterEach(guard);
254
+ expect(router._guards.after).toContain(guard);
255
+ });
256
+ });
257
+
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // onChange listener
261
+ // ---------------------------------------------------------------------------
262
+
263
+ describe('Router — onChange', () => {
264
+ it('onChange registers and returns unsubscribe', () => {
265
+ const router = createRouter({
266
+ mode: 'hash',
267
+ routes: [],
268
+ });
269
+ const fn = vi.fn();
270
+ const unsub = router.onChange(fn);
271
+ expect(router._listeners.has(fn)).toBe(true);
272
+ unsub();
273
+ expect(router._listeners.has(fn)).toBe(false);
274
+ });
275
+ });
276
+
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Path normalization
280
+ // ---------------------------------------------------------------------------
281
+
282
+ describe('Router — path normalization', () => {
283
+ it('normalizes relative paths', () => {
284
+ const router = createRouter({
285
+ mode: 'hash',
286
+ base: '/app',
287
+ routes: [],
288
+ });
289
+ expect(router._normalizePath('docs')).toBe('/docs');
290
+ expect(router._normalizePath('/docs')).toBe('/docs');
291
+ });
292
+
293
+ it('strips base prefix from path', () => {
294
+ const router = createRouter({
295
+ mode: 'hash',
296
+ base: '/app',
297
+ routes: [],
298
+ });
299
+ expect(router._normalizePath('/app/docs')).toBe('/docs');
300
+ expect(router._normalizePath('/app')).toBe('/');
301
+ });
302
+
303
+ it('resolve() adds base prefix', () => {
304
+ const router = createRouter({
305
+ mode: 'hash',
306
+ base: '/app',
307
+ routes: [],
308
+ });
309
+ expect(router.resolve('/docs')).toBe('/app/docs');
310
+ expect(router.resolve('about')).toBe('/app/about');
311
+ });
312
+ });
313
+
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Destroy
317
+ // ---------------------------------------------------------------------------
318
+
319
+ describe('Router — destroy', () => {
320
+ it('clears routes, guards, and listeners', () => {
321
+ const router = createRouter({
322
+ mode: 'hash',
323
+ routes: [{ path: '/', component: 'home-page' }],
324
+ });
325
+ router.beforeEach(() => {});
326
+ router.onChange(() => {});
327
+ router.destroy();
328
+ expect(router._routes.length).toBe(0);
329
+ expect(router._guards.before.length).toBe(0);
330
+ expect(router._listeners.size).toBe(0);
331
+ });
332
+ });
333
+
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Wildcard / catch-all routes
337
+ // ---------------------------------------------------------------------------
338
+
339
+ describe('Router — wildcard routes', () => {
340
+ it('compiles wildcard route', () => {
341
+ const router = createRouter({
342
+ mode: 'hash',
343
+ routes: [
344
+ { path: '/docs/:section', component: 'docs-page' },
345
+ { path: '*', component: 'home-page' },
346
+ ],
347
+ });
348
+ // Wildcard is compiled as last route
349
+ expect(router._routes.length).toBeGreaterThanOrEqual(2);
350
+ });
351
+ });
352
+
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Query string handling
356
+ // ---------------------------------------------------------------------------
357
+
358
+ describe('Router — query parsing', () => {
359
+ it('_normalizePath strips query string for route matching', () => {
360
+ const router = createRouter({
361
+ mode: 'hash',
362
+ routes: [],
363
+ });
364
+ // _normalizePath does not strip query params — it only normalizes slashes and base prefix
365
+ const path = router._normalizePath('/docs?section=intro');
366
+ expect(path).toBe('/docs?section=intro');
367
+ });
368
+ });
369
+
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Multiple guards
373
+ // ---------------------------------------------------------------------------
374
+
375
+ describe('Router — multiple guards', () => {
376
+ it('registers multiple beforeEach guards', () => {
377
+ const router = createRouter({
378
+ mode: 'hash',
379
+ routes: [{ path: '/', component: 'home-page' }],
380
+ });
381
+ const g1 = vi.fn();
382
+ const g2 = vi.fn();
383
+ router.beforeEach(g1);
384
+ router.beforeEach(g2);
385
+ expect(router._guards.before.length).toBe(2);
386
+ expect(router._guards.before).toContain(g1);
387
+ expect(router._guards.before).toContain(g2);
388
+ });
389
+
390
+ it('registers multiple afterEach guards', () => {
391
+ const router = createRouter({
392
+ mode: 'hash',
393
+ routes: [{ path: '/', component: 'home-page' }],
394
+ });
395
+ const g1 = vi.fn();
396
+ const g2 = vi.fn();
397
+ router.afterEach(g1);
398
+ router.afterEach(g2);
399
+ expect(router._guards.after.length).toBe(2);
400
+ });
401
+ });
402
+
403
+
404
+ // ---------------------------------------------------------------------------
405
+ // Route with multiple params
406
+ // ---------------------------------------------------------------------------
407
+
408
+ describe('Router — multi-param routes', () => {
409
+ it('compiles route with multiple params', () => {
410
+ const router = createRouter({
411
+ mode: 'hash',
412
+ routes: [
413
+ { path: '/post/:pid/comment/:cid', component: 'user-page' },
414
+ ],
415
+ });
416
+ const route = router._routes[0];
417
+ expect(route._regex.test('/post/1/comment/5')).toBe(true);
418
+ expect(route._keys).toEqual(['pid', 'cid']);
419
+ });
420
+ });
421
+
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // Same-path deduplication
425
+ // ---------------------------------------------------------------------------
426
+
427
+ describe('Router — same-path deduplication', () => {
428
+ let router;
429
+
430
+ beforeEach(() => {
431
+ document.body.innerHTML = '<div id="app"></div>';
432
+ window.location.hash = '#/';
433
+ router = createRouter({
434
+ el: '#app',
435
+ mode: 'hash',
436
+ routes: [
437
+ { path: '/', component: 'home-page' },
438
+ { path: '/about', component: 'about-page' },
439
+ ],
440
+ });
441
+ });
442
+
443
+ it('skips duplicate hash navigation to the same path', () => {
444
+ router.navigate('/about');
445
+ expect(window.location.hash).toBe('#/about');
446
+ // Navigate to the same path again — should be a no-op
447
+ const result = router.navigate('/about');
448
+ expect(window.location.hash).toBe('#/about');
449
+ expect(result).toBe(router); // still returns the router chain
450
+ });
451
+
452
+ it('allows forced duplicate navigation with options.force', () => {
453
+ router.navigate('/about');
454
+ // Force navigation to same path
455
+ router.navigate('/about', { force: true });
456
+ expect(window.location.hash).toBe('#/about');
457
+ });
458
+ });
459
+
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // History mode — same-path / hash-only navigation
463
+ // ---------------------------------------------------------------------------
464
+
465
+ describe('Router — history mode deduplication', () => {
466
+ let router;
467
+ let pushSpy, replaceSpy;
468
+
469
+ beforeEach(() => {
470
+ document.body.innerHTML = '<div id="app"></div>';
471
+ pushSpy = vi.spyOn(window.history, 'pushState');
472
+ replaceSpy = vi.spyOn(window.history, 'replaceState');
473
+ router = createRouter({
474
+ el: '#app',
475
+ mode: 'history',
476
+ routes: [
477
+ { path: '/', component: 'home-page' },
478
+ { path: '/about', component: 'about-page' },
479
+ { path: '/docs', component: 'docs-page' },
480
+ ],
481
+ });
482
+ // Reset spy call counts after initial resolve
483
+ pushSpy.mockClear();
484
+ replaceSpy.mockClear();
485
+ });
486
+
487
+ afterEach(() => {
488
+ pushSpy.mockRestore();
489
+ replaceSpy.mockRestore();
490
+ });
491
+
492
+ it('uses pushState for different routes', () => {
493
+ router.navigate('/about');
494
+ expect(pushSpy).toHaveBeenCalledTimes(1);
495
+ });
496
+
497
+ it('uses replaceState for same-route hash-only change', () => {
498
+ // Navigate to /docs first
499
+ router.navigate('/docs');
500
+ pushSpy.mockClear();
501
+ replaceSpy.mockClear();
502
+ // Navigate to /docs#section — same route, different hash
503
+ router.navigate('/docs#section');
504
+ expect(pushSpy).not.toHaveBeenCalled();
505
+ expect(replaceSpy).toHaveBeenCalledTimes(1);
506
+ });
507
+ });
508
+
509
+
510
+ // ---------------------------------------------------------------------------
511
+ // Sub-route history substates
512
+ // ---------------------------------------------------------------------------
513
+
514
+ describe('Router — substates', () => {
515
+ let router;
516
+ let pushSpy;
517
+
518
+ beforeEach(() => {
519
+ document.body.innerHTML = '<div id="app"></div>';
520
+ pushSpy = vi.spyOn(window.history, 'pushState');
521
+ router = createRouter({
522
+ el: '#app',
523
+ mode: 'history',
524
+ routes: [
525
+ { path: '/', component: 'home-page' },
526
+ ],
527
+ });
528
+ pushSpy.mockClear();
529
+ });
530
+
531
+ afterEach(() => {
532
+ pushSpy.mockRestore();
533
+ });
534
+
535
+ it('pushSubstate pushes a history entry with substate marker', () => {
536
+ router.pushSubstate('modal', { id: 'confirm' });
537
+ expect(pushSpy).toHaveBeenCalledTimes(1);
538
+ const state = pushSpy.mock.calls[0][0];
539
+ expect(state.__zq).toBe('substate');
540
+ expect(state.key).toBe('modal');
541
+ expect(state.data).toEqual({ id: 'confirm' });
542
+ });
543
+
544
+ it('onSubstate registers and unregisters listeners', () => {
545
+ const fn = vi.fn();
546
+ const unsub = router.onSubstate(fn);
547
+ expect(router._substateListeners).toContain(fn);
548
+ unsub();
549
+ expect(router._substateListeners).not.toContain(fn);
550
+ });
551
+
552
+ it('_fireSubstate calls listeners and returns true if any handles it', () => {
553
+ const fn1 = vi.fn(() => false);
554
+ const fn2 = vi.fn(() => true);
555
+ router.onSubstate(fn1);
556
+ router.onSubstate(fn2);
557
+ const handled = router._fireSubstate('modal', { id: 'x' }, 'pop');
558
+ expect(fn1).toHaveBeenCalledWith('modal', { id: 'x' }, 'pop');
559
+ expect(fn2).toHaveBeenCalledWith('modal', { id: 'x' }, 'pop');
560
+ expect(handled).toBe(true);
561
+ });
562
+
563
+ it('_fireSubstate returns false if no listener handles it', () => {
564
+ const fn = vi.fn(() => undefined);
565
+ router.onSubstate(fn);
566
+ const handled = router._fireSubstate('tab', { index: 0 }, 'pop');
567
+ expect(handled).toBe(false);
568
+ });
569
+
570
+ it('_fireSubstate catches errors in listeners', () => {
571
+ const fn = vi.fn(() => { throw new Error('oops'); });
572
+ router.onSubstate(fn);
573
+ // Should not throw
574
+ expect(() => router._fireSubstate('modal', {}, 'pop')).not.toThrow();
575
+ });
576
+
577
+ it('destroy clears substate listeners', () => {
578
+ router.onSubstate(() => {});
579
+ router.onSubstate(() => {});
580
+ router.destroy();
581
+ expect(router._substateListeners.length).toBe(0);
582
+ });
583
+
584
+ it('pushSubstate sets _inSubstate flag', () => {
585
+ expect(router._inSubstate).toBe(false);
586
+ router.pushSubstate('tab', { id: 'a' });
587
+ expect(router._inSubstate).toBe(true);
588
+ });
589
+
590
+ it('popstate past all substates fires reset action', () => {
591
+ const fn = vi.fn(() => true);
592
+ router.onSubstate(fn);
593
+ router.pushSubstate('tab', { id: 'a' });
594
+ expect(router._inSubstate).toBe(true);
595
+
596
+ // Simulate popstate landing on a non-substate entry
597
+ const evt = new PopStateEvent('popstate', { state: { __zq: 'route' } });
598
+ window.dispatchEvent(evt);
599
+
600
+ // Should have fired with reset action
601
+ expect(fn).toHaveBeenCalledWith(null, null, 'reset');
602
+ expect(router._inSubstate).toBe(false);
603
+ });
604
+
605
+ it('popstate on a substate entry keeps _inSubstate true', () => {
606
+ const fn = vi.fn(() => true);
607
+ router.onSubstate(fn);
608
+ router.pushSubstate('tab', { id: 'a' });
609
+ router.pushSubstate('tab', { id: 'b' });
610
+
611
+ // Simulate popstate landing on a substate entry
612
+ const evt = new PopStateEvent('popstate', {
613
+ state: { __zq: 'substate', key: 'tab', data: { id: 'a' } }
614
+ });
615
+ window.dispatchEvent(evt);
616
+
617
+ expect(fn).toHaveBeenCalledWith('tab', { id: 'a' }, 'pop');
618
+ expect(router._inSubstate).toBe(true);
619
+ });
620
+
621
+ it('reset action is not fired when no substates were active', () => {
622
+ const fn = vi.fn();
623
+ router.onSubstate(fn);
624
+ // _inSubstate is false, so popstate on a route entry should not fire reset
625
+ const evt = new PopStateEvent('popstate', { state: { __zq: 'route' } });
626
+ window.dispatchEvent(evt);
627
+ // fn should NOT have been called with 'reset'
628
+ const resetCalls = fn.mock.calls.filter(c => c[2] === 'reset');
629
+ expect(resetCalls.length).toBe(0);
630
+ });
631
+ });
632
+
633
+
634
+ // ---------------------------------------------------------------------------
635
+ // Edge cases
636
+ // ---------------------------------------------------------------------------
637
+
638
+ describe('Router — edge cases', () => {
639
+ it('handles empty routes array', () => {
640
+ const router = createRouter({ mode: 'hash', routes: [] });
641
+ expect(router._routes.length).toBe(0);
642
+ });
643
+
644
+ it('getRouter returns null before creation', () => {
645
+ // After the tests create routers, getRouter should return the latest
646
+ const r = getRouter();
647
+ expect(r).toBeDefined();
648
+ });
649
+ });