@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.
@@ -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
@@ -166,6 +166,21 @@ export interface LoaderContext {
166
166
  query: Record<string, string>
167
167
  /** Aborted when a newer navigation supersedes this one */
168
168
  signal: AbortSignal
169
+ /**
170
+ * The incoming HTTP `Request` — populated only when the loader runs during
171
+ * SSR (via `prefetchLoaderData`); `undefined` on every CSR navigation.
172
+ * Lets server-side loaders read cookies / auth headers and decide whether
173
+ * to `throw redirect('/login')` BEFORE the layout renders.
174
+ *
175
+ * @example
176
+ * loader: ({ request }) => {
177
+ * const cookie = request?.headers.get('cookie') ?? ''
178
+ * const sid = cookie.match(/sid=([^;]+)/)?.[1]
179
+ * if (!sid) redirect('/login')
180
+ * return { sid }
181
+ * }
182
+ */
183
+ request?: Request
169
184
  }
170
185
 
171
186
  export type RouteLoaderFn = (ctx: LoaderContext) => Promise<unknown>
@@ -383,7 +398,7 @@ export interface Router<TNames extends string = string> {
383
398
  * separately when creating the router (`createRouter({ url, ... })`) or
384
399
  * call this for the same `url` you initialised the router with.
385
400
  */
386
- preload(path: string): Promise<void>
401
+ preload(path: string, request?: Request): Promise<void>
387
402
  /**
388
403
  * Invalidate cached loader data. Forces loaders to re-run on next navigation.
389
404
  * - No args: invalidate ALL cached loader data
@@ -435,6 +450,13 @@ export interface RouterInstance extends Router {
435
450
  _navigationStartTime: number
436
451
  /** Key-based loader cache: cacheKey → { data, timestamp } */
437
452
  _loaderCache: Map<string, { data: unknown; timestamp: number }>
438
- /** In-flight loader dedup: cacheKey → Promise */
439
- _loaderInflight: Map<string, Promise<unknown>>
453
+ /**
454
+ * In-flight loader dedup: cacheKey → { promise, signal }.
455
+ * Tracking the signal lets dedup skip an in-flight entry whose signal is
456
+ * already aborted — otherwise nav-2 would inherit nav-1's aborted promise
457
+ * (`router.push` aborts the previous nav's controller before starting the
458
+ * next, so back-to-back nav to the same path could resolve nav-2 against
459
+ * nav-1's aborted fetch).
460
+ */
461
+ _loaderInflight: Map<string, { promise: Promise<unknown>; signal: AbortSignal }>
440
462
  }