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
51 changes: 36 additions & 15 deletions e2e/react-router/basic-scroll-restoration/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,42 @@ const rootRoute = createRootRoute({
function RootComponent() {
return (
<>
<div className="p-2 flex gap-2 sticky top-0 border-b bg-gray-100 dark:bg-gray-900">
<Link to="/" className="[&.active]:font-bold">
Home
</Link>{' '}
<Link to="/about" className="[&.active]:font-bold">
About
</Link>
<Link to="/about" resetScroll={false}>
About (No Reset)
</Link>
<Link to="/by-element" className="[&.active]:font-bold">
By-Element
</Link>
<div
id="sidebar"
className="fixed left-0 top-0 w-48 h-screen overflow-auto border-r bg-gray-50 dark:bg-gray-800 z-10"
>
<div className="p-2 space-y-2">
{Array.from({ length: 30 }).map((_, i) => (
<div
key={i}
className="h-[50px] p-2 rounded bg-gray-200 dark:bg-gray-700 text-sm"
>
Sidebar Item {i + 1}
</div>
))}
</div>
</div>
<div className="ml-48">
<div className="p-2 flex gap-2 sticky top-0 border-b bg-gray-100 dark:bg-gray-900">
<Link to="/" className="[&.active]:font-bold">
Home
</Link>{' '}
<Link to="/about" className="[&.active]:font-bold">
About
</Link>
<Link to="/about" resetScroll={false}>
About (No Reset)
</Link>
<Link to="/bar" resetScroll={false}>
Bar (No Reset)
</Link>
<Link to="/by-element" className="[&.active]:font-bold">
By-Element
</Link>
</div>
<Outlet />

</div>
<Outlet />
<TanStackRouterDevtools />
</>
)
Expand Down Expand Up @@ -267,7 +288,7 @@ const router = createRouter({
routeTree,
defaultPreload: 'intent',
scrollRestoration: true,
getScrollRestorationKey: (location) => location.pathname,
scrollToTopSelectors: ['#sidebar'],
})

declare global {
Expand Down
58 changes: 58 additions & 0 deletions e2e/react-router/basic-scroll-restoration/tests/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,61 @@ test('scroll to top when not scrolled, regression test for #4782', async ({
const restoredScrollPosition = await page.evaluate(() => window.scrollY)
expect(restoredScrollPosition).toBe(0)
})

test('resetScroll=false saves scroll position for back navigation without scroll event, regression test for #6595', async ({
page,
}) => {
const targetScrollPosition = 1500
const elementTargetScrollPosition = 600

await page.goto('/')
await page.waitForURL('/')
await expect(page.locator('#greeting')).toContainText('Welcome Home!');

await page.evaluate(
(scrollPos: number) => window.scrollTo(0, scrollPos),
targetScrollPosition,
);
await page.evaluate(
(scrollPos: number) => document.querySelector('#sidebar')?.scrollTo(0, scrollPos),
elementTargetScrollPosition,
);
await page.waitForTimeout(1000);

await checkScrollPositions();

async function checkScrollPositions() {
const scrollPosition = await page.evaluate(() => window.scrollY)
expect(scrollPosition).toBe(targetScrollPosition);

const sidebarScrollPosition = await page.evaluate(() => document.querySelector('#sidebar')?.scrollTop)
expect(sidebarScrollPosition).toBe(elementTargetScrollPosition);
}
await page.getByRole('link', { name: 'About (No Reset)', exact: true }).click()
await page.waitForURL('/about');
await expect(page.locator('#greeting')).toContainText('Hello from About!')

await checkScrollPositions();

await page.getByRole('link', { name: 'Bar (No Reset)', exact: true }).click()
await page.waitForURL('/bar');
await expect(page.locator('#greeting')).toContainText('Hello from Bar!')

await checkScrollPositions();

await page.goBack();
await page.waitForTimeout(1000)

await page.waitForURL('/about');
await expect(page.locator('#greeting')).toContainText('Hello from About!')

await checkScrollPositions();

await page.goBack();
await page.waitForTimeout(1000)

await page.waitForURL('/');
await expect(page.locator('#greeting')).toContainText('Welcome Home!');

await checkScrollPositions();
});
54 changes: 53 additions & 1 deletion packages/router-core/src/scroll-restoration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,19 @@ export function getCssSelector(el: any): string {
return `${path.reverse().join(' > ')}`.toLowerCase()
}

/**
* Finds the first matching string selector for a given element from a list of selectors.
* Ignores function selectors, see https://github.com/TanStack/router/pull/6632
*/
function findMatchingSelector(el: Element, selectors: Array<string | (() => Element | null | undefined)>): string | undefined {
for (const selector of selectors) {
if (typeof selector === 'string' && el.matches(selector)) {
return selector;
}
}
return undefined;
}

let ignoreScroll = false

// NOTE: This function must remain pure and not use any outside variables
Expand Down Expand Up @@ -302,7 +315,8 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
if (attrId) {
elementSelector = `[data-scroll-restoration-id="${attrId}"]`
} else {
elementSelector = getCssSelector(event.target)
elementSelector = findMatchingSelector(event.target as Element, router.options.scrollToTopSelectors ?? [])
?? getCssSelector(event.target)
}
}

Expand Down Expand Up @@ -341,7 +355,45 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {

// If the user doesn't want to restore the scroll position,
// we don't need to do anything.
//
// Remember the current scroll position of window and scroll to top selectors,
// in order to restore them if the user goes back without scrolling.
if (!router.resetNextScroll) {
scrollRestorationCache.set((state) => {
if (event.fromLocation) {
const fromKey = getKey(event.fromLocation);
const newState = {} as ScrollRestorationByElement;

const windowState = state[fromKey]?.['window'];

if (windowState) {
newState['window'] = {
scrollX: windowState.scrollX,
scrollY: windowState.scrollY,
};
}

if (router.options.scrollToTopSelectors) {
for (const selector of router.options.scrollToTopSelectors) {
if (typeof selector === 'string') {
const oldElement = state[fromKey]?.[selector];

if (oldElement) {
newState[selector] = {
scrollX: oldElement.scrollX,
scrollY: oldElement.scrollY,
}
}
}
}
}

state[cacheKey] ||= {} as ScrollRestorationByElement
state[cacheKey] = { ...state[cacheKey], ...newState };
}

return state
});
router.resetNextScroll = true
return
}
Expand Down