Repository: vercel/next.js
Pull request: fix: aliased navigations should apply scroll handling

When routing to the same page with different searchParams w/ a loading segment, we have handling to skip RSC roundtrip and re-use the existing RSC data. However, this handling was missing logic that exists in `navigate-reducer` which sets the `scrollableSegments`. As a result, the router would stay at the existing scroll position rather than scrolling to the top of the page, as it would in the case of a non-aliased navigation.

Fixes NEXT-4679

Changed files:
filename: packages/next/src/client/components/router-reducer/aliased-prefetch-navigations.ts
patch: @@ -1,10 +1,12 @@
 import type {
   CacheNodeSeedData,
   FlightRouterState,
+  FlightSegmentPath,
 } from '../../../shared/lib/app-router-types'
 import type { CacheNode } from '../../../shared/lib/app-router-types'
 import {
   addSearchParamsIfPageSegment,
+  DEFAULT_SEGMENT_KEY,
   PAGE_SEGMENT_KEY,
 } from '../../../shared/lib/segment'
 import type { NormalizedFlightData } from '../../flight-data-helpers'
@@ -14,6 +16,7 @@ import { createHrefFromUrl } from './create-href-from-url'
 import { createRouterCacheKey } from './create-router-cache-key'
 import { fillCacheWithNewSubTreeDataButOnlyLoading } from './fill-cache-with-new-subtree-data'
 import { handleMutable } from './handle-mutable'
+import { generateSegmentsFromPatch } from './reducers/navigate-reducer'
 import type { Mutable, ReadonlyReducerState } from './router-reducer-types'
 
 /**
@@ -34,6 +37,7 @@ export function handleAliasedPrefetchEntry(
   let currentCache = state.cache
   const href = createHrefFromUrl(url)
   let applied
+  let scrollableSegments: FlightSegmentPath[] = []
 
   if (typeof flightData === 'string') {
     return false
@@ -115,6 +119,20 @@ export function handleAliasedPrefetchEntry(
       currentCache = newCache
       applied = true
     }
+
+    for (const subSegment of generateSegmentsFromPatch(treePatch)) {
+      const scrollableSegmentPath = [
+        ...normalizedFlightData.pathToSegment,
+        ...subSegment,
+      ]
+      // Filter out the __DEFAULT__ paths as they shouldn't be scrolled to in this case.
+      if (
+        scrollableSegmentPath[scrollableSegmentPath.length - 1] !==
+        DEFAULT_SEGMENT_KEY
+      ) {
+        scrollableSegments.push(scrollableSegmentPath)
+      }
+    }
   }
 
   if (!applied) {
@@ -125,6 +143,7 @@ export function handleAliasedPrefetchEntry(
   mutable.cache = currentCache
   mutable.canonicalUrl = href
   mutable.hashFragment = url.hash
+  mutable.scrollableSegments = scrollableSegments
 
   return handleMutable(state, mutable)
 }

filename: packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts
patch: @@ -48,7 +48,7 @@ export function handleExternalUrl(
   return handleMutable(state, mutable)
 }
 
-function generateSegmentsFromPatch(
+export function generateSegmentsFromPatch(
   flightRouterPatch: FlightRouterState
 ): FlightSegmentPath[] {
   const segments: FlightSegmentPath[] = []

filename: test/e2e/app-dir/router-autoscroll/app/loading-scroll/page.tsx
patch: @@ -1,9 +1,16 @@
+import Link from 'next/link'
+
 export const dynamic = 'force-dynamic'
 
-export default async function Page() {
-  await new Promise((resolve) => setTimeout(resolve, 5000))
+export default async function Page(props: PageProps<'/loading-scroll'>) {
+  const search = await props.searchParams
+  const skipSleep = !!search.skipSleep
+  if (!skipSleep) {
+    await new Promise((resolve) => setTimeout(resolve, 5000))
+  }
   return (
     <>
+      {search.page ? <div id="current-page">{search.page}</div> : null}
       <div style={{ display: 'none' }}>Content that is hidden.</div>
       <div id="content-that-is-visible">Content which is not hidden.</div>
       {
@@ -12,6 +19,13 @@ export default async function Page() {
           <div key={i}>{i}</div>
         ))
       }
+      <div id="pages">
+        {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((item) => (
+          <Link key={item} href={`?page=${item}&skipSleep=1`}>
+            {item}
+          </Link>
+        ))}
+      </div>
     </>
   )
 }

filename: test/e2e/app-dir/router-autoscroll/router-autoscroll.test.ts
patch: @@ -248,5 +248,31 @@ describe('router autoscrolling on navigation', () => {
       await browser.waitForElementByCss('#content-that-is-visible')
       await check(() => browser.eval('window.scrollY'), 0)
     })
+
+    it('should scroll to top when navigating to same page with different search params', async () => {
+      const browser = await next.browser('/loading-scroll?skipSleep=1')
+
+      await retry(async () => {
+        // scroll to the links at the bottom of the page
+        await browser.eval(`document.getElementById("pages").scrollIntoView()`)
+
+        // grab the current scroll position
+        const scrollY = await browser.eval(`window.scrollY`)
+
+        // sanity check: we should not be scrolled to the top
+        expect(scrollY).not.toBe(0)
+      })
+
+      // click a link
+      await browser.elementByCss("a[href='?page=2&skipSleep=1']").click()
+
+      // assert the new page id has been committed
+      expect(await browser.elementById('current-page').text()).toBe('2')
+
+      await retry(async () => {
+        // we should have scrolled to the top
+        expect(await browser.eval(`window.scrollY`)).toBe(0)
+      })
+    })
   })
 })