@pyreon/router 0.13.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -2
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +467 -56
- package/lib/types/index.d.ts +218 -13
- package/package.json +6 -5
- package/src/components.tsx +299 -32
- package/src/env.d.ts +6 -0
- package/src/index.ts +5 -0
- package/src/loader.ts +18 -2
- package/src/manifest.ts +63 -0
- package/src/match.ts +48 -8
- package/src/not-found.ts +75 -0
- package/src/redirect.ts +63 -0
- package/src/router.ts +263 -45
- package/src/tests/loader.test.ts +149 -0
- package/src/tests/manifest-snapshot.test.ts +5 -1
- package/src/tests/match.test.ts +31 -0
- package/src/tests/native-markers.test.ts +18 -0
- package/src/tests/redirect.test.ts +96 -0
- package/src/tests/router.browser.test.tsx +68 -1
- package/src/tests/router.test.ts +686 -1
- package/src/tests/routerlink-reactive-to.browser.test.tsx +158 -0
- package/src/types.ts +95 -1
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
package/src/tests/loader.test.ts
CHANGED
|
@@ -581,3 +581,152 @@ describe('router.preload', () => {
|
|
|
581
581
|
expect(router._componentCache.get(routes[1] as RouteRecord)).toBe(Lazy)
|
|
582
582
|
})
|
|
583
583
|
})
|
|
584
|
+
|
|
585
|
+
// ─── _loaderCache LRU cap (regression for missing _maxCacheSize wiring) ────
|
|
586
|
+
describe('router — _loaderCache LRU cap', () => {
|
|
587
|
+
// Pre-fix: `_maxCacheSize` was wired through from `RouterOptions.maxCacheSize`
|
|
588
|
+
// (default 100) but the loader cache write paths in router.ts never read it
|
|
589
|
+
// — only `_componentCache` enforced the cap. Long-running SPAs navigating
|
|
590
|
+
// dynamic-param routes (`/posts/:id` with hundreds of unique IDs) would
|
|
591
|
+
// accumulate `_loaderCache` entries until manual `invalidateLoader()`.
|
|
592
|
+
// Post-fix: the helper `loaderCacheSet` evicts oldest (insertion-order FIFO)
|
|
593
|
+
// when over the cap, mirroring `_componentCache`.
|
|
594
|
+
test('caps _loaderCache at maxCacheSize, evicts oldest first', async () => {
|
|
595
|
+
const Page = () => null
|
|
596
|
+
const routes: RouteRecord[] = [
|
|
597
|
+
{
|
|
598
|
+
path: '/posts/:id',
|
|
599
|
+
component: Page,
|
|
600
|
+
loader: async ({ params }) => `post-${params.id}`,
|
|
601
|
+
loaderKey: ({ params }) => `posts:${params.id}`,
|
|
602
|
+
},
|
|
603
|
+
]
|
|
604
|
+
const router = createRouter({ routes, maxCacheSize: 3, url: '/' }) as RouterInstance
|
|
605
|
+
|
|
606
|
+
// Drive the loader through 4 distinct keys
|
|
607
|
+
await router.push('/posts/1')
|
|
608
|
+
await router.push('/posts/2')
|
|
609
|
+
await router.push('/posts/3')
|
|
610
|
+
await router.push('/posts/4')
|
|
611
|
+
|
|
612
|
+
// Cache must be capped at 3 (not 4).
|
|
613
|
+
expect(router._loaderCache.size).toBe(3)
|
|
614
|
+
|
|
615
|
+
// FIFO: the OLDEST insertion (posts:1) must have been evicted.
|
|
616
|
+
expect(router._loaderCache.has('posts:1')).toBe(false)
|
|
617
|
+
expect(router._loaderCache.has('posts:2')).toBe(true)
|
|
618
|
+
expect(router._loaderCache.has('posts:3')).toBe(true)
|
|
619
|
+
expect(router._loaderCache.has('posts:4')).toBe(true)
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
test('does not evict when cap is not exceeded', async () => {
|
|
623
|
+
const Page = () => null
|
|
624
|
+
const routes: RouteRecord[] = [
|
|
625
|
+
{
|
|
626
|
+
path: '/posts/:id',
|
|
627
|
+
component: Page,
|
|
628
|
+
loader: async ({ params }) => `post-${params.id}`,
|
|
629
|
+
loaderKey: ({ params }) => `posts:${params.id}`,
|
|
630
|
+
},
|
|
631
|
+
]
|
|
632
|
+
const router = createRouter({ routes, maxCacheSize: 100, url: '/' }) as RouterInstance
|
|
633
|
+
|
|
634
|
+
await router.push('/posts/1')
|
|
635
|
+
await router.push('/posts/2')
|
|
636
|
+
await router.push('/posts/3')
|
|
637
|
+
|
|
638
|
+
expect(router._loaderCache.size).toBe(3)
|
|
639
|
+
expect(router._loaderCache.has('posts:1')).toBe(true)
|
|
640
|
+
expect(router._loaderCache.has('posts:2')).toBe(true)
|
|
641
|
+
expect(router._loaderCache.has('posts:3')).toBe(true)
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
test('default maxCacheSize (100) caps cache after 100 unique keys', async () => {
|
|
645
|
+
const Page = () => null
|
|
646
|
+
const routes: RouteRecord[] = [
|
|
647
|
+
{
|
|
648
|
+
path: '/posts/:id',
|
|
649
|
+
component: Page,
|
|
650
|
+
loader: async ({ params }) => `post-${params.id}`,
|
|
651
|
+
loaderKey: ({ params }) => `posts:${params.id}`,
|
|
652
|
+
},
|
|
653
|
+
]
|
|
654
|
+
// No explicit maxCacheSize — uses default 100
|
|
655
|
+
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
656
|
+
|
|
657
|
+
for (let i = 0; i < 105; i++) {
|
|
658
|
+
await router.push(`/posts/${i}`)
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
expect(router._loaderCache.size).toBe(100)
|
|
662
|
+
// Earliest 5 keys (0-4) evicted.
|
|
663
|
+
expect(router._loaderCache.has('posts:0')).toBe(false)
|
|
664
|
+
expect(router._loaderCache.has('posts:4')).toBe(false)
|
|
665
|
+
expect(router._loaderCache.has('posts:5')).toBe(true)
|
|
666
|
+
expect(router._loaderCache.has('posts:104')).toBe(true)
|
|
667
|
+
})
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
// ─── Regression: dedup must not return aborted in-flight promise ───────────
|
|
671
|
+
//
|
|
672
|
+
// Pre-fix: `router.push` aborts `_abortController` BEFORE starting the next
|
|
673
|
+
// nav. If two pushes to the same path happen back-to-back, the in-flight
|
|
674
|
+
// Map still holds nav-1's promise (its `.catch` hasn't run yet). The dedup
|
|
675
|
+
// returned that promise to nav-2 — but its bound signal is already aborted,
|
|
676
|
+
// so nav-2's data path is broken even though it has its own fresh signal.
|
|
677
|
+
//
|
|
678
|
+
// Post-fix: `_loaderInflight` stores `{ promise, signal }`. Dedup is gated
|
|
679
|
+
// on `!signal.aborted`. Aborted entries fall through to a fresh execute
|
|
680
|
+
// using nav-2's signal.
|
|
681
|
+
describe('router — _loaderInflight aborted-signal dedup', () => {
|
|
682
|
+
test('back-to-back navigation re-executes loader with fresh signal when previous was aborted', async () => {
|
|
683
|
+
let invocations = 0
|
|
684
|
+
let resolveLoader1: ((data: unknown) => void) | null = null
|
|
685
|
+
|
|
686
|
+
const Page = () => null
|
|
687
|
+
const routes: RouteRecord[] = [
|
|
688
|
+
{ path: '/', component: Page },
|
|
689
|
+
{
|
|
690
|
+
path: '/data',
|
|
691
|
+
component: Page,
|
|
692
|
+
loader: async ({ signal }) => {
|
|
693
|
+
invocations++
|
|
694
|
+
const myInvocation = invocations
|
|
695
|
+
// Wire signal-abort → reject so the nav actually fails on abort.
|
|
696
|
+
// The first invocation hangs until manually resolved; later
|
|
697
|
+
// invocations resolve immediately.
|
|
698
|
+
if (myInvocation === 1) {
|
|
699
|
+
return new Promise((_resolve, reject) => {
|
|
700
|
+
signal?.addEventListener('abort', () => reject(new Error('aborted')))
|
|
701
|
+
resolveLoader1 = _resolve
|
|
702
|
+
})
|
|
703
|
+
}
|
|
704
|
+
return `data-${myInvocation}`
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
]
|
|
708
|
+
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
709
|
+
|
|
710
|
+
// Nav 1 → /data. Loader invocation #1 starts, hangs.
|
|
711
|
+
const nav1 = router.push('/data').catch(() => {})
|
|
712
|
+
await new Promise<void>((r) => queueMicrotask(() => r()))
|
|
713
|
+
|
|
714
|
+
// Nav 2 → /data. router.push aborts ac1 first, then calls executeLoader.
|
|
715
|
+
// Pre-fix: dedup returns nav-1's promise (whose signal is now aborted).
|
|
716
|
+
// Post-fix: dedup skipped (signal.aborted=true), fresh loader runs.
|
|
717
|
+
const nav2 = router.push('/data')
|
|
718
|
+
|
|
719
|
+
// Resolve nav-1's hung promise (won't actually deliver — already aborted)
|
|
720
|
+
const r1 = resolveLoader1 as ((d: unknown) => void) | null
|
|
721
|
+
if (r1) r1('data-1')
|
|
722
|
+
|
|
723
|
+
await nav2
|
|
724
|
+
await nav1
|
|
725
|
+
|
|
726
|
+
// Post-fix: 2 invocations (nav-1 aborted, nav-2 ran fresh).
|
|
727
|
+
// Pre-fix: 1 invocation (nav-2 deduped to nav-1's aborted promise).
|
|
728
|
+
expect(invocations).toBe(2)
|
|
729
|
+
expect(router.currentRoute().path).toBe('/data')
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
})
|
|
@@ -87,8 +87,12 @@ describe('gen-docs — router snapshot', () => {
|
|
|
87
87
|
|
|
88
88
|
it('renders @pyreon/router to MCP api-reference entries — one per api[] item', () => {
|
|
89
89
|
const record = renderApiReferenceEntries(routerManifest)
|
|
90
|
-
expect(Object.keys(record).length).toBe(
|
|
90
|
+
expect(Object.keys(record).length).toBe(18)
|
|
91
91
|
expect(Object.keys(record)).toContain('router/createRouter')
|
|
92
|
+
// PR-B added redirect/isRedirectError/getRedirectInfo entries.
|
|
93
|
+
expect(Object.keys(record)).toContain('router/redirect')
|
|
94
|
+
expect(Object.keys(record)).toContain('router/isRedirectError')
|
|
95
|
+
expect(Object.keys(record)).toContain('router/getRedirectInfo')
|
|
92
96
|
// Spot-check the flagship API — createRouter is the factory
|
|
93
97
|
const createRouter = record['router/createRouter']!
|
|
94
98
|
expect(createRouter.notes).toContain('routes')
|
package/src/tests/match.test.ts
CHANGED
|
@@ -465,3 +465,34 @@ describe('resolveRoute — wildcard patterns', () => {
|
|
|
465
465
|
expect(r.matched[r.matched.length - 1]?.component).toBe(NotFound)
|
|
466
466
|
})
|
|
467
467
|
})
|
|
468
|
+
|
|
469
|
+
// ─── + as space in query parsing (application/x-www-form-urlencoded) ────────
|
|
470
|
+
|
|
471
|
+
describe('parseQuery — + as space', () => {
|
|
472
|
+
it('decodes + as space in values', () => {
|
|
473
|
+
expect(parseQuery('name=john+doe')).toEqual({ name: 'john doe' })
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it('decodes + as space in keys', () => {
|
|
477
|
+
expect(parseQuery('first+name=Alice')).toEqual({ 'first name': 'Alice' })
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it('handles mixed + and %20', () => {
|
|
481
|
+
expect(parseQuery('a=hello+world&b=foo%20bar')).toEqual({
|
|
482
|
+
a: 'hello world',
|
|
483
|
+
b: 'foo bar',
|
|
484
|
+
})
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
it('handles multiple + in a value', () => {
|
|
488
|
+
expect(parseQuery('q=one+two+three')).toEqual({ q: 'one two three' })
|
|
489
|
+
})
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
describe('parseQueryMulti — + as space', () => {
|
|
493
|
+
it('decodes + as space in values', () => {
|
|
494
|
+
expect(parseQueryMulti('tag=hello+world&tag=foo+bar')).toEqual({
|
|
495
|
+
tag: ['hello world', 'foo bar'],
|
|
496
|
+
})
|
|
497
|
+
})
|
|
498
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { isNativeCompat } from '@pyreon/core'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { RouterLink, RouterProvider, RouterView } from '../components'
|
|
4
|
+
|
|
5
|
+
// Marker-presence assertion (PR 3 lock-in). Bisect-verified: removing
|
|
6
|
+
// any of the `nativeCompat(...)` calls in components.tsx fails the
|
|
7
|
+
// corresponding test.
|
|
8
|
+
describe('native-compat markers — @pyreon/router', () => {
|
|
9
|
+
it('RouterProvider is marked native', () => {
|
|
10
|
+
expect(isNativeCompat(RouterProvider)).toBe(true)
|
|
11
|
+
})
|
|
12
|
+
it('RouterView is marked native', () => {
|
|
13
|
+
expect(isNativeCompat(RouterView)).toBe(true)
|
|
14
|
+
})
|
|
15
|
+
it('RouterLink is marked native', () => {
|
|
16
|
+
expect(isNativeCompat(RouterLink)).toBe(true)
|
|
17
|
+
})
|
|
18
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { getRedirectInfo, isRedirectError, redirect } from '../redirect'
|
|
3
|
+
|
|
4
|
+
describe('redirect()', () => {
|
|
5
|
+
it('throws an error branded with the REDIRECT symbol', () => {
|
|
6
|
+
expect(() => redirect('/login')).toThrow()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('captures URL + default 307 status on the thrown error', () => {
|
|
10
|
+
let caught: unknown
|
|
11
|
+
try {
|
|
12
|
+
redirect('/login')
|
|
13
|
+
} catch (err) {
|
|
14
|
+
caught = err
|
|
15
|
+
}
|
|
16
|
+
const info = getRedirectInfo(caught)
|
|
17
|
+
expect(info).toEqual({ url: '/login', status: 307 })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('captures the custom status when one is provided', () => {
|
|
21
|
+
let caught: unknown
|
|
22
|
+
try {
|
|
23
|
+
redirect('/perm', 308)
|
|
24
|
+
} catch (err) {
|
|
25
|
+
caught = err
|
|
26
|
+
}
|
|
27
|
+
expect(getRedirectInfo(caught)?.status).toBe(308)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it.each([301, 302, 303, 307, 308] as const)('accepts %s as a valid status', (status) => {
|
|
31
|
+
let caught: unknown
|
|
32
|
+
try {
|
|
33
|
+
redirect('/x', status)
|
|
34
|
+
} catch (err) {
|
|
35
|
+
caught = err
|
|
36
|
+
}
|
|
37
|
+
expect(getRedirectInfo(caught)?.status).toBe(status)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('produces a human-readable Error message', () => {
|
|
41
|
+
let caught: Error | undefined
|
|
42
|
+
try {
|
|
43
|
+
redirect('/login')
|
|
44
|
+
} catch (err) {
|
|
45
|
+
caught = err as Error
|
|
46
|
+
}
|
|
47
|
+
expect(caught?.message).toBe('Redirect to /login')
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('isRedirectError()', () => {
|
|
52
|
+
it('returns true for an error thrown by redirect()', () => {
|
|
53
|
+
let caught: unknown
|
|
54
|
+
try {
|
|
55
|
+
redirect('/x')
|
|
56
|
+
} catch (err) {
|
|
57
|
+
caught = err
|
|
58
|
+
}
|
|
59
|
+
expect(isRedirectError(caught)).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('returns false for a plain Error', () => {
|
|
63
|
+
expect(isRedirectError(new Error('plain'))).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('returns false for non-error values', () => {
|
|
67
|
+
expect(isRedirectError(null)).toBe(false)
|
|
68
|
+
expect(isRedirectError(undefined)).toBe(false)
|
|
69
|
+
expect(isRedirectError('string')).toBe(false)
|
|
70
|
+
expect(isRedirectError(42)).toBe(false)
|
|
71
|
+
expect(isRedirectError({})).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('returns false for objects with a different brand', () => {
|
|
75
|
+
const fake = new Error('fake')
|
|
76
|
+
;(fake as unknown as Record<symbol, unknown>)[Symbol.for('something.else')] = true
|
|
77
|
+
expect(isRedirectError(fake)).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('getRedirectInfo()', () => {
|
|
82
|
+
it('returns null for non-redirect errors', () => {
|
|
83
|
+
expect(getRedirectInfo(new Error('plain'))).toBeNull()
|
|
84
|
+
expect(getRedirectInfo(null)).toBeNull()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('returns the redirect info for a thrown redirect()', () => {
|
|
88
|
+
let caught: unknown
|
|
89
|
+
try {
|
|
90
|
+
redirect('/destination', 303)
|
|
91
|
+
} catch (err) {
|
|
92
|
+
caught = err
|
|
93
|
+
}
|
|
94
|
+
expect(getRedirectInfo(caught)).toEqual({ url: '/destination', status: 303 })
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { h } from '@pyreon/core'
|
|
1
|
+
import { h, onMount } from '@pyreon/core'
|
|
2
2
|
import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
4
4
|
import {
|
|
@@ -439,4 +439,71 @@ describe('router in real browser', () => {
|
|
|
439
439
|
expect(container.querySelector('#home')).toBeNull()
|
|
440
440
|
unmount()
|
|
441
441
|
})
|
|
442
|
+
|
|
443
|
+
// ── Lock-in for the layout-remount loop fix (PR #406) ──────────────────────
|
|
444
|
+
//
|
|
445
|
+
// Pre-fix `RouterView`'s reactive child accessor read `_loadingSignal()` and
|
|
446
|
+
// the full `currentRoute` snapshot directly. Each navigation flow writes
|
|
447
|
+
// `_loadingSignal` at least twice (start tick + end tick) and writes
|
|
448
|
+
// `currentPath` once via `commitNavigation`. Any of those writes re-emitted
|
|
449
|
+
// the reactive child, and `mountReactive`'s teardown-then-mount cleanup
|
|
450
|
+
// remounted the entire matched-component subtree on each emission. So a
|
|
451
|
+
// single `router.push()` produced 2-3+ mounts of the destination component
|
|
452
|
+
// (instead of 1) — the "layout double/triple mount" loop.
|
|
453
|
+
//
|
|
454
|
+
// The fix routes the structural decision through a single
|
|
455
|
+
// `computed<DepthEntry>` keyed on `(rec, comp, errored, route)` reference
|
|
456
|
+
// equality. Within-navigation `_loadingSignal` ticks don't change
|
|
457
|
+
// `currentRoute` (it's `computed` memoized on `currentPath`), so the
|
|
458
|
+
// structural emission stays at exactly one per navigation.
|
|
459
|
+
//
|
|
460
|
+
// This test instruments a counter inside the destination component's
|
|
461
|
+
// `onMount` — an inflated count after a single `await router.push()` would
|
|
462
|
+
// mean the loop is back. Bisect-verifies against the structural decoupling
|
|
463
|
+
// commit: reverting that commit pushes the count to ≥ 2 and this assertion
|
|
464
|
+
// fails.
|
|
465
|
+
it('a single router.push() mounts the destination component exactly once (loop-prevention regression)', async () => {
|
|
466
|
+
let aboutMountCount = 0
|
|
467
|
+
const InstrumentedAbout = () => {
|
|
468
|
+
onMount(() => {
|
|
469
|
+
aboutMountCount++
|
|
470
|
+
})
|
|
471
|
+
return h('div', { id: 'about-instrumented' }, 'About Page')
|
|
472
|
+
}
|
|
473
|
+
const localRoutes = [
|
|
474
|
+
{ path: '/', component: Home },
|
|
475
|
+
{ path: '/about', component: InstrumentedAbout },
|
|
476
|
+
]
|
|
477
|
+
const router = createRouter({ routes: localRoutes, mode: 'hash' })
|
|
478
|
+
const { container, unmount } = mountInBrowser(
|
|
479
|
+
h(RouterProvider, { router }, h(RouterView, {})),
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
// Sanity: starting at /, About is not yet mounted.
|
|
483
|
+
expect(aboutMountCount).toBe(0)
|
|
484
|
+
expect(container.querySelector('#about-instrumented')).toBeNull()
|
|
485
|
+
|
|
486
|
+
await router.push('/about')
|
|
487
|
+
await flush()
|
|
488
|
+
|
|
489
|
+
// The structural decoupling fix means a single navigation produces a
|
|
490
|
+
// single emission at this depth. If `RouterView` ever reverts to reading
|
|
491
|
+
// `_loadingSignal` reactively, every loadingSignal tick during the
|
|
492
|
+
// navigate flow will remount the destination subtree and this count
|
|
493
|
+
// jumps to 2 or 3.
|
|
494
|
+
expect(container.querySelector('#about-instrumented')?.textContent).toBe('About Page')
|
|
495
|
+
expect(aboutMountCount).toBe(1)
|
|
496
|
+
|
|
497
|
+
// And navigating BACK to / + forward again to /about produces exactly
|
|
498
|
+
// one more mount — covers the case where stale subscribers from a prior
|
|
499
|
+
// mount could double-fire across navigations.
|
|
500
|
+
await router.push('/')
|
|
501
|
+
await flush()
|
|
502
|
+
await router.push('/about')
|
|
503
|
+
await flush()
|
|
504
|
+
expect(aboutMountCount).toBe(2)
|
|
505
|
+
|
|
506
|
+
expect(unhandledRejections).toEqual([])
|
|
507
|
+
unmount()
|
|
508
|
+
})
|
|
442
509
|
})
|