@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,588 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { createElement } from 'react'
|
|
3
|
+
import { createPage } from '../define/create-page'
|
|
4
|
+
import { createMemoryNavigationAdapter } from '../engine/navigation-adapter'
|
|
5
|
+
import { buildRouteTree } from '../engine/route-tree'
|
|
6
|
+
import { Router } from '../engine/router'
|
|
7
|
+
import { s } from '../engine/schema'
|
|
8
|
+
import type { RouteConfig, StandardSchema } from '../engine/types'
|
|
9
|
+
|
|
10
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
function dummyComponent() {
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createRouter(
|
|
17
|
+
configs: RouteConfig[],
|
|
18
|
+
initialURL = 'http://localhost/',
|
|
19
|
+
options?: { pageRegistry?: Record<string, string> },
|
|
20
|
+
) {
|
|
21
|
+
const routes = buildRouteTree(configs)
|
|
22
|
+
const adapter = createMemoryNavigationAdapter({ initialURL })
|
|
23
|
+
const router = new Router({ routes, adapter, ...options })
|
|
24
|
+
router.start()
|
|
25
|
+
return { router, adapter }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Minimal Standard Schema implementation for testing. */
|
|
29
|
+
function createSchema<T>(
|
|
30
|
+
validate: (value: unknown) => { value: T } | { issues: readonly { message: string }[] },
|
|
31
|
+
): StandardSchema<T> {
|
|
32
|
+
return {
|
|
33
|
+
'~standard': {
|
|
34
|
+
version: 1,
|
|
35
|
+
vendor: 'test',
|
|
36
|
+
validate,
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
describe('createPage', () => {
|
|
44
|
+
it('returns a PageDefinition with __buzolaPage marker', () => {
|
|
45
|
+
const page = createPage().render(({ params }) => null)
|
|
46
|
+
|
|
47
|
+
expect(page.__buzolaPage).toBe(true)
|
|
48
|
+
expect(typeof page.component).toBe('function')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns a PageDefinition with paramsSchema when schema provided', () => {
|
|
52
|
+
const schema = createSchema((v) => ({ value: v as { id: string } }))
|
|
53
|
+
const page = createPage()
|
|
54
|
+
.params(schema)
|
|
55
|
+
.render(({ params }) => null)
|
|
56
|
+
|
|
57
|
+
expect(page.__buzolaPage).toBe(true)
|
|
58
|
+
expect(page.paramsSchema).toBe(schema)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('paramsSchema is undefined when no schema provided', () => {
|
|
62
|
+
const page = createPage().render(({ params }) => null)
|
|
63
|
+
expect(page.paramsSchema).toBeUndefined()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('stores route pattern when .route() is called', () => {
|
|
67
|
+
const page = createPage()
|
|
68
|
+
.route('/about')
|
|
69
|
+
.render(({ params }) => null)
|
|
70
|
+
|
|
71
|
+
expect(page.route).toBe('/about')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('stores route pattern with params when schema matches', () => {
|
|
75
|
+
const schema = createSchema((v) => ({ value: v as { id: string } }))
|
|
76
|
+
const page = createPage()
|
|
77
|
+
.params(schema)
|
|
78
|
+
.route('/users/:id')
|
|
79
|
+
.render(({ params }) => null)
|
|
80
|
+
|
|
81
|
+
expect(page.route).toBe('/users/:id')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('route is undefined when .route() is not called', () => {
|
|
85
|
+
const page = createPage().render(({ params }) => null)
|
|
86
|
+
expect(page.route).toBeUndefined()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('supports full chain: .params().route().render()', () => {
|
|
90
|
+
const schema = createSchema((v) => ({ value: v as { id: string } }))
|
|
91
|
+
const page = createPage()
|
|
92
|
+
.params(schema)
|
|
93
|
+
.route('/project/:id')
|
|
94
|
+
.render(({ params }) => null)
|
|
95
|
+
|
|
96
|
+
expect(page.__buzolaPage).toBe(true)
|
|
97
|
+
expect(page.paramsSchema).toBe(schema)
|
|
98
|
+
expect(page.route).toBe('/project/:id')
|
|
99
|
+
expect(typeof page.component).toBe('function')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('populates paramsMeta from shape object', () => {
|
|
103
|
+
const page = createPage()
|
|
104
|
+
.params({ userId: s.string(), tab: s.optional(s.string()) })
|
|
105
|
+
.route('/users/:userId')
|
|
106
|
+
.render(({ params }) => null)
|
|
107
|
+
|
|
108
|
+
expect(page.paramsMeta).toEqual([
|
|
109
|
+
{ name: 'userId', optional: false, array: false },
|
|
110
|
+
{ name: 'tab', optional: true, array: false },
|
|
111
|
+
])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('populates paramsMeta from s.object schema', () => {
|
|
115
|
+
const page = createPage()
|
|
116
|
+
.params(s.object({ userId: s.string(), tab: s.optional(s.string()) }))
|
|
117
|
+
.route('/users/:userId')
|
|
118
|
+
.render(({ params }) => null)
|
|
119
|
+
|
|
120
|
+
expect(page.paramsMeta).toEqual([
|
|
121
|
+
{ name: 'userId', optional: false, array: false },
|
|
122
|
+
{ name: 'tab', optional: true, array: false },
|
|
123
|
+
])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('populates paramsMeta from string literals', () => {
|
|
127
|
+
const page = createPage()
|
|
128
|
+
.params({ id: 'uuid', order: 'number', text: '?string', tags: '?string[]' })
|
|
129
|
+
.render(({ params }) => null)
|
|
130
|
+
|
|
131
|
+
expect(page.paramsMeta).toEqual([
|
|
132
|
+
{ name: 'id', optional: false, array: false },
|
|
133
|
+
{ name: 'order', optional: false, array: false },
|
|
134
|
+
{ name: 'text', optional: true, array: false },
|
|
135
|
+
{ name: 'tags', optional: true, array: true },
|
|
136
|
+
])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('validates and coerces literal number params', () => {
|
|
140
|
+
const page = createPage()
|
|
141
|
+
.params({ order: 'number' })
|
|
142
|
+
.render(({ params }) => null)
|
|
143
|
+
|
|
144
|
+
const schema = page.paramsSchema!
|
|
145
|
+
expect(schema['~standard'].validate({ order: '42' })).toEqual({ value: { order: 42 } })
|
|
146
|
+
expect(schema['~standard'].validate({ order: 'abc' })).toHaveProperty('issues')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('validates literal uuid params', () => {
|
|
150
|
+
const page = createPage()
|
|
151
|
+
.params({ id: 'uuid' })
|
|
152
|
+
.render(({ params }) => null)
|
|
153
|
+
|
|
154
|
+
const schema = page.paramsSchema!
|
|
155
|
+
expect(schema['~standard'].validate({ id: '550e8400-e29b-41d4-a716-446655440000' }))
|
|
156
|
+
.toEqual({ value: { id: '550e8400-e29b-41d4-a716-446655440000' } })
|
|
157
|
+
expect(schema['~standard'].validate({ id: 'not-a-uuid' })).toHaveProperty('issues')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('validates literal array params', () => {
|
|
161
|
+
const page = createPage()
|
|
162
|
+
.params({ tags: 'string[]' })
|
|
163
|
+
.render(({ params }) => null)
|
|
164
|
+
|
|
165
|
+
const schema = page.paramsSchema!
|
|
166
|
+
expect(schema['~standard'].validate({ tags: ['a', 'b'] })).toEqual({ value: { tags: ['a', 'b'] } })
|
|
167
|
+
expect(schema['~standard'].validate({ tags: 'not-array' })).toHaveProperty('issues')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('coerces literal number[] params', () => {
|
|
171
|
+
const page = createPage()
|
|
172
|
+
.params({ ids: 'number[]' })
|
|
173
|
+
.render(({ params }) => null)
|
|
174
|
+
|
|
175
|
+
const schema = page.paramsSchema!
|
|
176
|
+
expect(schema['~standard'].validate({ ids: ['1', '2', '3'] })).toEqual({ value: { ids: [1, 2, 3] } })
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('validates optional array as undefined when empty', () => {
|
|
180
|
+
const page = createPage()
|
|
181
|
+
.params({ tags: '?string[]' })
|
|
182
|
+
.render(({ params }) => null)
|
|
183
|
+
|
|
184
|
+
const schema = page.paramsSchema!
|
|
185
|
+
expect(schema['~standard'].validate({ tags: undefined })).toEqual({ value: { tags: undefined } })
|
|
186
|
+
expect(schema['~standard'].validate({ tags: [] })).toEqual({ value: { tags: undefined } })
|
|
187
|
+
expect(schema['~standard'].validate({ tags: ['a'] })).toEqual({ value: { tags: ['a'] } })
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('paramsMeta is empty when no schema provided', () => {
|
|
191
|
+
const page = createPage().render(({ params }) => null)
|
|
192
|
+
expect(page.paramsMeta).toEqual([])
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('component is usable as RouteConfig component', () => {
|
|
196
|
+
const page = createPage().render(({ params }) => null)
|
|
197
|
+
|
|
198
|
+
const config: RouteConfig = {
|
|
199
|
+
path: '/test',
|
|
200
|
+
component: page.component,
|
|
201
|
+
}
|
|
202
|
+
expect(config.component).toBe(page.component)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('stores loader function in PageDefinition', () => {
|
|
206
|
+
const loader = async ({ params }: { params: { id: string } }) => ({ name: 'Alice' })
|
|
207
|
+
const page = createPage()
|
|
208
|
+
.params({ id: 'string' })
|
|
209
|
+
.loader(loader)
|
|
210
|
+
.render(({ params, data }) => null)
|
|
211
|
+
|
|
212
|
+
expect(page.loader).toBe(loader)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('loader is undefined when not provided', () => {
|
|
216
|
+
const page = createPage().render(({ params }) => null)
|
|
217
|
+
expect(page.loader).toBeUndefined()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('supports .params().loader().route().render() chain', () => {
|
|
221
|
+
const page = createPage()
|
|
222
|
+
.params({ id: 'uuid' })
|
|
223
|
+
.loader(async ({ params }) => ({ name: `User ${params.id}` }))
|
|
224
|
+
.route('/users/:id')
|
|
225
|
+
.render(({ params, data }) => null)
|
|
226
|
+
|
|
227
|
+
expect(page.__buzolaPage).toBe(true)
|
|
228
|
+
expect(page.route).toBe('/users/:id')
|
|
229
|
+
expect(page.loader).toBeDefined()
|
|
230
|
+
expect(typeof page.component).toBe('function')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('supports .loader() without params', () => {
|
|
234
|
+
const page = createPage()
|
|
235
|
+
.loader(async () => ({ stats: 42 }))
|
|
236
|
+
.route('/dashboard')
|
|
237
|
+
.render(({ data }) => null)
|
|
238
|
+
|
|
239
|
+
expect(page.loader).toBeDefined()
|
|
240
|
+
expect(page.route).toBe('/dashboard')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('supports .params().loader().render() without route', () => {
|
|
244
|
+
const page = createPage()
|
|
245
|
+
.params({ id: 'string' })
|
|
246
|
+
.loader(async ({ params }) => ({ name: 'test' }))
|
|
247
|
+
.render(({ params, data }) => null)
|
|
248
|
+
|
|
249
|
+
expect(page.loader).toBeDefined()
|
|
250
|
+
expect(page.route).toBeUndefined()
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('supports multiple loaders that merge results', () => {
|
|
254
|
+
const page = createPage()
|
|
255
|
+
.params({ id: 'string' })
|
|
256
|
+
.loader(async ({ params }) => ({ user: { name: 'Alice' } }))
|
|
257
|
+
.loader(async ({ params }) => ({ posts: [1, 2, 3] }))
|
|
258
|
+
.route('/users/:id')
|
|
259
|
+
.render(({ params, data }) => {
|
|
260
|
+
// Type-safe access to both loader results
|
|
261
|
+
const _user: { name: string } = data.user
|
|
262
|
+
const _posts: number[] = data.posts
|
|
263
|
+
return null
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
expect(page.loader).toBeDefined()
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('multiple loaders run in parallel and merge', async () => {
|
|
270
|
+
const order: string[] = []
|
|
271
|
+
|
|
272
|
+
const page = createPage()
|
|
273
|
+
.params({ id: 'string' })
|
|
274
|
+
.loader(async () => {
|
|
275
|
+
order.push('loader1-start')
|
|
276
|
+
await new Promise(r => setTimeout(r, 10))
|
|
277
|
+
order.push('loader1-end')
|
|
278
|
+
return { a: 1 }
|
|
279
|
+
})
|
|
280
|
+
.loader(async () => {
|
|
281
|
+
order.push('loader2-start')
|
|
282
|
+
await new Promise(r => setTimeout(r, 10))
|
|
283
|
+
order.push('loader2-end')
|
|
284
|
+
return { b: 2 }
|
|
285
|
+
})
|
|
286
|
+
.render(({ data }) => null)
|
|
287
|
+
|
|
288
|
+
const result = await page.loader!({ params: { id: '1' } })
|
|
289
|
+
expect(result).toEqual({ a: 1, b: 2 })
|
|
290
|
+
// Both loaders started before either finished (parallel)
|
|
291
|
+
expect(order[0]).toBe('loader1-start')
|
|
292
|
+
expect(order[1]).toBe('loader2-start')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('single loader is stored directly (not wrapped)', () => {
|
|
296
|
+
const loader = async () => ({ x: 1 })
|
|
297
|
+
const page = createPage()
|
|
298
|
+
.loader(loader)
|
|
299
|
+
.render(({ data }) => null)
|
|
300
|
+
|
|
301
|
+
expect(page.loader).toBe(loader)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('.catch() with static ReactNode returns valid PageDefinition', () => {
|
|
305
|
+
const page = createPage()
|
|
306
|
+
.catch(createElement('div', null, 'Error occurred'))
|
|
307
|
+
.render(({ params }) => null)
|
|
308
|
+
|
|
309
|
+
expect(page.__buzolaPage).toBe(true)
|
|
310
|
+
expect(typeof page.component).toBe('function')
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('.params().loader().catch().render() full chain', () => {
|
|
314
|
+
const page = createPage()
|
|
315
|
+
.params({ id: 'string' })
|
|
316
|
+
.loader(async ({ params }) => ({ name: `User ${params.id}` }))
|
|
317
|
+
.catch(({ error, retry }) => createElement('div', null, 'Failed'))
|
|
318
|
+
.render(({ params, data }) => null)
|
|
319
|
+
|
|
320
|
+
expect(page.__buzolaPage).toBe(true)
|
|
321
|
+
expect(page.loader).toBeDefined()
|
|
322
|
+
expect(typeof page.component).toBe('function')
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('.catch().route().render() chain', () => {
|
|
326
|
+
const page = createPage()
|
|
327
|
+
.catch(({ error }) => createElement('div', null, error.message))
|
|
328
|
+
.route('/about')
|
|
329
|
+
.render(({ params }) => null)
|
|
330
|
+
|
|
331
|
+
expect(page.__buzolaPage).toBe(true)
|
|
332
|
+
expect(page.route).toBe('/about')
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('.route().catch().render() chain', () => {
|
|
336
|
+
const page = createPage()
|
|
337
|
+
.route('/about')
|
|
338
|
+
.catch(({ error }) => createElement('div', null, error.message))
|
|
339
|
+
.render(({ params }) => null)
|
|
340
|
+
|
|
341
|
+
expect(page.__buzolaPage).toBe(true)
|
|
342
|
+
expect(page.route).toBe('/about')
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('invalidate is available in render props with loader', () => {
|
|
346
|
+
const page = createPage()
|
|
347
|
+
.loader(async () => ({ value: 1 }))
|
|
348
|
+
.render(({ data, invalidate }) => {
|
|
349
|
+
// invalidate should be a function
|
|
350
|
+
const _fn: () => void = invalidate
|
|
351
|
+
return null
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
expect(page.loader).toBeDefined()
|
|
355
|
+
})
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
describe('Router invalidation', () => {
|
|
359
|
+
it('invalidateAll calls all registered listeners', () => {
|
|
360
|
+
const { router } = createRouter([
|
|
361
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
362
|
+
])
|
|
363
|
+
|
|
364
|
+
const calls: number[] = []
|
|
365
|
+
router.onInvalidate(() => calls.push(1))
|
|
366
|
+
router.onInvalidate(() => calls.push(2))
|
|
367
|
+
|
|
368
|
+
router.invalidateAll()
|
|
369
|
+
expect(calls).toEqual([1, 2])
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('onInvalidate returns unsubscribe function', () => {
|
|
373
|
+
const { router } = createRouter([
|
|
374
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
375
|
+
])
|
|
376
|
+
|
|
377
|
+
const calls: number[] = []
|
|
378
|
+
const unsub = router.onInvalidate(() => calls.push(1))
|
|
379
|
+
|
|
380
|
+
router.invalidateAll()
|
|
381
|
+
expect(calls).toEqual([1])
|
|
382
|
+
|
|
383
|
+
unsub()
|
|
384
|
+
router.invalidateAll()
|
|
385
|
+
expect(calls).toEqual([1])
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('dispose clears invalidation listeners', () => {
|
|
389
|
+
const { router } = createRouter([
|
|
390
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
391
|
+
])
|
|
392
|
+
|
|
393
|
+
const calls: number[] = []
|
|
394
|
+
router.onInvalidate(() => calls.push(1))
|
|
395
|
+
|
|
396
|
+
router.dispose()
|
|
397
|
+
router.invalidateAll()
|
|
398
|
+
expect(calls).toEqual([])
|
|
399
|
+
})
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
describe('Router page registry', () => {
|
|
403
|
+
it('navigateToPage navigates using page registry', () => {
|
|
404
|
+
const { router } = createRouter(
|
|
405
|
+
[
|
|
406
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
407
|
+
{ path: '/users/:userId', component: dummyComponent },
|
|
408
|
+
],
|
|
409
|
+
'http://localhost/',
|
|
410
|
+
{
|
|
411
|
+
pageRegistry: {
|
|
412
|
+
'home': '/',
|
|
413
|
+
'users/detail': '/users/:userId',
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
router.navigateToPage('users/detail', { userId: '42' })
|
|
419
|
+
const state = router.getState()
|
|
420
|
+
expect(state.location.pathname).toBe('/users/42')
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('buildPagePath builds path from page registry', () => {
|
|
424
|
+
const { router } = createRouter(
|
|
425
|
+
[],
|
|
426
|
+
'http://localhost/',
|
|
427
|
+
{
|
|
428
|
+
pageRegistry: {
|
|
429
|
+
'users/detail': '/users/:userId',
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
const path = router.buildPagePath('users/detail', { userId: '42' })
|
|
435
|
+
expect(path).toBe('/users/42')
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('buildPagePath appends extra params as query string', () => {
|
|
439
|
+
const { router } = createRouter(
|
|
440
|
+
[],
|
|
441
|
+
'http://localhost/',
|
|
442
|
+
{
|
|
443
|
+
pageRegistry: {
|
|
444
|
+
'users/detail': '/users/:userId',
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
const path = router.buildPagePath('users/detail', { userId: '42', tab: 'posts' })
|
|
450
|
+
expect(path).toBe('/users/42?tab=posts')
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('buildPagePath throws for unknown page ID', () => {
|
|
454
|
+
const { router } = createRouter([], 'http://localhost/', { pageRegistry: {} })
|
|
455
|
+
|
|
456
|
+
expect(() => router.buildPagePath('nonexistent')).toThrow('Unknown page "nonexistent"')
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
it('navigateToPage with extra params as query string', () => {
|
|
460
|
+
const { router } = createRouter(
|
|
461
|
+
[
|
|
462
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
463
|
+
{ path: '/users/:userId', component: dummyComponent },
|
|
464
|
+
],
|
|
465
|
+
'http://localhost/',
|
|
466
|
+
{
|
|
467
|
+
pageRegistry: {
|
|
468
|
+
'users/detail': '/users/:userId',
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
router.navigateToPage('users/detail', { userId: '1', tab: 'posts' })
|
|
474
|
+
|
|
475
|
+
const state = router.getState()
|
|
476
|
+
expect(state.location.pathname).toBe('/users/1')
|
|
477
|
+
expect(state.location.searchParams.get('tab')).toBe('posts')
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it('buildPagePath for static route', () => {
|
|
481
|
+
const { router } = createRouter(
|
|
482
|
+
[],
|
|
483
|
+
'http://localhost/',
|
|
484
|
+
{
|
|
485
|
+
pageRegistry: {
|
|
486
|
+
'about': '/about',
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
const path = router.buildPagePath('about')
|
|
492
|
+
expect(path).toBe('/about')
|
|
493
|
+
})
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
describe('Router.buildPath with extra params', () => {
|
|
497
|
+
it('appends extra params as query string', () => {
|
|
498
|
+
const { router } = createRouter([])
|
|
499
|
+
const path = router.buildPath('/users/:userId', {
|
|
500
|
+
userId: '42',
|
|
501
|
+
tab: 'posts',
|
|
502
|
+
})
|
|
503
|
+
expect(path).toBe('/users/42?tab=posts')
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('does not append query string when all params are path params', () => {
|
|
507
|
+
const { router } = createRouter([])
|
|
508
|
+
const path = router.buildPath('/users/:userId', { userId: '42' })
|
|
509
|
+
expect(path).toBe('/users/42')
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
it('appends multiple extra params', () => {
|
|
513
|
+
const { router } = createRouter([])
|
|
514
|
+
const path = router.buildPath('/users/:userId', {
|
|
515
|
+
userId: '42',
|
|
516
|
+
tab: 'posts',
|
|
517
|
+
sort: 'date',
|
|
518
|
+
})
|
|
519
|
+
// URLSearchParams preserves insertion order
|
|
520
|
+
expect(path).toBe('/users/42?tab=posts&sort=date')
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
it('skips undefined extra params', () => {
|
|
524
|
+
const { router } = createRouter([])
|
|
525
|
+
const path = router.buildPath('/users/:userId', {
|
|
526
|
+
userId: '42',
|
|
527
|
+
tab: undefined as any,
|
|
528
|
+
sort: 'date',
|
|
529
|
+
})
|
|
530
|
+
expect(path).toBe('/users/42?sort=date')
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
it('handles static path with extra params', () => {
|
|
534
|
+
const { router } = createRouter([])
|
|
535
|
+
const path = router.buildPath('/about', { tab: 'team' })
|
|
536
|
+
expect(path).toBe('/about?tab=team')
|
|
537
|
+
})
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
describe('Router.resolveParams with extra params pass-through', () => {
|
|
541
|
+
it('passes through extra explicit params not in pattern', () => {
|
|
542
|
+
const { router } = createRouter([
|
|
543
|
+
{ path: '/users/:userId', component: dummyComponent },
|
|
544
|
+
])
|
|
545
|
+
|
|
546
|
+
const result = router.resolveParams('/users/:userId', { userId: '42', tab: 'posts' })
|
|
547
|
+
expect(result).toEqual({ userId: '42', tab: 'posts' })
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it('does not pass through when no explicit params given', () => {
|
|
551
|
+
const { router } = createRouter(
|
|
552
|
+
[{ path: '/users/:userId', component: dummyComponent }],
|
|
553
|
+
'http://localhost/users/42',
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
const result = router.resolveParams('/users/:userId')
|
|
557
|
+
expect(result).toEqual({ userId: '42' })
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
it('extra params do not override path params from current match', () => {
|
|
561
|
+
const { router } = createRouter(
|
|
562
|
+
[{ path: '/users/:userId', component: dummyComponent }],
|
|
563
|
+
'http://localhost/users/42',
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
// userId comes from current match, tab is extra
|
|
567
|
+
const result = router.resolveParams('/users/:userId', { tab: 'posts' })
|
|
568
|
+
expect(result).toEqual({ userId: '42', tab: 'posts' })
|
|
569
|
+
})
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
describe('navigate with extra params (integration)', () => {
|
|
573
|
+
it('navigates with extra params as query string', () => {
|
|
574
|
+
const { router } = createRouter([
|
|
575
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
576
|
+
{ path: '/users/:userId', component: dummyComponent },
|
|
577
|
+
])
|
|
578
|
+
|
|
579
|
+
// Simulate what useNavigate does via navigateToPage
|
|
580
|
+
const resolved = router.resolveParams('/users/:userId', { userId: '1', tab: 'posts' })
|
|
581
|
+
const path = router.buildPath('/users/:userId', resolved)
|
|
582
|
+
router.navigate(router.stripBasePath(path))
|
|
583
|
+
|
|
584
|
+
const state = router.getState()
|
|
585
|
+
expect(state.location.pathname).toBe('/users/1')
|
|
586
|
+
expect(state.location.searchParams.get('tab')).toBe('posts')
|
|
587
|
+
})
|
|
588
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { matchRoutes } from '../engine/matcher'
|
|
3
|
+
import { buildRouteTree } from '../engine/route-tree'
|
|
4
|
+
import type { RouteConfig } from '../engine/types'
|
|
5
|
+
|
|
6
|
+
function dummyComponent() {
|
|
7
|
+
return null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function createTestTree(configs: RouteConfig[]) {
|
|
11
|
+
return buildRouteTree(configs)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('matchRoutes', () => {
|
|
15
|
+
it('matches a simple root route', () => {
|
|
16
|
+
const tree = createTestTree([
|
|
17
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
const matches = matchRoutes(tree, new URL('http://localhost/'))
|
|
21
|
+
expect(matches).not.toBeNull()
|
|
22
|
+
expect(matches).toHaveLength(1)
|
|
23
|
+
expect(matches![0].node.fullPath).toBe('/')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('matches a static route', () => {
|
|
27
|
+
const tree = createTestTree([
|
|
28
|
+
{ path: '/about', component: dummyComponent },
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
const matches = matchRoutes(tree, new URL('http://localhost/about'))
|
|
32
|
+
expect(matches).not.toBeNull()
|
|
33
|
+
expect(matches).toHaveLength(1)
|
|
34
|
+
expect(matches![0].node.fullPath).toBe('/about')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('matches a dynamic route and extracts params', () => {
|
|
38
|
+
const tree = createTestTree([
|
|
39
|
+
{ path: '/users/:userId', component: dummyComponent },
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
const matches = matchRoutes(tree, new URL('http://localhost/users/42'))
|
|
43
|
+
expect(matches).not.toBeNull()
|
|
44
|
+
expect(matches).toHaveLength(1)
|
|
45
|
+
expect(matches![0].params).toEqual({ userId: '42' })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('matches nested routes through layouts', () => {
|
|
49
|
+
const tree = createTestTree([
|
|
50
|
+
{
|
|
51
|
+
path: '/',
|
|
52
|
+
component: dummyComponent,
|
|
53
|
+
isLayout: true,
|
|
54
|
+
children: [
|
|
55
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
56
|
+
{ path: '/about', component: dummyComponent },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
const indexMatches = matchRoutes(tree, new URL('http://localhost/'))
|
|
62
|
+
expect(indexMatches).not.toBeNull()
|
|
63
|
+
expect(indexMatches).toHaveLength(2) // layout + index
|
|
64
|
+
|
|
65
|
+
const aboutMatches = matchRoutes(tree, new URL('http://localhost/about'))
|
|
66
|
+
expect(aboutMatches).not.toBeNull()
|
|
67
|
+
expect(aboutMatches).toHaveLength(2) // layout + about
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('matches deeply nested dynamic routes', () => {
|
|
71
|
+
const tree = createTestTree([
|
|
72
|
+
{
|
|
73
|
+
path: '/users',
|
|
74
|
+
component: dummyComponent,
|
|
75
|
+
isLayout: true,
|
|
76
|
+
children: [
|
|
77
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
78
|
+
{ path: '/:userId', component: dummyComponent },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
const matches = matchRoutes(tree, new URL('http://localhost/users/99'))
|
|
84
|
+
expect(matches).not.toBeNull()
|
|
85
|
+
expect(matches).toHaveLength(2) // users layout + :userId
|
|
86
|
+
expect(matches![1].params).toEqual({ userId: '99' })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('extracts params from layout routes via prefixPattern', () => {
|
|
90
|
+
const tree = createTestTree([
|
|
91
|
+
{
|
|
92
|
+
path: '/users/:userId',
|
|
93
|
+
component: dummyComponent,
|
|
94
|
+
isLayout: true,
|
|
95
|
+
children: [
|
|
96
|
+
{ path: '/', component: dummyComponent, isIndex: true },
|
|
97
|
+
{ path: '/settings', component: dummyComponent },
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
])
|
|
101
|
+
|
|
102
|
+
const matches = matchRoutes(tree, new URL('http://localhost/users/42/settings'))
|
|
103
|
+
expect(matches).not.toBeNull()
|
|
104
|
+
expect(matches).toHaveLength(2)
|
|
105
|
+
// Layout should extract userId param via prefixPattern
|
|
106
|
+
expect(matches![0].params).toEqual({ userId: '42' })
|
|
107
|
+
expect(matches![0].node.isLayout).toBe(true)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('returns null for unmatched routes', () => {
|
|
111
|
+
const tree = createTestTree([
|
|
112
|
+
{ path: '/about', component: dummyComponent },
|
|
113
|
+
])
|
|
114
|
+
|
|
115
|
+
const matches = matchRoutes(tree, new URL('http://localhost/nonexistent'))
|
|
116
|
+
expect(matches).toBeNull()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('does not match partial paths', () => {
|
|
120
|
+
const tree = createTestTree([
|
|
121
|
+
{ path: '/about', component: dummyComponent },
|
|
122
|
+
])
|
|
123
|
+
|
|
124
|
+
const matches = matchRoutes(tree, new URL('http://localhost/about/extra'))
|
|
125
|
+
expect(matches).toBeNull()
|
|
126
|
+
})
|
|
127
|
+
})
|