Skip to content
22 changes: 22 additions & 0 deletions e2e/react-router/basic-file-based/tests/hover-preload-hash.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { expect, test } from '@playwright/test'
import { toRuntimePath } from '@tanstack/router-e2e-utils'

// This test guards against a regression where hovering (intent preload)
test('hovering links does not trigger hash scroll (no navigation)', async ({
page,
}) => {
// Go to a page with a hash target at the bottom
await page.goto(toRuntimePath('/normal-page#at-the-bottom'))
await expect(page.getByTestId('at-the-bottom')).toBeInViewport()

// Scroll up so the hash target is no longer in view
await page.evaluate(() => window.scrollBy(0, -300))
await expect(page.getByTestId('at-the-bottom')).not.toBeInViewport()

// Hover a header link to trigger intent preload (should NOT change scroll)
await page.getByRole('link', { name: 'Head-/lazy-page' }).hover()
await page.waitForTimeout(400)

// Ensure we did not jump back to the hash target
await expect(page.getByTestId('at-the-bottom')).not.toBeInViewport()
})
23 changes: 23 additions & 0 deletions e2e/solid-router/basic-file-based/tests/hover-preload-hash.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { expect, test } from '@playwright/test'
import { toRuntimePath } from '@tanstack/router-e2e-utils'

// Solid variant of the hover-preload hash scroll regression test.

test('hovering links does not trigger hash scroll (no navigation) [solid]', async ({
page,
}) => {
// Go to a page with a hash target at the bottom
await page.goto(toRuntimePath('/normal-page#at-the-bottom'))
await expect(page.getByTestId('at-the-bottom')).toBeInViewport()

// Scroll up so the hash target is no longer in view
await page.evaluate(() => window.scrollBy(0, -300))
await expect(page.getByTestId('at-the-bottom')).not.toBeInViewport()

// Hover a header link to trigger intent preload (should NOT change scroll)
await page.getByRole('link', { name: 'Head-/lazy-page' }).hover()
Comment thread
birkskyum marked this conversation as resolved.
Resolved
await page.waitForTimeout(400)

// Ensure we did not jump back to the hash target
await expect(page.getByTestId('at-the-bottom')).not.toBeInViewport()
})
10 changes: 6 additions & 4 deletions packages/react-router/src/Transitioner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,20 +108,22 @@ export function Transitioner() {
}, [isPagePending, previousIsPagePending, router])

useLayoutEffect(() => {
// The router was pending and now it's not
if (previousIsAnyPending && !isAnyPending) {
const changeInfo = getLocationChangeInfo(router.state)
router.emit({
type: 'onResolved',
...getLocationChangeInfo(router.state),
...changeInfo,
})

router.__store.setState((s) => ({
router.__store.setState((s: typeof router.state) => ({
...s,
status: 'idle',
resolvedLocation: s.location,
}))

handleHashScroll(router)
if (changeInfo.hrefChanged) {
handleHashScroll(router)
}
}
}, [isAnyPending, previousIsAnyPending, router])

Expand Down
8 changes: 5 additions & 3 deletions packages/solid-router/src/Transitioner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,11 @@ export function Transitioner() {
Solid.on(
[isAnyPending, previousIsAnyPending],
([isAnyPending, previousIsAnyPending]) => {
// The router was pending and now it's not
if (previousIsAnyPending.previous && !isAnyPending) {
const changeInfo = getLocationChangeInfo(router.state)
router.emit({
type: 'onResolved',
...getLocationChangeInfo(router.state),
...changeInfo,
})

router.__store.setState((s) => ({
Expand All @@ -135,7 +135,9 @@ export function Transitioner() {
resolvedLocation: s.location,
}))

handleHashScroll(router)
if (changeInfo.hrefChanged) {
handleHashScroll(router)
}
}
},
),
Expand Down
Loading