@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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +174 -51
- package/lib/types/index.d.ts +83 -8
- package/package.json +5 -4
- package/src/components.tsx +162 -27
- package/src/env.d.ts +6 -0
- package/src/index.ts +2 -0
- package/src/loader.ts +14 -4
- package/src/manifest.ts +63 -0
- package/src/match.ts +12 -1
- package/src/redirect.ts +63 -0
- package/src/router.ts +94 -34
- package/src/tests/loader.test.ts +149 -0
- package/src/tests/manifest-snapshot.test.ts +5 -1
- 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 +149 -0
- package/src/tests/routerlink-reactive-to.browser.test.tsx +158 -0
- package/src/types.ts +25 -3
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
package/src/tests/router.test.ts
CHANGED
|
@@ -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
|
-
/**
|
|
439
|
-
|
|
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
|
}
|