@pyreon/router 0.12.3 → 0.12.5
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 +173 -8
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +115 -5
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/components.tsx +21 -4
- package/src/index.ts +5 -0
- package/src/router.ts +187 -13
- package/src/scroll.ts +20 -0
- package/src/tests/router.test.ts +283 -0
- package/src/types.ts +44 -4
package/src/tests/router.test.ts
CHANGED
|
@@ -15,10 +15,14 @@ import {
|
|
|
15
15
|
useBlocker,
|
|
16
16
|
useIsActive,
|
|
17
17
|
useLoaderData,
|
|
18
|
+
useMiddlewareData,
|
|
18
19
|
useRoute,
|
|
19
20
|
useRouter,
|
|
20
21
|
useSearchParams,
|
|
22
|
+
useTransition,
|
|
23
|
+
useTypedSearchParams,
|
|
21
24
|
} from '../index'
|
|
25
|
+
import type { RouteMiddleware } from '../index'
|
|
22
26
|
import {
|
|
23
27
|
buildNameIndex,
|
|
24
28
|
buildPath,
|
|
@@ -4194,3 +4198,282 @@ describe('useIsActive', () => {
|
|
|
4194
4198
|
expect(isActive()).toBe(false)
|
|
4195
4199
|
})
|
|
4196
4200
|
})
|
|
4201
|
+
|
|
4202
|
+
// ─── useTypedSearchParams ────────────────────────────────────────────────────
|
|
4203
|
+
|
|
4204
|
+
describe('useTypedSearchParams', () => {
|
|
4205
|
+
const tspRoutes: RouteRecord[] = [
|
|
4206
|
+
{ path: '/', component: Home },
|
|
4207
|
+
{ path: '/search', component: About },
|
|
4208
|
+
]
|
|
4209
|
+
|
|
4210
|
+
it('coerces number params from query string', () => {
|
|
4211
|
+
const router = createRouter({ routes: tspRoutes, url: '/search?page=3' })
|
|
4212
|
+
const ctr = document.createElement('div')
|
|
4213
|
+
let result: { page: number } | undefined
|
|
4214
|
+
const TestComp = () => {
|
|
4215
|
+
const [params] = useTypedSearchParams({ page: 'number' })
|
|
4216
|
+
result = params() as { page: number }
|
|
4217
|
+
return null
|
|
4218
|
+
}
|
|
4219
|
+
mount(h(RouterProvider, { router }, h(TestComp, {})), ctr)
|
|
4220
|
+
expect(result!.page).toBe(3)
|
|
4221
|
+
expect(typeof result!.page).toBe('number')
|
|
4222
|
+
router.destroy()
|
|
4223
|
+
})
|
|
4224
|
+
|
|
4225
|
+
it('coerces boolean params from query string', () => {
|
|
4226
|
+
const router = createRouter({ routes: tspRoutes, url: '/search?active=true' })
|
|
4227
|
+
const ctr = document.createElement('div')
|
|
4228
|
+
let result: { active: boolean } | undefined
|
|
4229
|
+
const TestComp = () => {
|
|
4230
|
+
const [params] = useTypedSearchParams({ active: 'boolean' })
|
|
4231
|
+
result = params() as { active: boolean }
|
|
4232
|
+
return null
|
|
4233
|
+
}
|
|
4234
|
+
mount(h(RouterProvider, { router }, h(TestComp, {})), ctr)
|
|
4235
|
+
expect(result!.active).toBe(true)
|
|
4236
|
+
expect(typeof result!.active).toBe('boolean')
|
|
4237
|
+
router.destroy()
|
|
4238
|
+
})
|
|
4239
|
+
|
|
4240
|
+
it('coerces multiple typed params together', () => {
|
|
4241
|
+
const router = createRouter({ routes: tspRoutes, url: '/search?page=3&active=true&sort=name' })
|
|
4242
|
+
const ctr = document.createElement('div')
|
|
4243
|
+
let result: { page: number; active: boolean; sort: string } | undefined
|
|
4244
|
+
const TestComp = () => {
|
|
4245
|
+
const [params] = useTypedSearchParams({ page: 'number', active: 'boolean', sort: 'string' })
|
|
4246
|
+
result = params() as { page: number; active: boolean; sort: string }
|
|
4247
|
+
return null
|
|
4248
|
+
}
|
|
4249
|
+
mount(h(RouterProvider, { router }, h(TestComp, {})), ctr)
|
|
4250
|
+
expect(result!.page).toBe(3)
|
|
4251
|
+
expect(result!.active).toBe(true)
|
|
4252
|
+
expect(result!.sort).toBe('name')
|
|
4253
|
+
router.destroy()
|
|
4254
|
+
})
|
|
4255
|
+
|
|
4256
|
+
it('defaults number to 0 when missing from URL', () => {
|
|
4257
|
+
const router = createRouter({ routes: tspRoutes, url: '/search' })
|
|
4258
|
+
const ctr = document.createElement('div')
|
|
4259
|
+
let result: { page: number } | undefined
|
|
4260
|
+
const TestComp = () => {
|
|
4261
|
+
const [params] = useTypedSearchParams({ page: 'number' })
|
|
4262
|
+
result = params() as { page: number }
|
|
4263
|
+
return null
|
|
4264
|
+
}
|
|
4265
|
+
mount(h(RouterProvider, { router }, h(TestComp, {})), ctr)
|
|
4266
|
+
expect(result!.page).toBe(0)
|
|
4267
|
+
router.destroy()
|
|
4268
|
+
})
|
|
4269
|
+
|
|
4270
|
+
it('defaults boolean to false when missing from URL', () => {
|
|
4271
|
+
const router = createRouter({ routes: tspRoutes, url: '/search' })
|
|
4272
|
+
const ctr = document.createElement('div')
|
|
4273
|
+
let result: { active: boolean } | undefined
|
|
4274
|
+
const TestComp = () => {
|
|
4275
|
+
const [params] = useTypedSearchParams({ active: 'boolean' })
|
|
4276
|
+
result = params() as { active: boolean }
|
|
4277
|
+
return null
|
|
4278
|
+
}
|
|
4279
|
+
mount(h(RouterProvider, { router }, h(TestComp, {})), ctr)
|
|
4280
|
+
expect(result!.active).toBe(false)
|
|
4281
|
+
router.destroy()
|
|
4282
|
+
})
|
|
4283
|
+
|
|
4284
|
+
it('defaults string to empty string when missing from URL', () => {
|
|
4285
|
+
const router = createRouter({ routes: tspRoutes, url: '/search' })
|
|
4286
|
+
const ctr = document.createElement('div')
|
|
4287
|
+
let result: { sort: string } | undefined
|
|
4288
|
+
const TestComp = () => {
|
|
4289
|
+
const [params] = useTypedSearchParams({ sort: 'string' })
|
|
4290
|
+
result = params() as { sort: string }
|
|
4291
|
+
return null
|
|
4292
|
+
}
|
|
4293
|
+
mount(h(RouterProvider, { router }, h(TestComp, {})), ctr)
|
|
4294
|
+
expect(result!.sort).toBe('')
|
|
4295
|
+
router.destroy()
|
|
4296
|
+
})
|
|
4297
|
+
|
|
4298
|
+
it('setTypedSearchParams updates query and navigates', async () => {
|
|
4299
|
+
const router = createRouter({ routes: tspRoutes, url: '/search?page=1' })
|
|
4300
|
+
const ctr = document.createElement('div')
|
|
4301
|
+
let setter: ((updates: Partial<{ page: number }>) => Promise<void>) | undefined
|
|
4302
|
+
const TestComp = () => {
|
|
4303
|
+
const [, setParams] = useTypedSearchParams({ page: 'number' })
|
|
4304
|
+
setter = setParams as (updates: Partial<{ page: number }>) => Promise<void>
|
|
4305
|
+
return null
|
|
4306
|
+
}
|
|
4307
|
+
mount(h(RouterProvider, { router }, h(TestComp, {})), ctr)
|
|
4308
|
+
await setter!({ page: 5 })
|
|
4309
|
+
expect(router.currentRoute().query.page).toBe('5')
|
|
4310
|
+
router.destroy()
|
|
4311
|
+
})
|
|
4312
|
+
})
|
|
4313
|
+
|
|
4314
|
+
// ─── useTransition ──────────────────────────────────────────────────────────
|
|
4315
|
+
|
|
4316
|
+
describe('useTransition', () => {
|
|
4317
|
+
const trRoutes: RouteRecord[] = [
|
|
4318
|
+
{ path: '/', component: Home },
|
|
4319
|
+
{ path: '/slow', component: About, loader: async () => {
|
|
4320
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
4321
|
+
return 'data'
|
|
4322
|
+
}},
|
|
4323
|
+
]
|
|
4324
|
+
|
|
4325
|
+
it('returns a function that returns boolean', () => {
|
|
4326
|
+
const router = createRouter({ routes: trRoutes, url: '/' })
|
|
4327
|
+
const ctr = document.createElement('div')
|
|
4328
|
+
let isNavigating: (() => boolean) | undefined
|
|
4329
|
+
const TestComp = () => {
|
|
4330
|
+
isNavigating = useTransition()
|
|
4331
|
+
return null
|
|
4332
|
+
}
|
|
4333
|
+
mount(h(RouterProvider, { router }, h(TestComp, {})), ctr)
|
|
4334
|
+
expect(typeof isNavigating).toBe('function')
|
|
4335
|
+
expect(typeof isNavigating!()).toBe('boolean')
|
|
4336
|
+
router.destroy()
|
|
4337
|
+
})
|
|
4338
|
+
|
|
4339
|
+
it('is false when not navigating', () => {
|
|
4340
|
+
const router = createRouter({ routes: trRoutes, url: '/' })
|
|
4341
|
+
const ctr = document.createElement('div')
|
|
4342
|
+
let isNavigating: (() => boolean) | undefined
|
|
4343
|
+
const TestComp = () => {
|
|
4344
|
+
isNavigating = useTransition()
|
|
4345
|
+
return null
|
|
4346
|
+
}
|
|
4347
|
+
mount(h(RouterProvider, { router }, h(TestComp, {})), ctr)
|
|
4348
|
+
expect(isNavigating!()).toBe(false)
|
|
4349
|
+
router.destroy()
|
|
4350
|
+
})
|
|
4351
|
+
})
|
|
4352
|
+
|
|
4353
|
+
// ─── Middleware chain ───────────────────────────────────────────────────────
|
|
4354
|
+
|
|
4355
|
+
describe('route middleware', () => {
|
|
4356
|
+
it('middleware runs and accumulates data on navigation', async () => {
|
|
4357
|
+
const calls: string[] = []
|
|
4358
|
+
const authMiddleware: RouteMiddleware = (ctx) => {
|
|
4359
|
+
ctx.data.user = 'admin'
|
|
4360
|
+
calls.push('auth')
|
|
4361
|
+
}
|
|
4362
|
+
const logMiddleware: RouteMiddleware = (ctx) => {
|
|
4363
|
+
calls.push(`log:${ctx.data.user}`)
|
|
4364
|
+
}
|
|
4365
|
+
|
|
4366
|
+
const mwRoutes: RouteRecord[] = [
|
|
4367
|
+
{ path: '/', component: Home },
|
|
4368
|
+
{ path: '/protected', component: About, middleware: [authMiddleware, logMiddleware] },
|
|
4369
|
+
]
|
|
4370
|
+
|
|
4371
|
+
const router = createRouter({ routes: mwRoutes, url: '/' })
|
|
4372
|
+
await router.push('/protected')
|
|
4373
|
+
expect(calls).toEqual(['auth', 'log:admin'])
|
|
4374
|
+
router.destroy()
|
|
4375
|
+
})
|
|
4376
|
+
|
|
4377
|
+
it('middleware returning false cancels navigation', async () => {
|
|
4378
|
+
const blockMiddleware: RouteMiddleware = () => false
|
|
4379
|
+
|
|
4380
|
+
const mwRoutes: RouteRecord[] = [
|
|
4381
|
+
{ path: '/', component: Home },
|
|
4382
|
+
{ path: '/blocked', component: About, middleware: blockMiddleware },
|
|
4383
|
+
]
|
|
4384
|
+
|
|
4385
|
+
const router = createRouter({ routes: mwRoutes, url: '/' })
|
|
4386
|
+
await router.push('/blocked')
|
|
4387
|
+
// Navigation should be cancelled — stay on /
|
|
4388
|
+
expect(router.currentRoute().path).toBe('/')
|
|
4389
|
+
router.destroy()
|
|
4390
|
+
})
|
|
4391
|
+
|
|
4392
|
+
it('middleware returning string redirects', async () => {
|
|
4393
|
+
const redirectMiddleware: RouteMiddleware = () => '/about'
|
|
4394
|
+
|
|
4395
|
+
const mwRoutes: RouteRecord[] = [
|
|
4396
|
+
{ path: '/', component: Home },
|
|
4397
|
+
{ path: '/about', component: About },
|
|
4398
|
+
{ path: '/old', component: Home, middleware: redirectMiddleware },
|
|
4399
|
+
]
|
|
4400
|
+
|
|
4401
|
+
const router = createRouter({ routes: mwRoutes, url: '/' })
|
|
4402
|
+
await router.push('/old')
|
|
4403
|
+
expect(router.currentRoute().path).toBe('/about')
|
|
4404
|
+
router.destroy()
|
|
4405
|
+
})
|
|
4406
|
+
|
|
4407
|
+
it('async middleware runs before navigation commits', async () => {
|
|
4408
|
+
const order: string[] = []
|
|
4409
|
+
const asyncMiddleware: RouteMiddleware = async (ctx) => {
|
|
4410
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
4411
|
+
order.push('middleware')
|
|
4412
|
+
ctx.data.loaded = true
|
|
4413
|
+
}
|
|
4414
|
+
|
|
4415
|
+
const mwRoutes: RouteRecord[] = [
|
|
4416
|
+
{ path: '/', component: Home },
|
|
4417
|
+
{ path: '/dash', component: About, middleware: asyncMiddleware },
|
|
4418
|
+
]
|
|
4419
|
+
|
|
4420
|
+
const router = createRouter({ routes: mwRoutes, url: '/' })
|
|
4421
|
+
await router.push('/dash')
|
|
4422
|
+
expect(order).toEqual(['middleware'])
|
|
4423
|
+
expect(router.currentRoute().path).toBe('/dash')
|
|
4424
|
+
router.destroy()
|
|
4425
|
+
})
|
|
4426
|
+
})
|
|
4427
|
+
|
|
4428
|
+
// ─── View Transitions API ───────────────────────────────────────────────────
|
|
4429
|
+
|
|
4430
|
+
describe('View Transitions API', () => {
|
|
4431
|
+
it('calls document.startViewTransition on navigation when available', async () => {
|
|
4432
|
+
const startViewTransition = vi.fn((cb: () => void) => { cb() })
|
|
4433
|
+
;(document as any).startViewTransition = startViewTransition
|
|
4434
|
+
|
|
4435
|
+
const vtRoutes: RouteRecord[] = [
|
|
4436
|
+
{ path: '/', component: Home },
|
|
4437
|
+
{ path: '/about', component: About },
|
|
4438
|
+
]
|
|
4439
|
+
|
|
4440
|
+
const router = createRouter({ routes: vtRoutes, url: '/' })
|
|
4441
|
+
await router.push('/about')
|
|
4442
|
+
expect(startViewTransition).toHaveBeenCalled()
|
|
4443
|
+
|
|
4444
|
+
delete (document as any).startViewTransition
|
|
4445
|
+
router.destroy()
|
|
4446
|
+
})
|
|
4447
|
+
|
|
4448
|
+
it('does NOT call startViewTransition when meta.viewTransition is false', async () => {
|
|
4449
|
+
const startViewTransition = vi.fn((cb: () => void) => { cb() })
|
|
4450
|
+
;(document as any).startViewTransition = startViewTransition
|
|
4451
|
+
|
|
4452
|
+
const vtRoutes: RouteRecord[] = [
|
|
4453
|
+
{ path: '/', component: Home },
|
|
4454
|
+
{ path: '/no-vt', component: About, meta: { viewTransition: false } },
|
|
4455
|
+
]
|
|
4456
|
+
|
|
4457
|
+
const router = createRouter({ routes: vtRoutes, url: '/' })
|
|
4458
|
+
await router.push('/no-vt')
|
|
4459
|
+
expect(startViewTransition).not.toHaveBeenCalled()
|
|
4460
|
+
|
|
4461
|
+
delete (document as any).startViewTransition
|
|
4462
|
+
router.destroy()
|
|
4463
|
+
})
|
|
4464
|
+
|
|
4465
|
+
it('navigates normally when startViewTransition is not available', async () => {
|
|
4466
|
+
// Ensure startViewTransition is not on document
|
|
4467
|
+
delete (document as any).startViewTransition
|
|
4468
|
+
|
|
4469
|
+
const vtRoutes: RouteRecord[] = [
|
|
4470
|
+
{ path: '/', component: Home },
|
|
4471
|
+
{ path: '/about', component: About },
|
|
4472
|
+
]
|
|
4473
|
+
|
|
4474
|
+
const router = createRouter({ routes: vtRoutes, url: '/' })
|
|
4475
|
+
await router.push('/about')
|
|
4476
|
+
expect(router.currentRoute().path).toBe('/about')
|
|
4477
|
+
router.destroy()
|
|
4478
|
+
})
|
|
4479
|
+
})
|
package/src/types.ts
CHANGED
|
@@ -52,6 +52,8 @@ export interface RouteMeta {
|
|
|
52
52
|
requiresAuth?: boolean
|
|
53
53
|
/** Scroll behavior for this route */
|
|
54
54
|
scrollBehavior?: 'top' | 'restore' | 'none'
|
|
55
|
+
/** Set to false to disable View Transitions API for this route. Default: true */
|
|
56
|
+
viewTransition?: boolean
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
// ─── Resolved route ───────────────────────────────────────────────────────────
|
|
@@ -110,6 +112,31 @@ export type NavigationGuard = (
|
|
|
110
112
|
|
|
111
113
|
export type AfterEachHook = (to: ResolvedRoute, from: ResolvedRoute) => void
|
|
112
114
|
|
|
115
|
+
// ─── Route middleware ────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Context object passed through the middleware chain.
|
|
119
|
+
* Middleware can read/write arbitrary data on `ctx.data`.
|
|
120
|
+
*/
|
|
121
|
+
export interface RouteMiddlewareContext {
|
|
122
|
+
/** The route being navigated to. */
|
|
123
|
+
to: ResolvedRoute
|
|
124
|
+
/** The route being navigated from. */
|
|
125
|
+
from: ResolvedRoute
|
|
126
|
+
/** Shared data — middleware can accumulate state here for downstream middleware/components. */
|
|
127
|
+
data: Record<string, unknown>
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Route middleware function. Called before guards.
|
|
132
|
+
* - Return nothing/undefined to continue
|
|
133
|
+
* - Return `false` to cancel navigation
|
|
134
|
+
* - Return a string to redirect
|
|
135
|
+
*/
|
|
136
|
+
export type RouteMiddleware = (
|
|
137
|
+
ctx: RouteMiddlewareContext,
|
|
138
|
+
) => void | false | string | Promise<void | false | string>
|
|
139
|
+
|
|
113
140
|
// ─── Navigation blockers ──────────────────────────────────────────────────────
|
|
114
141
|
|
|
115
142
|
/**
|
|
@@ -178,6 +205,8 @@ export interface RouteRecord<TPath extends string = string> {
|
|
|
178
205
|
staleWhileRevalidate?: boolean
|
|
179
206
|
/** Component rendered when this route's loader throws an error */
|
|
180
207
|
errorComponent?: ComponentFn
|
|
208
|
+
/** Per-route middleware — runs before guards, can accumulate context data. */
|
|
209
|
+
middleware?: RouteMiddleware | RouteMiddleware[]
|
|
181
210
|
}
|
|
182
211
|
|
|
183
212
|
// ─── Router options ───────────────────────────────────────────────────────────
|
|
@@ -236,12 +265,23 @@ export interface RouterOptions {
|
|
|
236
265
|
|
|
237
266
|
// ─── Router interface ─────────────────────────────────────────────────────────
|
|
238
267
|
|
|
239
|
-
|
|
268
|
+
/**
|
|
269
|
+
* Router interface. Parameterized by route name union for type-safe named navigation.
|
|
270
|
+
*
|
|
271
|
+
* @example
|
|
272
|
+
* ```ts
|
|
273
|
+
* type MyRoutes = 'home' | 'user' | 'settings'
|
|
274
|
+
* const router: Router<MyRoutes> = createRouter({ routes })
|
|
275
|
+
* router.push({ name: 'user', params: { id: '42' } }) // ✓
|
|
276
|
+
* router.push({ name: 'typo' }) // TS error
|
|
277
|
+
* ```
|
|
278
|
+
*/
|
|
279
|
+
export interface Router<TNames extends string = string> {
|
|
240
280
|
/** Navigate to a path */
|
|
241
281
|
push(path: string): Promise<void>
|
|
242
|
-
/** Navigate to a
|
|
282
|
+
/** Navigate to a named route */
|
|
243
283
|
push(location: {
|
|
244
|
-
name:
|
|
284
|
+
name: TNames
|
|
245
285
|
params?: Record<string, string>
|
|
246
286
|
query?: Record<string, string>
|
|
247
287
|
}): Promise<void>
|
|
@@ -249,7 +289,7 @@ export interface Router {
|
|
|
249
289
|
replace(path: string): Promise<void>
|
|
250
290
|
/** Replace current history entry using a named route */
|
|
251
291
|
replace(location: {
|
|
252
|
-
name:
|
|
292
|
+
name: TNames
|
|
253
293
|
params?: Record<string, string>
|
|
254
294
|
query?: Record<string, string>
|
|
255
295
|
}): Promise<void>
|