@vobs/router 0.3.0 → 1.1.0

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,574 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { createElement, createText, createVobs, insertBefore, setRenderer, createDOMRenderer, type VobsNode } from '@vobs/vobs'
3
+ import {
4
+ createMemoryHistory,
5
+ createBrowserHistory,
6
+ createRouter,
7
+ lazy,
8
+ NavigationCancelledError,
9
+ ROUTER_KEY,
10
+ RouterView,
11
+ routerPlugin,
12
+ useRoute,
13
+ useRouter
14
+ } from './index'
15
+
16
+ describe('@vobs/router', () => {
17
+ beforeEach(() => {
18
+ setRenderer(createDOMRenderer())
19
+ })
20
+
21
+ afterEach(() => {
22
+ vi.restoreAllMocks()
23
+ })
24
+
25
+ it('匹配静态路由、参数、query 和 hash,并支持命名导航', () => {
26
+ const router = createRouter({
27
+ history: createMemoryHistory('/users/42?tab=profile&tag=a&tag=b#bio'),
28
+ routes: [
29
+ { path: '/', name: 'home', component: () => createText('home') },
30
+ { path: '/users/:id', name: 'user', component: () => createText('user') }
31
+ ]
32
+ })
33
+
34
+ expect(router.currentRoute.value.path).toBe('/users/42')
35
+ expect(router.currentRoute.value.params).toEqual({ id: '42' })
36
+ expect(router.currentRoute.value.query).toEqual({ tab: 'profile', tag: ['a', 'b'] })
37
+ expect(router.currentRoute.value.hash).toBe('#bio')
38
+ expect(router.resolve({ name: 'user', params: { id: '7' }, query: { tab: 'activity' } }).fullPath)
39
+ .toBe('/users/7?tab=activity')
40
+ router.destroy()
41
+ })
42
+
43
+ it('导航 state 随 history 条目存取,back 时恢复', async () => {
44
+ const router = createRouter({
45
+ history: createMemoryHistory('/'),
46
+ routes: [
47
+ { path: '/', name: 'home', component: () => createText('home') },
48
+ { path: '/result', name: 'result', component: () => createText('result') }
49
+ ]
50
+ })
51
+
52
+ // push 携带 state(对象目标与字符串目标的透传路径都要覆盖)
53
+ await router.push({ path: '/result', state: { orderId: 'A-1' } })
54
+ expect(router.currentRoute.value.path).toBe('/result')
55
+ expect(router.currentRoute.value.state).toEqual({ orderId: 'A-1' })
56
+ expect(router.history.state).toEqual({ orderId: 'A-1' })
57
+
58
+ await router.push({ path: '/', state: { orderId: 'A-2' } })
59
+ expect(router.currentRoute.value.state).toEqual({ orderId: 'A-2' })
60
+
61
+ // back 回到上一条目,state 一并恢复
62
+ router.history.back()
63
+ await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/result'))
64
+ expect(router.currentRoute.value.state).toEqual({ orderId: 'A-1' })
65
+ router.destroy()
66
+ })
67
+
68
+ it('replace 更新当前条目的 state,同路径 state-only 变更被去重忽略', async () => {
69
+ const history = createMemoryHistory('/')
70
+ const router = createRouter({
71
+ history,
72
+ routes: [
73
+ { path: '/', name: 'home', component: () => createText('home') },
74
+ { path: '/a', name: 'a', component: () => createText('a') },
75
+ { path: '/b', name: 'b', component: () => createText('b') }
76
+ ]
77
+ })
78
+
79
+ await router.push('/a')
80
+ expect(router.currentRoute.value.state).toBeUndefined()
81
+
82
+ // replace 到不同路径:state 写入新条目
83
+ await router.replace({ path: '/b', state: { retried: true } })
84
+ expect(router.currentRoute.value.path).toBe('/b')
85
+ expect(router.currentRoute.value.state).toEqual({ retried: true })
86
+ expect(router.history.state).toEqual({ retried: true })
87
+
88
+ // 与既有“相同目标忽略”语义一致:同路径仅 state 变化不会触发导航
89
+ await router.replace({ path: '/b', state: { retried: false } })
90
+ expect(router.currentRoute.value.state).toEqual({ retried: true })
91
+
92
+ // replace 覆盖了 /a 的条目而非新增:back 直接回到初始 /
93
+ // (若 replace 误作 push,这里会回到 /a)
94
+ router.history.back()
95
+ await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/'))
96
+ router.destroy()
97
+ })
98
+
99
+ it('支持无路径父路由、嵌套 children、父子 meta 合并和命名导航', () => {
100
+ const Layout = () => createText('layout')
101
+ const router = createRouter({
102
+ history: createMemoryHistory('/users/42'),
103
+ routes: [{
104
+ component: Layout,
105
+ meta: { requiresAuth: true, section: 'app' },
106
+ children: [{
107
+ path: '/users/:id',
108
+ name: 'user',
109
+ meta: { section: 'users' },
110
+ component: () => createText('user')
111
+ }]
112
+ }]
113
+ })
114
+
115
+ expect(router.currentRoute.value.record?.name).toBe('user')
116
+ expect(router.currentRoute.value.matched).toHaveLength(2)
117
+ expect(router.currentRoute.value.params).toEqual({ id: '42' })
118
+ expect(router.currentRoute.value.meta).toEqual({ requiresAuth: true, section: 'users' })
119
+ expect(router.resolve({ name: 'user', params: { id: 7 } }).path).toBe('/users/7')
120
+ router.destroy()
121
+ })
122
+
123
+ it('父级路径下省略 path 的子路由匹配为 index 路由', () => {
124
+ const router = createRouter({
125
+ history: createMemoryHistory('/admin'),
126
+ routes: [{
127
+ path: '/admin',
128
+ component: () => createText('layout'),
129
+ children: [{ component: () => createText('index') }]
130
+ }]
131
+ })
132
+
133
+ expect(router.currentRoute.value.path).toBe('/admin')
134
+ expect(router.currentRoute.value.record).toBeTruthy()
135
+ router.destroy()
136
+ })
137
+
138
+ it('push、replace 和 back 更新 currentRoute,并忽略相同目标', async () => {
139
+ const history = createMemoryHistory('/')
140
+ const router = createRouter({
141
+ history,
142
+ routes: [
143
+ { path: '/', component: () => createText('home') },
144
+ { path: '/users', component: () => createText('users') }
145
+ ]
146
+ })
147
+
148
+ const first = await router.push('/users')
149
+ expect(first).toMatchObject({ path: '/users' })
150
+ expect(history.location).toBe('/users')
151
+ expect(await router.push('/users')).toBe(first)
152
+
153
+ await router.replace({ path: '/', query: { page: 2 } })
154
+ expect(router.currentRoute.value.fullPath).toBe('/?page=2')
155
+ expect(history.location).toBe('/?page=2')
156
+
157
+ await router.push('/users')
158
+ router.back()
159
+ await Promise.resolve()
160
+ await Promise.resolve()
161
+ expect(router.currentRoute.value.fullPath).toBe('/?page=2')
162
+ router.destroy()
163
+ })
164
+
165
+ it('browser history 支持 base 路径并转发 popstate', () => {
166
+ const originalURL = window.location.href
167
+ window.history.replaceState(null, '', '/vobs/users?tab=all')
168
+ const history = createBrowserHistory('/vobs')
169
+ const paths: string[] = []
170
+ const stop = history.listen(path => paths.push(path))
171
+
172
+ expect(history.location).toBe('/users?tab=all')
173
+ history.push('/users/2#details')
174
+ expect(window.location.pathname).toBe('/vobs/users/2')
175
+ expect(window.location.hash).toBe('#details')
176
+ window.history.pushState(null, '', '/vobs/users/3')
177
+ window.dispatchEvent(new PopStateEvent('popstate'))
178
+ expect(paths).toEqual(['/users/3'])
179
+
180
+ stop()
181
+ window.history.replaceState(null, '', originalURL)
182
+ })
183
+
184
+ it('守卫支持异步放行、取消和重定向', async () => {
185
+ const router = createRouter({
186
+ history: createMemoryHistory('/'),
187
+ routes: [
188
+ { path: '/', component: () => createText('home') },
189
+ { path: '/login', component: () => createText('login') },
190
+ { path: '/private', component: () => createText('private') }
191
+ ]
192
+ })
193
+ router.beforeEach(async to => {
194
+ await Promise.resolve()
195
+ return to.path === '/private' ? '/login' : undefined
196
+ })
197
+
198
+ expect(await router.push('/private')).toMatchObject({ path: '/login' })
199
+ expect(router.currentRoute.value.path).toBe('/login')
200
+
201
+ const removeGuard = router.beforeEach(() => false)
202
+ expect(await router.push('/')).toBe(false)
203
+ expect(router.currentRoute.value.path).toBe('/login')
204
+ removeGuard()
205
+ router.destroy()
206
+ })
207
+
208
+ it('旧导航的 loader 迟到完成时不得覆盖新导航', async () => {
209
+ let resolveSlowLoader!: () => void
210
+ const router = createRouter({
211
+ history: createMemoryHistory('/'),
212
+ routes: [
213
+ { path: '/', component: () => createText('home') },
214
+ {
215
+ path: '/slow',
216
+ component: () => createText('slow'),
217
+ loader: () => new Promise<void>(resolve => { resolveSlowLoader = resolve })
218
+ },
219
+ { path: '/fast', component: () => createText('fast'), loader: () => Promise.resolve() }
220
+ ]
221
+ })
222
+
223
+ const slowNavigation = router.push('/slow')
224
+ expect(router.currentRoute.value.path).toBe('/')
225
+ await router.push('/fast')
226
+ expect(router.currentRoute.value.path).toBe('/fast')
227
+
228
+ resolveSlowLoader()
229
+ await expect(slowNavigation).rejects.toThrowError(NavigationCancelledError)
230
+ // 被抢占的旧导航不允许改写当前路由与 history
231
+ expect(router.currentRoute.value.path).toBe('/fast')
232
+ expect(router.history.location).toBe('/fast')
233
+ router.destroy()
234
+ })
235
+
236
+ it('守卫重定向到当前目标时结束导航,不进入无限循环', async () => {
237
+ const router = createRouter({
238
+ history: createMemoryHistory('/'),
239
+ routes: [
240
+ { path: '/', component: () => createText('home') },
241
+ { path: '/target', component: () => createText('target') }
242
+ ]
243
+ })
244
+ router.beforeEach(to => to.path === '/target' ? '/target' : undefined)
245
+
246
+ await expect(router.push('/target')).resolves.toBe(false)
247
+ expect(router.currentRoute.value.path).toBe('/')
248
+ router.destroy()
249
+ })
250
+
251
+ it('新的导航会取消仍在等待守卫的旧导航', async () => {
252
+ let release!: () => void
253
+ const router = createRouter({
254
+ history: createMemoryHistory('/'),
255
+ routes: [
256
+ { path: '/', component: () => createText('home') },
257
+ { path: '/slow', component: () => createText('slow') },
258
+ { path: '/fast', component: () => createText('fast') }
259
+ ]
260
+ })
261
+ router.beforeEach(async to => {
262
+ if (to.path === '/slow') await new Promise<void>(resolve => { release = resolve })
263
+ })
264
+
265
+ const slow = router.push('/slow')
266
+ await Promise.resolve()
267
+ await expect(router.push('/fast')).resolves.toMatchObject({ path: '/fast' })
268
+ release()
269
+ await expect(slow).rejects.toBeInstanceOf(NavigationCancelledError)
270
+ router.destroy()
271
+ })
272
+
273
+ it('history 回退被守卫重定向时同步 history 地址', async () => {
274
+ const history = createMemoryHistory('/')
275
+ const router = createRouter({
276
+ history,
277
+ routes: [
278
+ { path: '/', component: () => createText('home') },
279
+ { path: '/login', component: () => createText('login') },
280
+ { path: '/private', component: () => createText('private') }
281
+ ]
282
+ })
283
+ await router.push('/login')
284
+ await router.push('/private')
285
+ router.beforeEach(to => to.path === '/login' ? '/private' : undefined)
286
+
287
+ router.back()
288
+ await vi.waitFor(() => {
289
+ expect(router.currentRoute.value.path).toBe('/private')
290
+ expect(history.location).toBe('/private')
291
+ })
292
+ router.destroy()
293
+ })
294
+
295
+ it('RouterView 响应路由切换,并把 router 注入路由组件', async () => {
296
+ const router = createRouter({
297
+ history: createMemoryHistory('/'),
298
+ routes: [
299
+ { path: '/', component: () => createText('home') },
300
+ {
301
+ path: '/users/:id',
302
+ component: () => {
303
+ const route = useRoute()
304
+ const node = createElement('p')
305
+ node.textContent = `user:${route.value.params.id}`
306
+ expect(useRouter()).toBe(router)
307
+ return node
308
+ }
309
+ }
310
+ ]
311
+ })
312
+ const container = document.createElement('div')
313
+ const app = createVobs({
314
+ render: () => RouterView({
315
+ loading: () => createText('loading'),
316
+ notFound: () => createText('not-found')
317
+ }),
318
+ plugins: [routerPlugin({ router })]
319
+ })
320
+
321
+ app.mount(container)
322
+ expect(container.textContent).toBe('home')
323
+ await router.push('/users/7')
324
+ app.update()
325
+ expect(container.textContent).toBe('user:7')
326
+ await router.push('/missing')
327
+ app.update()
328
+ expect(container.textContent).toBe('not-found')
329
+ app.destroy()
330
+ router.destroy()
331
+ })
332
+
333
+ it('RouterView 按从外到内的顺序包裹嵌套路由布局', () => {
334
+ const wrap = (tag: string, label: string) => (props: { children?: VobsNode }) => {
335
+ const node = createElement(tag)
336
+ node.setAttribute('data-layout', label)
337
+ if (props.children) insertBefore(node, props.children, null)
338
+ return node
339
+ }
340
+ const router = createRouter({
341
+ history: createMemoryHistory('/'),
342
+ routes: [{
343
+ component: wrap('main', 'app'),
344
+ children: [{
345
+ component: wrap('section', 'section'),
346
+ children: [{ path: '/', component: () => createText('dashboard') }]
347
+ }]
348
+ }]
349
+ })
350
+ const container = document.createElement('div')
351
+ const app = createVobs({ render: () => RouterView({ router }) })
352
+
353
+ app.mount(container)
354
+ expect(container.querySelector('[data-layout="app"] [data-layout="section"]')?.textContent)
355
+ .toBe('dashboard')
356
+ app.destroy()
357
+ router.destroy()
358
+ })
359
+
360
+ it('懒加载路由先显示 loading,完成后显示组件,失败可重试', async () => {
361
+ let loadCount = 0
362
+ const loader = vi.fn(async () => {
363
+ loadCount++
364
+ if (loadCount === 1) throw new Error('chunk failed')
365
+ return { default: () => createText('lazy') }
366
+ })
367
+ const router = createRouter({
368
+ history: createMemoryHistory('/lazy'),
369
+ routes: [{ path: '/lazy', component: lazy(loader) }]
370
+ })
371
+ const container = document.createElement('div')
372
+ const app = createVobs({
373
+ render: () => RouterView({
374
+ loading: () => createText('loading'),
375
+ error: (error, retry) => {
376
+ const node = createElement('button')
377
+ node.textContent = error.message
378
+ ;(node as HTMLButtonElement).onclick = retry
379
+ return node
380
+ }
381
+ }),
382
+ plugins: [routerPlugin({ router })]
383
+ })
384
+
385
+ app.mount(container)
386
+ expect(container.textContent).toBe('loading')
387
+ await vi.waitFor(() => {
388
+ app.update()
389
+ expect(container.textContent).toBe('chunk failed')
390
+ })
391
+ const button = container.querySelector('button') as HTMLButtonElement | null
392
+ expect(button).toBeTruthy()
393
+ button?.click()
394
+ await vi.waitFor(() => {
395
+ app.update()
396
+ expect(container.textContent).toBe('lazy')
397
+ })
398
+ expect(loadCount).toBe(2)
399
+ app.destroy()
400
+ router.destroy()
401
+ })
402
+
403
+ it('routerPlugin 在没有显式 router 时创建并清理自己的 router', () => {
404
+ let injected = false
405
+ const router = createRouter({
406
+ history: createMemoryHistory('/'),
407
+ routes: [{ path: '/', component: () => createText('home') }]
408
+ })
409
+ const plugin = routerPlugin({ routes: [{ path: '/', component: () => createText('home') }] })
410
+ const consumer = {
411
+ name: 'consumer',
412
+ requires: [plugin],
413
+ install(context: { inject: (key: typeof ROUTER_KEY) => unknown }) {
414
+ injected = context.inject(ROUTER_KEY) !== undefined
415
+ }
416
+ }
417
+ const app = createVobs({ render: () => createText('app'), plugins: [consumer] })
418
+ expect(injected).toBe(true)
419
+ app.destroy()
420
+ router.destroy()
421
+ })
422
+
423
+ it('路由组件渲染错误进入统一错误边界,并在切换路由后恢复', async () => {
424
+ const router = createRouter({
425
+ history: createMemoryHistory('/broken'),
426
+ routes: [
427
+ { path: '/broken', component: () => { throw new Error('page failed') } },
428
+ { path: '/ok', component: () => createText('ok') }
429
+ ]
430
+ })
431
+ const container = document.createElement('div')
432
+ const app = createVobs({
433
+ render: () => RouterView({
434
+ router,
435
+ error: error => createText(`route error: ${error.message}`)
436
+ })
437
+ })
438
+
439
+ app.mount(container)
440
+ app.update()
441
+ expect(container.textContent).toBe('route error: page failed')
442
+ await router.push('/ok')
443
+ app.update()
444
+ expect(container.textContent).toBe('ok')
445
+ app.destroy()
446
+ router.destroy()
447
+ })
448
+
449
+ it('Router 暴露真实的路由树、导航状态、历史和性能调试数据', async () => {
450
+ const router = createRouter({
451
+ history: createMemoryHistory('/'),
452
+ routes: [{
453
+ path: '/',
454
+ component: () => createText('home'),
455
+ source: 'src/routes.ts:4',
456
+ meta: { requiresAuth: true },
457
+ children: [
458
+ { path: 'users/:id', name: 'user', component: () => createText('user') }
459
+ ]
460
+ }]
461
+ })
462
+ const events: unknown[] = []
463
+ const stop = router.devtools.subscribe('navigation:end', payload => events.push(payload))
464
+
465
+ expect(router.devtools.getRouteTree()[0]).toMatchObject({ path: '/', source: 'src/routes.ts:4', children: [{ path: '/users/:id', name: 'user' }] })
466
+ await router.push('/users/42?tab=profile')
467
+
468
+ expect(router.devtools.getCurrentRoute()).toMatchObject({ path: '/users/42', params: { id: '42' }, query: { tab: 'profile' } })
469
+ expect(router.devtools.getNavigationState().status).toBe('idle')
470
+ expect(router.devtools.getNavigationHistory()).toHaveLength(1)
471
+ expect(router.devtools.getNavigationHistory()[0]).toMatchObject({ from: '/', to: '/users/42?tab=profile', status: 'success', source: 'push' })
472
+ expect(router.devtools.getPerformanceMetrics().navigationCount).toBe(1)
473
+ expect(events).toHaveLength(1)
474
+
475
+ stop()
476
+ router.destroy()
477
+ })
478
+
479
+ it('Router 调试协议记录数据请求并支持 revalidate', async () => {
480
+ const router = createRouter({
481
+ history: createMemoryHistory('/'),
482
+ routes: [{ path: '/', component: () => createText('home') }]
483
+ })
484
+ let loads = 0
485
+ await router.devtools.trackDataRequest('loader', 'route:/', async () => {
486
+ loads++
487
+ return { loads }
488
+ })
489
+ expect(router.devtools.getDataRequests()[0]).toMatchObject({ kind: 'loader', key: 'route:/', status: 'success', result: { loads: 1 } })
490
+ await router.devtools.revalidate()
491
+ expect(loads).toBe(2)
492
+ expect(router.devtools.getDataRequests().filter(request => request.status === 'success')).toHaveLength(2)
493
+ router.destroy()
494
+ })
495
+
496
+ it('路由 loader 在导航前执行并纳入数据请求轨迹', async () => {
497
+ let loaded = 0
498
+ const router = createRouter({
499
+ history: createMemoryHistory('/'),
500
+ routes: [
501
+ { path: '/', component: () => createText('home') },
502
+ { path: '/reports', component: () => createText('reports'), loader: async () => ({ count: ++loaded }) }
503
+ ]
504
+ })
505
+ await router.push('/reports')
506
+ const request = router.devtools.getDataRequests()[0]
507
+ expect(request).toMatchObject({ kind: 'loader', key: '/reports#/reports', status: 'success', result: { count: 1 } })
508
+ expect(router.currentRoute.value.path).toBe('/reports')
509
+ router.destroy()
510
+ })
511
+
512
+ it('loader 失败会记录请求错误并结束导航 error 状态', async () => {
513
+ const router = createRouter({
514
+ history: createMemoryHistory('/'),
515
+ routes: [
516
+ { path: '/', component: () => createText('home') },
517
+ { path: '/broken', component: () => createText('broken'), loader: async () => { throw new Error('loader failed') } }
518
+ ]
519
+ })
520
+
521
+ await expect(router.push('/broken')).rejects.toThrow('loader failed')
522
+ expect(router.devtools.getNavigationState()).toMatchObject({ status: 'error', to: '/broken', error: 'loader failed' })
523
+ expect(router.devtools.getNavigationHistory()).toMatchObject([{ status: 'error', to: '/broken', error: 'loader failed' }])
524
+ expect(router.devtools.getDataRequests()).toMatchObject([{ kind: 'loader', status: 'error', error: 'loader failed' }])
525
+ expect(router.currentRoute.value.path).toBe('/')
526
+ router.destroy()
527
+ })
528
+
529
+ it('DevTools 错误协议保留阶段、路由和 stack', () => {
530
+ const router = createRouter({
531
+ history: createMemoryHistory('/'),
532
+ routes: [{ path: '/', component: () => createText('home') }]
533
+ })
534
+ router.devtools.reportError('render', new Error('render failed'), '/')
535
+ expect(router.devtools.getErrors()[0]).toMatchObject({ phase: 'render', route: '/', message: 'render failed' })
536
+ router.destroy()
537
+ })
538
+
539
+ it('action 和 fetcher 统一纳入数据请求轨迹', async () => {
540
+ const router = createRouter({
541
+ history: createMemoryHistory('/'),
542
+ routes: [{ path: '/', component: () => createText('home') }]
543
+ })
544
+
545
+ let actionRuns = 0
546
+ await expect(router.devtools.runAction('form:save', async () => ({ ok: ++actionRuns }))).resolves.toEqual({ ok: 1 })
547
+ await expect(router.devtools.runFetcher('users:refresh', async () => ['a'])).resolves.toEqual(['a'])
548
+ await router.devtools.revalidate()
549
+ expect(actionRuns).toBe(1)
550
+ expect(router.devtools.getDataRequests()).toMatchObject([
551
+ { kind: 'action', key: 'form:save', route: '/', status: 'success', result: { ok: 1 } },
552
+ { kind: 'fetcher', key: 'users:refresh', route: '/', status: 'success', result: ['a'] }
553
+ ])
554
+ router.destroy()
555
+ })
556
+
557
+ it('数据请求事件携带导航关联和 loading 到 success 的完整过程', async () => {
558
+ const router = createRouter({
559
+ history: createMemoryHistory('/'),
560
+ routes: [{ path: '/', component: () => createText('home') }]
561
+ })
562
+ const events: unknown[] = []
563
+ const stop = router.devtools.subscribe('data-request', event => events.push(event))
564
+ await router.devtools.trackDataRequest('loader', 'route:/', async () => ({ ok: true }), {
565
+ trigger: 'manual',
566
+ route: '/'
567
+ })
568
+ expect(events).toHaveLength(2)
569
+ expect(events[0]).toMatchObject({ status: 'loading', trigger: 'manual', route: '/' })
570
+ expect(events[1]).toMatchObject({ status: 'success', duration: expect.any(Number) })
571
+ stop()
572
+ router.destroy()
573
+ })
574
+ })