AI & Agent Dev Bug Sandbox logo
AI & Agent Dev Bug Sandbox
Back to Radar

Scrolling Happens When User Returns To A Page With Hash Using Browser Back Button

When using the browser back button to return to a page with a URL hash, Next.js erroneously scrolls to the anchor element, disregarding the preserved scroll position. This is caused by the Container component's componentDidUpdate triggering scrollToHash even on popstate navigations.

mediumConfidence 72%Next.jsAffected V9.4.4

Origin Analysis

In Next.js v9.4.4's client-side routing, the Container component's componentDidUpdate always calls scrollToHash when the hash prop changes. However, during a popstate event (e.g., browser back/forward), the hash prop may appear different from the previous render (due to component remount or state update), causing an unwanted scroll. The underlying design flaw is that the router does not distinguish between in-app navigations (where scrolling to hash is expected) and history restorations (where the browser should handle scroll restoration).
1. Clone https://github.com/monae/nextjs-scrolltohash-bug.git and run npm run dev. 2. Click 'Open the test page without hash' and scroll down. 3. Click 'Open another page'. 4. Click browser back button; observe that scroll position is preserved (Chrome/Safari) or top (Firefox). 5. Repeat for 'Open the test page with hash'; after clicking back, the page scrolls to the anchor instead of preserving the previous scroll position.

Fixing Code Block

// In packages/next/client/index.js, modify the Container class as follows: class Container extends React.Component { constructor(props) { super(props) this._isPopState = false this.handlePopState = this.handlePopState.bind(this) } componentDidMount() { window.addEventListener('popstate', this.handlePopState) if (!this._isPopState) { this.scrollToHash() } } componentWillUnmount() { window.removeEventListener('popstate', this.handlePopState) } handlePopState() { this._isPopState = true // Reset the flag after the current tick to allow subsequent hash scrolls setTimeout(() => { this._isPopState = false }, 0) } componentDidUpdate(prevProps) { if (this.props.hash && this.props.hash !== prevProps.hash) { // Only scroll to hash if this is not a popstate navigation if (!this._isPopState) { this.scrollToHash(this.props.hash) } } } scrollToHash(hash) { const el = document.getElementById(hash) if (el) { el.scrollIntoView() } } }
The fix adds a `_isPopState` flag that is set to `true` when a popstate event occurs. In `componentDidMount` and `componentDidUpdate`, the scroll-to-hash logic is skipped when this flag is active, allowing the browser's native scroll restoration to take effect. The flag is reset after a microtask to avoid suppressing hash scrolling on subsequent in-app navigations.

Edge Case Audit

This hotfix relies on internal component lifecycle and may break if Next.js refactors the Container component. It does not handle multiple rapid popstate events perfectly; the flag reset via setTimeout(0) may be cleared before delayed hash updates, potentially causing unintended scrolls. Additionally, this approach may conflict with future versions that introduce official scroll restoration options. It is recommended to test thoroughly with different browsers and navigation patterns. Rollback by removing the added flag and listeners if unexpected behavior occurs.

Ecosystem Topology