@pyreon/router 0.14.0 → 0.16.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.
@@ -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
  })
@@ -2,6 +2,7 @@ import { h, popContext } from '@pyreon/core'
2
2
  import { mount } from '@pyreon/runtime-dom'
3
3
  import type { ResolvedRoute, RouteRecord } from '../index'
4
4
  import { isNotFoundError, NotFoundBoundary, notFound } from '../not-found'
5
+ import { getRedirectInfo, redirect } from '../redirect'
5
6
  import {
6
7
  createRouter,
7
8
  hydrateLoaderData,
@@ -5258,3 +5259,151 @@ describe('loader cache', () => {
5258
5259
  router.destroy()
5259
5260
  })
5260
5261
  })
5262
+
5263
+ // ─── redirect() — loader-thrown redirects propagate through the navigate flow
5264
+
5265
+ describe('redirect() from loader', () => {
5266
+ test('throws and triggers router.replace to the target path', async () => {
5267
+ const Home = () => h('div', null, 'home')
5268
+ const Login = () => h('div', null, 'login')
5269
+ const Protected = () => h('div', null, 'protected')
5270
+
5271
+ const routes: RouteRecord[] = [
5272
+ { path: '/', component: Home },
5273
+ { path: '/login', component: Login },
5274
+ {
5275
+ path: '/app',
5276
+ component: Protected,
5277
+ loader: async () => {
5278
+ // No session — redirect to /login
5279
+ redirect('/login')
5280
+ },
5281
+ },
5282
+ ]
5283
+
5284
+ const router = createRouter({ routes, url: '/' })
5285
+ await router.push('/app')
5286
+
5287
+ expect(router.currentRoute().path).toBe('/login')
5288
+ router.destroy()
5289
+ })
5290
+
5291
+ test("doesn't fire when loader resolves normally", async () => {
5292
+ const Home = () => h('div', null, 'home')
5293
+ const Protected = () => h('div', null, 'protected')
5294
+
5295
+ let loaderRan = false
5296
+ const routes: RouteRecord[] = [
5297
+ { path: '/', component: Home },
5298
+ {
5299
+ path: '/app',
5300
+ component: Protected,
5301
+ loader: async () => {
5302
+ loaderRan = true
5303
+ return { user: 'me' }
5304
+ },
5305
+ },
5306
+ ]
5307
+
5308
+ const router = createRouter({ routes, url: '/' })
5309
+ await router.push('/app')
5310
+
5311
+ expect(loaderRan).toBe(true)
5312
+ expect(router.currentRoute().path).toBe('/app')
5313
+ router.destroy()
5314
+ })
5315
+
5316
+ test('parent layout loader can redirect, blocking the child page', async () => {
5317
+ const Home = () => h('div', null, 'home')
5318
+ const Login = () => h('div', null, 'login')
5319
+ const Layout = () => h('div', null, 'layout')
5320
+ const Page = () => h('div', null, 'page')
5321
+
5322
+ const routes: RouteRecord[] = [
5323
+ { path: '/', component: Home },
5324
+ { path: '/login', component: Login },
5325
+ {
5326
+ path: '/app',
5327
+ component: Layout,
5328
+ loader: async () => {
5329
+ redirect('/login')
5330
+ },
5331
+ children: [
5332
+ {
5333
+ // Relative path — `createRouter` joins it with the parent. fs-router
5334
+ // emits absolute paths instead, but the routing semantics at runtime
5335
+ // are identical via `resolveRoute`.
5336
+ path: 'dashboard',
5337
+ component: Page,
5338
+ loader: async () => ({ data: 'page-data' }),
5339
+ },
5340
+ ],
5341
+ },
5342
+ ]
5343
+
5344
+ const router = createRouter({ routes, url: '/' })
5345
+ await router.push('/app/dashboard')
5346
+
5347
+ // The contract: navigation lands on the redirect target, NOT the child page.
5348
+ expect(router.currentRoute().path).toBe('/login')
5349
+ router.destroy()
5350
+ })
5351
+
5352
+ test('redirect() with custom status preserves the status on the thrown error', async () => {
5353
+ // The router doesn't surface the status itself (it just calls .replace),
5354
+ // but the SSR handler reads it via getRedirectInfo. Verify the throw path
5355
+ // captures the status the same way the helper unit tests do.
5356
+ const Home = () => h('div', null, 'home')
5357
+ const Login = () => h('div', null, 'login')
5358
+
5359
+ const routes: RouteRecord[] = [
5360
+ { path: '/', component: Home },
5361
+ { path: '/login', component: Login },
5362
+ {
5363
+ path: '/old',
5364
+ component: Home,
5365
+ loader: async () => {
5366
+ redirect('/login', 308)
5367
+ },
5368
+ },
5369
+ ]
5370
+
5371
+ const router = createRouter({ routes, url: '/' })
5372
+ await router.push('/old')
5373
+ expect(router.currentRoute().path).toBe('/login')
5374
+ router.destroy()
5375
+ })
5376
+
5377
+ test('redirect from prefetchLoaderData propagates as a thrown RedirectError (SSR contract)', async () => {
5378
+ const Home = () => h('div', null, 'home')
5379
+ const Protected = () => h('div', null, 'protected')
5380
+
5381
+ const routes: RouteRecord[] = [
5382
+ { path: '/', component: Home },
5383
+ {
5384
+ path: '/app',
5385
+ component: Protected,
5386
+ loader: async () => {
5387
+ redirect('/login')
5388
+ },
5389
+ },
5390
+ ]
5391
+
5392
+ const router = createRouter({ routes, url: '/' })
5393
+
5394
+ let caught: unknown
5395
+ try {
5396
+ await prefetchLoaderData(router as never, '/app')
5397
+ } catch (err) {
5398
+ caught = err
5399
+ }
5400
+
5401
+ // The SSR handler catches this thrown error and converts it to a 302/307.
5402
+ // The contract here is just "the throw propagates" — the conversion is
5403
+ // tested in the server package's handler tests.
5404
+ expect(caught).toBeDefined()
5405
+ const info = getRedirectInfo(caught)
5406
+ expect(info?.url).toBe('/login')
5407
+ router.destroy()
5408
+ })
5409
+ })
@@ -0,0 +1,158 @@
1
+ import { _rp, h } from '@pyreon/core'
2
+ import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
4
+ import { createRouter, RouterLink, RouterProvider, useIsActive } from '../index'
5
+ import { setActiveRouter } from '../router'
6
+
7
+ describe('RouterLink reactive `to` prop', () => {
8
+ beforeEach(() => {
9
+ window.location.hash = ''
10
+ })
11
+ afterEach(() => {
12
+ setActiveRouter(null)
13
+ window.location.hash = ''
14
+ })
15
+
16
+ it('resolves _rp-wrapped `to` accessor to its string value, not the function literal', async () => {
17
+ const routes = [{ path: '/', component: () => h('div', null, 'home') }]
18
+ const router = createRouter({ routes, mode: 'hash' })
19
+ const { container, unmount } = mountInBrowser(
20
+ h(
21
+ RouterProvider,
22
+ { router },
23
+ h(RouterLink, { to: _rp(() => '/about') as unknown as string, id: 'link' }, 'Go'),
24
+ ),
25
+ )
26
+ await flush()
27
+ const link = container.querySelector<HTMLAnchorElement>('#link')
28
+ expect(link).not.toBeNull()
29
+ expect(link!.getAttribute('href')).toBe('#/about')
30
+ unmount()
31
+ })
32
+
33
+ it('NavItem-shape: parent gets `path` as `_rp` getter and forwards it to RouterLink', async () => {
34
+ // Mirrors `examples/fundamentals-playground/src/routes/_layout.tsx`:
35
+ // function NavItem(props) {
36
+ // return <RouterLink to={props.path} ...>{props.label}</RouterLink>
37
+ // }
38
+ // <NavItem path={tab.path} ... />
39
+ // The compiler emits `_rp(() => tab.path)` for the parent's `path` prop,
40
+ // which `makeReactiveProps` turns into a getter on NavItem's `props`.
41
+ // Then `<RouterLink to={props.path}>` re-wraps that getter access as
42
+ // `_rp(() => props.path)`. The outermost getter points back through
43
+ // NavItem's getter to the literal value.
44
+ const NavItem = (props: Record<string, unknown>) =>
45
+ h(RouterLink, {
46
+ to: _rp(() => props.path as string) as unknown as string,
47
+ id: 'nav-link',
48
+ })
49
+
50
+ const routes = [
51
+ { path: '/', component: () => h('div', { id: 'home' }, 'home') },
52
+ { path: '/about', component: () => h('div', { id: 'about' }, 'about') },
53
+ ]
54
+ const router = createRouter({ routes, mode: 'hash' })
55
+ const { container, unmount } = mountInBrowser(
56
+ h(
57
+ RouterProvider,
58
+ { router },
59
+ h(NavItem, { path: _rp(() => '/about') as unknown as string }),
60
+ ),
61
+ )
62
+ await flush()
63
+ const link = container.querySelector<HTMLAnchorElement>('#nav-link')
64
+ expect(link).not.toBeNull()
65
+ expect(link!.getAttribute('href')).toBe('#/about')
66
+ expect(link!.getAttribute('href')).not.toContain('=>')
67
+ unmount()
68
+ })
69
+
70
+ it('activeClass updates reactively when `to` is `_rp`-wrapped and route changes', async () => {
71
+ // Pre-fix, `RouterLink`'s own `activeClass` computation read `props.to`
72
+ // ONCE at component setup time and compared against the current path.
73
+ // Even if `props.to` correctly resolved to the string, the comparison
74
+ // was static — `activeClass` was a function returning a string but the
75
+ // captured `target = props.to` (declared inline) was hoisted in setup
76
+ // scope. After fixing setup-time captures of `props.to` (so the activeClass
77
+ // accessor reads `props.to` lazily on each invocation), navigation
78
+ // updates the class reactively.
79
+ const routes = [
80
+ { path: '/', component: () => h('div', { id: 'home' }, 'home') },
81
+ { path: '/about', component: () => h('div', { id: 'about' }, 'about') },
82
+ ]
83
+ const router = createRouter({ routes, mode: 'hash' })
84
+
85
+ const NavItem = (props: Record<string, unknown>) =>
86
+ h(RouterLink, {
87
+ to: _rp(() => props.path as string) as unknown as string,
88
+ id: `link-${(props as { _id: string })._id}`,
89
+ })
90
+
91
+ const { container, unmount } = mountInBrowser(
92
+ h(
93
+ RouterProvider,
94
+ { router },
95
+ h('div', null, [
96
+ h(NavItem, { _id: 'home', path: _rp(() => '/') as unknown as string }),
97
+ h(NavItem, { _id: 'about', path: _rp(() => '/about') as unknown as string }),
98
+ ]),
99
+ ),
100
+ )
101
+ await flush()
102
+
103
+ // RouterLink applies the default `router-link-active` class when the
104
+ // current path matches `to`.
105
+ expect(container.querySelector('#link-home')!.className).toContain('router-link-active')
106
+ expect(container.querySelector('#link-about')!.className).not.toContain('router-link-active')
107
+
108
+ await router.push('/about')
109
+ await flush()
110
+
111
+ expect(container.querySelector('#link-about')!.className).toContain('router-link-active')
112
+ expect(container.querySelector('#link-home')!.className).not.toContain('router-link-active')
113
+ unmount()
114
+ })
115
+
116
+ it('useIsActive(props.path) reactively flips when current route matches', async () => {
117
+ // The full fundamentals layout shape: NavItem reads `props.path` (a
118
+ // getter from `_rp`) and passes it BOTH to `RouterLink.to` AND to
119
+ // `useIsActive`. Pre-fix, `useIsActive(props.path)` captured the
120
+ // getter as the `path` argument — non-string — and silently returned
121
+ // false for every route check.
122
+ const routes = [
123
+ { path: '/', component: () => h('div', { id: 'home' }, 'home') },
124
+ { path: '/about', component: () => h('div', { id: 'about' }, 'about') },
125
+ ]
126
+ const router = createRouter({ routes, mode: 'hash' })
127
+
128
+ const NavItem = (props: Record<string, unknown>) => {
129
+ const isActive = useIsActive(props.path as string, true)
130
+ return h(RouterLink, {
131
+ to: _rp(() => props.path as string) as unknown as string,
132
+ id: `link-${(props as { _id: string })._id}`,
133
+ class: () => (isActive() ? 'is-active' : ''),
134
+ })
135
+ }
136
+
137
+ const { container, unmount } = mountInBrowser(
138
+ h(
139
+ RouterProvider,
140
+ { router },
141
+ h('div', null, [
142
+ h(NavItem, { _id: 'home', path: _rp(() => '/') as unknown as string }),
143
+ h(NavItem, { _id: 'about', path: _rp(() => '/about') as unknown as string }),
144
+ ]),
145
+ ),
146
+ )
147
+ await flush()
148
+
149
+ await router.push('/about')
150
+ await flush()
151
+
152
+ // The /about NavItem's useIsActive should now be true → className
153
+ // includes 'is-active'.
154
+ expect(container.querySelector('#link-about')!.className).toContain('is-active')
155
+ expect(container.querySelector('#link-home')!.className).not.toContain('is-active')
156
+ unmount()
157
+ })
158
+ })
package/src/types.ts CHANGED
@@ -78,6 +78,15 @@ export interface ResolvedRoute<
78
78
  search?: Record<string, unknown> | undefined
