vercel / vercel/next.js

Scroll is not reset on navigation when a parallel route slot renders only a position: fixed element (appNewScrollHandler regression in 16.3.0)

Open
#96,855 2 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
JavaScript
Stars
142k
Forks
32.4k
Avg merge
2d 14h
Merged PRs (30d)
351

Description

Link to the code that reproduces this issue

https://github.com/Pilaton/next-fixed-slot-scroll-repro

To Reproduce
  1. Create an app with a parallel route slot whose only rendered element is position: fixed, e.g. a @header slot:
app/
  layout.tsx            // renders {header} {children}
  @header/page.tsx      // <header className="fixed top-2"> ... </header>
  @header/[...rest]/page.tsx
  about/page.tsx
  1. pnpm dev, open /, scroll down ~2000px.
  2. Click a <Link> to /about.
Current vs. Expected behavior

Current (16.3.0): the new page opens at the previous scroll offset. If the target page is shorter than the scroll offset, it opens with the footer on screen.

Expected (16.2.11 and earlier): the new page opens at the top.

Cause

experimental.appNewScrollHandler changed default from false to true:

  • 16.2.11dist/server/config-shared.js: appNewScrollHandler: false
  • 16.3.0dist/server/config-shared.js: appNewScrollHandler: true

InnerScrollAndFocusHandlerOld skipped fixed/sticky elements when picking the scroll target, with an explicit comment stating why:

function shouldSkipElement(element) {
  // we ignore fixed or sticky positioned elements since they'll likely pass the "in-viewport" check
  // and will result in a situation we bail on scroll because of something like a fixed nav,
  // even though the actual page content is offscreen
  if (['sticky', 'fixed'].includes(getComputedStyle(element).position)) {
    return true;
  }
  ...
}

InnerScrollHandlerNew (the Fragment-ref fork that is now the default) has no equivalent check. It passes the Fragment ref straight to getScrollTargetState, which reads the top edge of whatever host children the slot rendered:

return elementTop >= getScrollPaddingTop() && elementTop <= viewportHeight ? 1 : 2;

For a slot containing only a fixed header, elementTop is a small positive number on every navigation, so the result is always 1 ("already in viewport"). The handler then marks the shared scrollRef as handled and returns without scrolling:

didHandleScroll = true;
// Mark as scrolled so no other segment scrolls for this navigation.
scrollRef.current = false;
...
if (initialTargetState === 1) {
  return;
}

Because accumulateScrollRef assigns the same scrollRef object to every changed cache node, the slot that renders the fixed header claims the scroll intent before the children slot's layout effect runs, and the actual page content is never scrolled into view.

Verification

On a 430x932 viewport, 7 navigations from a scroll offset of 2000:

condition navigations that reset scroll to 0
default (appNewScrollHandler: true) 0 / 7
html { scroll-padding-top: 200px } 7 / 7
experimental.appNewScrollHandler: false 7 / 7

The scroll-padding-top row changes no layout at all — it only raises the threshold inside getScrollTargetState. That it alone flips the outcome isolates the behavior to that comparison.

Workaround

// next.config.ts
experimental: {
  appNewScrollHandler: false,
},
Provide environment information
Operating System:
  Platform: darwin
  Arch: x64
  Version: Darwin Kernel Version 24.6.0
  Available memory (MB): 32768
  Available CPU cores: 6
Binaries:
  Node: 26.7.0
  npm: 11.19.0
  pnpm: 11.20.0
Relevant Packages:
  next: 16.3.0 // Latest available version is detected (16.3.0).
  react: 19.2.8
  react-dom: 19.2.8
  typescript: 6.0.3
Next.js Config:
  output: N/A
Which area(s) are affected? (Select all that apply)

Linking and Navigating, Parallel & Intercepting Routes

Which stage(s) are affected? (Select all that apply)

next dev (local), next start (local), Vercel (Deployed)

Additional context

No response

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the appNewScrollHandler configuration and compare InnerScrollHandlerOld with InnerScrollHandlerNew, especially getScrollTargetState and the shared scrollRef behavior. Reproduce the issue with the linked next-fixed-slot-scroll-repro app, then verify that navigation from a scrolled page resets the content scroll position when a parallel slot contains only a fixed element.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, nextjs, react
Domain
frontend, web-dev
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.