@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.
@@ -1195,11 +1195,11 @@ describe('useRouter / useRoute', () => {
1195
1195
  })
1196
1196
 
1197
1197
  test('useRouter throws when no router installed', () => {
1198
- expect(() => useRouter()).toThrow('[pyreon-router] No router installed')
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('[pyreon-router] No router installed')
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('mouseEnter'))
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('mouseEnter'))
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('mouseEnter'))
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('mouseEnter'))).not.toThrow()
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('mouseEnter'))
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('mouseEnter'))
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
  }