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.
- package/README.md +39 -29
- package/cli/commands/build.js +113 -4
- package/cli/commands/bundle.js +392 -29
- package/cli/commands/create.js +1 -1
- package/cli/commands/dev/devtools/index.js +56 -0
- package/cli/commands/dev/devtools/js/components.js +49 -0
- package/cli/commands/dev/devtools/js/core.js +409 -0
- package/cli/commands/dev/devtools/js/elements.js +413 -0
- package/cli/commands/dev/devtools/js/network.js +166 -0
- package/cli/commands/dev/devtools/js/performance.js +73 -0
- package/cli/commands/dev/devtools/js/router.js +105 -0
- package/cli/commands/dev/devtools/js/source.js +132 -0
- package/cli/commands/dev/devtools/js/stats.js +35 -0
- package/cli/commands/dev/devtools/js/tabs.js +79 -0
- package/cli/commands/dev/devtools/panel.html +95 -0
- package/cli/commands/dev/devtools/styles.css +244 -0
- package/cli/commands/dev/index.js +29 -4
- package/cli/commands/dev/logger.js +6 -1
- package/cli/commands/dev/overlay.js +428 -2
- package/cli/commands/dev/server.js +42 -5
- package/cli/commands/dev/watcher.js +59 -1
- package/cli/help.js +8 -5
- package/cli/scaffold/{scripts → app}/app.js +16 -23
- package/cli/scaffold/{scripts → app}/components/about.js +4 -4
- package/cli/scaffold/{scripts → app}/components/api-demo.js +1 -1
- package/cli/scaffold/{scripts → app}/components/contacts/contacts.css +0 -7
- package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +3 -3
- package/cli/scaffold/app/components/home.js +137 -0
- package/cli/scaffold/{scripts → app}/routes.js +1 -1
- package/cli/scaffold/{scripts → app}/store.js +6 -6
- package/cli/scaffold/assets/.gitkeep +0 -0
- package/cli/scaffold/{styles/styles.css → global.css} +4 -2
- package/cli/scaffold/index.html +12 -11
- package/cli/utils.js +111 -6
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +1122 -158
- package/dist/zquery.min.js +3 -16
- package/index.d.ts +129 -1290
- package/index.js +15 -10
- package/package.json +7 -6
- package/src/component.js +172 -49
- package/src/core.js +359 -18
- package/src/diff.js +256 -58
- package/src/expression.js +33 -3
- package/src/reactive.js +37 -5
- package/src/router.js +243 -7
- package/tests/component.test.js +886 -0
- package/tests/core.test.js +977 -0
- package/tests/diff.test.js +525 -0
- package/tests/errors.test.js +162 -0
- package/tests/expression.test.js +482 -0
- package/tests/http.test.js +289 -0
- package/tests/reactive.test.js +339 -0
- package/tests/router.test.js +649 -0
- package/tests/store.test.js +379 -0
- package/tests/utils.test.js +512 -0
- package/types/collection.d.ts +383 -0
- package/types/component.d.ts +217 -0
- package/types/errors.d.ts +103 -0
- package/types/http.d.ts +81 -0
- package/types/misc.d.ts +179 -0
- package/types/reactive.d.ts +76 -0
- package/types/router.d.ts +161 -0
- package/types/ssr.d.ts +49 -0
- package/types/store.d.ts +107 -0
- package/types/utils.d.ts +142 -0
- package/cli/commands/dev.old.js +0 -520
- package/cli/scaffold/scripts/components/home.js +0 -137
- /package/cli/scaffold/{scripts → app}/components/contacts/contacts.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/counter.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/not-found.js +0 -0
- /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
|
+
});
|