79
79
  /** Middleware data attached during navigation (populated by middleware chain) */
80
80
  _middlewareData?: Record<string, unknown> | undefined
81
+ /**
82
+ * `true` when the URL didn't match any route AND a parent record's
83
+ * `notFoundComponent` was used as a synthetic fallback leaf. The
84
+ * `matched` chain ends with a synthetic `RouteRecord` rendering the
85
+ * not-found component INSIDE all its ancestor layouts — so 404 pages
86
+ * carry the same chrome (headers, footers, navigation) as regular
87
+ * pages. SSR handlers read this to set HTTP status 404.
88
+ */
89
+ isNotFound?: boolean
81
90
  }
82
91
 
83
92
  // ─── Lazy component ───────────────────────────────────────────────────────────
@@ -166,6 +175,21 @@ export interface LoaderContext {
166
175
  query: Record<string, string>
167
176
  /** Aborted when a newer navigation supersedes this one */
168
177
  signal: AbortSignal
178
+ /**
179
+ * The incoming HTTP `Request` — populated only when the loader runs during
180
+ * SSR (via `prefetchLoaderData`); `undefined` on every CSR navigation.
181
+ * Lets server-side loaders read cookies / auth headers and decide whether
182
+ * to `throw redirect('/login')` BEFORE the layout renders.
183
+ *
184
+ * @example
185
+ * loader: ({ request }) => {
186
+ * const cookie = request?.headers.get('cookie') ?? ''
187
+ * const sid = cookie.match(/sid=([^;]+)/)?.[1]
188
+ * if (!sid) redirect('/login')
189
+ * return { sid }
190
+ * }
191
+ */
192
+ request?: Request
169
193
  }
