@pyreon/router 0.12.4 → 0.12.6

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.
@@ -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
- export interface Router {
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 path by name */
282
+ /** Navigate to a named route */
243
283
  push(location: {
244
- name: string
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: string
292
+ name: TNames
253
293
  params?: Record<string, string>
254
294
  query?: Record<string, string>
255
295
  }): Promise<void>