@pyreon/router 0.11.3 → 0.11.4

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/src/router.ts CHANGED
@@ -529,8 +529,18 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
529
529
  return "continue"
530
530
  }
531
531
 
532
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: navigation is inherently multi-step
532
533
  async function navigate(rawPath: string, replace: boolean, redirectDepth = 0): Promise<void> {
533
- if (redirectDepth > 10) return
534
+ if (redirectDepth > 10) {
535
+ if (__DEV__) {
536
+ // biome-ignore lint/suspicious/noConsole: dev-only warning
537
+ console.warn(
538
+ `[Pyreon] Navigation to "${rawPath}" aborted: redirect depth exceeded 10 levels. ` +
539
+ "This likely indicates a redirect loop in your route configuration.",
540
+ )
541
+ }
542
+ return
543
+ }
534
544
 
535
545
  const path = normalizeTrailingSlash(rawPath, trailingSlash)
536
546
  const gen = ++_navGen
@@ -0,0 +1,515 @@
1
+ import { hydrateLoaderData, prefetchLoaderData, serializeLoaderData } from "../loader"
2
+ import { createRouter, setActiveRouter, useIsActive, useSearchParams } from "../router"
3
+ import type { RouteRecord, RouterInstance } from "../types"
4
+
5
+ const Home = () => null
6
+ const About = () => null
7
+ const User = () => null
8
+
9
+ // ─── serializeLoaderData / hydrateLoaderData round-trip edge cases ────────────
10
+
11
+ describe("loader data serialization — edge cases", () => {
12
+ test("serializes multiple route loaders", async () => {
13
+ const routes: RouteRecord[] = [
14
+ {
15
+ path: "/admin",
16
+ component: Home,
17
+ loader: async () => "admin-data",
18
+ children: [
19
+ {
20
+ path: "users",
21
+ component: About,
22
+ loader: async () => "users-data",
23
+ },
24
+ ],
25
+ },
26
+ ]
27
+ const router = createRouter({ routes, url: "/admin/users" }) as RouterInstance
28
+ await prefetchLoaderData(router, "/admin/users")
29
+
30
+ const serialized = serializeLoaderData(router)
31
+ expect(serialized["/admin"]).toBe("admin-data")
32
+ expect(serialized.users).toBe("users-data")
33
+ })
34
+
35
+ test("hydrate ignores paths not in current route matched", () => {
36
+ const routes: RouteRecord[] = [
37
+ { path: "/", component: Home },
38
+ { path: "/page", component: About, loader: async () => [] },
39
+ ]
40
+ const router = createRouter({ routes, url: "/" }) as RouterInstance
41
+ // Hydrate with data for a path that is NOT currently matched
42
+ hydrateLoaderData(router, { "/page": "should-be-ignored" })
43
+ expect(router._loaderData.size).toBe(0)
44
+ })
45
+
46
+ test("hydrate with non-object values is no-op", () => {
47
+ const routes: RouteRecord[] = [{ path: "/", component: Home }]
48
+ const router = createRouter({ routes, url: "/" }) as RouterInstance
49
+
50
+ // These should not throw
51
+ hydrateLoaderData(router, null as unknown as Record<string, unknown>)
52
+ hydrateLoaderData(router, undefined as unknown as Record<string, unknown>)
53
+ hydrateLoaderData(router, 42 as unknown as Record<string, unknown>)
54
+ expect(router._loaderData.size).toBe(0)
55
+ })
56
+
57
+ test("round-trip with complex data types", async () => {
58
+ const complexData = {
59
+ items: [
60
+ { id: 1, name: "Item 1" },
61
+ { id: 2, name: "Item 2" },
62
+ ],
63
+ meta: { total: 2, page: 1 },
64
+ nested: { deep: { value: true } },
65
+ }
66
+ const routes: RouteRecord[] = [
67
+ { path: "/data", component: Home, loader: async () => complexData },
68
+ ]
69
+ const ssrRouter = createRouter({ routes, url: "/data" }) as RouterInstance
70
+ await prefetchLoaderData(ssrRouter, "/data")
71
+
72
+ const serialized = serializeLoaderData(ssrRouter)
73
+ const clientRouter = createRouter({ routes, url: "/data" }) as RouterInstance
74
+ hydrateLoaderData(clientRouter, serialized)
75
+
76
+ const values = Array.from(clientRouter._loaderData.values())
77
+ expect(values[0]).toEqual(complexData)
78
+ })
79
+
80
+ test("prefetchLoaderData passes AbortSignal to loaders", async () => {
81
+ let receivedSignal: AbortSignal | undefined
82
+ const routes: RouteRecord[] = [
83
+ {
84
+ path: "/data",
85
+ component: Home,
86
+ loader: async ({ signal }) => {
87
+ receivedSignal = signal
88
+ return "ok"
89
+ },
90
+ },
91
+ ]
92
+ const router = createRouter({ routes, url: "/" }) as RouterInstance
93
+ await prefetchLoaderData(router, "/data")
94
+ expect(receivedSignal).toBeDefined()
95
+ expect(receivedSignal).toBeInstanceOf(AbortSignal)
96
+ })
97
+
98
+ test("prefetchLoaderData skips routes without loaders", async () => {
99
+ const routes: RouteRecord[] = [
100
+ {
101
+ path: "/admin",
102
+ component: Home,
103
+ children: [
104
+ { path: "users", component: About }, // no loader
105
+ ],
106
+ },
107
+ ]
108
+ const router = createRouter({ routes, url: "/" }) as RouterInstance
109
+ await prefetchLoaderData(router, "/admin/users")
110
+ expect(router._loaderData.size).toBe(0)
111
+ })
112
+ })
113
+
114
+ // ─── useIsActive — edge cases ────────────────────────────────────────────────
115
+
116
+ describe("useIsActive — edge cases", () => {
117
+ beforeEach(() => {
118
+ setActiveRouter(null)
119
+ })
120
+
121
+ test("throws when no router installed", () => {
122
+ expect(() => useIsActive("/")).toThrow("[pyreon-router] No router installed")
123
+ })
124
+
125
+ test("exact match for root path", () => {
126
+ const router = createRouter({ routes: [{ path: "/", component: Home }], url: "/" })
127
+ setActiveRouter(router as RouterInstance)
128
+ const isActive = useIsActive("/", true)
129
+ expect(isActive()).toBe(true)
130
+ })
131
+
132
+ test("partial match: /admin matches /admin/users", () => {
133
+ const routes: RouteRecord[] = [
134
+ {
135
+ path: "/admin",
136
+ component: Home,
137
+ children: [{ path: "users", component: About }],
138
+ },
139
+ ]
140
+ const router = createRouter({ routes, url: "/admin/users" })
141
+ setActiveRouter(router as RouterInstance)
142
+ const isActive = useIsActive("/admin")
143
+ expect(isActive()).toBe(true)
144
+ })
145
+
146
+ test("partial match: /admin does NOT match /admin-panel", async () => {
147
+ const routes: RouteRecord[] = [
148
+ { path: "/admin", component: Home },
149
+ { path: "/admin-panel", component: About },
150
+ ]
151
+ const router = createRouter({ routes, url: "/admin-panel" })
152
+ setActiveRouter(router as RouterInstance)
153
+ const isActive = useIsActive("/admin")
154
+ expect(isActive()).toBe(false)
155
+ })
156
+
157
+ test("exact match: /admin does NOT match /admin/users", () => {
158
+ const routes: RouteRecord[] = [
159
+ {
160
+ path: "/admin",
161
+ component: Home,
162
+ children: [{ path: "users", component: About }],
163
+ },
164
+ ]
165
+ const router = createRouter({ routes, url: "/admin/users" })
166
+ setActiveRouter(router as RouterInstance)
167
+ const isActive = useIsActive("/admin", true)
168
+ expect(isActive()).toBe(false)
169
+ })
170
+
171
+ test("root path partial match only matches /", () => {
172
+ const routes: RouteRecord[] = [
173
+ { path: "/", component: Home },
174
+ { path: "/about", component: About },
175
+ ]
176
+ const router = createRouter({ routes, url: "/about" })
177
+ setActiveRouter(router as RouterInstance)
178
+ const isActive = useIsActive("/")
179
+ // Root path in partial mode should only match "/"
180
+ expect(isActive()).toBe(false)
181
+ })
182
+
183
+ test("param pattern: /user/:id matches /user/42 in exact mode", () => {
184
+ const routes: RouteRecord[] = [{ path: "/user/:id", component: User }]
185
+ const router = createRouter({ routes, url: "/user/42" })
186
+ setActiveRouter(router as RouterInstance)
187
+ const isActive = useIsActive("/user/:id", true)
188
+ expect(isActive()).toBe(true)
189
+ })
190
+
191
+ test("param pattern: /user/:id matches /user/42 in partial mode", () => {
192
+ const routes: RouteRecord[] = [
193
+ {
194
+ path: "/user/:id",
195
+ component: User,
196
+ children: [{ path: "posts", component: About }],
197
+ },
198
+ ]
199
+ const router = createRouter({ routes, url: "/user/42/posts" })
200
+ setActiveRouter(router as RouterInstance)
201
+ const isActive = useIsActive("/user/:id")
202
+ expect(isActive()).toBe(true)
203
+ })
204
+
205
+ test("exact match with wrong segment count returns false", () => {
206
+ const routes: RouteRecord[] = [{ path: "/a/b/c", component: Home }]
207
+ const router = createRouter({ routes, url: "/a/b/c" })
208
+ setActiveRouter(router as RouterInstance)
209
+ const isActive = useIsActive("/a/b", true)
210
+ expect(isActive()).toBe(false)
211
+ })
212
+
213
+ test("partial match with more pattern segments than current returns false", () => {
214
+ const routes: RouteRecord[] = [{ path: "/a", component: Home }]
215
+ const router = createRouter({ routes, url: "/a" })
216
+ setActiveRouter(router as RouterInstance)
217
+ const isActive = useIsActive("/a/b/c")
218
+ expect(isActive()).toBe(false)
219
+ })
220
+ })
221
+
222
+ // ─── useSearchParams — edge cases ────────────────────────────────────────────
223
+
224
+ describe("useSearchParams — edge cases", () => {
225
+ beforeEach(() => {
226
+ setActiveRouter(null)
227
+ })
228
+
229
+ test("throws when no router installed", () => {
230
+ expect(() => useSearchParams()).toThrow("[pyreon-router] No router installed")
231
+ })
232
+
233
+ test("returns query params from current route", () => {
234
+ const routes: RouteRecord[] = [{ path: "/search", component: Home }]
235
+ const router = createRouter({ routes, url: "/search?q=hello&page=1" })
236
+ setActiveRouter(router as RouterInstance)
237
+ const [get] = useSearchParams()
238
+ expect(get().q).toBe("hello")
239
+ expect(get().page).toBe("1")
240
+ })
241
+
242
+ test("merges defaults with route query", () => {
243
+ const routes: RouteRecord[] = [{ path: "/search", component: Home }]
244
+ const router = createRouter({ routes, url: "/search?q=hello" })
245
+ setActiveRouter(router as RouterInstance)
246
+ const [get] = useSearchParams({ q: "", page: "1", sort: "name" })
247
+ expect(get().q).toBe("hello") // from URL, overrides default
248
+ expect(get().page).toBe("1") // from default
249
+ expect(get().sort).toBe("name") // from default
250
+ })
251
+
252
+ test("set navigates with merged query", async () => {
253
+ const routes: RouteRecord[] = [{ path: "/search", component: Home }]
254
+ const router = createRouter({ routes, url: "/search?q=hello" })
255
+ setActiveRouter(router as RouterInstance)
256
+ const [, set] = useSearchParams({ q: "", page: "1" })
257
+
258
+ await set({ page: "2" })
259
+ // Router should navigate — check that the route updated
260
+ const route = router.currentRoute()
261
+ expect(route.query.page).toBe("2")
262
+ expect(route.query.q).toBe("hello")
263
+ })
264
+ })
265
+
266
+ // ─── Router — trailing slash normalization ───────────────────────────────────
267
+
268
+ describe("router — trailing slash normalization", () => {
269
+ const routes: RouteRecord[] = [
270
+ { path: "/", component: Home },
271
+ { path: "/about", component: About },
272
+ ]
273
+
274
+ test("strip mode (default) removes trailing slashes", () => {
275
+ const router = createRouter({ routes, url: "/about/" })
276
+ expect(router.currentRoute().path).toBe("/about")
277
+ })
278
+
279
+ test("add mode ensures trailing slashes", () => {
280
+ const router = createRouter({ routes, url: "/about", trailingSlash: "add" })
281
+ expect(router.currentRoute().path).toBe("/about/")
282
+ })
283
+
284
+ test("ignore mode does not modify path", () => {
285
+ const router = createRouter({ routes, url: "/about/", trailingSlash: "ignore" })
286
+ expect(router.currentRoute().path).toBe("/about/")
287
+ })
288
+
289
+ test("root path is not modified by strip mode", () => {
290
+ const router = createRouter({ routes, url: "/", trailingSlash: "strip" })
291
+ expect(router.currentRoute().path).toBe("/")
292
+ })
293
+
294
+ test("strip mode handles path with query string", async () => {
295
+ const router = createRouter({ routes, url: "/" })
296
+ await router.push("/about/?q=1")
297
+ expect(router.currentRoute().path).toBe("/about")
298
+ expect(router.currentRoute().query.q).toBe("1")
299
+ })
300
+ })
301
+
302
+ // ─── Router — onError handler ────────────────────────────────────────────────
303
+
304
+ describe("router — onError handler", () => {
305
+ test("onError receives error from failed loader", async () => {
306
+ const errors: unknown[] = []
307
+ const routes: RouteRecord[] = [
308
+ { path: "/", component: Home },
309
+ {
310
+ path: "/fail",
311
+ component: About,
312
+ loader: async () => {
313
+ throw new Error("loader-error")
314
+ },
315
+ },
316
+ ]
317
+ const router = createRouter({
318
+ routes,
319
+ url: "/",
320
+ onError: (err) => {
321
+ errors.push(err)
322
+ return undefined
323
+ },
324
+ })
325
+
326
+ await router.push("/fail")
327
+ expect(errors.length).toBe(1)
328
+ expect((errors[0] as Error).message).toBe("loader-error")
329
+ expect(router.currentRoute().path).toBe("/fail")
330
+ })
331
+
332
+ test("onError returning false cancels navigation", async () => {
333
+ const routes: RouteRecord[] = [
334
+ { path: "/", component: Home },
335
+ {
336
+ path: "/fail",
337
+ component: About,
338
+ loader: async () => {
339
+ throw new Error("fail")
340
+ },
341
+ },
342
+ ]
343
+ const router = createRouter({
344
+ routes,
345
+ url: "/",
346
+ onError: () => false,
347
+ })
348
+
349
+ await router.push("/fail")
350
+ expect(router.currentRoute().path).toBe("/")
351
+ })
352
+ })
353
+
354
+ // ─── Router — destroy ────────────────────────────────────────────────────────
355
+
356
+ describe("router — destroy", () => {
357
+ test("destroy clears guards, hooks, caches, and blockers", () => {
358
+ const routes: RouteRecord[] = [{ path: "/", component: Home }]
359
+ const router = createRouter({ routes, url: "/" }) as RouterInstance
360
+ router.beforeEach(() => true)
361
+ router.afterEach(() => {})
362
+ router._blockers.add(() => false)
363
+ router._loaderData.set(routes[0] as RouteRecord, "data")
364
+
365
+ router.destroy()
366
+
367
+ expect(router._blockers.size).toBe(0)
368
+ expect(router._loaderData.size).toBe(0)
369
+ expect(router._abortController).toBeNull()
370
+ })
371
+
372
+ test("destroy is idempotent (safe to call twice)", () => {
373
+ const routes: RouteRecord[] = [{ path: "/", component: Home }]
374
+ const router = createRouter({ routes, url: "/" })
375
+ expect(() => {
376
+ router.destroy()
377
+ router.destroy()
378
+ }).not.toThrow()
379
+ })
380
+ })
381
+
382
+ // ─── Router — isReady ────────────────────────────────────────────────────────
383
+
384
+ describe("router — isReady", () => {
385
+ test("isReady resolves after initial navigation", async () => {
386
+ const routes: RouteRecord[] = [{ path: "/", component: Home }]
387
+ const router = createRouter({ routes, url: "/" })
388
+ await router.isReady()
389
+ // Should not hang
390
+ expect(router.currentRoute().path).toBe("/")
391
+ })
392
+ })
393
+
394
+ // ─── Router — relative path navigation ───────────────────────────────────────
395
+
396
+ describe("router — relative path navigation", () => {
397
+ const routes: RouteRecord[] = [
398
+ { path: "/", component: Home },
399
+ { path: "/user/:id", component: User },
400
+ {
401
+ path: "/admin",
402
+ component: Home,
403
+ children: [
404
+ { path: "users", component: About },
405
+ { path: "settings", component: User },
406
+ ],
407
+ },
408
+ ]
409
+
410
+ test("relative path ./sibling navigates correctly", async () => {
411
+ const router = createRouter({ routes, url: "/admin/users" })
412
+ await router.push("./settings")
413
+ expect(router.currentRoute().path).toBe("/admin/settings")
414
+ })
415
+
416
+ test("relative path ../up navigates correctly", async () => {
417
+ const router = createRouter({ routes, url: "/admin/users" })
418
+ await router.push("../")
419
+ expect(router.currentRoute().path).toBe("/")
420
+ })
421
+
422
+ test("absolute path is not modified", async () => {
423
+ const router = createRouter({ routes, url: "/admin/users" })
424
+ await router.push("/user/42")
425
+ expect(router.currentRoute().path).toBe("/user/42")
426
+ })
427
+ })
428
+
429
+ // ─── Router — replace with named route ───────────────────────────────────────
430
+
431
+ describe("router — replace with named route", () => {
432
+ test("replace with named route navigates correctly", async () => {
433
+ const routes: RouteRecord[] = [
434
+ { path: "/", component: Home, name: "home" },
435
+ { path: "/user/:id", component: User, name: "user" },
436
+ ]
437
+ const router = createRouter({ routes, url: "/" })
438
+ await router.replace({ name: "user", params: { id: "42" } })
439
+ expect(router.currentRoute().path).toBe("/user/42")
440
+ })
441
+
442
+ test("replace with unknown named route falls back to /", async () => {
443
+ const routes: RouteRecord[] = [{ path: "/", component: Home }]
444
+ const router = createRouter({ routes, url: "/" })
445
+ await router.replace({ name: "nonexistent" })
446
+ expect(router.currentRoute().path).toBe("/")
447
+ })
448
+ })
449
+
450
+ // ─── Router — sanitize unsafe URLs ───────────────────────────────────────────
451
+
452
+ describe("router — URL sanitization", () => {
453
+ const routes: RouteRecord[] = [
454
+ { path: "/", component: Home },
455
+ { path: "/about", component: About },
456
+ ]
457
+
458
+ test("blocks vbscript: URI", async () => {
459
+ const router = createRouter({ routes, url: "/" })
460
+ await router.push("vbscript:alert(1)")
461
+ expect(router.currentRoute().path).toBe("/")
462
+ })
463
+
464
+ test("blocks absolute URLs (http)", async () => {
465
+ const router = createRouter({ routes, url: "/" })
466
+ await router.push("http://evil.com")
467
+ expect(router.currentRoute().path).toBe("/")
468
+ })
469
+
470
+ test("blocks absolute URLs (https)", async () => {
471
+ const router = createRouter({ routes, url: "/" })
472
+ await router.push("https://evil.com")
473
+ expect(router.currentRoute().path).toBe("/")
474
+ })
475
+
476
+ test("blocks protocol-relative URLs", async () => {
477
+ const router = createRouter({ routes, url: "/" })
478
+ await router.push("//evil.com")
479
+ expect(router.currentRoute().path).toBe("/")
480
+ })
481
+ })
482
+
483
+ // ─── Router — staleWhileRevalidate ───────────────────────────────────────────
484
+
485
+ describe("router — staleWhileRevalidate", () => {
486
+ test("serves stale data immediately, revalidates in background", async () => {
487
+ let loaderCallCount = 0
488
+ const routes: RouteRecord[] = [
489
+ { path: "/", component: Home },
490
+ {
491
+ path: "/data",
492
+ component: About,
493
+ staleWhileRevalidate: true,
494
+ loader: async () => {
495
+ loaderCallCount++
496
+ return `data-v${loaderCallCount}`
497
+ },
498
+ },
499
+ ]
500
+ const router = createRouter({ routes, url: "/" }) as RouterInstance
501
+
502
+ // First navigation — loader runs as blocking
503
+ await router.push("/data")
504
+ expect(loaderCallCount).toBe(1)
505
+ expect(router._loaderData.get(routes[1] as RouteRecord)).toBe("data-v1")
506
+
507
+ // Navigate away and back — should show stale data and revalidate
508
+ await router.push("/")
509
+ await router.push("/data")
510
+
511
+ // Give background revalidation time
512
+ await new Promise<void>((r) => setTimeout(r, 50))
513
+ expect(loaderCallCount).toBe(2)
514
+ })
515
+ })