170
194
 
171
195
  export type RouteLoaderFn = (ctx: LoaderContext) => Promise<unknown>
@@ -231,6 +255,14 @@ export interface RouteRecord<TPath extends string = string> {
231
255
  gcTime?: number
232
256
  /** Component rendered when this route's loader throws an error */
233
257
  errorComponent?: ComponentFn
258
+ /**
259
+ * Component rendered when a URL doesn't match any descendant route under
260
+ * this record's path. Acts as a "404 within layout" — the matched chain
261
+ * is `[...ancestors, this, syntheticLeaf]` so the not-found component
262
+ * renders INSIDE this layout's chrome. fs-router attaches this when it
263
+ * detects a `_404.tsx` / `_not-found.tsx` file under this layout.
264
+ */
265
+ notFoundComponent?: ComponentFn
234
266
  /**
235
267
  * Component rendered while this route's loader is running.
236
268
  * Only shown after `pendingMs` (default: 0) to avoid flash on fast loads.
@@ -383,7 +415,11 @@ export interface Router<TNames extends string = string> {
383
415
  * separately when creating the router (`createRouter({ url, ... })`) or
384
416
  * call this for the same `url` you initialised the router with.
385
417
  */
386
- preload(path: string): Promise<void>
418
+ preload(
419
+ path: string,
420
+ request?: Request,
421
+ options?: { skipLoaders?: boolean },
422
+ ): Promise<void>
387
423
  /**
388
424
  * Invalidate cached loader data. Forces loaders to re-run on next navigation.
389
425
  * - No args: invalidate ALL cached loader data
@@ -435,6 +471,13 @@ export interface RouterInstance extends Router {
435
471
  _navigationStartTime: number
436
472
  /** Key-based loader cache: cacheKey → { data, timestamp } */
437
473
  _loaderCache: Map<string, { data: unknown; timestamp: number }>
438
- /** In-flight loader dedup: cacheKey → Promise */
439
- _loaderInflight: Map<string, Promise<unknown>>
474
+ /**
475
+ * In-flight loader dedup: cacheKey → { promise, signal }.
476
+ * Tracking the signal lets dedup skip an in-flight entry whose signal is
477
+ * already aborted — otherwise nav-2 would inherit nav-1's aborted promise
478
+ * (`router.push` aborts the previous nav's controller before starting the
479
+ * next, so back-to-back nav to the same path could resolve nav-2 against
480
+ * nav-1's aborted fetch).
481
+ */
482
+ _loaderInflight: Map<string, { promise: Promise<unknown>; signal: AbortSignal }>
440
483
  }