@pyreon/router 0.12.12 → 0.12.14
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/README.md +14 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +53 -30
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +13 -0
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +7 -4
- package/src/router.ts +107 -33
- package/src/scroll.ts +6 -1
- package/src/tests/loader.test.ts +52 -2
- package/src/tests/router.browser.test.tsx +442 -0
- package/src/tests/router.test.ts +253 -8
- package/src/types.ts +13 -0
package/src/tests/router.test.ts
CHANGED
|
@@ -1195,11 +1195,11 @@ describe('useRouter / useRoute', () => {
|
|
|
1195
1195
|
})
|
|
1196
1196
|
|
|
1197
1197
|
test('useRouter throws when no router installed', () => {
|
|
1198
|
-
expect(() => useRouter()).toThrow('[
|
|
1198
|
+
expect(() => useRouter()).toThrow('[Pyreon] No router installed')
|
|
1199
1199
|
})
|
|
1200
1200
|
|
|
1201
1201
|
test('useRoute throws when no router installed', () => {
|
|
1202
|
-
expect(() => useRoute()).toThrow('[
|
|
1202
|
+
expect(() => useRoute()).toThrow('[Pyreon] No router installed')
|
|
1203
1203
|
})
|
|
1204
1204
|
|
|
1205
1205
|
test('useRouter returns router after setActiveRouter', () => {
|
|
@@ -1511,7 +1511,7 @@ describe('RouterLink', () => {
|
|
|
1511
1511
|
// applyProp converts onMouseEnter -> addEventListener("mouseEnter", ...) via:
|
|
1512
1512
|
// key[2].toLowerCase() + key.slice(3) = "m" + "ouseEnter" = "mouseEnter"
|
|
1513
1513
|
// So the event type is "mouseEnter" (camelCase), not "mouseenter"
|
|
1514
|
-
anchor.dispatchEvent(new Event('
|
|
1514
|
+
anchor.dispatchEvent(new Event('mouseenter'))
|
|
1515
1515
|
await new Promise<void>((r) => setTimeout(r, 100))
|
|
1516
1516
|
expect(loaderCalled).toBe(true)
|
|
1517
1517
|
})
|
|
@@ -2079,12 +2079,12 @@ describe('prefetch error handling', () => {
|
|
|
2079
2079
|
const router = createRouter({ routes: failRoutes, url: '/' })
|
|
2080
2080
|
mount(h(RouterProvider, { router }, h(RouterLink, { to: '/fail' }, 'Fail')), el)
|
|
2081
2081
|
const anchor = el.querySelector('a') as HTMLAnchorElement
|
|
2082
|
-
anchor.dispatchEvent(new Event('
|
|
2082
|
+
anchor.dispatchEvent(new Event('mouseenter'))
|
|
2083
2083
|
await new Promise<void>((r) => setTimeout(r, 100))
|
|
2084
2084
|
expect(loaderCallCount).toBe(1)
|
|
2085
2085
|
// After the error, the path is removed from prefetched set, so hovering again
|
|
2086
2086
|
// should trigger another attempt
|
|
2087
|
-
anchor.dispatchEvent(new Event('
|
|
2087
|
+
anchor.dispatchEvent(new Event('mouseenter'))
|
|
2088
2088
|
await new Promise<void>((r) => setTimeout(r, 100))
|
|
2089
2089
|
expect(loaderCallCount).toBe(2)
|
|
2090
2090
|
})
|
|
@@ -3065,7 +3065,7 @@ describe('RouterLink handleMouseEnter without router', () => {
|
|
|
3065
3065
|
mount(h(RouterLink, { to: '/test' }), el)
|
|
3066
3066
|
const anchor = el.querySelector('a') as HTMLAnchorElement
|
|
3067
3067
|
// Dispatch mouseEnter — handleMouseEnter should return early since no router
|
|
3068
|
-
expect(() => anchor.dispatchEvent(new Event('
|
|
3068
|
+
expect(() => anchor.dispatchEvent(new Event('mouseenter'))).not.toThrow()
|
|
3069
3069
|
})
|
|
3070
3070
|
})
|
|
3071
3071
|
|
|
@@ -3090,11 +3090,11 @@ describe('RouterLink prefetch deduplication', () => {
|
|
|
3090
3090
|
mount(h(RouterProvider, { router }, h(RouterLink, { to: '/dedup' }, 'Dedup')), el)
|
|
3091
3091
|
const anchor = el.querySelector('a') as HTMLAnchorElement
|
|
3092
3092
|
// First hover
|
|
3093
|
-
anchor.dispatchEvent(new Event('
|
|
3093
|
+
anchor.dispatchEvent(new Event('mouseenter'))
|
|
3094
3094
|
await new Promise<void>((r) => setTimeout(r, 100))
|
|
3095
3095
|
expect(loaderCallCount).toBe(1)
|
|
3096
3096
|
// Second hover — should be deduplicated
|
|
3097
|
-
anchor.dispatchEvent(new Event('
|
|
3097
|
+
anchor.dispatchEvent(new Event('mouseenter'))
|
|
3098
3098
|
await new Promise<void>((r) => setTimeout(r, 100))
|
|
3099
3099
|
expect(loaderCallCount).toBe(1) // still 1, deduplication via set.has(path)
|
|
3100
3100
|
})
|
|
@@ -4476,4 +4476,249 @@ describe('View Transitions API', () => {
|
|
|
4476
4476
|
expect(router.currentRoute().path).toBe('/about')
|
|
4477
4477
|
router.destroy()
|
|
4478
4478
|
})
|
|
4479
|
+
|
|
4480
|
+
it('await router.push() resolves AFTER updateCallbackDone (DOM live, not animation)', async () => {
|
|
4481
|
+
// Key contract: the promise returned by push() resolves once the
|
|
4482
|
+
// ViewTransition's `updateCallbackDone` settles (callback finished,
|
|
4483
|
+
// DOM swapped) — NOT once `.finished` settles (full animation done).
|
|
4484
|
+
// Test verifies both:
|
|
4485
|
+
// 1. push() awaits the callback-done promise
|
|
4486
|
+
// 2. push() does NOT wait for `.finished`
|
|
4487
|
+
const deferred: { resolve?: () => void } = {}
|
|
4488
|
+
const updateCallbackDone = Promise.resolve()
|
|
4489
|
+
const finished = new Promise<void>((r) => {
|
|
4490
|
+
deferred.resolve = r
|
|
4491
|
+
})
|
|
4492
|
+
const startViewTransition = vi.fn((cb: () => void) => {
|
|
4493
|
+
cb()
|
|
4494
|
+
return {
|
|
4495
|
+
updateCallbackDone,
|
|
4496
|
+
ready: Promise.resolve(),
|
|
4497
|
+
finished, // intentionally never resolved during the test
|
|
4498
|
+
}
|
|
4499
|
+
})
|
|
4500
|
+
;(document as any).startViewTransition = startViewTransition
|
|
4501
|
+
|
|
4502
|
+
const router = createRouter({
|
|
4503
|
+
routes: [
|
|
4504
|
+
{ path: '/', component: Home },
|
|
4505
|
+
{ path: '/about', component: About },
|
|
4506
|
+
],
|
|
4507
|
+
url: '/',
|
|
4508
|
+
})
|
|
4509
|
+
|
|
4510
|
+
// push() MUST settle even though `.finished` never resolves.
|
|
4511
|
+
let pushSettled = false
|
|
4512
|
+
const pushPromise = router.push('/about').then(() => {
|
|
4513
|
+
pushSettled = true
|
|
4514
|
+
})
|
|
4515
|
+
await pushPromise
|
|
4516
|
+
expect(pushSettled).toBe(true)
|
|
4517
|
+
expect(router.currentRoute().path).toBe('/about')
|
|
4518
|
+
|
|
4519
|
+
// Resolve .finished after the fact — should be a no-op.
|
|
4520
|
+
deferred.resolve?.()
|
|
4521
|
+
delete (document as any).startViewTransition
|
|
4522
|
+
router.destroy()
|
|
4523
|
+
})
|
|
4524
|
+
|
|
4525
|
+
it('VT callback throwing does not hang navigation (updateCallbackDone rejection is swallowed)', async () => {
|
|
4526
|
+
// If the user-land state commit inside the transition callback
|
|
4527
|
+
// throws (e.g. a signal subscriber throws synchronously),
|
|
4528
|
+
// `updateCallbackDone` rejects. The router's try/catch around the
|
|
4529
|
+
// await ensures the navigation chain still settles.
|
|
4530
|
+
const updateCallbackDone = Promise.reject(new Error('callback threw'))
|
|
4531
|
+
// Attach a .catch here to silence Node's "unhandled rejection"
|
|
4532
|
+
// warning on the test fixture itself (unrelated to the router).
|
|
4533
|
+
updateCallbackDone.catch(() => {})
|
|
4534
|
+
const startViewTransition = vi.fn((cb: () => void) => {
|
|
4535
|
+
cb()
|
|
4536
|
+
return {
|
|
4537
|
+
updateCallbackDone,
|
|
4538
|
+
ready: Promise.resolve(),
|
|
4539
|
+
finished: Promise.resolve(),
|
|
4540
|
+
}
|
|
4541
|
+
})
|
|
4542
|
+
;(document as any).startViewTransition = startViewTransition
|
|
4543
|
+
|
|
4544
|
+
const router = createRouter({
|
|
4545
|
+
routes: [
|
|
4546
|
+
{ path: '/', component: Home },
|
|
4547
|
+
{ path: '/about', component: About },
|
|
4548
|
+
],
|
|
4549
|
+
url: '/',
|
|
4550
|
+
})
|
|
4551
|
+
|
|
4552
|
+
// Navigation still settles without throwing.
|
|
4553
|
+
await expect(router.push('/about')).resolves.toBeUndefined()
|
|
4554
|
+
// State still committed inside the callback (cb ran before the
|
|
4555
|
+
// promise rejected).
|
|
4556
|
+
expect(router.currentRoute().path).toBe('/about')
|
|
4557
|
+
|
|
4558
|
+
delete (document as any).startViewTransition
|
|
4559
|
+
router.destroy()
|
|
4560
|
+
})
|
|
4561
|
+
|
|
4562
|
+
it('`.ready` and `.finished` rejecting with AbortError does not break navigation', async () => {
|
|
4563
|
+
// Node-side smoke for the AbortError-swallowing contract. The
|
|
4564
|
+
// browser test in router.browser.test.tsx asserts the stronger
|
|
4565
|
+
// property (no unhandled rejection escapes to the event loop);
|
|
4566
|
+
// happy-dom doesn't fire `unhandledrejection` reliably, so here
|
|
4567
|
+
// we just assert that navigation still completes when those
|
|
4568
|
+
// promises reject.
|
|
4569
|
+
const ready = Promise.reject(new DOMException('Transition was skipped', 'AbortError'))
|
|
4570
|
+
const finished = Promise.reject(new DOMException('Transition was skipped', 'AbortError'))
|
|
4571
|
+
// Pre-catch on the test side so happy-dom's process.exit('unhandled')
|
|
4572
|
+
// doesn't trip on the test fixture itself.
|
|
4573
|
+
ready.catch(() => {})
|
|
4574
|
+
finished.catch(() => {})
|
|
4575
|
+
const startViewTransition = vi.fn((cb: () => void) => {
|
|
4576
|
+
cb()
|
|
4577
|
+
return {
|
|
4578
|
+
updateCallbackDone: Promise.resolve(),
|
|
4579
|
+
ready,
|
|
4580
|
+
finished,
|
|
4581
|
+
}
|
|
4582
|
+
})
|
|
4583
|
+
;(document as any).startViewTransition = startViewTransition
|
|
4584
|
+
|
|
4585
|
+
const router = createRouter({
|
|
4586
|
+
routes: [
|
|
4587
|
+
{ path: '/', component: Home },
|
|
4588
|
+
{ path: '/about', component: About },
|
|
4589
|
+
],
|
|
4590
|
+
url: '/',
|
|
4591
|
+
})
|
|
4592
|
+
await expect(router.push('/about')).resolves.toBeUndefined()
|
|
4593
|
+
expect(router.currentRoute().path).toBe('/about')
|
|
4594
|
+
|
|
4595
|
+
delete (document as any).startViewTransition
|
|
4596
|
+
router.destroy()
|
|
4597
|
+
})
|
|
4598
|
+
|
|
4599
|
+
it('afterEach hook sees the NEW route state (post-commit ordering)', async () => {
|
|
4600
|
+
// Regression: before the VT-await fix, afterEach hooks fired after
|
|
4601
|
+
// commitNavigation() returned but BEFORE the VT callback actually
|
|
4602
|
+
// ran — so hooks briefly saw the OLD currentPath. After the fix,
|
|
4603
|
+
// hooks fire AFTER vt.updateCallbackDone, so they observe the NEW
|
|
4604
|
+
// state. This is the correct behavior per the hook's documented
|
|
4605
|
+
// semantics ("after-navigation hook") — assert it to prevent a
|
|
4606
|
+
// future refactor from moving the loop back above the await.
|
|
4607
|
+
const startViewTransition = vi.fn((cb: () => void) => {
|
|
4608
|
+
cb()
|
|
4609
|
+
return {
|
|
4610
|
+
updateCallbackDone: Promise.resolve(),
|
|
4611
|
+
ready: Promise.resolve(),
|
|
4612
|
+
finished: Promise.resolve(),
|
|
4613
|
+
}
|
|
4614
|
+
})
|
|
4615
|
+
;(document as any).startViewTransition = startViewTransition
|
|
4616
|
+
|
|
4617
|
+
const router = createRouter({
|
|
4618
|
+
routes: [
|
|
4619
|
+
{ path: '/', component: Home },
|
|
4620
|
+
{ path: '/about', component: About },
|
|
4621
|
+
],
|
|
4622
|
+
url: '/',
|
|
4623
|
+
})
|
|
4624
|
+
|
|
4625
|
+
const observed: { to: string; current: string }[] = []
|
|
4626
|
+
router.afterEach((to) => {
|
|
4627
|
+
observed.push({ to: to.path, current: router.currentRoute().path })
|
|
4628
|
+
})
|
|
4629
|
+
|
|
4630
|
+
await router.push('/about')
|
|
4631
|
+
|
|
4632
|
+
// Hook fired exactly once with the new state live.
|
|
4633
|
+
expect(observed).toHaveLength(1)
|
|
4634
|
+
expect(observed[0]).toEqual({ to: '/about', current: '/about' })
|
|
4635
|
+
|
|
4636
|
+
delete (document as any).startViewTransition
|
|
4637
|
+
router.destroy()
|
|
4638
|
+
})
|
|
4639
|
+
|
|
4640
|
+
it('useTransition() loading signal stays TRUE during VT commit, FALSE only after DOM swap', async () => {
|
|
4641
|
+
// The loadingSignal is decremented AFTER commitNavigation completes
|
|
4642
|
+
// (i.e. after vt.updateCallbackDone). A subscriber observing the
|
|
4643
|
+
// signal during `await router.push()` must not see it flip to 0
|
|
4644
|
+
// before the DOM is live. Prevents regressions where someone moves
|
|
4645
|
+
// the decrement above the await to "save a microtask."
|
|
4646
|
+
const deferred: { resolve?: () => void } = {}
|
|
4647
|
+
const updateCallbackDone = new Promise<void>((r) => {
|
|
4648
|
+
deferred.resolve = r
|
|
4649
|
+
})
|
|
4650
|
+
const startViewTransition = vi.fn((cb: () => void) => {
|
|
4651
|
+
cb()
|
|
4652
|
+
return {
|
|
4653
|
+
updateCallbackDone,
|
|
4654
|
+
ready: Promise.resolve(),
|
|
4655
|
+
finished: Promise.resolve(),
|
|
4656
|
+
}
|
|
4657
|
+
})
|
|
4658
|
+
;(document as any).startViewTransition = startViewTransition
|
|
4659
|
+
|
|
4660
|
+
const router = createRouter({
|
|
4661
|
+
routes: [
|
|
4662
|
+
{ path: '/', component: Home },
|
|
4663
|
+
{ path: '/about', component: About },
|
|
4664
|
+
],
|
|
4665
|
+
url: '/',
|
|
4666
|
+
})
|
|
4667
|
+
|
|
4668
|
+
const observations: number[] = []
|
|
4669
|
+
// Fire push without awaiting yet.
|
|
4670
|
+
const pushPromise = router.push('/about')
|
|
4671
|
+
|
|
4672
|
+
// Drain all pending microtasks (guards + middleware + loaders) so
|
|
4673
|
+
// navigate() reaches its `await commitNavigation(...)` — which in
|
|
4674
|
+
// turn parks on `await vt.updateCallbackDone` (held pending by our
|
|
4675
|
+
// deferred). One `setTimeout(0)` cycle yields to the macrotask
|
|
4676
|
+
// queue, which guarantees all pending microtasks have drained.
|
|
4677
|
+
await new Promise<void>((r) => setTimeout(r, 0))
|
|
4678
|
+
// At this point, commitNavigation IS suspended on updateCallbackDone.
|
|
4679
|
+
// If the fix holds, the loadingSignal decrement hasn't run yet.
|
|
4680
|
+
// If the fix regresses (decrement moved above the await), the signal
|
|
4681
|
+
// would already be 0 here — this assertion catches that.
|
|
4682
|
+
observations.push((router as unknown as { _loadingSignal: { peek(): number } })._loadingSignal.peek())
|
|
4683
|
+
|
|
4684
|
+
// Release updateCallbackDone; navigate unwinds, push() settles.
|
|
4685
|
+
deferred.resolve?.()
|
|
4686
|
+
await pushPromise
|
|
4687
|
+
observations.push((router as unknown as { _loadingSignal: { peek(): number } })._loadingSignal.peek())
|
|
4688
|
+
|
|
4689
|
+
// Observation 1: in-flight → loadingSignal > 0.
|
|
4690
|
+
expect(observations[0]).toBeGreaterThan(0)
|
|
4691
|
+
// Observation 2: after await → loadingSignal = 0.
|
|
4692
|
+
expect(observations[1]).toBe(0)
|
|
4693
|
+
|
|
4694
|
+
delete (document as any).startViewTransition
|
|
4695
|
+
router.destroy()
|
|
4696
|
+
})
|
|
4697
|
+
|
|
4698
|
+
it('meta.viewTransition:false opt-out skips VT even when the API is available', async () => {
|
|
4699
|
+
// Tripwire: startViewTransition is installed on document, but the
|
|
4700
|
+
// route opts out via meta.viewTransition:false. The router must
|
|
4701
|
+
// skip the VT branch AND still commit the navigation. Regression
|
|
4702
|
+
// guard for the meta-check that's part of the useVT conjunction.
|
|
4703
|
+
let vtCalled = false
|
|
4704
|
+
;(document as any).startViewTransition = () => {
|
|
4705
|
+
vtCalled = true
|
|
4706
|
+
return { updateCallbackDone: Promise.resolve() }
|
|
4707
|
+
}
|
|
4708
|
+
|
|
4709
|
+
const router = createRouter({
|
|
4710
|
+
routes: [
|
|
4711
|
+
{ path: '/', component: Home },
|
|
4712
|
+
{ path: '/no-vt', component: About, meta: { viewTransition: false } },
|
|
4713
|
+
],
|
|
4714
|
+
url: '/',
|
|
4715
|
+
})
|
|
4716
|
+
|
|
4717
|
+
await router.push('/no-vt')
|
|
4718
|
+
expect(vtCalled).toBe(false)
|
|
4719
|
+
expect(router.currentRoute().path).toBe('/no-vt')
|
|
4720
|
+
|
|
4721
|
+
delete (document as any).startViewTransition
|
|
4722
|
+
router.destroy()
|
|
4723
|
+
})
|
|
4479
4724
|
})
|
package/src/types.ts
CHANGED
|
@@ -312,6 +312,19 @@ export interface Router<TNames extends string = string> {
|
|
|
312
312
|
* Useful for SSR and for delaying rendering until the first route is resolved.
|
|
313
313
|
*/
|
|
314
314
|
isReady(): Promise<void>
|
|
315
|
+
/**
|
|
316
|
+
* Resolve `path` and prepare everything needed to render it: load any lazy
|
|
317
|
+
* route components into the router's cache and run the matched routes'
|
|
318
|
+
* loaders. After this resolves, a `RouterView` rendered against this router
|
|
319
|
+
* for `path` will produce final HTML synchronously — no loading fallbacks,
|
|
320
|
+
* no `useLoaderData()` returning `undefined`.
|
|
321
|
+
*
|
|
322
|
+
* Used by SSR/SSG to hydrate the route tree before `renderToString`.
|
|
323
|
+
* The router's `currentRoute` is NOT changed by `preload` — pass the path
|
|
324
|
+
* separately when creating the router (`createRouter({ url, ... })`) or
|
|
325
|
+
* call this for the same `url` you initialised the router with.
|
|
326
|
+
*/
|
|
327
|
+
preload(path: string): Promise<void>
|
|
315
328
|
/** Remove all event listeners, clear caches, and abort in-flight navigations. */
|
|
316
329
|
destroy(): void
|
|
317
330
|
}
|