@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.
@@ -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
- })