@pyreon/router 0.24.4 → 0.24.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.
@@ -1,1024 +0,0 @@
1
- import { hydrateLoaderData, prefetchLoaderData, serializeLoaderData, stringifyLoaderData } from '../loader'
2
- import { createRouter, setActiveRouter, useIsActive, useSearchParams } from '../router'
3
- import { lazy } from '../types'
4
- import type { RouteRecord, RouterInstance } from '../types'
5
-
6
- const Home = () => null
7
- const About = () => null
8
- const User = () => null
9
-
10
- // ─── serializeLoaderData / hydrateLoaderData round-trip edge cases ────────────
11
-
12
- describe('loader data serialization — edge cases', () => {
13
- test('serializes multiple route loaders', async () => {
14
- const routes: RouteRecord[] = [
15
- {
16
- path: '/admin',
17
- component: Home,
18
- loader: async () => 'admin-data',
19
- children: [
20
- {
21
- path: 'users',
22
- component: About,
23
- loader: async () => 'users-data',
24
- },
25
- ],
26
- },
27
- ]
28
- const router = createRouter({ routes, url: '/admin/users' }) as RouterInstance
29
- await prefetchLoaderData(router, '/admin/users')
30
-
31
- const serialized = serializeLoaderData(router)
32
- expect(serialized['/admin']).toBe('admin-data')
33
- expect(serialized.users).toBe('users-data')
34
- })
35
-
36
- test('hydrate ignores paths not in current route matched', () => {
37
- const routes: RouteRecord[] = [
38
- { path: '/', component: Home },
39
- { path: '/page', component: About, loader: async () => [] },
40
- ]
41
- const router = createRouter({ routes, url: '/' }) as RouterInstance
42
- // Hydrate with data for a path that is NOT currently matched
43
- hydrateLoaderData(router, { '/page': 'should-be-ignored' })
44
- expect(router._loaderData.size).toBe(0)
45
- })
46
-
47
- test('hydrate with non-object values is no-op', () => {
48
- const routes: RouteRecord[] = [{ path: '/', component: Home }]
49
- const router = createRouter({ routes, url: '/' }) as RouterInstance
50
-
51
- // These should not throw
52
- hydrateLoaderData(router, null as unknown as Record<string, unknown>)
53
- hydrateLoaderData(router, undefined as unknown as Record<string, unknown>)
54
- hydrateLoaderData(router, 42 as unknown as Record<string, unknown>)
55
- expect(router._loaderData.size).toBe(0)
56
- })
57
-
58
- test('round-trip with complex data types', async () => {
59
- const complexData = {
60
- items: [
61
- { id: 1, name: 'Item 1' },
62
- { id: 2, name: 'Item 2' },
63
- ],
64
- meta: { total: 2, page: 1 },
65
- nested: { deep: { value: true } },
66
- }
67
- const routes: RouteRecord[] = [
68
- { path: '/data', component: Home, loader: async () => complexData },
69
- ]
70
- const ssrRouter = createRouter({ routes, url: '/data' }) as RouterInstance
71
- await prefetchLoaderData(ssrRouter, '/data')
72
-
73
- const serialized = serializeLoaderData(ssrRouter)
74
- const clientRouter = createRouter({ routes, url: '/data' }) as RouterInstance
75
- hydrateLoaderData(clientRouter, serialized)
76
-
77
- const values = Array.from(clientRouter._loaderData.values())
78
- expect(values[0]).toEqual(complexData)
79
- })
80
-
81
- test('prefetchLoaderData does NOT clobber router._abortController', async () => {
82
- // Regression: `prefetchLoaderData` used to overwrite
83
- // `router._abortController` with its own fresh controller. Hovering
84
- // a <Link> during an in-flight navigation destroyed the nav's
85
- // abort capability — subsequent navigations couldn't cancel the
86
- // first one. Fix: prefetch uses a LOCAL controller.
87
- const routes: RouteRecord[] = [
88
- { path: '/data', component: Home, loader: async () => 'ok' },
89
- ]
90
- const router = createRouter({ routes, url: '/' }) as RouterInstance
91
- const navController = new AbortController()
92
- router._abortController = navController
93
- await prefetchLoaderData(router, '/data')
94
- // Prefetch finished; nav's controller must be untouched.
95
- expect(router._abortController).toBe(navController)
96
- expect(navController.signal.aborted).toBe(false)
97
- })
98
-
99
- test('prefetchLoaderData passes AbortSignal to loaders', async () => {
100
- let receivedSignal: AbortSignal | undefined
101
- const routes: RouteRecord[] = [
102
- {
103
- path: '/data',
104
- component: Home,
105
- loader: async ({ signal }) => {
106
- receivedSignal = signal
107
- return 'ok'
108
- },
109
- },
110
- ]
111
- const router = createRouter({ routes, url: '/' }) as RouterInstance
112
- await prefetchLoaderData(router, '/data')
113
- expect(receivedSignal).toBeDefined()
114
- expect(receivedSignal).toBeInstanceOf(AbortSignal)
115
- })
116
-
117
- test('prefetchLoaderData skips routes without loaders', async () => {
118
- const routes: RouteRecord[] = [
119
- {
120
- path: '/admin',
121
- component: Home,
122
- children: [
123
- { path: 'users', component: About }, // no loader
124
- ],
125
- },
126
- ]
127
- const router = createRouter({ routes, url: '/' }) as RouterInstance
128
- await prefetchLoaderData(router, '/admin/users')
129
- expect(router._loaderData.size).toBe(0)
130
- })
131
- })
132
-
133
- // ─── M2.2 — stringifyLoaderData ────────────────────────────────────────────
134
-
135
- describe('stringifyLoaderData (M2.2)', () => {
136
- // Bisect-load-bearing: revert the replacer (use bare `JSON.stringify(d).replace(/<\//g, '<\\/')`)
137
- // → the function-strip + circular-error specs fail. The bare-strings-only spec
138
- // would still pass since JSON.stringify also drops function values for objects.
139
-
140
- test('strips function values silently', () => {
141
- const json = stringifyLoaderData({
142
- '/home': { data: 1, fn: () => {} },
143
- })
144
- expect(json).not.toContain('fn')
145
- expect(json).toContain('"data":1')
146
- })
147
-
148
- test('strips symbol values silently', () => {
149
- const json = stringifyLoaderData({
150
- '/home': { data: 1, sym: Symbol('x') as unknown as string },
151
- })
152
- expect(json).not.toContain('sym')
153
- expect(json).toContain('"data":1')
154
- })
155
-
156
- test('throws Pyreon-prefixed error on circular reference naming the offending key', () => {
157
- interface Cyclic {
158
- data: number
159
- self?: Cyclic
160
- }
161
- const cyclic: Cyclic = { data: 1 }
162
- cyclic.self = cyclic
163
- expect(() => stringifyLoaderData({ '/posts/1': cyclic })).toThrow(/\[Pyreon\] Loader returned circular reference/)
164
- // The error names the path: `/posts/1.self` (or similar).
165
- expect(() => stringifyLoaderData({ '/posts/1': cyclic })).toThrow(/\/posts\/1/)
166
- })
167
-
168
- test('escapes </script> to prevent script-tag escape', () => {
169
- const json = stringifyLoaderData({
170
- '/home': { html: '</script><script>alert(1)' },
171
- })
172
- expect(json).not.toContain('</script>')
173
- expect(json).toContain('<\\/script>')
174
- })
175
-
176
- test('passes plain data through unchanged', () => {
177
- const json = stringifyLoaderData({
178
- '/posts': [{ id: 1, title: 'A' }],
179
- '/about': { count: 42 },
180
- })
181
- expect(JSON.parse(json)).toEqual({
182
- '/posts': [{ id: 1, title: 'A' }],
183
- '/about': { count: 42 },
184
- })
185
- })
186
-
187
- test('shared (DAG) references serialize — only true cycles throw', () => {
188
- // BEHAVIOUR CHANGE (intentional, bug fix). Previously the all-seen
189
- // WeakSet threw "circular reference" on ANY object visited twice — a
190
- // shared reference (DAG), not a cycle. That 500'd the SSR response for
191
- // extremely common loader payloads (an ORM returning the same instance
192
- // twice). The original code's own comment anticipated this remedy:
193
- // "if this becomes too aggressive, relax with a post-visit drop
194
- // instead of WeakSet" — which is exactly what ancestor-path detection
195
- // does. Strictly more permissive: inputs that throw before now succeed;
196
- // inputs that worked are unchanged; real cycles still throw.
197
- const shared = [1, 2, 3]
198
- const json = stringifyLoaderData({ '/a': shared, '/b': shared })
199
- expect(JSON.parse(json)).toEqual({ '/a': [1, 2, 3], '/b': [1, 2, 3] })
200
-
201
- // Canonical real-world shape: one user object referenced twice.
202
- const user = { id: 7, name: 'Ada' }
203
- const post = stringifyLoaderData({ '/posts/1': { author: user, lastEditor: user } })
204
- expect(JSON.parse(post)).toEqual({
205
- '/posts/1': { author: { id: 7, name: 'Ada' }, lastEditor: { id: 7, name: 'Ada' } },
206
- })
207
-
208
- // Diamond: same node reachable via two paths, no cycle.
209
- const leaf = { v: 1 }
210
- const diamond = stringifyLoaderData({ '/d': { left: { leaf }, right: { leaf } } })
211
- expect(JSON.parse(diamond)).toEqual({ '/d': { left: { leaf: { v: 1 } }, right: { leaf: { v: 1 } } } })
212
- })
213
-
214
- test('still throws on a true cycle through a shared-looking path', () => {
215
- // A shared ref that ALSO closes a cycle must still throw.
216
- interface N {
217
- id: number
218
- next?: N
219
- }
220
- const a: N = { id: 1 }
221
- const b: N = { id: 2 }
222
- a.next = b
223
- b.next = a // real cycle
224
- expect(() => stringifyLoaderData({ '/x': { a, b } })).toThrow(/\[Pyreon\] Loader returned circular reference/)
225
- })
226
-
227
- test('empty record produces empty object JSON', () => {
228
- expect(stringifyLoaderData({})).toBe('{}')
229
- })
230
- })
231
-
232
- // ─── useIsActive — edge cases ────────────────────────────────────────────────
233
-
234
- describe('useIsActive — edge cases', () => {
235
- beforeEach(() => {
236
- setActiveRouter(null)
237
- })
238
-
239
- test('throws when no router installed', () => {
240
- expect(() => useIsActive('/')).toThrow('[Pyreon] No router installed')
241
- })
242
-
243
- test('exact match for root path', () => {
244
- const router = createRouter({ routes: [{ path: '/', component: Home }], url: '/' })
245
- setActiveRouter(router as RouterInstance)
246
- const isActive = useIsActive('/', true)
247
- expect(isActive()).toBe(true)
248
- })
249
-
250
- test('partial match: /admin matches /admin/users', () => {
251
- const routes: RouteRecord[] = [
252
- {
253
- path: '/admin',
254
- component: Home,
255
- children: [{ path: 'users', component: About }],
256
- },
257
- ]
258
- const router = createRouter({ routes, url: '/admin/users' })
259
- setActiveRouter(router as RouterInstance)
260
- const isActive = useIsActive('/admin')
261
- expect(isActive()).toBe(true)
262
- })
263
-
264
- test('partial match: /admin does NOT match /admin-panel', async () => {
265
- const routes: RouteRecord[] = [
266
- { path: '/admin', component: Home },
267
- { path: '/admin-panel', component: About },
268
- ]
269
- const router = createRouter({ routes, url: '/admin-panel' })
270
- setActiveRouter(router as RouterInstance)
271
- const isActive = useIsActive('/admin')
272
- expect(isActive()).toBe(false)
273
- })
274
-
275
- test('exact match: /admin does NOT match /admin/users', () => {
276
- const routes: RouteRecord[] = [
277
- {
278
- path: '/admin',
279
- component: Home,
280
- children: [{ path: 'users', component: About }],
281
- },
282
- ]
283
- const router = createRouter({ routes, url: '/admin/users' })
284
- setActiveRouter(router as RouterInstance)
285
- const isActive = useIsActive('/admin', true)
286
- expect(isActive()).toBe(false)
287
- })
288
-
289
- test('root path partial match only matches /', () => {
290
- const routes: RouteRecord[] = [
291
- { path: '/', component: Home },
292
- { path: '/about', component: About },
293
- ]
294
- const router = createRouter({ routes, url: '/about' })
295
- setActiveRouter(router as RouterInstance)
296
- const isActive = useIsActive('/')
297
- // Root path in partial mode should only match "/"
298
- expect(isActive()).toBe(false)
299
- })
300
-
301
- test('param pattern: /user/:id matches /user/42 in exact mode', () => {
302
- const routes: RouteRecord[] = [{ path: '/user/:id', component: User }]
303
- const router = createRouter({ routes, url: '/user/42' })
304
- setActiveRouter(router as RouterInstance)
305
- const isActive = useIsActive('/user/:id', true)
306
- expect(isActive()).toBe(true)
307
- })
308
-
309
- test('param pattern: /user/:id matches /user/42 in partial mode', () => {
310
- const routes: RouteRecord[] = [
311
- {
312
- path: '/user/:id',
313
- component: User,
314
- children: [{ path: 'posts', component: About }],
315
- },
316
- ]
317
- const router = createRouter({ routes, url: '/user/42/posts' })
318
- setActiveRouter(router as RouterInstance)
319
- const isActive = useIsActive('/user/:id')
320
- expect(isActive()).toBe(true)
321
- })
322
-
323
- test('exact match with wrong segment count returns false', () => {
324
- const routes: RouteRecord[] = [{ path: '/a/b/c', component: Home }]
325
- const router = createRouter({ routes, url: '/a/b/c' })
326
- setActiveRouter(router as RouterInstance)
327
- const isActive = useIsActive('/a/b', true)
328
- expect(isActive()).toBe(false)
329
- })
330
-
331
- test('partial match with more pattern segments than current returns false', () => {
332
- const routes: RouteRecord[] = [{ path: '/a', component: Home }]
333
- const router = createRouter({ routes, url: '/a' })
334
- setActiveRouter(router as RouterInstance)
335
- const isActive = useIsActive('/a/b/c')
336
- expect(isActive()).toBe(false)
337
- })
338
- })
339
-
340
- // ─── useSearchParams — edge cases ────────────────────────────────────────────
341
-
342
- describe('useSearchParams — edge cases', () => {
343
- beforeEach(() => {
344
- setActiveRouter(null)
345
- })
346
-
347
- test('throws when no router installed', () => {
348
- expect(() => useSearchParams()).toThrow('[Pyreon] No router installed')
349
- })
350
-
351
- test('returns query params from current route', () => {
352
- const routes: RouteRecord[] = [{ path: '/search', component: Home }]
353
- const router = createRouter({ routes, url: '/search?q=hello&page=1' })
354
- setActiveRouter(router as RouterInstance)
355
- const [get] = useSearchParams()
356
- expect(get().q).toBe('hello')
357
- expect(get().page).toBe('1')
358
- })
359
-
360
- test('merges defaults with route query', () => {
361
- const routes: RouteRecord[] = [{ path: '/search', component: Home }]
362
- const router = createRouter({ routes, url: '/search?q=hello' })
363
- setActiveRouter(router as RouterInstance)
364
- const [get] = useSearchParams({ q: '', page: '1', sort: 'name' })
365
- expect(get().q).toBe('hello') // from URL, overrides default
366
- expect(get().page).toBe('1') // from default
367
- expect(get().sort).toBe('name') // from default
368
- })
369
-
370
- test('set navigates with merged query', async () => {
371
- const routes: RouteRecord[] = [{ path: '/search', component: Home }]
372
- const router = createRouter({ routes, url: '/search?q=hello' })
373
- setActiveRouter(router as RouterInstance)
374
- const [, set] = useSearchParams({ q: '', page: '1' })
375
-
376
- await set({ page: '2' })
377
- // Router should navigate — check that the route updated
378
- const route = router.currentRoute()
379
- expect(route.query.page).toBe('2')
380
- expect(route.query.q).toBe('hello')
381
- })
382
- })
383
-
384
- // ─── Router — trailing slash normalization ───────────────────────────────────
385
-
386
- describe('router — trailing slash normalization', () => {
387
- const routes: RouteRecord[] = [
388
- { path: '/', component: Home },
389
- { path: '/about', component: About },
390
- ]
391
-
392
- test('strip mode (default) removes trailing slashes', () => {
393
- const router = createRouter({ routes, url: '/about/' })
394
- expect(router.currentRoute().path).toBe('/about')
395
- })
396
-
397
- test('add mode ensures trailing slashes', () => {
398
- const router = createRouter({ routes, url: '/about', trailingSlash: 'add' })
399
- expect(router.currentRoute().path).toBe('/about/')
400
- })
401
-
402
- test('ignore mode does not modify path', () => {
403
- const router = createRouter({ routes, url: '/about/', trailingSlash: 'ignore' })
404
- expect(router.currentRoute().path).toBe('/about/')
405
- })
406
-
407
- test('root path is not modified by strip mode', () => {
408
- const router = createRouter({ routes, url: '/', trailingSlash: 'strip' })
409
- expect(router.currentRoute().path).toBe('/')
410
- })
411
-
412
- test('strip mode handles path with query string', async () => {
413
- const router = createRouter({ routes, url: '/' })
414
- await router.push('/about/?q=1')
415
- expect(router.currentRoute().path).toBe('/about')
416
- expect(router.currentRoute().query.q).toBe('1')
417
- })
418
- })
419
-
420
- // ─── Router — onError handler ────────────────────────────────────────────────
421
-
422
- describe('router — onError handler', () => {
423
- test('onError receives error from failed loader', async () => {
424
- const errors: unknown[] = []
425
- const routes: RouteRecord[] = [
426
- { path: '/', component: Home },
427
- {
428
- path: '/fail',
429
- component: About,
430
- loader: async () => {
431
- throw new Error('loader-error')
432
- },
433
- },
434
- ]
435
- const router = createRouter({
436
- routes,
437
- url: '/',
438
- onError: (err) => {
439
- errors.push(err)
440
- return undefined
441
- },
442
- })
443
-
444
- await router.push('/fail')
445
- expect(errors.length).toBe(1)
446
- expect((errors[0] as Error).message).toBe('loader-error')
447
- expect(router.currentRoute().path).toBe('/fail')
448
- })
449
-
450
- test('onError returning false cancels navigation', async () => {
451
- const routes: RouteRecord[] = [
452
- { path: '/', component: Home },
453
- {
454
- path: '/fail',
455
- component: About,
456
- loader: async () => {
457
- throw new Error('fail')
458
- },
459
- },
460
- ]
461
- const router = createRouter({
462
- routes,
463
- url: '/',
464
- onError: () => false,
465
- })
466
-
467
- await router.push('/fail')
468
- expect(router.currentRoute().path).toBe('/')
469
- })
470
- })
471
-
472
- // ─── Router — destroy ────────────────────────────────────────────────────────
473
-
474
- describe('router — destroy', () => {
475
- test('destroy clears guards, hooks, caches, and blockers', () => {
476
- const routes: RouteRecord[] = [{ path: '/', component: Home }]
477
- const router = createRouter({ routes, url: '/' }) as RouterInstance
478
- router.beforeEach(() => true)
479
- router.afterEach(() => {})
480
- router._blockers.add(() => false)
481
- router._loaderData.set(routes[0] as RouteRecord, 'data')
482
-
483
- router.destroy()
484
-
485
- expect(router._blockers.size).toBe(0)
486
- expect(router._loaderData.size).toBe(0)
487
- expect(router._abortController).toBeNull()
488
- })
489
-
490
- test('destroy is idempotent (safe to call twice)', () => {
491
- const routes: RouteRecord[] = [{ path: '/', component: Home }]
492
- const router = createRouter({ routes, url: '/' })
493
- expect(() => {
494
- router.destroy()
495
- router.destroy()
496
- }).not.toThrow()
497
- })
498
- })
499
-
500
- // ─── Router — isReady ────────────────────────────────────────────────────────
501
-
502
- describe('router — isReady', () => {
503
- test('isReady resolves after initial navigation', async () => {
504
- const routes: RouteRecord[] = [{ path: '/', component: Home }]
505
- const router = createRouter({ routes, url: '/' })
506
- await router.isReady()
507
- // Should not hang
508
- expect(router.currentRoute().path).toBe('/')
509
- })
510
- })
511
-
512
- // ─── Router — relative path navigation ───────────────────────────────────────
513
-
514
- describe('router — relative path navigation', () => {
515
- const routes: RouteRecord[] = [
516
- { path: '/', component: Home },
517
- { path: '/user/:id', component: User },
518
- {
519
- path: '/admin',
520
- component: Home,
521
- children: [
522
- { path: 'users', component: About },
523
- { path: 'settings', component: User },
524
- ],
525
- },
526
- ]
527
-
528
- test('relative path ./sibling navigates correctly', async () => {
529
- const router = createRouter({ routes, url: '/admin/users' })
530
- await router.push('./settings')
531
- expect(router.currentRoute().path).toBe('/admin/settings')
532
- })
533
-
534
- test('relative path ../up navigates correctly', async () => {
535
- const router = createRouter({ routes, url: '/admin/users' })
536
- await router.push('../')
537
- expect(router.currentRoute().path).toBe('/')
538
- })
539
-
540
- test('absolute path is not modified', async () => {
541
- const router = createRouter({ routes, url: '/admin/users' })
542
- await router.push('/user/42')
543
- expect(router.currentRoute().path).toBe('/user/42')
544
- })
545
- })
546
-
547
- // ─── Router — replace with named route ───────────────────────────────────────
548
-
549
- describe('router — replace with named route', () => {
550
- test('replace with named route navigates correctly', async () => {
551
- const routes: RouteRecord[] = [
552
- { path: '/', component: Home, name: 'home' },
553
- { path: '/user/:id', component: User, name: 'user' },
554
- ]
555
- const router = createRouter({ routes, url: '/' })
556
- await router.replace({ name: 'user', params: { id: '42' } })
557
- expect(router.currentRoute().path).toBe('/user/42')
558
- })
559
-
560
- test('replace with unknown named route falls back to /', async () => {
561
- const routes: RouteRecord[] = [{ path: '/', component: Home }]
562
- const router = createRouter({ routes, url: '/' })
563
- await router.replace({ name: 'nonexistent' })
564
- expect(router.currentRoute().path).toBe('/')
565
- })
566
- })
567
-
568
- // ─── Router — sanitize unsafe URLs ───────────────────────────────────────────
569
-
570
- describe('router — URL sanitization', () => {
571
- const routes: RouteRecord[] = [
572
- { path: '/', component: Home },
573
- { path: '/about', component: About },
574
- ]
575
-
576
- test('blocks vbscript: URI', async () => {
577
- const router = createRouter({ routes, url: '/' })
578
- await router.push('vbscript:alert(1)')
579
- expect(router.currentRoute().path).toBe('/')
580
- })
581
-
582
- test('blocks absolute URLs (http)', async () => {
583
- const router = createRouter({ routes, url: '/' })
584
- await router.push('http://evil.com')
585
- expect(router.currentRoute().path).toBe('/')
586
- })
587
-
588
- test('blocks absolute URLs (https)', async () => {
589
- const router = createRouter({ routes, url: '/' })
590
- await router.push('https://evil.com')
591
- expect(router.currentRoute().path).toBe('/')
592
- })
593
-
594
- test('blocks protocol-relative URLs', async () => {
595
- const router = createRouter({ routes, url: '/' })
596
- await router.push('//evil.com')
597
- expect(router.currentRoute().path).toBe('/')
598
- })
599
- })
600
-
601
- // ─── Router — staleWhileRevalidate ───────────────────────────────────────────
602
-
603
- describe('router — staleWhileRevalidate', () => {
604
- test('serves stale data immediately, revalidates in background', async () => {
605
- let loaderCallCount = 0
606
- const routes: RouteRecord[] = [
607
- { path: '/', component: Home },
608
- {
609
- path: '/data',
610
- component: About,
611
- staleWhileRevalidate: true,
612
- loader: async () => {
613
- loaderCallCount++
614
- const v = loaderCallCount
615
- // The revalidation call (2nd) takes REAL async time so the
616
- // stale window is deterministic, not microtask-races. The
617
- // first (blocking) call resolves immediately.
618
- if (v >= 2) await new Promise<void>((r) => setTimeout(r, 40))
619
- return `data-v${v}`
620
- },
621
- },
622
- ]
623
- const swrRecord = routes[1] as RouteRecord
624
- const router = createRouter({ routes, url: '/' }) as RouterInstance
625
-
626
- // First navigation — loader runs as blocking
627
- await router.push('/data')
628
- expect(loaderCallCount).toBe(1)
629
- expect(router._loaderData.get(swrRecord)).toBe('data-v1')
630
-
631
- // Navigate AWAY, then BACK. The away nav must NOT prune the SWR
632
- // route's loader data (that was the bug — see the regression note
633
- // below); on return the SWR fast-path must serve the STALE value
634
- // immediately and revalidate in the BACKGROUND.
635
- await router.push('/')
636
- await router.push('/data')
637
-
638
- // SWR discriminator (load-bearing): the return navigation resolves
639
- // WITHOUT blocking on the (40ms) loader — the served data is still
640
- // STALE `data-v1`, revalidation pending. Pre-fix this navigation
641
- // went through the BLOCKING path (the prune wiped the entry), so
642
- // `await push('/data')` would have awaited the loader and the data
643
- // here would already be `data-v2`.
644
- expect(router._loaderData.get(swrRecord)).toBe('data-v1')
645
-
646
- // Background revalidation then completes and swaps in fresh data.
647
- await new Promise<void>((r) => setTimeout(r, 80))
648
- expect(loaderCallCount).toBe(2)
649
- expect(router._loaderData.get(swrRecord)).toBe('data-v2')
650
- })
651
-
652
- test('a failing background revalidation surfaces via onError without cancelling or clobbering', async () => {
653
- // This path only became testable after #617 made the SWR branch
654
- // actually run (the prune previously made `revalidateSwrLoaders`
655
- // dead code — the reason an earlier attempt at this fix shipped
656
- // WITHOUT a test and was reverted). With SWR live, an empty `.catch`
657
- // is the silent-failure anti-pattern: a persistently-failing
658
- // revalidation gives the developer zero signal. Contract: the error
659
- // is surfaced via `onError` exactly once; the (already-settled)
660
- // navigation is NOT cancelled; the failed revalidation does NOT
661
- // clobber the stale data (it stays valid + on screen).
662
- let loaderCallCount = 0
663
- const onError = vi.fn<(err: unknown, route: unknown) => undefined | false>(() => undefined)
664
- const routes: RouteRecord[] = [
665
- { path: '/', component: Home },
666
- {
667
- path: '/data',
668
- component: About,
669
- staleWhileRevalidate: true,
670
- loader: async () => {
671
- loaderCallCount++
672
- const v = loaderCallCount
673
- if (v >= 2) {
674
- // Real async delay so the rejection is genuinely a
675
- // background failure (deterministic, not a microtask race).
676
- await new Promise<void>((r) => setTimeout(r, 40))
677
- throw new Error('revalidation upstream 503')
678
- }
679
- return `data-v${v}`
680
- },
681
- },
682
- ]
683
- const swrRecord = routes[1] as RouteRecord
684
- const router = createRouter({ routes, url: '/', onError }) as RouterInstance
685
-
686
- await router.push('/data') // blocking — primes stale data-v1
687
- expect(router._loaderData.get(swrRecord)).toBe('data-v1')
688
-
689
- await router.push('/') // nav away (SWR data survives — #617)
690
- await router.push('/data') // SWR: stale served, revalidate in bg
691
-
692
- // Returned on STALE data without blocking; revalidation not failed yet.
693
- expect(router.currentRoute().path).toBe('/data')
694
- expect(router._loaderData.get(swrRecord)).toBe('data-v1')
695
- expect(onError).not.toHaveBeenCalled()
696
-
697
- await new Promise<void>((r) => setTimeout(r, 90)) // revalidation rejects
698
-
699
- expect(loaderCallCount).toBe(2)
700
- // The previously-empty `.catch` swallowed this entirely. Contract:
701
- expect(onError).toHaveBeenCalledTimes(1)
702
- expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error)
703
- expect((onError.mock.calls[0]?.[0] as Error).message).toBe('revalidation upstream 503')
704
- // Navigation was NOT cancelled, and the stale value was NOT clobbered
705
- // by the failed revalidation — it stays valid and on screen.
706
- expect(router.currentRoute().path).toBe('/data')
707
- expect(router._loaderData.get(swrRecord)).toBe('data-v1')
708
- })
709
-
710
- // REGRESSION NOTE (fixed in this PR). `staleWhileRevalidate` was
711
- // effectively a no-op for the realistic nav-away/back case:
712
- // `commitNavigation`'s `doCommit` pruned `_loaderData` for every
713
- // record not in the NEW matched chain on every navigation, so
714
- // navigating away deleted the SWR route's data and `runLoaders`'
715
- // `r.staleWhileRevalidate && _loaderData.has(r)` gate was always false
716
- // on return — `revalidateSwrLoaders` never ran; every visit went
717
- // through the blocking path. (NB: the earlier hypothesis that
718
- // `resolveRoute` returns fresh `RouteRecord` objects was WRONG —
719
- // identity is stable; the prune was the sole cause, proven by an
720
- // instrumented probe showing SWR fires correctly for `/data → /data`
721
- // with no nav-away but not for `/data → / → /data`.) Fix: the prune
722
- // skips `staleWhileRevalidate` records so their data survives
723
- // navigating away. The strengthened test above is the regression
724
- // guard — its stale-window assertion fails on the pre-fix prune.
725
- })
726
-
727
- describe('router.preload', () => {
728
- test('runs loaders for the preloaded path', async () => {
729
- let calls = 0
730
- const routes: RouteRecord[] = [
731
- { path: '/', component: Home },
732
- {
733
- path: '/u/:id',
734
- component: User,
735
- loader: async ({ params }) => {
736
- calls++
737
- return { id: params.id, name: `User ${params.id}` }
738
- },
739
- },
740
- ]
741
- const router = createRouter({ routes, url: '/' }) as RouterInstance
742
-
743
- await router.preload('/u/7')
744
-
745
- expect(calls).toBe(1)
746
- expect(router._loaderData.get(routes[1] as RouteRecord)).toEqual({
747
- id: '7',
748
- name: 'User 7',
749
- })
750
- // currentRoute is unchanged — preload prepares data, doesn't navigate
751
- expect(router.currentRoute().path).toBe('/')
752
- })
753
-
754
- test('loads lazy components into the cache so render is synchronous', async () => {
755
- let lazyLoadCalls = 0
756
- const Lazy = () => null
757
- const routes: RouteRecord[] = [
758
- { path: '/', component: Home },
759
- {
760
- path: '/lazy',
761
- component: lazy(async () => {
762
- lazyLoadCalls++
763
- return Lazy
764
- }),
765
- },
766
- ]
767
- const router = createRouter({ routes, url: '/' }) as RouterInstance
768
-
769
- await router.preload('/lazy')
770
-
771
- expect(lazyLoadCalls).toBe(1)
772
- expect(router._componentCache.get(routes[1] as RouteRecord)).toBe(Lazy)
773
- })
774
-
775
- // ─── PR C — skipLoaders option for 404 build paths ──────────────────────
776
- //
777
- // The SSG plugin's `__renderNotFound` opts out of loader execution
778
- // when generating a static 404 page — parent-layout loaders that hit
779
- // auth resources / external APIs shouldn't fire when there's no real
780
- // request context to drive them. `skipLoaders: true` skips the loader
781
- // step entirely while keeping the lazy-component resolution intact
782
- // (so the synthetic chain still renders cleanly).
783
- test('skipLoaders: true skips loader execution', async () => {
784
- let calls = 0
785
- const routes: RouteRecord[] = [
786
- { path: '/', component: Home },
787
- {
788
- path: '/u/:id',
789
- component: User,
790
- loader: async ({ params }) => {
791
- calls++
792
- return { id: params.id }
793
- },
794
- },
795
- ]
796
- const router = createRouter({ routes, url: '/' }) as RouterInstance
797
-
798
- await router.preload('/u/7', undefined, { skipLoaders: true })
799
-
800
- expect(calls).toBe(0)
801
- expect(router._loaderData.get(routes[1] as RouteRecord)).toBeUndefined()
802
- })
803
-
804
- test('skipLoaders: false (default) still runs loaders', async () => {
805
- let calls = 0
806
- const routes: RouteRecord[] = [
807
- { path: '/', component: Home },
808
- {
809
- path: '/u/:id',
810
- component: User,
811
- loader: async () => {
812
- calls++
813
- return null
814
- },
815
- },
816
- ]
817
- const router = createRouter({ routes, url: '/' }) as RouterInstance
818
-
819
- // No options arg
820
- await router.preload('/u/7')
821
- expect(calls).toBe(1)
822
-
823
- // Explicit false
824
- await router.preload('/u/7', undefined, { skipLoaders: false })
825
- expect(calls).toBe(2)
826
- })
827
-
828
- test('skipLoaders: true still loads lazy components (preserves render readiness)', async () => {
829
- // The 404 build path needs the synthetic-chain components resolved
830
- // so the render pass doesn't fall back to loadingComponent. Only
831
- // the data-fetching `r.loader()` calls are skipped.
832
- let lazyLoadCalls = 0
833
- let loaderCalls = 0
834
- const Lazy = () => null
835
- const routes: RouteRecord[] = [
836
- { path: '/', component: Home },
837
- {
838
- path: '/lazy',
839
- component: lazy(async () => {
840
- lazyLoadCalls++
841
- return Lazy
842
- }),
843
- loader: async () => {
844
- loaderCalls++
845
- return null
846
- },
847
- },
848
- ]
849
- const router = createRouter({ routes, url: '/' }) as RouterInstance
850
-
851
- await router.preload('/lazy', undefined, { skipLoaders: true })
852
-
853
- // Lazy component IS resolved (needed for render readiness).
854
- expect(lazyLoadCalls).toBe(1)
855
- expect(router._componentCache.get(routes[1] as RouteRecord)).toBe(Lazy)
856
- // Loader was NOT called.
857
- expect(loaderCalls).toBe(0)
858
- })
859
-
860
- test('skipLoaders: true preserves currentRoute (preload is non-navigational)', async () => {
861
- const routes: RouteRecord[] = [
862
- { path: '/', component: Home },
863
- {
864
- path: '/u/:id',
865
- component: User,
866
- loader: async () => null,
867
- },
868
- ]
869
- const router = createRouter({ routes, url: '/' }) as RouterInstance
870
-
871
- await router.preload('/u/7', undefined, { skipLoaders: true })
872
-
873
- expect(router.currentRoute().path).toBe('/')
874
- })
875
- })
876
-
877
- // ─── _loaderCache LRU cap (regression for missing _maxCacheSize wiring) ────
878
- describe('router — _loaderCache LRU cap', () => {
879
- // Pre-fix: `_maxCacheSize` was wired through from `RouterOptions.maxCacheSize`
880
- // (default 100) but the loader cache write paths in router.ts never read it
881
- // — only `_componentCache` enforced the cap. Long-running SPAs navigating
882
- // dynamic-param routes (`/posts/:id` with hundreds of unique IDs) would
883
- // accumulate `_loaderCache` entries until manual `invalidateLoader()`.
884
- // Post-fix: the helper `loaderCacheSet` evicts oldest (insertion-order FIFO)
885
- // when over the cap, mirroring `_componentCache`.
886
- test('caps _loaderCache at maxCacheSize, evicts oldest first', async () => {
887
- const Page = () => null
888
- const routes: RouteRecord[] = [
889
- {
890
- path: '/posts/:id',
891
- component: Page,
892
- loader: async ({ params }) => `post-${params.id}`,
893
- loaderKey: ({ params }) => `posts:${params.id}`,
894
- },
895
- ]
896
- const router = createRouter({ routes, maxCacheSize: 3, url: '/' }) as RouterInstance
897
-
898
- // Drive the loader through 4 distinct keys
899
- await router.push('/posts/1')
900
- await router.push('/posts/2')
901
- await router.push('/posts/3')
902
- await router.push('/posts/4')
903
-
904
- // Cache must be capped at 3 (not 4).
905
- expect(router._loaderCache.size).toBe(3)
906
-
907
- // FIFO: the OLDEST insertion (posts:1) must have been evicted.
908
- expect(router._loaderCache.has('posts:1')).toBe(false)
909
- expect(router._loaderCache.has('posts:2')).toBe(true)
910
- expect(router._loaderCache.has('posts:3')).toBe(true)
911
- expect(router._loaderCache.has('posts:4')).toBe(true)
912
- })
913
-
914
- test('does not evict when cap is not exceeded', async () => {
915
- const Page = () => null
916
- const routes: RouteRecord[] = [
917
- {
918
- path: '/posts/:id',
919
- component: Page,
920
- loader: async ({ params }) => `post-${params.id}`,
921
- loaderKey: ({ params }) => `posts:${params.id}`,
922
- },
923
- ]
924
- const router = createRouter({ routes, maxCacheSize: 100, url: '/' }) as RouterInstance
925
-
926
- await router.push('/posts/1')
927
- await router.push('/posts/2')
928
- await router.push('/posts/3')
929
-
930
- expect(router._loaderCache.size).toBe(3)
931
- expect(router._loaderCache.has('posts:1')).toBe(true)
932
- expect(router._loaderCache.has('posts:2')).toBe(true)
933
- expect(router._loaderCache.has('posts:3')).toBe(true)
934
- })
935
-
936
- test('default maxCacheSize (100) caps cache after 100 unique keys', async () => {
937
- const Page = () => null
938
- const routes: RouteRecord[] = [
939
- {
940
- path: '/posts/:id',
941
- component: Page,
942
- loader: async ({ params }) => `post-${params.id}`,
943
- loaderKey: ({ params }) => `posts:${params.id}`,
944
- },
945
- ]
946
- // No explicit maxCacheSize — uses default 100
947
- const router = createRouter({ routes, url: '/' }) as RouterInstance
948
-
949
- for (let i = 0; i < 105; i++) {
950
- await router.push(`/posts/${i}`)
951
- }
952
-
953
- expect(router._loaderCache.size).toBe(100)
954
- // Earliest 5 keys (0-4) evicted.
955
- expect(router._loaderCache.has('posts:0')).toBe(false)
956
- expect(router._loaderCache.has('posts:4')).toBe(false)
957
- expect(router._loaderCache.has('posts:5')).toBe(true)
958
- expect(router._loaderCache.has('posts:104')).toBe(true)
959
- })
960
- })
961
-
962
- // ─── Regression: dedup must not return aborted in-flight promise ───────────
963
- //
964
- // Pre-fix: `router.push` aborts `_abortController` BEFORE starting the next
965
- // nav. If two pushes to the same path happen back-to-back, the in-flight
966
- // Map still holds nav-1's promise (its `.catch` hasn't run yet). The dedup
967
- // returned that promise to nav-2 — but its bound signal is already aborted,
968
- // so nav-2's data path is broken even though it has its own fresh signal.
969
- //
970
- // Post-fix: `_loaderInflight` stores `{ promise, signal }`. Dedup is gated
971
- // on `!signal.aborted`. Aborted entries fall through to a fresh execute
972
- // using nav-2's signal.
973
- describe('router — _loaderInflight aborted-signal dedup', () => {
974
- test('back-to-back navigation re-executes loader with fresh signal when previous was aborted', async () => {
975
- let invocations = 0
976
- let resolveLoader1: ((data: unknown) => void) | null = null
977
-
978
- const Page = () => null
979
- const routes: RouteRecord[] = [
980
- { path: '/', component: Page },
981
- {
982
- path: '/data',
983
- component: Page,
984
- loader: async ({ signal }) => {
985
- invocations++
986
- const myInvocation = invocations
987
- // Wire signal-abort → reject so the nav actually fails on abort.
988
- // The first invocation hangs until manually resolved; later
989
- // invocations resolve immediately.
990
- if (myInvocation === 1) {
991
- return new Promise((_resolve, reject) => {
992
- signal?.addEventListener('abort', () => reject(new Error('aborted')))
993
- resolveLoader1 = _resolve
994
- })
995
- }
996
- return `data-${myInvocation}`
997
- },
998
- },
999
- ]
1000
- const router = createRouter({ routes, url: '/' }) as RouterInstance
1001
-
1002
- // Nav 1 → /data. Loader invocation #1 starts, hangs.
1003
- const nav1 = router.push('/data').catch(() => {})
1004
- await new Promise<void>((r) => queueMicrotask(() => r()))
1005
-
1006
- // Nav 2 → /data. router.push aborts ac1 first, then calls executeLoader.
1007
- // Pre-fix: dedup returns nav-1's promise (whose signal is now aborted).
1008
- // Post-fix: dedup skipped (signal.aborted=true), fresh loader runs.
1009
- const nav2 = router.push('/data')
1010
-
1011
- // Resolve nav-1's hung promise (won't actually deliver — already aborted)
1012
- const r1 = resolveLoader1 as ((d: unknown) => void) | null
1013
- if (r1) r1('data-1')
1014
-
1015
- await nav2
1016
- await nav1
1017
-
1018
- // Post-fix: 2 invocations (nav-1 aborted, nav-2 ran fresh).
1019
- // Pre-fix: 1 invocation (nav-2 deduped to nav-1's aborted promise).
1020
- expect(invocations).toBe(2)
1021
- expect(router.currentRoute().path).toBe('/data')
1022
- })
1023
-
1024
- })