@pyreon/router 0.24.5 → 0.24.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -6
- package/src/components.tsx +0 -650
- package/src/env.d.ts +0 -6
- package/src/index.ts +0 -106
- package/src/loader.ts +0 -200
- package/src/manifest.ts +0 -399
- package/src/match.ts +0 -921
- package/src/not-found.ts +0 -75
- package/src/redirect.ts +0 -63
- package/src/router.ts +0 -1424
- package/src/scroll.ts +0 -93
- package/src/tests/integration.test.tsx +0 -298
- package/src/tests/loader.test.ts +0 -1024
- package/src/tests/manifest-snapshot.test.ts +0 -101
- package/src/tests/match.test.ts +0 -782
- package/src/tests/native-markers.test.ts +0 -18
- package/src/tests/redirect.test.ts +0 -96
- package/src/tests/router.browser.test.tsx +0 -509
- package/src/tests/router.test.ts +0 -5498
- package/src/tests/routerlink-reactive-to.browser.test.tsx +0 -158
- package/src/tests/scroll.test.ts +0 -31
- package/src/tests/setup.ts +0 -3
- package/src/types.ts +0 -517
package/src/tests/match.test.ts
DELETED
|
@@ -1,782 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
buildNameIndex,
|
|
3
|
-
buildPath,
|
|
4
|
-
findRouteByName,
|
|
5
|
-
matchPath,
|
|
6
|
-
parseQuery,
|
|
7
|
-
parseQueryMulti,
|
|
8
|
-
resolveRoute,
|
|
9
|
-
stringifyQuery,
|
|
10
|
-
} from '../match'
|
|
11
|
-
// Importing from components.tsx triggers the module-load side-effect that
|
|
12
|
-
// registers DefaultChromeLayout with match.ts (via _setDefaultChromeLayout).
|
|
13
|
-
// Without this import, the layout-less fallback in findNotFoundFallback
|
|
14
|
-
// returns null because no chrome layout is registered. Tests below verify
|
|
15
|
-
// the registered layout is used as the synthetic chain's first entry.
|
|
16
|
-
import { DefaultChromeLayout } from '../components'
|
|
17
|
-
import type { RouteRecord } from '../types'
|
|
18
|
-
|
|
19
|
-
const Home = () => null
|
|
20
|
-
const About = () => null
|
|
21
|
-
const User = () => null
|
|
22
|
-
const NotFound = () => null
|
|
23
|
-
|
|
24
|
-
// ─── parseQuery — edge cases ─────────────────────────────────────────────────
|
|
25
|
-
|
|
26
|
-
describe('parseQuery — edge cases', () => {
|
|
27
|
-
test('handles URI-encoded keys', () => {
|
|
28
|
-
expect(parseQuery('hello%20world=value')).toEqual({ 'hello world': 'value' })
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
test('handles multiple equals signs in value', () => {
|
|
32
|
-
// Only the first `=` is the delimiter
|
|
33
|
-
expect(parseQuery('expr=a=b')).toEqual({ expr: 'a=b' })
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
test('last value wins for duplicate keys', () => {
|
|
37
|
-
expect(parseQuery('a=1&a=2')).toEqual({ a: '2' })
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
test('handles empty key (skipped)', () => {
|
|
41
|
-
// "=value" has empty key, should be skipped
|
|
42
|
-
expect(parseQuery('=value')).toEqual({})
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
test('handles key-only entry with no equals', () => {
|
|
46
|
-
expect(parseQuery('active')).toEqual({ active: '' })
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
test('handles mixed entries', () => {
|
|
50
|
-
expect(parseQuery('a=1&flag&b=2')).toEqual({ a: '1', flag: '', b: '2' })
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
test('decodes both keys and values', () => {
|
|
54
|
-
expect(parseQuery('na%2Fme=val%26ue')).toEqual({ 'na/me': 'val&ue' })
|
|
55
|
-
})
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
// ─── parseQueryMulti ─────────────────────────────────────────────────────────
|
|
59
|
-
|
|
60
|
-
describe('parseQueryMulti', () => {
|
|
61
|
-
test('returns empty object for empty string', () => {
|
|
62
|
-
expect(parseQueryMulti('')).toEqual({})
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
test('single value stays as string', () => {
|
|
66
|
-
expect(parseQueryMulti('color=red')).toEqual({ color: 'red' })
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
test('duplicate keys become arrays', () => {
|
|
70
|
-
expect(parseQueryMulti('color=red&color=blue')).toEqual({ color: ['red', 'blue'] })
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
test('triple duplicate keys become array of three', () => {
|
|
74
|
-
expect(parseQueryMulti('a=1&a=2&a=3')).toEqual({ a: ['1', '2', '3'] })
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
test('mixed single and multi values', () => {
|
|
78
|
-
expect(parseQueryMulti('color=red&color=blue&size=lg')).toEqual({
|
|
79
|
-
color: ['red', 'blue'],
|
|
80
|
-
size: 'lg',
|
|
81
|
-
})
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
test('key without value', () => {
|
|
85
|
-
expect(parseQueryMulti('flag')).toEqual({ flag: '' })
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
test('key without value duplicated', () => {
|
|
89
|
-
expect(parseQueryMulti('flag&flag')).toEqual({ flag: ['', ''] })
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
test('empty key is skipped', () => {
|
|
93
|
-
expect(parseQueryMulti('=value')).toEqual({})
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
test('decodes URI-encoded keys and values', () => {
|
|
97
|
-
expect(parseQueryMulti('na%2Fme=val%26ue')).toEqual({ 'na/me': 'val&ue' })
|
|
98
|
-
})
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
// ─── stringifyQuery — edge cases ─────────────────────────────────────────────
|
|
102
|
-
|
|
103
|
-
describe('stringifyQuery — edge cases', () => {
|
|
104
|
-
test('encodes special characters', () => {
|
|
105
|
-
const result = stringifyQuery({ 'key with space': 'value&more' })
|
|
106
|
-
expect(result).toBe('?key%20with%20space=value%26more')
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
test('handles single key-value pair', () => {
|
|
110
|
-
expect(stringifyQuery({ page: '1' })).toBe('?page=1')
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
test('handles key with empty value', () => {
|
|
114
|
-
expect(stringifyQuery({ debug: '' })).toBe('?debug')
|
|
115
|
-
})
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
// ─── matchPath — edge cases ──────────────────────────────────────────────────
|
|
119
|
-
|
|
120
|
-
describe('matchPath — edge cases', () => {
|
|
121
|
-
test('splat param captures remaining path', () => {
|
|
122
|
-
const result = matchPath('/files/:path*', '/files/a/b/c')
|
|
123
|
-
expect(result).toEqual({ path: 'a/b/c' })
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
test('splat param captures single segment', () => {
|
|
127
|
-
const result = matchPath('/files/:path*', '/files/readme.txt')
|
|
128
|
-
expect(result).toEqual({ path: 'readme.txt' })
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
test('optional param matches with value', () => {
|
|
132
|
-
const result = matchPath('/user/:id?', '/user/42')
|
|
133
|
-
expect(result).toEqual({ id: '42' })
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
test('optional param matches without value', () => {
|
|
137
|
-
const result = matchPath('/user/:id?', '/user')
|
|
138
|
-
expect(result).toEqual({})
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
test('returns null for too many path segments', () => {
|
|
142
|
-
expect(matchPath('/a/b', '/a/b/c')).toBeNull()
|
|
143
|
-
})
|
|
144
|
-
|
|
145
|
-
test('exact static match returns empty params', () => {
|
|
146
|
-
expect(matchPath('/about', '/about')).toEqual({})
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
test('root path matches root pattern', () => {
|
|
150
|
-
expect(matchPath('/', '/')).toEqual({})
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
test('mismatched static segment returns null', () => {
|
|
154
|
-
expect(matchPath('/foo', '/bar')).toBeNull()
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
test('decodes URI-encoded segments', () => {
|
|
158
|
-
const result = matchPath('/user/:name', '/user/hello%20world')
|
|
159
|
-
expect(result).toEqual({ name: 'hello world' })
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
test('multiple params in a row', () => {
|
|
163
|
-
const result = matchPath('/:a/:b/:c', '/x/y/z')
|
|
164
|
-
expect(result).toEqual({ a: 'x', b: 'y', c: 'z' })
|
|
165
|
-
})
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
// ─── resolveRoute — edge cases ───────────────────────────────────────────────
|
|
169
|
-
|
|
170
|
-
describe('resolveRoute — edge cases', () => {
|
|
171
|
-
const routes: RouteRecord[] = [
|
|
172
|
-
{ path: '/', component: Home },
|
|
173
|
-
{ path: '/about', component: About },
|
|
174
|
-
{ path: '/user/:id', component: User },
|
|
175
|
-
{
|
|
176
|
-
path: '/admin',
|
|
177
|
-
component: Home,
|
|
178
|
-
meta: { requiresAuth: true },
|
|
179
|
-
children: [
|
|
180
|
-
{ path: 'users', component: User },
|
|
181
|
-
{ path: 'settings', component: About },
|
|
182
|
-
],
|
|
183
|
-
},
|
|
184
|
-
{ path: '*', component: NotFound },
|
|
185
|
-
]
|
|
186
|
-
|
|
187
|
-
test('resolves root path with empty query', () => {
|
|
188
|
-
const r = resolveRoute('/', routes)
|
|
189
|
-
expect(r.path).toBe('/')
|
|
190
|
-
expect(r.params).toEqual({})
|
|
191
|
-
expect(r.query).toEqual({})
|
|
192
|
-
expect(r.hash).toBe('')
|
|
193
|
-
})
|
|
194
|
-
|
|
195
|
-
test('resolves path with query and hash in path portion', () => {
|
|
196
|
-
// Hash in the path portion (before ?) is extracted from pathAndHash
|
|
197
|
-
const r = resolveRoute('/about#section?key=val', routes)
|
|
198
|
-
expect(r.path).toBe('/about')
|
|
199
|
-
expect(r.hash).toBe('section')
|
|
200
|
-
expect(r.query).toEqual({ key: 'val' })
|
|
201
|
-
})
|
|
202
|
-
|
|
203
|
-
test('resolves path with query containing hash (hash after query)', () => {
|
|
204
|
-
// When hash follows query: /about?key=val#section
|
|
205
|
-
// The # is part of the query value since ? comes first
|
|
206
|
-
const r = resolveRoute('/about?key=val#section', routes)
|
|
207
|
-
expect(r.path).toBe('/about')
|
|
208
|
-
// The hash ends up in the query value since it's after the ?
|
|
209
|
-
expect(r.query.key).toContain('val')
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
test('resolves nested route with merged meta', () => {
|
|
213
|
-
const r = resolveRoute('/admin/users', routes)
|
|
214
|
-
expect(r.matched.length).toBe(2)
|
|
215
|
-
expect(r.meta.requiresAuth).toBe(true)
|
|
216
|
-
})
|
|
217
|
-
|
|
218
|
-
test('resolves dynamic param route', () => {
|
|
219
|
-
const r = resolveRoute('/user/123', routes)
|
|
220
|
-
expect(r.params.id).toBe('123')
|
|
221
|
-
expect(r.matched.length).toBeGreaterThan(0)
|
|
222
|
-
})
|
|
223
|
-
|
|
224
|
-
test('wildcard catches unmatched paths', () => {
|
|
225
|
-
const r = resolveRoute('/totally/unknown/path', routes)
|
|
226
|
-
expect(r.matched.length).toBeGreaterThan(0)
|
|
227
|
-
expect(r.matched[r.matched.length - 1]?.component).toBe(NotFound)
|
|
228
|
-
})
|
|
229
|
-
|
|
230
|
-
test('returns empty matched for no match without wildcard', () => {
|
|
231
|
-
const simpleRoutes: RouteRecord[] = [
|
|
232
|
-
{ path: '/', component: Home },
|
|
233
|
-
{ path: '/about', component: About },
|
|
234
|
-
]
|
|
235
|
-
const r = resolveRoute('/nonexistent', simpleRoutes)
|
|
236
|
-
expect(r.matched).toHaveLength(0)
|
|
237
|
-
})
|
|
238
|
-
|
|
239
|
-
test('resolves path with hash before query (edge case)', () => {
|
|
240
|
-
// hash in the path portion (before ?), query is separate
|
|
241
|
-
const r = resolveRoute('/#anchor?key=val', routes)
|
|
242
|
-
expect(r.hash).toBe('anchor')
|
|
243
|
-
})
|
|
244
|
-
|
|
245
|
-
test('resolves deeply nested routes', () => {
|
|
246
|
-
const deepRoutes: RouteRecord[] = [
|
|
247
|
-
{
|
|
248
|
-
path: '/a',
|
|
249
|
-
component: Home,
|
|
250
|
-
children: [
|
|
251
|
-
{
|
|
252
|
-
path: 'b',
|
|
253
|
-
component: About,
|
|
254
|
-
children: [{ path: 'c', component: User }],
|
|
255
|
-
},
|
|
256
|
-
],
|
|
257
|
-
},
|
|
258
|
-
]
|
|
259
|
-
const r = resolveRoute('/a/b/c', deepRoutes)
|
|
260
|
-
expect(r.matched.length).toBe(3)
|
|
261
|
-
})
|
|
262
|
-
|
|
263
|
-
test('optional param route matches with and without param', () => {
|
|
264
|
-
const optRoutes: RouteRecord[] = [{ path: '/page/:slug?', component: Home }]
|
|
265
|
-
const withParam = resolveRoute('/page/about', optRoutes)
|
|
266
|
-
expect(withParam.params.slug).toBe('about')
|
|
267
|
-
|
|
268
|
-
const withoutParam = resolveRoute('/page', optRoutes)
|
|
269
|
-
expect(withoutParam.matched.length).toBeGreaterThan(0)
|
|
270
|
-
})
|
|
271
|
-
|
|
272
|
-
test('splat route captures all remaining segments', () => {
|
|
273
|
-
const splatRoutes: RouteRecord[] = [{ path: '/docs/:rest*', component: Home }]
|
|
274
|
-
const r = resolveRoute('/docs/api/reference/types', splatRoutes)
|
|
275
|
-
expect(r.params.rest).toBe('api/reference/types')
|
|
276
|
-
})
|
|
277
|
-
|
|
278
|
-
test('caches compiled routes (same reference gives same result)', () => {
|
|
279
|
-
const r1 = resolveRoute('/about', routes)
|
|
280
|
-
const r2 = resolveRoute('/about', routes)
|
|
281
|
-
expect(r1.path).toBe(r2.path)
|
|
282
|
-
expect(r1.matched.length).toBe(r2.matched.length)
|
|
283
|
-
})
|
|
284
|
-
})
|
|
285
|
-
|
|
286
|
-
// ─── resolveRoute — alias support ────────────────────────────────────────────
|
|
287
|
-
|
|
288
|
-
describe('resolveRoute — alias', () => {
|
|
289
|
-
test('alias string resolves to same component', () => {
|
|
290
|
-
const aliasRoutes: RouteRecord[] = [
|
|
291
|
-
{ path: '/user/:id', alias: '/profile/:id', component: User },
|
|
292
|
-
]
|
|
293
|
-
const r = resolveRoute('/profile/42', aliasRoutes)
|
|
294
|
-
expect(r.matched.length).toBeGreaterThan(0)
|
|
295
|
-
expect(r.matched[0]?.component).toBe(User)
|
|
296
|
-
expect(r.params.id).toBe('42')
|
|
297
|
-
})
|
|
298
|
-
|
|
299
|
-
test('alias array resolves multiple paths to same component', () => {
|
|
300
|
-
const aliasRoutes: RouteRecord[] = [
|
|
301
|
-
{ path: '/home', alias: ['/index', '/main'], component: Home },
|
|
302
|
-
]
|
|
303
|
-
const r1 = resolveRoute('/index', aliasRoutes)
|
|
304
|
-
const r2 = resolveRoute('/main', aliasRoutes)
|
|
305
|
-
expect(r1.matched[0]?.component).toBe(Home)
|
|
306
|
-
expect(r2.matched[0]?.component).toBe(Home)
|
|
307
|
-
})
|
|
308
|
-
|
|
309
|
-
test('primary path still works with alias defined', () => {
|
|
310
|
-
const aliasRoutes: RouteRecord[] = [{ path: '/home', alias: '/index', component: Home }]
|
|
311
|
-
const r = resolveRoute('/home', aliasRoutes)
|
|
312
|
-
expect(r.matched[0]?.component).toBe(Home)
|
|
313
|
-
})
|
|
314
|
-
})
|
|
315
|
-
|
|
316
|
-
// ─── buildPath — edge cases ──────────────────────────────────────────────────
|
|
317
|
-
|
|
318
|
-
describe('buildPath — edge cases', () => {
|
|
319
|
-
test('omits segment for missing optional param', () => {
|
|
320
|
-
const result = buildPath('/user/:id?', {})
|
|
321
|
-
expect(result).toBe('/user')
|
|
322
|
-
})
|
|
323
|
-
|
|
324
|
-
test('includes segment for provided optional param', () => {
|
|
325
|
-
const result = buildPath('/user/:id?', { id: '42' })
|
|
326
|
-
expect(result).toBe('/user/42')
|
|
327
|
-
})
|
|
328
|
-
|
|
329
|
-
test('splat param preserves slashes', () => {
|
|
330
|
-
// buildPath regex captures the full param name including * via [^/]+
|
|
331
|
-
// so the key in params must match what the regex captures
|
|
332
|
-
const result = buildPath('/docs/:path*', { 'path*': 'api/reference/types' })
|
|
333
|
-
expect(result).toBe('/docs/api/reference/types')
|
|
334
|
-
})
|
|
335
|
-
|
|
336
|
-
test('encodes special characters in params', () => {
|
|
337
|
-
const result = buildPath('/user/:name', { name: 'hello world' })
|
|
338
|
-
expect(result).toBe('/user/hello%20world')
|
|
339
|
-
})
|
|
340
|
-
|
|
341
|
-
test('handles path with no params', () => {
|
|
342
|
-
const result = buildPath('/about', {})
|
|
343
|
-
expect(result).toBe('/about')
|
|
344
|
-
})
|
|
345
|
-
|
|
346
|
-
test('handles root path', () => {
|
|
347
|
-
const result = buildPath('/', {})
|
|
348
|
-
expect(result).toBe('/')
|
|
349
|
-
})
|
|
350
|
-
|
|
351
|
-
test('encodes splat param segments individually', () => {
|
|
352
|
-
// buildPath regex captures full param name including * via [^/]+
|
|
353
|
-
const result = buildPath('/files/:path*', { 'path*': 'dir/my file.txt' })
|
|
354
|
-
expect(result).toBe('/files/dir/my%20file.txt')
|
|
355
|
-
})
|
|
356
|
-
})
|
|
357
|
-
|
|
358
|
-
// ─── findRouteByName — edge cases ────────────────────────────────────────────
|
|
359
|
-
|
|
360
|
-
describe('findRouteByName — edge cases', () => {
|
|
361
|
-
test('finds deeply nested route', () => {
|
|
362
|
-
const routes: RouteRecord[] = [
|
|
363
|
-
{
|
|
364
|
-
path: '/a',
|
|
365
|
-
component: Home,
|
|
366
|
-
children: [
|
|
367
|
-
{
|
|
368
|
-
path: 'b',
|
|
369
|
-
component: About,
|
|
370
|
-
children: [{ path: 'c', component: User, name: 'deep' }],
|
|
371
|
-
},
|
|
372
|
-
],
|
|
373
|
-
},
|
|
374
|
-
]
|
|
375
|
-
const found = findRouteByName('deep', routes)
|
|
376
|
-
expect(found).not.toBeNull()
|
|
377
|
-
expect(found?.path).toBe('c')
|
|
378
|
-
})
|
|
379
|
-
|
|
380
|
-
test('returns first match in definition order', () => {
|
|
381
|
-
const routes: RouteRecord[] = [
|
|
382
|
-
{ path: '/first', component: Home, name: 'dup' },
|
|
383
|
-
{ path: '/second', component: About, name: 'dup' },
|
|
384
|
-
]
|
|
385
|
-
const found = findRouteByName('dup', routes)
|
|
386
|
-
expect(found?.path).toBe('/first')
|
|
387
|
-
})
|
|
388
|
-
|
|
389
|
-
test('returns null for empty routes array', () => {
|
|
390
|
-
expect(findRouteByName('anything', [])).toBeNull()
|
|
391
|
-
})
|
|
392
|
-
})
|
|
393
|
-
|
|
394
|
-
// ─── buildNameIndex — edge cases ─────────────────────────────────────────────
|
|
395
|
-
|
|
396
|
-
describe('buildNameIndex — edge cases', () => {
|
|
397
|
-
test('handles empty routes', () => {
|
|
398
|
-
const index = buildNameIndex([])
|
|
399
|
-
expect(index.size).toBe(0)
|
|
400
|
-
})
|
|
401
|
-
|
|
402
|
-
test('does not index routes without names', () => {
|
|
403
|
-
const routes: RouteRecord[] = [
|
|
404
|
-
{ path: '/', component: Home },
|
|
405
|
-
{ path: '/about', component: About },
|
|
406
|
-
]
|
|
407
|
-
const index = buildNameIndex(routes)
|
|
408
|
-
expect(index.size).toBe(0)
|
|
409
|
-
})
|
|
410
|
-
|
|
411
|
-
test('indexes deeply nested named routes', () => {
|
|
412
|
-
const routes: RouteRecord[] = [
|
|
413
|
-
{
|
|
414
|
-
path: '/a',
|
|
415
|
-
component: Home,
|
|
416
|
-
name: 'a',
|
|
417
|
-
children: [
|
|
418
|
-
{
|
|
419
|
-
path: 'b',
|
|
420
|
-
component: About,
|
|
421
|
-
name: 'b',
|
|
422
|
-
children: [{ path: 'c', component: User, name: 'c' }],
|
|
423
|
-
},
|
|
424
|
-
],
|
|
425
|
-
},
|
|
426
|
-
]
|
|
427
|
-
const index = buildNameIndex(routes)
|
|
428
|
-
expect(index.size).toBe(3)
|
|
429
|
-
expect(index.get('c')?.path).toBe('c')
|
|
430
|
-
})
|
|
431
|
-
})
|
|
432
|
-
|
|
433
|
-
// ─── resolveRoute — dynamic first segment ────────────────────────────────────
|
|
434
|
-
|
|
435
|
-
describe('resolveRoute — dynamic first segment routing', () => {
|
|
436
|
-
test('matches route where first segment is a param', () => {
|
|
437
|
-
const routes: RouteRecord[] = [{ path: '/:lang/about', component: About }]
|
|
438
|
-
const r = resolveRoute('/en/about', routes)
|
|
439
|
-
expect(r.matched.length).toBeGreaterThan(0)
|
|
440
|
-
expect(r.params.lang).toBe('en')
|
|
441
|
-
})
|
|
442
|
-
|
|
443
|
-
test('static routes take priority over dynamic first segment', () => {
|
|
444
|
-
const routes: RouteRecord[] = [
|
|
445
|
-
{ path: '/about', component: About },
|
|
446
|
-
{ path: '/:slug', component: User },
|
|
447
|
-
]
|
|
448
|
-
const r = resolveRoute('/about', routes)
|
|
449
|
-
expect(r.matched[0]?.component).toBe(About)
|
|
450
|
-
})
|
|
451
|
-
})
|
|
452
|
-
|
|
453
|
-
// ─── resolveRoute — wildcard children ────────────────────────────────────────
|
|
454
|
-
|
|
455
|
-
describe('resolveRoute — wildcard patterns', () => {
|
|
456
|
-
test('(.*) catches any path', () => {
|
|
457
|
-
const routes: RouteRecord[] = [
|
|
458
|
-
{ path: '/', component: Home },
|
|
459
|
-
{ path: '(.*)', component: NotFound },
|
|
460
|
-
]
|
|
461
|
-
const r = resolveRoute('/any/path/here', routes)
|
|
462
|
-
expect(r.matched[r.matched.length - 1]?.component).toBe(NotFound)
|
|
463
|
-
})
|
|
464
|
-
|
|
465
|
-
test('* catches any path', () => {
|
|
466
|
-
const routes: RouteRecord[] = [
|
|
467
|
-
{ path: '/', component: Home },
|
|
468
|
-
{ path: '*', component: NotFound },
|
|
469
|
-
]
|
|
470
|
-
const r = resolveRoute('/any/path/here', routes)
|
|
471
|
-
expect(r.matched[r.matched.length - 1]?.component).toBe(NotFound)
|
|
472
|
-
})
|
|
473
|
-
})
|
|
474
|
-
|
|
475
|
-
// ─── + as space in query parsing (application/x-www-form-urlencoded) ────────
|
|
476
|
-
|
|
477
|
-
describe('parseQuery — + as space', () => {
|
|
478
|
-
it('decodes + as space in values', () => {
|
|
479
|
-
expect(parseQuery('name=john+doe')).toEqual({ name: 'john doe' })
|
|
480
|
-
})
|
|
481
|
-
|
|
482
|
-
it('decodes + as space in keys', () => {
|
|
483
|
-
expect(parseQuery('first+name=Alice')).toEqual({ 'first name': 'Alice' })
|
|
484
|
-
})
|
|
485
|
-
|
|
486
|
-
it('handles mixed + and %20', () => {
|
|
487
|
-
expect(parseQuery('a=hello+world&b=foo%20bar')).toEqual({
|
|
488
|
-
a: 'hello world',
|
|
489
|
-
b: 'foo bar',
|
|
490
|
-
})
|
|
491
|
-
})
|
|
492
|
-
|
|
493
|
-
it('handles multiple + in a value', () => {
|
|
494
|
-
expect(parseQuery('q=one+two+three')).toEqual({ q: 'one two three' })
|
|
495
|
-
})
|
|
496
|
-
})
|
|
497
|
-
|
|
498
|
-
describe('parseQueryMulti — + as space', () => {
|
|
499
|
-
it('decodes + as space in values', () => {
|
|
500
|
-
expect(parseQueryMulti('tag=hello+world&tag=foo+bar')).toEqual({
|
|
501
|
-
tag: ['hello world', 'foo bar'],
|
|
502
|
-
})
|
|
503
|
-
})
|
|
504
|
-
})
|
|
505
|
-
|
|
506
|
-
// ─── resolveRoute — notFoundComponent fallback (PR L5) ───────────────────────
|
|
507
|
-
//
|
|
508
|
-
// When a URL doesn't match any route AND a parent record has a
|
|
509
|
-
// `notFoundComponent`, resolveRoute builds a synthetic matched chain
|
|
510
|
-
// `[...ancestors, parentLayout, syntheticLeaf]` so the not-found
|
|
511
|
-
// component renders INSIDE its ancestor layouts' chrome.
|
|
512
|
-
|
|
513
|
-
describe('resolveRoute — notFoundComponent fallback', () => {
|
|
514
|
-
const Layout = () => null
|
|
515
|
-
const NotFoundPage = () => null
|
|
516
|
-
|
|
517
|
-
it('synthesises chain through root layout when URL is unmatched', () => {
|
|
518
|
-
const routes: RouteRecord[] = [
|
|
519
|
-
{
|
|
520
|
-
path: '/',
|
|
521
|
-
component: Layout,
|
|
522
|
-
notFoundComponent: NotFoundPage,
|
|
523
|
-
children: [
|
|
524
|
-
{ path: '/', component: Home },
|
|
525
|
-
{ path: '/about', component: About },
|
|
526
|
-
],
|
|
527
|
-
},
|
|
528
|
-
]
|
|
529
|
-
|
|
530
|
-
const r = resolveRoute('/this-does-not-exist', routes)
|
|
531
|
-
expect(r.isNotFound).toBe(true)
|
|
532
|
-
// Chain: [rootLayout, syntheticLeaf]. The synthetic leaf carries
|
|
533
|
-
// NotFoundPage as its component so the deepest RouterView resolves it.
|
|
534
|
-
expect(r.matched.length).toBe(2)
|
|
535
|
-
expect(r.matched[0]?.component).toBe(Layout)
|
|
536
|
-
expect(r.matched[1]?.component).toBe(NotFoundPage)
|
|
537
|
-
expect(r.matched[1]?.path).toBe('__pyreon_not_found_leaf__')
|
|
538
|
-
})
|
|
539
|
-
|
|
540
|
-
it('returns empty matched when no notFoundComponent anywhere', () => {
|
|
541
|
-
const routes: RouteRecord[] = [
|
|
542
|
-
{ path: '/', component: Home },
|
|
543
|
-
{ path: '/about', component: About },
|
|
544
|
-
]
|
|
545
|
-
|
|
546
|
-
const r = resolveRoute('/unknown', routes)
|
|
547
|
-
expect(r.isNotFound).toBeUndefined()
|
|
548
|
-
expect(r.matched.length).toBe(0)
|
|
549
|
-
})
|
|
550
|
-
|
|
551
|
-
it('does not trigger fallback for matched routes', () => {
|
|
552
|
-
const routes: RouteRecord[] = [
|
|
553
|
-
{
|
|
554
|
-
path: '/',
|
|
555
|
-
component: Layout,
|
|
556
|
-
notFoundComponent: NotFoundPage,
|
|
557
|
-
children: [{ path: '/about', component: About }],
|
|
558
|
-
},
|
|
559
|
-
]
|
|
560
|
-
|
|
561
|
-
const r = resolveRoute('/about', routes)
|
|
562
|
-
expect(r.isNotFound).toBeUndefined()
|
|
563
|
-
expect(r.matched).not.toContain(NotFoundPage)
|
|
564
|
-
})
|
|
565
|
-
|
|
566
|
-
it('picks the DEEPEST matching parent when nested layouts have notFoundComponent', () => {
|
|
567
|
-
const DeNotFound = () => null
|
|
568
|
-
const RootNotFound = () => null
|
|
569
|
-
const DeLayout = () => null
|
|
570
|
-
const routes: RouteRecord[] = [
|
|
571
|
-
{
|
|
572
|
-
path: '/',
|
|
573
|
-
component: Layout,
|
|
574
|
-
notFoundComponent: RootNotFound,
|
|
575
|
-
children: [
|
|
576
|
-
{
|
|
577
|
-
path: '/de',
|
|
578
|
-
component: DeLayout,
|
|
579
|
-
notFoundComponent: DeNotFound,
|
|
580
|
-
children: [{ path: '/de/about', component: About }],
|
|
581
|
-
},
|
|
582
|
-
],
|
|
583
|
-
},
|
|
584
|
-
]
|
|
585
|
-
|
|
586
|
-
// URL under /de prefix — should pick the DEEPER /de layout's notFound,
|
|
587
|
-
// not the root's
|
|
588
|
-
const r = resolveRoute('/de/unknown', routes)
|
|
589
|
-
expect(r.isNotFound).toBe(true)
|
|
590
|
-
expect(r.matched[r.matched.length - 1]?.component).toBe(DeNotFound)
|
|
591
|
-
// URL under root only — should fall back to root layout's notFound
|
|
592
|
-
const r2 = resolveRoute('/about-typo', routes)
|
|
593
|
-
expect(r2.isNotFound).toBe(true)
|
|
594
|
-
expect(r2.matched[r2.matched.length - 1]?.component).toBe(RootNotFound)
|
|
595
|
-
})
|
|
596
|
-
|
|
597
|
-
it('respects segment boundary in path-prefix match (no substring confusion)', () => {
|
|
598
|
-
const EnNotFound = () => null
|
|
599
|
-
const routes: RouteRecord[] = [
|
|
600
|
-
{
|
|
601
|
-
path: '/en',
|
|
602
|
-
component: Layout,
|
|
603
|
-
notFoundComponent: EnNotFound,
|
|
604
|
-
children: [],
|
|
605
|
-
},
|
|
606
|
-
]
|
|
607
|
-
|
|
608
|
-
// `/encyclopedia` MUST NOT match `/en` as a prefix — full segment boundary required.
|
|
609
|
-
const r = resolveRoute('/encyclopedia', routes)
|
|
610
|
-
expect(r.isNotFound).toBeUndefined()
|
|
611
|
-
expect(r.matched.length).toBe(0)
|
|
612
|
-
})
|
|
613
|
-
|
|
614
|
-
it('non-matching URL under a layout prefix triggers fallback (deeper than root)', () => {
|
|
615
|
-
const routes: RouteRecord[] = [
|
|
616
|
-
{
|
|
617
|
-
path: '/admin',
|
|
618
|
-
component: Layout,
|
|
619
|
-
notFoundComponent: NotFoundPage,
|
|
620
|
-
children: [{ path: '/admin/users', component: User }],
|
|
621
|
-
},
|
|
622
|
-
]
|
|
623
|
-
|
|
624
|
-
// `/admin/missing` doesn't match `/admin` (layout itself) OR `/admin/users`
|
|
625
|
-
// → notFoundComponent fallback applies, chain wraps the admin layout
|
|
626
|
-
const r = resolveRoute('/admin/missing', routes)
|
|
627
|
-
expect(r.isNotFound).toBe(true)
|
|
628
|
-
expect(r.matched[0]?.component).toBe(Layout)
|
|
629
|
-
expect(r.matched[r.matched.length - 1]?.component).toBe(NotFoundPage)
|
|
630
|
-
})
|
|
631
|
-
|
|
632
|
-
it('synthetic leaf has the right path marker (for runtime identification)', () => {
|
|
633
|
-
const routes: RouteRecord[] = [
|
|
634
|
-
{
|
|
635
|
-
path: '/',
|
|
636
|
-
component: Layout,
|
|
637
|
-
notFoundComponent: NotFoundPage,
|
|
638
|
-
children: [{ path: '/', component: Home }],
|
|
639
|
-
},
|
|
640
|
-
]
|
|
641
|
-
const r = resolveRoute('/unknown', routes)
|
|
642
|
-
expect(r.matched[r.matched.length - 1]?.path).toBe('__pyreon_not_found_leaf__')
|
|
643
|
-
})
|
|
644
|
-
|
|
645
|
-
it('preserves query string on the synthetic 404 resolution', () => {
|
|
646
|
-
const routes: RouteRecord[] = [
|
|
647
|
-
{
|
|
648
|
-
path: '/',
|
|
649
|
-
component: Layout,
|
|
650
|
-
notFoundComponent: NotFoundPage,
|
|
651
|
-
children: [{ path: '/', component: Home }],
|
|
652
|
-
},
|
|
653
|
-
]
|
|
654
|
-
const r = resolveRoute('/unknown?foo=bar', routes)
|
|
655
|
-
expect(r.isNotFound).toBe(true)
|
|
656
|
-
expect(r.query).toEqual({ foo: 'bar' })
|
|
657
|
-
expect(r.path).toBe('/unknown')
|
|
658
|
-
})
|
|
659
|
-
|
|
660
|
-
it('fires fallback via DefaultChromeLayout when the only notFoundComponent is on a page record without children', () => {
|
|
661
|
-
// PR B (layout-less app fallback): page-level `notFoundComponent` now
|
|
662
|
-
// gets wrapped in a synthetic `DefaultChromeLayout` (`<main data-
|
|
663
|
-
// pyreon-default-chrome>`) so the render pipeline produces semantic-
|
|
664
|
-
// HTML output instead of bare component markup. Pre-PR-B the resolver
|
|
665
|
-
// returned an empty chain here — the standalone-render path in the
|
|
666
|
-
// SSG plugin / runtime handler would render the component bare with
|
|
667
|
-
// no wrapping (the documented "no chrome" limitation).
|
|
668
|
-
//
|
|
669
|
-
// Tests in the `layout-less app fallback (PR B)` describe block
|
|
670
|
-
// below cover the synthetic chain shape in detail.
|
|
671
|
-
const PageOnly = () => null
|
|
672
|
-
const routes: RouteRecord[] = [
|
|
673
|
-
{ path: '/', component: PageOnly, notFoundComponent: NotFoundPage },
|
|
674
|
-
]
|
|
675
|
-
const r = resolveRoute('/unknown', routes)
|
|
676
|
-
expect(r.isNotFound).toBe(true)
|
|
677
|
-
// Synthetic chain: [DefaultChromeLayout, syntheticLeaf]
|
|
678
|
-
expect(r.matched).toHaveLength(2)
|
|
679
|
-
expect(r.matched[0]?.component).toBe(DefaultChromeLayout)
|
|
680
|
-
expect(r.matched[1]?.component).toBe(NotFoundPage)
|
|
681
|
-
})
|
|
682
|
-
|
|
683
|
-
it('does NOT fire when wildcard catch-all is configured', () => {
|
|
684
|
-
const Catchall = () => null
|
|
685
|
-
const routes: RouteRecord[] = [
|
|
686
|
-
{ path: '/', component: Home, notFoundComponent: NotFoundPage },
|
|
687
|
-
{ path: '(.*)', component: Catchall },
|
|
688
|
-
]
|
|
689
|
-
|
|
690
|
-
// Wildcard catches everything first — notFoundComponent fallback never runs.
|
|
691
|
-
const r = resolveRoute('/unknown', routes)
|
|
692
|
-
expect(r.isNotFound).toBeUndefined()
|
|
693
|
-
expect(r.matched[0]?.component).toBe(Catchall)
|
|
694
|
-
})
|
|
695
|
-
|
|
696
|
-
// ─── Layout-less app fallback (PR B) ───────────────────────────────────────
|
|
697
|
-
//
|
|
698
|
-
// When the user has a page-level `notFoundComponent` (`_404.tsx` at the
|
|
699
|
-
// route root without a wrapping `_layout.tsx`), the resolver synthesizes
|
|
700
|
-
// a chain `[DefaultChromeLayout, syntheticLeaf]` so the render pipeline
|
|
701
|
-
// produces 404 HTML wrapped in `<main data-pyreon-default-chrome>`.
|
|
702
|
-
//
|
|
703
|
-
// These tests import `./components` so the setter call at the bottom of
|
|
704
|
-
// components.tsx runs and registers `DefaultChromeLayout` with match.ts.
|
|
705
|
-
// Without that import, `_defaultChromeLayout` would be null and the
|
|
706
|
-
// fallback returns null (graceful degradation to the standalone-render
|
|
707
|
-
// path). The import happens at the top of the test file via the
|
|
708
|
-
// top-level `import` chain — describe block doesn't need to do anything.
|
|
709
|
-
describe('layout-less app fallback (PR B)', () => {
|
|
710
|
-
it('synthesizes a [DefaultChromeLayout, syntheticLeaf] chain when only a page record has notFoundComponent', () => {
|
|
711
|
-
const Index = () => null
|
|
712
|
-
const NotFound = () => null
|
|
713
|
-
const routes: RouteRecord[] = [
|
|
714
|
-
{ path: '/', component: Index, notFoundComponent: NotFound },
|
|
715
|
-
]
|
|
716
|
-
const r = resolveRoute('/missing', routes)
|
|
717
|
-
expect(r.isNotFound).toBe(true)
|
|
718
|
-
// Chain shape: [synthetic chrome layout, synthetic leaf]
|
|
719
|
-
expect(r.matched).toHaveLength(2)
|
|
720
|
-
// First entry is the synthetic chrome layout (with the
|
|
721
|
-
// page's `fullPath` carried for downstream identification).
|
|
722
|
-
expect(r.matched[0]?.path).toBe('/')
|
|
723
|
-
expect(typeof r.matched[0]?.component).toBe('function')
|
|
724
|
-
// Second entry is the synthetic leaf with the user's notFoundComponent.
|
|
725
|
-
expect(r.matched[1]?.component).toBe(NotFound)
|
|
726
|
-
})
|
|
727
|
-
|
|
728
|
-
it('the synthetic chrome layout wraps the leaf in <main data-pyreon-default-chrome>', () => {
|
|
729
|
-
// Render the chain through the actual default chrome component to
|
|
730
|
-
// confirm the `<main>` wrapper materializes. The component reads
|
|
731
|
-
// RouterContext to render its inner RouterView, so we need a
|
|
732
|
-
// minimal harness — easiest path is to verify it's the DefaultChromeLayout
|
|
733
|
-
// we exported from components.tsx (identity check).
|
|
734
|
-
const NotFound = () => null
|
|
735
|
-
const routes: RouteRecord[] = [
|
|
736
|
-
{ path: '/', component: () => null, notFoundComponent: NotFound },
|
|
737
|
-
]
|
|
738
|
-
const r = resolveRoute('/missing', routes)
|
|
739
|
-
// Identity-check: the synthetic layout's component IS the registered
|
|
740
|
-
// DefaultChromeLayout. Avoids re-rendering — the runtime render path
|
|
741
|
-
// is covered by the verify-modes / e2e cells.
|
|
742
|
-
expect(r.matched[0]?.component).toBe(DefaultChromeLayout)
|
|
743
|
-
})
|
|
744
|
-
|
|
745
|
-
it('layout-with-notFoundComponent still wins over a page-level one (same urlPath)', () => {
|
|
746
|
-
// Both layout AND page have notFoundComponent. The layout-first
|
|
747
|
-
// logic from PR L5 still applies — page-level is ONLY the fallback.
|
|
748
|
-
const PageNotFound = () => null
|
|
749
|
-
const LayoutNotFound = () => null
|
|
750
|
-
const routes: RouteRecord[] = [
|
|
751
|
-
{
|
|
752
|
-
path: '/',
|
|
753
|
-
component: () => null,
|
|
754
|
-
notFoundComponent: LayoutNotFound,
|
|
755
|
-
children: [
|
|
756
|
-
{ path: '/page', component: () => null, notFoundComponent: PageNotFound },
|
|
757
|
-
],
|
|
758
|
-
},
|
|
759
|
-
]
|
|
760
|
-
const r = resolveRoute('/missing', routes)
|
|
761
|
-
expect(r.isNotFound).toBe(true)
|
|
762
|
-
// Should pick the layout, not the page — layout has children so
|
|
763
|
-
// the layout pass matches and wins.
|
|
764
|
-
const leaf = r.matched[r.matched.length - 1]
|
|
765
|
-
expect(leaf?.component).toBe(LayoutNotFound)
|
|
766
|
-
})
|
|
767
|
-
|
|
768
|
-
it('does NOT wrap when there is a wildcard catch-all (wildcard always wins)', () => {
|
|
769
|
-
// The wildcard route matches the URL directly, so the fallback never
|
|
770
|
-
// fires. Same precedence as the existing wildcard test above.
|
|
771
|
-
const Catchall = () => null
|
|
772
|
-
const NotFound = () => null
|
|
773
|
-
const routes: RouteRecord[] = [
|
|
774
|
-
{ path: '/', component: () => null, notFoundComponent: NotFound },
|
|
775
|
-
{ path: '(.*)', component: Catchall },
|
|
776
|
-
]
|
|
777
|
-
const r = resolveRoute('/missing', routes)
|
|
778
|
-
expect(r.isNotFound).toBeUndefined()
|
|
779
|
-
expect(r.matched[0]?.component).toBe(Catchall)
|
|
780
|
-
})
|
|
781
|
-
})
|
|
782
|
-
})
|