Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pending-scroll-reset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/router-core': patch
---

Preserve a pending page scroll reset when the destination route performs a same-path URL patch before the page navigation has rendered.
3 changes: 3 additions & 0 deletions packages/router-core/src/history.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { HistoryLocation } from '@tanstack/history'

export const resetScrollStateKey = '__TSR_resetScroll'

declare module '@tanstack/history' {
interface HistoryState {
__tempLocation?: HistoryLocation
__tempKey?: string
__hashScrollIntoViewOptions?: boolean | ScrollIntoViewOptions
[resetScrollStateKey]?: boolean
}
}
13 changes: 12 additions & 1 deletion packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
import { createSieveCache } from './sieve-cache'
import { isNotFound } from './not-found'
import { setupScrollRestoration } from './scroll-restoration'
import { resetScrollStateKey } from './history'
import { defaultParseSearch, defaultStringifySearch } from './searchParams'
import { rootRouteId } from './root'
import { isRedirect } from './redirect'
Expand Down Expand Up @@ -922,6 +923,7 @@ export function _getUserHistoryState({
__TSR_key: _tsrKey,
__TSR_index: _tsrIndex,
__hashScrollIntoViewOptions: _hashScroll,
[resetScrollStateKey]: _resetScroll,
...state
}: ParsedHistoryState): HistoryState {
return state
Expand Down Expand Up @@ -1070,6 +1072,7 @@ export class RouterCore<
next: boolean
// True until the current PUSH/REPLACE renders, so its hash owns window scroll.
hash?: boolean
pending?: boolean
restoring?: boolean
restoration?: boolean
reset?: boolean
Expand Down Expand Up @@ -2129,6 +2132,12 @@ export class RouterCore<
...next
}) => {
let historyAction: HistoryAction | undefined
const nextResetScroll = next.resetScroll ?? true
const pendingSamePathReset =
this._scroll.pending === true &&
this._scroll.next &&
this.latestLocation.pathname === next.pathname
const resetScroll = nextResetScroll || pendingSamePathReset
const isSameLocation =
trimPathRight(this.latestLocation.href) === trimPathRight(next.href) &&
deepEqual(
Expand Down Expand Up @@ -2190,6 +2199,7 @@ export class RouterCore<

nextHistory.state.__hashScrollIntoViewOptions =
hashScrollIntoView ?? this.options.defaultHashScrollIntoView ?? true
nextHistory.state[resetScrollStateKey] = resetScroll

this.shouldViewTransition = viewTransition

Expand All @@ -2208,7 +2218,8 @@ export class RouterCore<
}
}

this._scroll.next = next.resetScroll ?? true
this._scroll.next = resetScroll
this._scroll.pending = true

return this._commitPromise
}
Expand Down
5 changes: 4 additions & 1 deletion packages/router-core/src/scroll-restoration.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isServer } from '@tanstack/router-core/isServer'
import { resetScrollStateKey } from './history'
import type { AnyRouter } from './router'
import type { ParsedLocation } from './location'

Expand Down Expand Up @@ -239,12 +240,14 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
router.subscribe('onRendered', (event) => {
const behavior = router.options.scrollRestorationBehavior
const scrollToTopSelectors = router.options.scrollToTopSelectors
const shouldResetScroll = scroll.next
const shouldResetScroll =
event.toLocation.state[resetScrollStateKey] ?? scroll.next
const hashNavigation = scroll.hash
let scrollToTopElements: Set<Element> | undefined
trackedScrollTargets.clear()
scroll.next = true
scroll.hash = false
scroll.pending = false

if (
typeof router.options.scrollRestoration === 'function' &&
Expand Down
2 changes: 2 additions & 0 deletions packages/router-core/tests/build-location.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
retainSearchParams,
stripSearchParams,
} from '../src'
import { resetScrollStateKey } from '../src/history'
import type { SearchMiddleware } from '../src'
import { _getUserHistoryState } from '../src/router'
import { createTestRouter } from './routerTestUtils'
Expand All @@ -17,6 +18,7 @@ test('_getUserHistoryState removes volatile router bookkeeping but keeps mask pa
__TSR_key: 'key',
__TSR_index: 1,
__hashScrollIntoViewOptions: true,
[resetScrollStateKey]: true,
__tempLocation: {} as any,
__tempKey: 'temp-key',
user: 'state',
Expand Down
64 changes: 64 additions & 0 deletions packages/router-core/tests/scroll-restoration.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createMemoryHistory } from '@tanstack/history'
import { afterEach, describe, expect, test, vi } from 'vitest'
import { BaseRootRoute, BaseRoute } from '../src'
import { resetScrollStateKey } from '../src/history'
import { createTestRouter } from './routerTestUtils'
import type { ParsedLocation } from '../src'

Expand Down Expand Up @@ -38,6 +39,7 @@ function getLocation(
return {
...router.latestLocation,
href: pathname,
publicHref: pathname,
pathname,
}
}
Expand Down Expand Up @@ -328,4 +330,66 @@ describe('setupScrollRestoration', () => {

expect(getElement).not.toHaveBeenCalled()
})

test('preserves a pending page reset through a same-path URL patch', () => {
const windowScrollTo = vi.fn()
vi.stubGlobal('scrollTo', windowScrollTo)

const router = createRouter({
scrollRestoration: true,
getScrollRestorationKey: (location) => location.pathname,
})
const source = getLocation(router, '/unit-overlap-source')
const destination = getLocation(router, '/unit-overlap-destination')

router._scroll.next = true
const pendingPatch = {
...getLocation(router, '/unit-overlap-destination'),
href: '/unit-overlap-destination?color=blue',
searchStr: '?color=blue',
search: { color: 'blue' },
state: {
...destination.state,
[resetScrollStateKey]: true,
},
}
router._scroll.next = false

emitNavigation(router, 'onRendered', source, pendingPatch)

expect(windowScrollTo).toHaveBeenCalledWith({
top: 0,
left: 0,
behavior: undefined,
})
})

test('commits same-path URL patches with inherited pending reset state', () => {
const destinationPath = '/unit-overlap-destination'
const patchedHref = `${destinationPath}?color=blue`
const router = createRouter()
const unsubscribe = router.history.subscribe(() => {})
const destination = getLocation(router, destinationPath)
router.latestLocation = destination
router._scroll.next = true
router._scroll.pending = true

try {
void router.commitLocation({
...destination,
href: patchedHref,
publicHref: patchedHref,
searchStr: '?color=blue',
search: { color: 'blue' },
state: { ...destination.state },
replace: true,
resetScroll: false,
} as any)

expect(router.history.location.state[resetScrollStateKey]).toBe(true)
expect(router._scroll.next).toBe(true)
} finally {
unsubscribe()
}
})
})