@vobs/router 0.1.0 → 1.0.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.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 vobsjs
3
+ Copyright (c) 2026 vobs contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # @vobs/router
2
+
3
+ Signals-first client router for Vobs with guards, route loaders, and cancellation of superseded navigations.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @vobs/router
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```ts
14
+ import { createVobs, createText } from '@vobs/vobs'
15
+ import { createRouter, createMemoryHistory, RouterView, routerPlugin } from '@vobs/router'
16
+
17
+ const router = createRouter({
18
+ history: createMemoryHistory('/'),
19
+ routes: [
20
+ { path: '/', name: 'home', component: () => createText('home') },
21
+ { path: '/users/:id', name: 'user', component: () => createText('user') }
22
+ ]
23
+ })
24
+
25
+ // Guards return undefined (allow), false (cancel), or a redirect target.
26
+ router.beforeEach(to => (to.path.startsWith('/admin') ? '/login' : undefined))
27
+
28
+ const app = createVobs({
29
+ render: () => RouterView({ loading: () => createText('loading'), notFound: () => createText('not-found') }),
30
+ plugins: [routerPlugin({ router })]
31
+ })
32
+
33
+ await router.push({ name: 'user', params: { id: '7' }, query: { tab: 'activity' } })
34
+ router.currentRoute.value.fullPath // '/users/7?tab=activity'
35
+ ```
36
+
37
+ ## API
38
+
39
+ | Signature | Description |
40
+ | --- | --- |
41
+ | `createRouter(options: RouterOptions): Router` | Creates a router; defaults to browser history in the DOM and memory history otherwise. |
42
+ | `router.currentRoute: Signal<RouteLocation>` | Reactive current route with `path`, `params`, `query`, `hash`, `meta`, and `matched` records. |
43
+ | `router.push(to: RouteTarget)` | Navigates and resolves to the new `RouteLocation`, or `false` when a guard cancels. |
44
+ | `router.replace(to: RouteTarget)` | Like `push` but replaces the history entry. |
45
+ | `router.resolve(to: RouteTarget): RouteLocation` | Resolves a target to a location without navigating. |
46
+ | `router.back(): void` | Goes back in history. |
47
+ | `router.beforeEach(guard: NavigationGuard): () => void` | Registers a global guard and returns its unregister function. |
48
+ | `router.getViewState(route: RouteLocation): RouterViewState` | Resolves `ready`, `loading`, `error`, or `not-found` for the matched components. |
49
+ | `router.devtools: RouterDevToolsAPI` | Navigation traces, data-request tracking, errors, metrics, and revalidation. |
50
+ | `router.destroy(): void` | Stops history listening and disposes internal signals. |
51
+ | `routerPlugin(options?: RouterPluginOptions): VobsPlugin` | Provides the router through `ROUTER_KEY`. |
52
+ | `RouterView(props?: RouterViewProps): VobsNode` | Renders the matched component with nested layouts plus `loading`, `notFound`, and `error` slots. |
53
+ | `useRouter(): Router` | Injects the router inside components. |
54
+ | `useRoute(): Signal<RouteLocation>` | Injects the current-route signal. |
55
+ | `lazy(loader: RouteComponentLoader): LazyRouteComponent` | Wraps a dynamic import as a lazy route component. |
56
+ | `createMemoryHistory(initial?): RouterHistory` | In-memory history for tests and SSR. |
57
+ | `createBrowserHistory(base?): RouterHistory` | History API adapter with `popstate` support. |
58
+
59
+ Guards and route `loader`s run before a navigation commits. A newer navigation cancels the pending one: its promise rejects with `NavigationCancelledError`, and a late loader result cannot overwrite the current route or history. Guard redirects are capped at 10 hops (`NavigationRedirectError`).
60
+
61
+ ## Types
62
+
63
+ `RouteLocation`, `RouteRecord`, `RouteComponent`, `RouteComponentProps`, `RouteComponentModule`, `RouteComponentLoader`, `RouteComponentDefinition`, `LazyRouteComponent`, `RouteLoader`, `RouteLoaderContext`, `NavigationGuard`, `NavigationGuardResult`, `RouteTarget`, `RouteLocationRaw`, `RouteQueryInput`, `RouteParams`, `RouteQuery`, `RouteQueryValue`, `RouteMeta`, `RouterOptions`, `RouterHistory`, `RouterViewState`, `Router`, `RouterViewProps`, `RouterPluginOptions`, `NavigationState`, `NavigationTrace`, `RouteErrorTrace`, `NavigationCancelledError`, `NavigationRedirectError`, `RouterPerformanceMetrics`, `RouterDevToolsAPI`, `RouterDevToolsEvent`, `RouterDataRequestKind`, `RouterDataRequestTrace`, `RouterDataRequestOptions`, `RouteDebugNode`, `RouterDebugEvent`, `RouterDebugEventType`
package/package.json CHANGED
@@ -1,44 +1,21 @@
1
1
  {
2
- "name": "@vobs/router",
3
- "version": "0.1.0",
4
- "description": "Client and server route resolution for vobs pages.",
5
- "type": "module",
6
- "publishConfig": {
7
- "access": "public"
8
- },
9
2
  "license": "MIT",
10
- "author": "vobsjs",
11
- "repository": {
12
- "type": "git",
13
- "url": "git+https://github.com/vobsjs/vobs.git",
14
- "directory": "packages/features/router"
15
- },
16
- "bugs": {
17
- "url": "https://github.com/vobsjs/vobs/issues"
18
- },
19
- "homepage": "https://github.com/vobsjs/vobs#readme",
20
- "dependencies": {
21
- "@vobs/reactivity": "0.1.0",
22
- "@vobs/runtime-core": "0.1.0"
23
- },
24
3
  "files": [
25
- "dist"
4
+ "src",
5
+ "README.md",
6
+ "LICENSE"
26
7
  ],
8
+ "name": "@vobs/router",
9
+ "version": "1.0.0",
10
+ "type": "module",
11
+ "main": "src/index.ts",
12
+ "types": "src/index.ts",
27
13
  "exports": {
28
- ".": {
29
- "types": "./dist/index.d.ts",
30
- "import": "./dist/index.js"
31
- },
32
- "./package.json": "./package.json"
14
+ ".": "./src/index.ts"
33
15
  },
34
- "types": "./dist/index.d.ts",
35
- "module": "./dist/index.js",
36
- "main": "./dist/index.js",
37
- "sideEffects": false,
38
- "devDependencies": {
39
- "@vobs/resource": "0.1.0"
40
- },
41
- "engines": {
42
- "node": ">=20.19.0"
16
+ "dependencies": {
17
+ "@vobs/reactivity": "1.0.0",
18
+ "@vobs/runtime": "1.0.0",
19
+ "@vobs/vobs": "1.0.0"
43
20
  }
44
21
  }
package/src/debug.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { RuntimeDebugContext } from '@vobs/runtime'
2
+
3
+ export type RouterDebugEventType =
4
+ | 'navigation:start'
5
+ | 'navigation:end'
6
+ | 'route:update'
7
+ | 'data-request'
8
+ | 'error'
9
+
10
+ export interface RouterDebugEvent {
11
+ readonly routerId: string
12
+ readonly type: RouterDebugEventType
13
+ readonly payload: unknown
14
+ readonly context?: RuntimeDebugContext
15
+ }
16
+
17
+ type RouterDebugListener = (event: RouterDebugEvent) => void
18
+
19
+ const listeners = new Set<RouterDebugListener>()
20
+ let nextRouterId = 1
21
+
22
+ export function createRouterDebugId(): string {
23
+ return `router-${nextRouterId++}`
24
+ }
25
+
26
+ export function subscribeRouterDebug(listener: RouterDebugListener): () => void {
27
+ listeners.add(listener)
28
+ return () => listeners.delete(listener)
29
+ }
30
+
31
+ export function emitRouterDebug(
32
+ routerId: string,
33
+ type: RouterDebugEventType,
34
+ payload: unknown,
35
+ context?: RuntimeDebugContext
36
+ ): void {
37
+ const event: RouterDebugEvent = { routerId, type, payload, context }
38
+ for (const listener of [...listeners]) {
39
+ try { listener(event) } catch { /* diagnostics must not affect navigation */ }
40
+ }
41
+ }
@@ -0,0 +1,518 @@
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('支持无路径父路由、嵌套 children、父子 meta 合并和命名导航', () => {
44
+ const Layout = () => createText('layout')
45
+ const router = createRouter({
46
+ history: createMemoryHistory('/users/42'),
47
+ routes: [{
48
+ component: Layout,
49
+ meta: { requiresAuth: true, section: 'app' },
50
+ children: [{
51
+ path: '/users/:id',
52
+ name: 'user',
53
+ meta: { section: 'users' },
54
+ component: () => createText('user')
55
+ }]
56
+ }]
57
+ })
58
+
59
+ expect(router.currentRoute.value.record?.name).toBe('user')
60
+ expect(router.currentRoute.value.matched).toHaveLength(2)
61
+ expect(router.currentRoute.value.params).toEqual({ id: '42' })
62
+ expect(router.currentRoute.value.meta).toEqual({ requiresAuth: true, section: 'users' })
63
+ expect(router.resolve({ name: 'user', params: { id: 7 } }).path).toBe('/users/7')
64
+ router.destroy()
65
+ })
66
+
67
+ it('父级路径下省略 path 的子路由匹配为 index 路由', () => {
68
+ const router = createRouter({
69
+ history: createMemoryHistory('/admin'),
70
+ routes: [{
71
+ path: '/admin',
72
+ component: () => createText('layout'),
73
+ children: [{ component: () => createText('index') }]
74
+ }]
75
+ })
76
+
77
+ expect(router.currentRoute.value.path).toBe('/admin')
78
+ expect(router.currentRoute.value.record).toBeTruthy()
79
+ router.destroy()
80
+ })
81
+
82
+ it('push、replace 和 back 更新 currentRoute,并忽略相同目标', async () => {
83
+ const history = createMemoryHistory('/')
84
+ const router = createRouter({
85
+ history,
86
+ routes: [
87
+ { path: '/', component: () => createText('home') },
88
+ { path: '/users', component: () => createText('users') }
89
+ ]
90
+ })
91
+
92
+ const first = await router.push('/users')
93
+ expect(first).toMatchObject({ path: '/users' })
94
+ expect(history.location).toBe('/users')
95
+ expect(await router.push('/users')).toBe(first)
96
+
97
+ await router.replace({ path: '/', query: { page: 2 } })
98
+ expect(router.currentRoute.value.fullPath).toBe('/?page=2')
99
+ expect(history.location).toBe('/?page=2')
100
+
101
+ await router.push('/users')
102
+ router.back()
103
+ await Promise.resolve()
104
+ await Promise.resolve()
105
+ expect(router.currentRoute.value.fullPath).toBe('/?page=2')
106
+ router.destroy()
107
+ })
108
+
109
+ it('browser history 支持 base 路径并转发 popstate', () => {
110
+ const originalURL = window.location.href
111
+ window.history.replaceState(null, '', '/vobs/users?tab=all')
112
+ const history = createBrowserHistory('/vobs')
113
+ const paths: string[] = []
114
+ const stop = history.listen(path => paths.push(path))
115
+
116
+ expect(history.location).toBe('/users?tab=all')
117
+ history.push('/users/2#details')
118
+ expect(window.location.pathname).toBe('/vobs/users/2')
119
+ expect(window.location.hash).toBe('#details')
120
+ window.history.pushState(null, '', '/vobs/users/3')
121
+ window.dispatchEvent(new PopStateEvent('popstate'))
122
+ expect(paths).toEqual(['/users/3'])
123
+
124
+ stop()
125
+ window.history.replaceState(null, '', originalURL)
126
+ })
127
+
128
+ it('守卫支持异步放行、取消和重定向', async () => {
129
+ const router = createRouter({
130
+ history: createMemoryHistory('/'),
131
+ routes: [
132
+ { path: '/', component: () => createText('home') },
133
+ { path: '/login', component: () => createText('login') },
134
+ { path: '/private', component: () => createText('private') }
135
+ ]
136
+ })
137
+ router.beforeEach(async to => {
138
+ await Promise.resolve()
139
+ return to.path === '/private' ? '/login' : undefined
140
+ })
141
+
142
+ expect(await router.push('/private')).toMatchObject({ path: '/login' })
143
+ expect(router.currentRoute.value.path).toBe('/login')
144
+
145
+ const removeGuard = router.beforeEach(() => false)
146
+ expect(await router.push('/')).toBe(false)
147
+ expect(router.currentRoute.value.path).toBe('/login')
148
+ removeGuard()
149
+ router.destroy()
150
+ })
151
+
152
+ it('旧导航的 loader 迟到完成时不得覆盖新导航', async () => {
153
+ let resolveSlowLoader!: () => void
154
+ const router = createRouter({
155
+ history: createMemoryHistory('/'),
156
+ routes: [
157
+ { path: '/', component: () => createText('home') },
158
+ {
159
+ path: '/slow',
160
+ component: () => createText('slow'),
161
+ loader: () => new Promise<void>(resolve => { resolveSlowLoader = resolve })
162
+ },
163
+ { path: '/fast', component: () => createText('fast'), loader: () => Promise.resolve() }
164
+ ]
165
+ })
166
+
167
+ const slowNavigation = router.push('/slow')
168
+ expect(router.currentRoute.value.path).toBe('/')
169
+ await router.push('/fast')
170
+ expect(router.currentRoute.value.path).toBe('/fast')
171
+
172
+ resolveSlowLoader()
173
+ await expect(slowNavigation).rejects.toThrowError(NavigationCancelledError)
174
+ // 被抢占的旧导航不允许改写当前路由与 history
175
+ expect(router.currentRoute.value.path).toBe('/fast')
176
+ expect(router.history.location).toBe('/fast')
177
+ router.destroy()
178
+ })
179
+
180
+ it('守卫重定向到当前目标时结束导航,不进入无限循环', async () => {
181
+ const router = createRouter({
182
+ history: createMemoryHistory('/'),
183
+ routes: [
184
+ { path: '/', component: () => createText('home') },
185
+ { path: '/target', component: () => createText('target') }
186
+ ]
187
+ })
188
+ router.beforeEach(to => to.path === '/target' ? '/target' : undefined)
189
+
190
+ await expect(router.push('/target')).resolves.toBe(false)
191
+ expect(router.currentRoute.value.path).toBe('/')
192
+ router.destroy()
193
+ })
194
+
195
+ it('新的导航会取消仍在等待守卫的旧导航', async () => {
196
+ let release!: () => void
197
+ const router = createRouter({
198
+ history: createMemoryHistory('/'),
199
+ routes: [
200
+ { path: '/', component: () => createText('home') },
201
+ { path: '/slow', component: () => createText('slow') },
202
+ { path: '/fast', component: () => createText('fast') }
203
+ ]
204
+ })
205
+ router.beforeEach(async to => {
206
+ if (to.path === '/slow') await new Promise<void>(resolve => { release = resolve })
207
+ })
208
+
209
+ const slow = router.push('/slow')
210
+ await Promise.resolve()
211
+ await expect(router.push('/fast')).resolves.toMatchObject({ path: '/fast' })
212
+ release()
213
+ await expect(slow).rejects.toBeInstanceOf(NavigationCancelledError)
214
+ router.destroy()
215
+ })
216
+
217
+ it('history 回退被守卫重定向时同步 history 地址', async () => {
218
+ const history = createMemoryHistory('/')
219
+ const router = createRouter({
220
+ history,
221
+ routes: [
222
+ { path: '/', component: () => createText('home') },
223
+ { path: '/login', component: () => createText('login') },
224
+ { path: '/private', component: () => createText('private') }
225
+ ]
226
+ })
227
+ await router.push('/login')
228
+ await router.push('/private')
229
+ router.beforeEach(to => to.path === '/login' ? '/private' : undefined)
230
+
231
+ router.back()
232
+ await vi.waitFor(() => {
233
+ expect(router.currentRoute.value.path).toBe('/private')
234
+ expect(history.location).toBe('/private')
235
+ })
236
+ router.destroy()
237
+ })
238
+
239
+ it('RouterView 响应路由切换,并把 router 注入路由组件', async () => {
240
+ const router = createRouter({
241
+ history: createMemoryHistory('/'),
242
+ routes: [
243
+ { path: '/', component: () => createText('home') },
244
+ {
245
+ path: '/users/:id',
246
+ component: () => {
247
+ const route = useRoute()
248
+ const node = createElement('p')
249
+ node.textContent = `user:${route.value.params.id}`
250
+ expect(useRouter()).toBe(router)
251
+ return node
252
+ }
253
+ }
254
+ ]
255
+ })
256
+ const container = document.createElement('div')
257
+ const app = createVobs({
258
+ render: () => RouterView({
259
+ loading: () => createText('loading'),
260
+ notFound: () => createText('not-found')
261
+ }),
262
+ plugins: [routerPlugin({ router })]
263
+ })
264
+
265
+ app.mount(container)
266
+ expect(container.textContent).toBe('home')
267
+ await router.push('/users/7')
268
+ app.update()
269
+ expect(container.textContent).toBe('user:7')
270
+ await router.push('/missing')
271
+ app.update()
272
+ expect(container.textContent).toBe('not-found')
273
+ app.destroy()
274
+ router.destroy()
275
+ })
276
+
277
+ it('RouterView 按从外到内的顺序包裹嵌套路由布局', () => {
278
+ const wrap = (tag: string, label: string) => (props: { children?: VobsNode }) => {
279
+ const node = createElement(tag)
280
+ node.setAttribute('data-layout', label)
281
+ if (props.children) insertBefore(node, props.children, null)
282
+ return node
283
+ }
284
+ const router = createRouter({
285
+ history: createMemoryHistory('/'),
286
+ routes: [{
287
+ component: wrap('main', 'app'),
288
+ children: [{
289
+ component: wrap('section', 'section'),
290
+ children: [{ path: '/', component: () => createText('dashboard') }]
291
+ }]
292
+ }]
293
+ })
294
+ const container = document.createElement('div')
295
+ const app = createVobs({ render: () => RouterView({ router }) })
296
+
297
+ app.mount(container)
298
+ expect(container.querySelector('[data-layout="app"] [data-layout="section"]')?.textContent)
299
+ .toBe('dashboard')
300
+ app.destroy()
301
+ router.destroy()
302
+ })
303
+
304
+ it('懒加载路由先显示 loading,完成后显示组件,失败可重试', async () => {
305
+ let loadCount = 0
306
+ const loader = vi.fn(async () => {
307
+ loadCount++
308
+ if (loadCount === 1) throw new Error('chunk failed')
309
+ return { default: () => createText('lazy') }
310
+ })
311
+ const router = createRouter({
312
+ history: createMemoryHistory('/lazy'),
313
+ routes: [{ path: '/lazy', component: lazy(loader) }]
314
+ })
315
+ const container = document.createElement('div')
316
+ const app = createVobs({
317
+ render: () => RouterView({
318
+ loading: () => createText('loading'),
319
+ error: (error, retry) => {
320
+ const node = createElement('button')
321
+ node.textContent = error.message
322
+ ;(node as HTMLButtonElement).onclick = retry
323
+ return node
324
+ }
325
+ }),
326
+ plugins: [routerPlugin({ router })]
327
+ })
328
+
329
+ app.mount(container)
330
+ expect(container.textContent).toBe('loading')
331
+ await vi.waitFor(() => {
332
+ app.update()
333
+ expect(container.textContent).toBe('chunk failed')
334
+ })
335
+ const button = container.querySelector('button') as HTMLButtonElement | null
336
+ expect(button).toBeTruthy()
337
+ button?.click()
338
+ await vi.waitFor(() => {
339
+ app.update()
340
+ expect(container.textContent).toBe('lazy')
341
+ })
342
+ expect(loadCount).toBe(2)
343
+ app.destroy()
344
+ router.destroy()
345
+ })
346
+
347
+ it('routerPlugin 在没有显式 router 时创建并清理自己的 router', () => {
348
+ let injected = false
349
+ const router = createRouter({
350
+ history: createMemoryHistory('/'),
351
+ routes: [{ path: '/', component: () => createText('home') }]
352
+ })
353
+ const plugin = routerPlugin({ routes: [{ path: '/', component: () => createText('home') }] })
354
+ const consumer = {
355
+ name: 'consumer',
356
+ requires: [plugin],
357
+ install(context: { inject: (key: typeof ROUTER_KEY) => unknown }) {
358
+ injected = context.inject(ROUTER_KEY) !== undefined
359
+ }
360
+ }
361
+ const app = createVobs({ render: () => createText('app'), plugins: [consumer] })
362
+ expect(injected).toBe(true)
363
+ app.destroy()
364
+ router.destroy()
365
+ })
366
+
367
+ it('路由组件渲染错误进入统一错误边界,并在切换路由后恢复', async () => {
368
+ const router = createRouter({
369
+ history: createMemoryHistory('/broken'),
370
+ routes: [
371
+ { path: '/broken', component: () => { throw new Error('page failed') } },
372
+ { path: '/ok', component: () => createText('ok') }
373
+ ]
374
+ })
375
+ const container = document.createElement('div')
376
+ const app = createVobs({
377
+ render: () => RouterView({
378
+ router,
379
+ error: error => createText(`route error: ${error.message}`)
380
+ })
381
+ })
382
+
383
+ app.mount(container)
384
+ app.update()
385
+ expect(container.textContent).toBe('route error: page failed')
386
+ await router.push('/ok')
387
+ app.update()
388
+ expect(container.textContent).toBe('ok')
389
+ app.destroy()
390
+ router.destroy()
391
+ })
392
+
393
+ it('Router 暴露真实的路由树、导航状态、历史和性能调试数据', async () => {
394
+ const router = createRouter({
395
+ history: createMemoryHistory('/'),
396
+ routes: [{
397
+ path: '/',
398
+ component: () => createText('home'),
399
+ source: 'src/routes.ts:4',
400
+ meta: { requiresAuth: true },
401
+ children: [
402
+ { path: 'users/:id', name: 'user', component: () => createText('user') }
403
+ ]
404
+ }]
405
+ })
406
+ const events: unknown[] = []
407
+ const stop = router.devtools.subscribe('navigation:end', payload => events.push(payload))
408
+
409
+ expect(router.devtools.getRouteTree()[0]).toMatchObject({ path: '/', source: 'src/routes.ts:4', children: [{ path: '/users/:id', name: 'user' }] })
410
+ await router.push('/users/42?tab=profile')
411
+
412
+ expect(router.devtools.getCurrentRoute()).toMatchObject({ path: '/users/42', params: { id: '42' }, query: { tab: 'profile' } })
413
+ expect(router.devtools.getNavigationState().status).toBe('idle')
414
+ expect(router.devtools.getNavigationHistory()).toHaveLength(1)
415
+ expect(router.devtools.getNavigationHistory()[0]).toMatchObject({ from: '/', to: '/users/42?tab=profile', status: 'success', source: 'push' })
416
+ expect(router.devtools.getPerformanceMetrics().navigationCount).toBe(1)
417
+ expect(events).toHaveLength(1)
418
+
419
+ stop()
420
+ router.destroy()
421
+ })
422
+
423
+ it('Router 调试协议记录数据请求并支持 revalidate', async () => {
424
+ const router = createRouter({
425
+ history: createMemoryHistory('/'),
426
+ routes: [{ path: '/', component: () => createText('home') }]
427
+ })
428
+ let loads = 0
429
+ await router.devtools.trackDataRequest('loader', 'route:/', async () => {
430
+ loads++
431
+ return { loads }
432
+ })
433
+ expect(router.devtools.getDataRequests()[0]).toMatchObject({ kind: 'loader', key: 'route:/', status: 'success', result: { loads: 1 } })
434
+ await router.devtools.revalidate()
435
+ expect(loads).toBe(2)
436
+ expect(router.devtools.getDataRequests().filter(request => request.status === 'success')).toHaveLength(2)
437
+ router.destroy()
438
+ })
439
+
440
+ it('路由 loader 在导航前执行并纳入数据请求轨迹', async () => {
441
+ let loaded = 0
442
+ const router = createRouter({
443
+ history: createMemoryHistory('/'),
444
+ routes: [
445
+ { path: '/', component: () => createText('home') },
446
+ { path: '/reports', component: () => createText('reports'), loader: async () => ({ count: ++loaded }) }
447
+ ]
448
+ })
449
+ await router.push('/reports')
450
+ const request = router.devtools.getDataRequests()[0]
451
+ expect(request).toMatchObject({ kind: 'loader', key: '/reports#/reports', status: 'success', result: { count: 1 } })
452
+ expect(router.currentRoute.value.path).toBe('/reports')
453
+ router.destroy()
454
+ })
455
+
456
+ it('loader 失败会记录请求错误并结束导航 error 状态', async () => {
457
+ const router = createRouter({
458
+ history: createMemoryHistory('/'),
459
+ routes: [
460
+ { path: '/', component: () => createText('home') },
461
+ { path: '/broken', component: () => createText('broken'), loader: async () => { throw new Error('loader failed') } }
462
+ ]
463
+ })
464
+
465
+ await expect(router.push('/broken')).rejects.toThrow('loader failed')
466
+ expect(router.devtools.getNavigationState()).toMatchObject({ status: 'error', to: '/broken', error: 'loader failed' })
467
+ expect(router.devtools.getNavigationHistory()).toMatchObject([{ status: 'error', to: '/broken', error: 'loader failed' }])
468
+ expect(router.devtools.getDataRequests()).toMatchObject([{ kind: 'loader', status: 'error', error: 'loader failed' }])
469
+ expect(router.currentRoute.value.path).toBe('/')
470
+ router.destroy()
471
+ })
472
+
473
+ it('DevTools 错误协议保留阶段、路由和 stack', () => {
474
+ const router = createRouter({
475
+ history: createMemoryHistory('/'),
476
+ routes: [{ path: '/', component: () => createText('home') }]
477
+ })
478
+ router.devtools.reportError('render', new Error('render failed'), '/')
479
+ expect(router.devtools.getErrors()[0]).toMatchObject({ phase: 'render', route: '/', message: 'render failed' })
480
+ router.destroy()
481
+ })
482
+
483
+ it('action 和 fetcher 统一纳入数据请求轨迹', async () => {
484
+ const router = createRouter({
485
+ history: createMemoryHistory('/'),
486
+ routes: [{ path: '/', component: () => createText('home') }]
487
+ })
488
+
489
+ let actionRuns = 0
490
+ await expect(router.devtools.runAction('form:save', async () => ({ ok: ++actionRuns }))).resolves.toEqual({ ok: 1 })
491
+ await expect(router.devtools.runFetcher('users:refresh', async () => ['a'])).resolves.toEqual(['a'])
492
+ await router.devtools.revalidate()
493
+ expect(actionRuns).toBe(1)
494
+ expect(router.devtools.getDataRequests()).toMatchObject([
495
+ { kind: 'action', key: 'form:save', route: '/', status: 'success', result: { ok: 1 } },
496
+ { kind: 'fetcher', key: 'users:refresh', route: '/', status: 'success', result: ['a'] }
497
+ ])
498
+ router.destroy()
499
+ })
500
+
501
+ it('数据请求事件携带导航关联和 loading 到 success 的完整过程', async () => {
502
+ const router = createRouter({
503
+ history: createMemoryHistory('/'),
504
+ routes: [{ path: '/', component: () => createText('home') }]
505
+ })
506
+ const events: unknown[] = []
507
+ const stop = router.devtools.subscribe('data-request', event => events.push(event))
508
+ await router.devtools.trackDataRequest('loader', 'route:/', async () => ({ ok: true }), {
509
+ trigger: 'manual',
510
+ route: '/'
511
+ })
512
+ expect(events).toHaveLength(2)
513
+ expect(events[0]).toMatchObject({ status: 'loading', trigger: 'manual', route: '/' })
514
+ expect(events[1]).toMatchObject({ status: 'success', duration: expect.any(Number) })
515
+ stop()
516
+ router.destroy()
517
+ })
518
+ })