diff --git a/.changeset/pending-scroll-reset.md b/.changeset/pending-scroll-reset.md new file mode 100644 index 00000000000..318ba1c8554 --- /dev/null +++ b/.changeset/pending-scroll-reset.md @@ -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. diff --git a/packages/router-core/src/history.ts b/packages/router-core/src/history.ts index 2271af692cd..d5da603bd01 100644 --- a/packages/router-core/src/history.ts +++ b/packages/router-core/src/history.ts @@ -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 } } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index fa4168b9ce1..ec6c84622b6 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -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' @@ -922,6 +923,7 @@ export function _getUserHistoryState({ __TSR_key: _tsrKey, __TSR_index: _tsrIndex, __hashScrollIntoViewOptions: _hashScroll, + [resetScrollStateKey]: _resetScroll, ...state }: ParsedHistoryState): HistoryState { return state @@ -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 @@ -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( @@ -2190,6 +2199,7 @@ export class RouterCore< nextHistory.state.__hashScrollIntoViewOptions = hashScrollIntoView ?? this.options.defaultHashScrollIntoView ?? true + nextHistory.state[resetScrollStateKey] = resetScroll this.shouldViewTransition = viewTransition @@ -2208,7 +2218,8 @@ export class RouterCore< } } - this._scroll.next = next.resetScroll ?? true + this._scroll.next = resetScroll + this._scroll.pending = true return this._commitPromise } diff --git a/packages/router-core/src/scroll-restoration.ts b/packages/router-core/src/scroll-restoration.ts index 5e4ae0d3ee3..62467fca18f 100644 --- a/packages/router-core/src/scroll-restoration.ts +++ b/packages/router-core/src/scroll-restoration.ts @@ -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' @@ -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 | undefined trackedScrollTargets.clear() scroll.next = true scroll.hash = false + scroll.pending = false if ( typeof router.options.scrollRestoration === 'function' && diff --git a/packages/router-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts index edb763fa03a..c01d400790a 100644 --- a/packages/router-core/tests/build-location.test.ts +++ b/packages/router-core/tests/build-location.test.ts @@ -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' @@ -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', diff --git a/packages/router-core/tests/scroll-restoration.test.ts b/packages/router-core/tests/scroll-restoration.test.ts index c02a8f8783e..d2afb6b9a8e 100644 --- a/packages/router-core/tests/scroll-restoration.test.ts +++ b/packages/router-core/tests/scroll-restoration.test.ts @@ -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' @@ -38,6 +39,7 @@ function getLocation( return { ...router.latestLocation, href: pathname, + publicHref: pathname, pathname, } } @@ -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() + } + }) })