@buzola/router 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/create-page.test.d.ts +2 -0
- package/dist/__tests__/create-page.test.d.ts.map +1 -0
- package/dist/__tests__/create-page.test.js +457 -0
- package/dist/__tests__/create-page.test.js.map +1 -0
- package/dist/__tests__/define-routes.test.d.ts +2 -0
- package/dist/__tests__/define-routes.test.d.ts.map +1 -0
- package/dist/__tests__/define-routes.test.js +42 -0
- package/dist/__tests__/define-routes.test.js.map +1 -0
- package/dist/__tests__/matcher.test.d.ts +2 -0
- package/dist/__tests__/matcher.test.d.ts.map +1 -0
- package/dist/__tests__/matcher.test.js +108 -0
- package/dist/__tests__/matcher.test.js.map +1 -0
- package/dist/__tests__/router.test.d.ts +2 -0
- package/dist/__tests__/router.test.d.ts.map +1 -0
- package/dist/__tests__/router.test.js +327 -0
- package/dist/__tests__/router.test.js.map +1 -0
- package/dist/__tests__/type-safety.test.d.ts +15 -0
- package/dist/__tests__/type-safety.test.d.ts.map +1 -0
- package/dist/__tests__/type-safety.test.js +163 -0
- package/dist/__tests__/type-safety.test.js.map +1 -0
- package/dist/__tests__/types.test.d.ts +2 -0
- package/dist/__tests__/types.test.d.ts.map +1 -0
- package/dist/__tests__/types.test.js +22 -0
- package/dist/__tests__/types.test.js.map +1 -0
- package/dist/define/create-page.d.ts +87 -0
- package/dist/define/create-page.d.ts.map +1 -0
- package/dist/define/create-page.js +305 -0
- package/dist/define/create-page.js.map +1 -0
- package/dist/define/define-routes.d.ts +34 -0
- package/dist/define/define-routes.d.ts.map +1 -0
- package/dist/define/define-routes.js +53 -0
- package/dist/define/define-routes.js.map +1 -0
- package/dist/engine/matcher.d.ts +8 -0
- package/dist/engine/matcher.d.ts.map +1 -0
- package/dist/engine/matcher.js +79 -0
- package/dist/engine/matcher.js.map +1 -0
- package/dist/engine/navigation-adapter.d.ts +13 -0
- package/dist/engine/navigation-adapter.d.ts.map +1 -0
- package/dist/engine/navigation-adapter.js +156 -0
- package/dist/engine/navigation-adapter.js.map +1 -0
- package/dist/engine/route-tree.d.ts +21 -0
- package/dist/engine/route-tree.d.ts.map +1 -0
- package/dist/engine/route-tree.js +95 -0
- package/dist/engine/route-tree.js.map +1 -0
- package/dist/engine/router.d.ts +162 -0
- package/dist/engine/router.d.ts.map +1 -0
- package/dist/engine/router.js +400 -0
- package/dist/engine/router.js.map +1 -0
- package/dist/engine/schema.d.ts +46 -0
- package/dist/engine/schema.d.ts.map +1 -0
- package/dist/engine/schema.js +110 -0
- package/dist/engine/schema.js.map +1 -0
- package/dist/engine/types.d.ts +183 -0
- package/dist/engine/types.d.ts.map +1 -0
- package/dist/engine/types.js +2 -0
- package/dist/engine/types.js.map +1 -0
- package/dist/engine/utils.d.ts +7 -0
- package/dist/engine/utils.d.ts.map +1 -0
- package/dist/engine/utils.js +17 -0
- package/dist/engine/utils.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/react/context.d.ts +21 -0
- package/dist/react/context.d.ts.map +1 -0
- package/dist/react/context.js +7 -0
- package/dist/react/context.js.map +1 -0
- package/dist/react/error-boundary.d.ts +24 -0
- package/dist/react/error-boundary.d.ts.map +1 -0
- package/dist/react/error-boundary.js +37 -0
- package/dist/react/error-boundary.js.map +1 -0
- package/dist/react/hooks.d.ts +85 -0
- package/dist/react/hooks.d.ts.map +1 -0
- package/dist/react/hooks.js +161 -0
- package/dist/react/hooks.js.map +1 -0
- package/dist/react/index.d.ts +12 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +7 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/link.d.ts +42 -0
- package/dist/react/link.d.ts.map +1 -0
- package/dist/react/link.js +38 -0
- package/dist/react/link.js.map +1 -0
- package/dist/react/outlet.d.ts +13 -0
- package/dist/react/outlet.d.ts.map +1 -0
- package/dist/react/outlet.js +46 -0
- package/dist/react/outlet.js.map +1 -0
- package/dist/react/provider.d.ts +22 -0
- package/dist/react/provider.d.ts.map +1 -0
- package/dist/react/provider.js +37 -0
- package/dist/react/provider.js.map +1 -0
- package/package.json +29 -0
- package/src/__tests__/create-page.test.ts +588 -0
- package/src/__tests__/matcher.test.ts +127 -0
- package/src/__tests__/router.test.ts +424 -0
- package/src/__tests__/type-safety.test.tsx +207 -0
- package/src/define/create-page.tsx +472 -0
- package/src/engine/matcher.ts +88 -0
- package/src/engine/navigation-adapter.ts +191 -0
- package/src/engine/route-tree.ts +129 -0
- package/src/engine/router.ts +471 -0
- package/src/engine/schema.ts +130 -0
- package/src/engine/types.ts +203 -0
- package/src/engine/utils.ts +16 -0
- package/src/env.d.ts +120 -0
- package/src/index.ts +61 -0
- package/src/react/context.ts +24 -0
- package/src/react/error-boundary.tsx +56 -0
- package/src/react/hooks.ts +211 -0
- package/src/react/index.ts +16 -0
- package/src/react/link.tsx +93 -0
- package/src/react/outlet.tsx +67 -0
- package/src/react/provider.tsx +60 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from 'bun:test'
|
|
2
|
+
import { createMemoryNavigationAdapter } from '../engine/navigation-adapter'
|
|
3
|
+
import { buildRouteTree } from '../engine/route-tree'
|
|
4
|
+
import { Router } from '../engine/router'
|
|
5
|
+
import type { GuardContext, RouteConfig } from '../engine/types'
|
|
6
|
+
|
|
7
|
+
function dummyComponent() {
|
|
8
|
+
return null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function createRouter(
|
|
12
|
+
configs: RouteConfig[],
|
|
13
|
+
initialURL = 'http://localhost/',
|
|
14
|
+
options?: { persistentParams?: () => Record<string, string> },
|
|
15
|
+
) {
|
|
16
|
+
const routes = buildRouteTree(configs)
|
|
17
|
+
const adapter = createMemoryNavigationAdapter({ initialURL })
|
|
18
|
+
const router = new Router({ routes, adapter, ...options })
|
|
19
|
+
router.start()
|
|
20
|
+
return { router, adapter }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('Router', () => {
|
|
24
|
+
it('initializes with the current URL state', () => {
|
|
25
|
+
const { router } = createRouter([
|
|
26
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
const state = router.getState()
|
|
30
|
+
expect(state.location.pathname).toBe('/')
|
|
31
|
+
expect(state.matches).toHaveLength(1)
|
|
32
|
+
expect(state.isPending).toBe(false)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('navigates to a new route', () => {
|
|
36
|
+
const { router } = createRouter([
|
|
37
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
38
|
+
{ path: '/about', component: dummyComponent },
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
router.navigate('/about')
|
|
42
|
+
const state = router.getState()
|
|
43
|
+
expect(state.location.pathname).toBe('/about')
|
|
44
|
+
expect(state.matches).toHaveLength(1)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('notifies subscribers on navigation', () => {
|
|
48
|
+
const { router } = createRouter([
|
|
49
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
50
|
+
{ path: '/about', component: dummyComponent },
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
let lastPathname = ''
|
|
54
|
+
const subscriber = mock((state: { location: URL }) => {
|
|
55
|
+
lastPathname = state.location.pathname
|
|
56
|
+
})
|
|
57
|
+
router.subscribe(subscriber)
|
|
58
|
+
|
|
59
|
+
router.navigate('/about')
|
|
60
|
+
expect(subscriber).toHaveBeenCalled()
|
|
61
|
+
expect(lastPathname).toBe('/about')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('unsubscribes correctly', () => {
|
|
65
|
+
const { router } = createRouter([
|
|
66
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
67
|
+
{ path: '/about', component: dummyComponent },
|
|
68
|
+
])
|
|
69
|
+
|
|
70
|
+
const subscriber = mock(() => {})
|
|
71
|
+
const unsub = router.subscribe(subscriber)
|
|
72
|
+
unsub()
|
|
73
|
+
|
|
74
|
+
router.navigate('/about')
|
|
75
|
+
expect(subscriber).not.toHaveBeenCalled()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('extracts params from dynamic routes', () => {
|
|
79
|
+
const { router } = createRouter([
|
|
80
|
+
{ path: '/users/:userId', component: dummyComponent },
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
router.navigate('/users/42')
|
|
84
|
+
const state = router.getState()
|
|
85
|
+
expect(state.matches[0].params).toEqual({ userId: '42' })
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('builds paths from patterns and params', () => {
|
|
89
|
+
const { router } = createRouter([])
|
|
90
|
+
const path = router.buildPath('/users/:userId/posts/:postId', {
|
|
91
|
+
userId: '42',
|
|
92
|
+
postId: '7',
|
|
93
|
+
})
|
|
94
|
+
expect(path).toBe('/users/42/posts/7')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('buildPath throws on missing params', () => {
|
|
98
|
+
const { router } = createRouter([])
|
|
99
|
+
expect(() => router.buildPath('/users/:userId')).toThrow('Missing params')
|
|
100
|
+
expect(() => router.buildPath('/users/:userId', {})).toThrow('Missing param "userId"')
|
|
101
|
+
expect(() => router.buildPath('/users/:userId/posts/:postId', { userId: '1' })).toThrow('Missing param "postId"')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('buildPath works for static paths without params', () => {
|
|
105
|
+
const { router } = createRouter([])
|
|
106
|
+
expect(router.buildPath('/about')).toBe('/about')
|
|
107
|
+
expect(router.buildPath('/')).toBe('/')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('handles route guards', async () => {
|
|
111
|
+
const guard = mock(() => false) // Block navigation
|
|
112
|
+
|
|
113
|
+
const { router } = createRouter([
|
|
114
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
115
|
+
{ path: '/protected', component: dummyComponent, beforeEnter: guard },
|
|
116
|
+
])
|
|
117
|
+
|
|
118
|
+
router.navigate('/protected')
|
|
119
|
+
// Guard blocked it, should stay at /
|
|
120
|
+
expect(router.getState().location.pathname).toBe('/')
|
|
121
|
+
expect(guard).toHaveBeenCalled()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('handles async route guards that allow navigation', async () => {
|
|
125
|
+
const guard = mock(() => Promise.resolve(true))
|
|
126
|
+
|
|
127
|
+
const { router } = createRouter([
|
|
128
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
129
|
+
{ path: '/protected', component: dummyComponent, beforeEnter: guard },
|
|
130
|
+
])
|
|
131
|
+
|
|
132
|
+
router.navigate('/protected')
|
|
133
|
+
// Async guard — need to wait for microtask
|
|
134
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
135
|
+
expect(router.getState().location.pathname).toBe('/protected')
|
|
136
|
+
expect(guard).toHaveBeenCalled()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('handles async route guards that block navigation', async () => {
|
|
140
|
+
const guard = mock(() => Promise.resolve(false))
|
|
141
|
+
|
|
142
|
+
const { router } = createRouter([
|
|
143
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
144
|
+
{ path: '/protected', component: dummyComponent, beforeEnter: guard },
|
|
145
|
+
])
|
|
146
|
+
|
|
147
|
+
router.navigate('/protected')
|
|
148
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
149
|
+
expect(router.getState().location.pathname).toBe('/')
|
|
150
|
+
expect(guard).toHaveBeenCalled()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('handles route guards that redirect', async () => {
|
|
154
|
+
const guard = mock(() => ({ redirect: '/login' }))
|
|
155
|
+
|
|
156
|
+
const { router } = createRouter([
|
|
157
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
158
|
+
{ path: '/login', component: dummyComponent },
|
|
159
|
+
{ path: '/protected', component: dummyComponent, beforeEnter: guard },
|
|
160
|
+
])
|
|
161
|
+
|
|
162
|
+
router.navigate('/protected')
|
|
163
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
164
|
+
expect(router.getState().location.pathname).toBe('/login')
|
|
165
|
+
expect(guard).toHaveBeenCalled()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('handles async route guards that redirect', async () => {
|
|
169
|
+
const guard = mock(() => Promise.resolve({ redirect: '/login' }))
|
|
170
|
+
|
|
171
|
+
const { router } = createRouter([
|
|
172
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
173
|
+
{ path: '/login', component: dummyComponent },
|
|
174
|
+
{ path: '/protected', component: dummyComponent, beforeEnter: guard },
|
|
175
|
+
])
|
|
176
|
+
|
|
177
|
+
router.navigate('/protected')
|
|
178
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
179
|
+
expect(router.getState().location.pathname).toBe('/login')
|
|
180
|
+
expect(guard).toHaveBeenCalled()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('dispose stops intercepting navigations', () => {
|
|
184
|
+
const { router } = createRouter([
|
|
185
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
186
|
+
{ path: '/about', component: dummyComponent },
|
|
187
|
+
])
|
|
188
|
+
|
|
189
|
+
const subscriber = mock(() => {})
|
|
190
|
+
router.subscribe(subscriber)
|
|
191
|
+
router.dispose()
|
|
192
|
+
|
|
193
|
+
router.navigate('/about')
|
|
194
|
+
expect(subscriber).not.toHaveBeenCalled()
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('updates routes for HMR', () => {
|
|
198
|
+
const { router } = createRouter([
|
|
199
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
200
|
+
])
|
|
201
|
+
|
|
202
|
+
const newRoutes = buildRouteTree([
|
|
203
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
204
|
+
{ path: '/new-route', component: dummyComponent },
|
|
205
|
+
])
|
|
206
|
+
|
|
207
|
+
router.updateRoutes(newRoutes)
|
|
208
|
+
router.navigate('/new-route')
|
|
209
|
+
expect(router.getState().location.pathname).toBe('/new-route')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('dispose clears all subscribers', () => {
|
|
213
|
+
const { router } = createRouter([
|
|
214
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
215
|
+
{ path: '/about', component: dummyComponent },
|
|
216
|
+
])
|
|
217
|
+
|
|
218
|
+
const subscriber = mock(() => {})
|
|
219
|
+
router.subscribe(subscriber)
|
|
220
|
+
router.dispose()
|
|
221
|
+
|
|
222
|
+
router.navigate('/about')
|
|
223
|
+
expect(subscriber).not.toHaveBeenCalled()
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('passes GuardContext with AbortSignal to guards', async () => {
|
|
227
|
+
let receivedContext: GuardContext | undefined
|
|
228
|
+
const guard = mock((_match: unknown, context: GuardContext) => {
|
|
229
|
+
receivedContext = context
|
|
230
|
+
return true
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
const { router } = createRouter([
|
|
234
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
235
|
+
{ path: '/protected', component: dummyComponent, beforeEnter: guard },
|
|
236
|
+
])
|
|
237
|
+
|
|
238
|
+
router.navigate('/protected')
|
|
239
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
240
|
+
expect(guard).toHaveBeenCalled()
|
|
241
|
+
expect(receivedContext).toBeDefined()
|
|
242
|
+
expect(receivedContext!.signal).toBeInstanceOf(AbortSignal)
|
|
243
|
+
expect(receivedContext!.signal.aborted).toBe(false)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('aborts previous guard signal on new navigation', async () => {
|
|
247
|
+
let firstSignal: AbortSignal | undefined
|
|
248
|
+
let resolveFirst: (() => void) | undefined
|
|
249
|
+
|
|
250
|
+
const slowGuard = mock((_match: unknown, context: GuardContext) => {
|
|
251
|
+
firstSignal = context.signal
|
|
252
|
+
return new Promise<boolean>(resolve => {
|
|
253
|
+
resolveFirst = () => resolve(true)
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
const { router } = createRouter([
|
|
258
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
259
|
+
{ path: '/slow', component: dummyComponent, beforeEnter: slowGuard },
|
|
260
|
+
{ path: '/fast', component: dummyComponent },
|
|
261
|
+
])
|
|
262
|
+
|
|
263
|
+
// Start navigation to /slow (guard will hang)
|
|
264
|
+
router.navigate('/slow')
|
|
265
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
266
|
+
expect(firstSignal).toBeDefined()
|
|
267
|
+
expect(firstSignal!.aborted).toBe(false)
|
|
268
|
+
|
|
269
|
+
// Start a new navigation — should abort the previous signal
|
|
270
|
+
router.navigate('/fast')
|
|
271
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
272
|
+
expect(firstSignal!.aborted).toBe(true)
|
|
273
|
+
|
|
274
|
+
// Clean up
|
|
275
|
+
resolveFirst?.()
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('throws on guard redirect to non-existent route', async () => {
|
|
279
|
+
const guard = mock(() => ({ redirect: '/nowhere' }))
|
|
280
|
+
|
|
281
|
+
const routes = buildRouteTree([
|
|
282
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
283
|
+
{ path: '/protected', component: dummyComponent, beforeEnter: guard },
|
|
284
|
+
])
|
|
285
|
+
const adapter = createMemoryNavigationAdapter({ initialURL: 'http://localhost/' })
|
|
286
|
+
|
|
287
|
+
// Intercept the navigate event to capture the handler promise for assertions
|
|
288
|
+
// while converting the error to NavigationAbortedError so the adapter swallows it.
|
|
289
|
+
let handlerPromise: Promise<void> | undefined
|
|
290
|
+
const origAddEventListener = adapter.addEventListener
|
|
291
|
+
adapter.addEventListener = (_type, listener) => {
|
|
292
|
+
origAddEventListener.call(adapter, _type, (event) => {
|
|
293
|
+
const origEventIntercept = event.intercept.bind(event)
|
|
294
|
+
event.intercept = (opts) => {
|
|
295
|
+
handlerPromise = opts.handler()
|
|
296
|
+
origEventIntercept({
|
|
297
|
+
...opts,
|
|
298
|
+
handler: () =>
|
|
299
|
+
handlerPromise!.catch(() => {
|
|
300
|
+
// Convert to NavigationAbortedError so the adapter swallows it
|
|
301
|
+
const err = new Error()
|
|
302
|
+
err.name = 'NavigationAbortedError'
|
|
303
|
+
throw err
|
|
304
|
+
}),
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
listener(event)
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const router = new Router({ routes, adapter })
|
|
312
|
+
router.start()
|
|
313
|
+
|
|
314
|
+
router.navigate('/protected')
|
|
315
|
+
expect(handlerPromise).toBeDefined()
|
|
316
|
+
await expect(handlerPromise!).rejects.toThrow('does not match any route')
|
|
317
|
+
expect(router.getState().location.pathname).toBe('/')
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('generates deterministic path-based IDs', () => {
|
|
321
|
+
const routes = buildRouteTree([
|
|
322
|
+
{
|
|
323
|
+
path: '/',
|
|
324
|
+
component: dummyComponent,
|
|
325
|
+
isLayout: true,
|
|
326
|
+
children: [
|
|
327
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
328
|
+
{ path: '/about', component: dummyComponent },
|
|
329
|
+
],
|
|
330
|
+
},
|
|
331
|
+
])
|
|
332
|
+
|
|
333
|
+
expect(routes[0].id).toBe('route:/:layout')
|
|
334
|
+
expect(routes[0].children[0].id).toBe('route:/')
|
|
335
|
+
expect(routes[0].children[1].id).toBe('route:/about')
|
|
336
|
+
})
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
describe('Router.resolveParams', () => {
|
|
340
|
+
it('returns explicit params only when no persistent params configured', () => {
|
|
341
|
+
const { router } = createRouter([
|
|
342
|
+
{ path: '/users/:userId', component: dummyComponent },
|
|
343
|
+
])
|
|
344
|
+
|
|
345
|
+
const result = router.resolveParams('/users/:userId', { userId: '42' })
|
|
346
|
+
expect(result).toEqual({ userId: '42' })
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('resolves params from current URL match', () => {
|
|
350
|
+
const { router } = createRouter([
|
|
351
|
+
{ path: '/:lang/users', component: dummyComponent },
|
|
352
|
+
], 'http://localhost/en/users')
|
|
353
|
+
|
|
354
|
+
const result = router.resolveParams('/:lang/users')
|
|
355
|
+
expect(result).toEqual({ lang: 'en' })
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('explicit params override current match', () => {
|
|
359
|
+
const { router } = createRouter([
|
|
360
|
+
{ path: '/:lang/users', component: dummyComponent },
|
|
361
|
+
], 'http://localhost/en/users')
|
|
362
|
+
|
|
363
|
+
const result = router.resolveParams('/:lang/users', { lang: 'cs' })
|
|
364
|
+
expect(result).toEqual({ lang: 'cs' })
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('falls back to persistentParams callback', () => {
|
|
368
|
+
const { router } = createRouter(
|
|
369
|
+
[
|
|
370
|
+
{ path: '/admin', component: dummyComponent },
|
|
371
|
+
{ path: '/:lang/users', component: dummyComponent },
|
|
372
|
+
],
|
|
373
|
+
'http://localhost/admin',
|
|
374
|
+
{ persistentParams: () => ({ lang: 'en' }) },
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
const result = router.resolveParams('/:lang/users')
|
|
378
|
+
expect(result).toEqual({ lang: 'en' })
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('current match overrides callback', () => {
|
|
382
|
+
const { router } = createRouter(
|
|
383
|
+
[{ path: '/:lang/users', component: dummyComponent }],
|
|
384
|
+
'http://localhost/cs/users',
|
|
385
|
+
{ persistentParams: () => ({ lang: 'en' }) },
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
const result = router.resolveParams('/:lang/users')
|
|
389
|
+
expect(result).toEqual({ lang: 'cs' })
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('only fills params present in target pattern', () => {
|
|
393
|
+
const { router } = createRouter(
|
|
394
|
+
[{ path: '/:lang/users/:userId', component: dummyComponent }],
|
|
395
|
+
'http://localhost/en/users/42',
|
|
396
|
+
{ persistentParams: () => ({ lang: 'en' }) },
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
// Target pattern only has :lang, not :userId
|
|
400
|
+
const result = router.resolveParams('/:lang/about')
|
|
401
|
+
expect(result).toEqual({ lang: 'en' })
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('handles mixed persistent and non-persistent params', () => {
|
|
405
|
+
const { router } = createRouter(
|
|
406
|
+
[{ path: '/:lang/users', component: dummyComponent }],
|
|
407
|
+
'http://localhost/en/users',
|
|
408
|
+
{ persistentParams: () => ({ lang: 'en' }) },
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
const result = router.resolveParams('/:lang/users/:userId', { userId: '42' })
|
|
412
|
+
expect(result).toEqual({ lang: 'en', userId: '42' })
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('returns empty object when pattern has no params', () => {
|
|
416
|
+
const { router } = createRouter(
|
|
417
|
+
[{ path: '/about', component: dummyComponent }],
|
|
418
|
+
'http://localhost/about',
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
const result = router.resolveParams('/about')
|
|
422
|
+
expect(result).toEqual({})
|
|
423
|
+
})
|
|
424
|
+
})
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { createPage } from '../define/create-page.js'
|
|
4
|
+
import type { StandardSchema } from '../engine/types.js'
|
|
5
|
+
import type { LinkProps } from '../react/link.js'
|
|
6
|
+
|
|
7
|
+
// Register pages for type-safe testing
|
|
8
|
+
declare module '../engine/types.js' {
|
|
9
|
+
interface BuzolaPageMap {
|
|
10
|
+
'home': {}
|
|
11
|
+
'about': {}
|
|
12
|
+
'users/detail': { userId: string }
|
|
13
|
+
'users/posts': { userId: string; postId: string }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ─── Helper ─────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function mockSchema<T>(): StandardSchema<T> {
|
|
20
|
+
return { '~standard': { version: 1, vendor: 'test', validate: (v) => ({ value: v as T }) } }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ─── createPage().route() type safety ───────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
describe('createPage route type safety', () => {
|
|
26
|
+
it('allows static route without params', () => {
|
|
27
|
+
const _page = createPage().route('/about').render(() => null)
|
|
28
|
+
expect(true).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('rejects dynamic route without params', () => {
|
|
32
|
+
// @ts-expect-error — :id in route but no .params() defined
|
|
33
|
+
const _page = createPage().route('/users/:id').render(() => null)
|
|
34
|
+
expect(true).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('allows dynamic route when param is in schema', () => {
|
|
38
|
+
const _page = createPage()
|
|
39
|
+
.params(mockSchema<{ id: string }>())
|
|
40
|
+
.route('/users/:id')
|
|
41
|
+
.render(({ params }) => null)
|
|
42
|
+
expect(true).toBe(true)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('rejects dynamic route when param is not in schema', () => {
|
|
46
|
+
const _page = createPage()
|
|
47
|
+
.params(mockSchema<{ userId: string }>())
|
|
48
|
+
// @ts-expect-error — :id is not a key of { userId: string }
|
|
49
|
+
.route('/users/:id')
|
|
50
|
+
.render(({ params }) => null)
|
|
51
|
+
expect(true).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('allows multiple route params when all are in schema', () => {
|
|
55
|
+
const _page = createPage()
|
|
56
|
+
.params(mockSchema<{ userId: string; postId: string }>())
|
|
57
|
+
.route('/users/:userId/posts/:postId')
|
|
58
|
+
.render(({ params }) => null)
|
|
59
|
+
expect(true).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('rejects when one route param is missing from schema', () => {
|
|
63
|
+
const _page = createPage()
|
|
64
|
+
.params(mockSchema<{ userId: string }>())
|
|
65
|
+
// @ts-expect-error — :postId is not in { userId: string }
|
|
66
|
+
.route('/users/:userId/posts/:postId')
|
|
67
|
+
.render(({ params }) => null)
|
|
68
|
+
expect(true).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('allows schema params that are not in route (they become query params)', () => {
|
|
72
|
+
const _page = createPage()
|
|
73
|
+
.params(mockSchema<{ userId: string; tab: string }>())
|
|
74
|
+
.route('/users/:userId')
|
|
75
|
+
.render(({ params }) => null)
|
|
76
|
+
expect(true).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// ─── createPage().catch() type safety ────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
describe('createPage catch type safety', () => {
|
|
83
|
+
it('.catch() is available before .render()', () => {
|
|
84
|
+
const _page = createPage()
|
|
85
|
+
.catch(({ error }) => <div>{error.message}</div>)
|
|
86
|
+
.render(() => null)
|
|
87
|
+
expect(true).toBe(true)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('.catch() is available after .route()', () => {
|
|
91
|
+
const _page = createPage()
|
|
92
|
+
.route('/about')
|
|
93
|
+
.catch(({ error }) => <div>{error.message}</div>)
|
|
94
|
+
.render(() => null)
|
|
95
|
+
expect(true).toBe(true)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('no .catch() after .catch()', () => {
|
|
99
|
+
const withCatch = createPage().catch(({ error }) => <div>{error.message}</div>)
|
|
100
|
+
// @ts-expect-error — .catch() should not be available after .catch()
|
|
101
|
+
withCatch.catch
|
|
102
|
+
expect(true).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('no .loader() after .catch()', () => {
|
|
106
|
+
const withCatch = createPage().catch(({ error }) => <div>{error.message}</div>)
|
|
107
|
+
// @ts-expect-error — .loader() should not be available after .catch()
|
|
108
|
+
withCatch.loader
|
|
109
|
+
expect(true).toBe(true)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// ─── LinkProps ──────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
describe('LinkProps type safety', () => {
|
|
116
|
+
it('static page does not require params', () => {
|
|
117
|
+
const _a: LinkProps<'home'> = { to: 'home' }
|
|
118
|
+
const _b: LinkProps<'about'> = { to: 'about' }
|
|
119
|
+
expect(true).toBe(true)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('static page rejects params', () => {
|
|
123
|
+
// @ts-expect-error — static page should not accept params
|
|
124
|
+
const _a: LinkProps<'about'> = { to: 'about', params: { foo: 'bar' } }
|
|
125
|
+
expect(true).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('dynamic page requires params', () => {
|
|
129
|
+
const _a: LinkProps<'users/detail'> = { to: 'users/detail', params: { userId: '1' } }
|
|
130
|
+
|
|
131
|
+
// @ts-expect-error — missing required params
|
|
132
|
+
const _b: LinkProps<'users/detail'> = { to: 'users/detail' }
|
|
133
|
+
expect(true).toBe(true)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('dynamic page requires correct param names', () => {
|
|
137
|
+
// @ts-expect-error — wrong param name
|
|
138
|
+
const _a: LinkProps<'users/detail'> = { to: 'users/detail', params: { id: '1' } }
|
|
139
|
+
expect(true).toBe(true)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('multi-param page requires all params', () => {
|
|
143
|
+
const _a: LinkProps<'users/posts'> = {
|
|
144
|
+
to: 'users/posts',
|
|
145
|
+
params: { userId: '1', postId: '2' },
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const _b: LinkProps<'users/posts'> = {
|
|
149
|
+
to: 'users/posts',
|
|
150
|
+
// @ts-expect-error — missing postId
|
|
151
|
+
params: { userId: '1' },
|
|
152
|
+
}
|
|
153
|
+
expect(true).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('rejects unregistered pages', () => {
|
|
157
|
+
// @ts-expect-error — page not in BuzolaPageMap
|
|
158
|
+
const _a: LinkProps<'unknown'> = { to: 'unknown' }
|
|
159
|
+
expect(true).toBe(true)
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// ─── Link JSX usage (generic inference) ─────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
import { Link } from '../react/link.js'
|
|
166
|
+
|
|
167
|
+
describe('Link JSX generic inference', () => {
|
|
168
|
+
it('accepts static page without params', () => {
|
|
169
|
+
const _el = <Link to="home">Home</Link>
|
|
170
|
+
const _el2 = <Link to="about">About</Link>
|
|
171
|
+
expect(true).toBe(true)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('rejects params on static page', () => {
|
|
175
|
+
// @ts-expect-error — static page should not accept params
|
|
176
|
+
const _el = <Link to="about" params={{ foo: 'bar' }}>About</Link>
|
|
177
|
+
expect(true).toBe(true)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('requires params on dynamic page', () => {
|
|
181
|
+
const _el = <Link to="users/detail" params={{ userId: '1' }}>User</Link>
|
|
182
|
+
|
|
183
|
+
// @ts-expect-error — missing required params
|
|
184
|
+
const _el2 = <Link to="users/detail">User</Link>
|
|
185
|
+
expect(true).toBe(true)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('rejects wrong param names', () => {
|
|
189
|
+
// @ts-expect-error — wrong param name
|
|
190
|
+
const _el = <Link to="users/detail" params={{ id: '1' }}>User</Link>
|
|
191
|
+
expect(true).toBe(true)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('requires all params for multi-param page', () => {
|
|
195
|
+
const _ok = <Link to="users/posts" params={{ userId: '1', postId: '2' }}>Post</Link>
|
|
196
|
+
|
|
197
|
+
// @ts-expect-error — missing postId
|
|
198
|
+
const _bad = <Link to="users/posts" params={{ userId: '1' }}>Post</Link>
|
|
199
|
+
expect(true).toBe(true)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('rejects unregistered pages', () => {
|
|
203
|
+
// @ts-expect-error — page not in BuzolaPageMap
|
|
204
|
+
const _el = <Link to="nope">Nope</Link>
|
|
205
|
+
expect(true).toBe(true)
|
|
206
|
+
})
|
|
207
|
+
})
|