@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.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +326 -51
- package/lib/types/index.d.ts +131 -8
- package/package.json +6 -5
- package/src/components.tsx +192 -27
- package/src/env.d.ts +6 -0
- package/src/index.ts +9 -1
- package/src/loader.ts +72 -4
- package/src/manifest.ts +63 -0
- package/src/match.ts +227 -2
- package/src/redirect.ts +63 -0
- package/src/router.ts +105 -35
- package/src/tests/loader.test.ts +326 -1
- package/src/tests/manifest-snapshot.test.ts +5 -1
- package/src/tests/match.test.ts +284 -0
- 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 +46 -3
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
|
@@ -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
|
})
|
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
|
@@ -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(
|
|
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
|
-
/**
|
|
439
|
-
|
|
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
|
}
|