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