@pyreon/router 0.14.0 → 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/src/router.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createContext, onUnmount, useContext } from '@pyreon/core'
2
2
  import { computed, signal } from '@pyreon/reactivity'
3
3
  import { buildNameIndex, buildPath, resolveRoute, stringifyQuery } from './match'
4
+ import { getRedirectInfo } from './redirect'
4
5
  import { ScrollManager } from './scroll'
5
6
  import {
6
7
  type AfterEachHook,
@@ -26,8 +27,7 @@ import {
26
27
  const _isBrowser = typeof window !== 'undefined'
27
28
  // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
28
29
  // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
29
- // @ts-ignore `import.meta.env.DEV` is provided by Vite/Rolldown at build time
30
- const __DEV__ = import.meta.env?.DEV === true
30
+ const __DEV__ = process.env.NODE_ENV !== 'production'
31
31
 
32
32
  // Dev-time counter sink — see packages/internals/perf-harness for contract.
33
33
  const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
@@ -569,18 +569,24 @@ export function createRouter<TNames extends string = string>(
569
569
  record: RouteRecord,
570
570
  ac: AbortController,
571
571
  to: ResolvedRoute,
572
- ): boolean {
572
+ ): GuardOutcome {
573
573
  if (result.status === 'fulfilled') {
574
574
  router._loaderData.set(record, result.value)
575
- return true
575
+ return { action: 'continue' }
576
576
  }
577
- if (ac.signal.aborted) return true
577
+ if (ac.signal.aborted) return { action: 'continue' }
578
+ // `redirect()` from a loader: propagate as a router-level redirect so the
579
+ // navigate flow re-runs against the target path BEFORE the matched route's
580
+ // layout / page mounts. Bypasses the user-supplied `_onError` hook — a
581
+ // redirect is intentional flow control, not an error.
582
+ const info = getRedirectInfo(result.reason)
583
+ if (info) return { action: 'redirect', target: info.url }
578
584
  if (router._onError) {
579
585
  const cancel = router._onError(result.reason, to)
580
- if (cancel === false) return false
586
+ if (cancel === false) return { action: 'cancel' }
581
587
  }
582
588
  router._loaderData.set(record, undefined)
583
- return true
589
+ return { action: 'continue' }
584
590
  }
585
591
 
586
592
  function syncBrowserUrl(path: string, replace: boolean): void {
@@ -633,6 +639,25 @@ export function createRouter<TNames extends string = string>(
633
639
  return Date.now() - entry.timestamp < gcTime
634
640
  }
635
641
 
642
+ /**
643
+ * Bounded set into `_loaderCache`: evicts the oldest entry (insertion-order
644
+ * FIFO) when the cap is exceeded. The `gcTime` TTL handles staleness, but
645
+ * without a size cap a long-running SPA navigating across many distinct
646
+ * loader keys (e.g. `/posts/:id` with hundreds of unique IDs) would
647
+ * accumulate cache entries indefinitely until manual `invalidateLoader()`
648
+ * — `_maxCacheSize` was wired through from `RouterOptions.maxCacheSize`
649
+ * (default 100) but the loader cache write paths never read it. Mirrors
650
+ * the same pattern used for `_componentCache` in `components.tsx`.
651
+ */
652
+ function loaderCacheSet(key: string, data: unknown): void {
653
+ router._loaderCache.set(key, { data, timestamp: Date.now() })
654
+ if (router._loaderCache.size > router._maxCacheSize) {
655
+ // Map iterates in insertion order — first key is oldest
656
+ const oldest = router._loaderCache.keys().next().value as string | undefined
657
+ if (oldest !== undefined) router._loaderCache.delete(oldest)
658
+ }
659
+ }
660
+
636
661
  /**
637
662
  * Execute a loader with cache + dedup:
638
663
  * 1. Cache hit + fresh → return cached data (skip loader entirely)
@@ -653,25 +678,41 @@ export function createRouter<TNames extends string = string>(
653
678
  }
654
679
  }
655
680
 
656
- // 2. Dedup in-flight
681
+ // 2. Dedup in-flight — but only if the in-flight signal is still live.
682
+ // Pre-fix: nav-1 starts loader (signal=ac1.signal). User navigates again
683
+ // to the same path → nav-2's `router.push` first calls `_abortController?.abort()`
684
+ // (aborting ac1), then calls executeLoader. The Map still holds nav-1's
685
+ // promise (the .catch hasn't run yet); deduping returns it, but its
686
+ // signal is already aborted → nav-2 ends up with a rejected promise
687
+ // even though it has its own fresh ac2.signal. Now we check liveness.
657
688
  const inflight = router._loaderInflight.get(key)
658
- if (inflight) return inflight
689
+ if (inflight && !inflight.signal.aborted) return inflight.promise
659
690
 
660
- // 3. Execute
691
+ // 3. Execute. Wrap with `Promise.resolve().then(...)` so a SYNCHRONOUS
692
+ // throw from the loader (`redirect('/login')` / `notFound()` / a plain
693
+ // `throw new Error(...)`) becomes a rejected promise the `.catch` can
694
+ // handle — instead of escaping past the promise chain and surfacing as
695
+ // an unhandled exception in `runBlockingLoaders`'s `Promise.allSettled`.
661
696
  if (__DEV__) _countSink.__pyreon_count__?.('router.loaderRun')
662
- const promise = record
663
- .loader(loaderCtx)
697
+ const promise = Promise.resolve()
698
+ .then(() => record.loader!(loaderCtx))
664
699
  .then((data) => {
665
- router._loaderCache.set(key, { data, timestamp: Date.now() })
666
- router._loaderInflight.delete(key)
700
+ loaderCacheSet(key, data)
701
+ // Only delete if WE'RE still the registered in-flight (a later nav
702
+ // may have replaced the entry with a fresh promise).
703
+ if (router._loaderInflight.get(key)?.promise === promise) {
704
+ router._loaderInflight.delete(key)
705
+ }
667
706
  return data
668
707
  })
669
708
  .catch((err) => {
670
- router._loaderInflight.delete(key)
709
+ if (router._loaderInflight.get(key)?.promise === promise) {
710
+ router._loaderInflight.delete(key)
711
+ }
671
712
  throw err
672
713
  })
673
714
 
674
- router._loaderInflight.set(key, promise)
715
+ router._loaderInflight.set(key, { promise, signal: loaderCtx.signal })
675
716
  return promise
676
717
  }
677
718
 
@@ -680,17 +721,20 @@ export function createRouter<TNames extends string = string>(
680
721
  to: ResolvedRoute,
681
722
  gen: number,
682
723
  ac: AbortController,
683
- ): Promise<boolean> {
724
+ ): Promise<GuardOutcome> {
684
725
  const loaderCtx: LoaderContext = { params: to.params, query: to.query, signal: ac.signal }
685
726
  const results = await Promise.allSettled(records.map((r) => executeLoader(r, loaderCtx)))
686
- if (gen !== _navGen) return false
727
+ if (gen !== _navGen) return { action: 'cancel' }
687
728
  for (let i = 0; i < records.length; i++) {
688
729
  const result = results[i]
689
730
  const record = records[i]
690
731
  if (!result || !record) continue
691
- if (!processLoaderResult(result, record, ac, to)) return false
732
+ const outcome = processLoaderResult(result, record, ac, to)
733
+ // Short-circuit on first redirect or cancel — later loaders' results
734
+ // are irrelevant once we know the navigation isn't committing here.
735
+ if (outcome.action !== 'continue') return outcome
692
736
  }
693
- return true
737
+ return { action: 'continue' }
694
738
  }
695
739
 
696
740
  /** Fire-and-forget background revalidation for stale-while-revalidate routes. */
@@ -705,7 +749,7 @@ export function createRouter<TNames extends string = string>(
705
749
  router._loaderData.set(r, data)
706
750
  // Update cache with fresh data
707
751
  const key = getCacheKey(r, loaderCtx)
708
- router._loaderCache.set(key, { data, timestamp: Date.now() })
752
+ loaderCacheSet(key, data)
709
753
  // Bump loadingSignal to trigger reactive re-render with fresh data
710
754
  loadingSignal.update((n) => n + 1)
711
755
  loadingSignal.update((n) => n - 1)
@@ -717,9 +761,13 @@ export function createRouter<TNames extends string = string>(
717
761
  }
718
762
  }
719
763
 
720
- async function runLoaders(to: ResolvedRoute, gen: number, ac: AbortController): Promise<boolean> {
764
+ async function runLoaders(
765
+ to: ResolvedRoute,
766
+ gen: number,
767
+ ac: AbortController,
768
+ ): Promise<GuardOutcome> {
721
769
  const loadableRecords = to.matched.filter((r) => r.loader)
722
- if (loadableRecords.length === 0) return true
770
+ if (loadableRecords.length === 0) return { action: 'continue' }
723
771
 
724
772
  const blocking: RouteRecord[] = []
725
773
  const swr: RouteRecord[] = []
@@ -732,11 +780,11 @@ export function createRouter<TNames extends string = string>(
732
780
  }
733
781
 
734
782
  if (blocking.length > 0) {
735
- const ok = await runBlockingLoaders(blocking, to, gen, ac)
736
- if (!ok) return false
783
+ const outcome = await runBlockingLoaders(blocking, to, gen, ac)
784
+ if (outcome.action !== 'continue') return outcome
737
785
  }
738
786
  if (swr.length > 0) revalidateSwrLoaders(swr, to, ac)
739
- return true
787
+ return { action: 'continue' }
740
788
  }
741
789
 
742
790
  async function commitNavigation(
@@ -932,9 +980,12 @@ export function createRouter<TNames extends string = string>(
932
980
  const ac = new AbortController()
933
981
  router._abortController = ac
934
982
 
935
- const loadersOk = await runLoaders(to, gen, ac)
936
- if (!loadersOk) {
983
+ const loaderOutcome = await runLoaders(to, gen, ac)
984
+ if (loaderOutcome.action !== 'continue') {
937
985
  loadingSignal.update((n) => n - 1)
986
+ if (loaderOutcome.action === 'redirect') {
987
+ return navigate(sanitizePath(loaderOutcome.target), replace, redirectDepth + 1)
988
+ }
938
989
  return
939
990
  }
940
991
 
@@ -1046,7 +1097,7 @@ export function createRouter<TNames extends string = string>(
1046
1097
  return router._readyPromise
1047
1098
  },
1048
1099
 
1049
- async preload(path: string) {
1100
+ async preload(path: string, request?: Request) {
1050
1101
  const resolved = resolveRoute(path, routes)
1051
1102
  // Load lazy components in parallel and populate the component cache so
1052
1103
  // the synchronous render pass finds ready components instead of kicking
@@ -1076,11 +1127,20 @@ export function createRouter<TNames extends string = string>(
1076
1127
  resolved.matched
1077
1128
  .filter((r) => r.loader)
1078
1129
  .map(async (r) => {
1079
- const data = await r.loader?.({
1080
- params: resolved.params,
1081
- query: resolved.query,
1082
- signal: ac.signal,
1083
- })
1130
+ // Wrap with `Promise.resolve().then(...)` so a SYNCHRONOUS
1131
+ // throw — `redirect('/login')` from a sync loader, `notFound()`,
1132
+ // a plain `throw new Error(...)` — becomes a rejected promise
1133
+ // the surrounding Promise.all surfaces. Bare `await r.loader(...)`
1134
+ // would let synchronous throws escape past the `await` and
1135
+ // surface as an uncaught exception in the Vite dev SSR pipeline.
1136
+ const data = await Promise.resolve().then(() =>
1137
+ r.loader!({
1138
+ params: resolved.params,
1139
+ query: resolved.query,
1140
+ signal: ac.signal,
1141
+ ...(request ? { request } : {}),
1142
+ }),
1143
+ )
1084
1144
  router._loaderData.set(r, data)
1085
1145
  }),
1086
1146
  )
@@ -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')
@@ -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
  })