Skip to content

Simplify scroll restoration with shared ScrollRef on CacheNode#91348

Open
acdlite wants to merge 1 commit intovercel:canaryfrom
acdlite:scroll-ref-on-cache-node
Open

Simplify scroll restoration with shared ScrollRef on CacheNode#91348
acdlite wants to merge 1 commit intovercel:canaryfrom
acdlite:scroll-ref-on-cache-node

Conversation

@acdlite
Copy link
Contributor

@acdlite acdlite commented Mar 13, 2026

Replaces the segment-path-matching scroll system with a simpler model based on a shared mutable ScrollRef on CacheNode.

The old system accumulated segment paths during navigation and matched them in layout-router to decide which segments should scroll. This was necessary when CacheNodes were created lazily during render. Now that we construct the entire CacheNode tree immediately upon navigation, we can assign a shared ScrollRef directly to each new leaf node. When any segment scrolls, it flips the ref to false, preventing other segments from also scrolling. This removes all the segment path accumulation and matching logic.

Fixes a regression where calling refresh() from a server action scrolled the page to the top. The old system had a semantic gap between null (no segments) and [] (scroll everything) — a server action refresh with no new segments fell through to a path that scrolled unconditionally. The new model avoids this: refresh creates no new CacheNodes, so no ScrollRef is assigned, and nothing scrolls.

Repro: https://github.com/stipsan/nextjs-refresh-regression-repro

There is extensive existing test coverage for scroll restoration behavior. This adds one additional test for the server action refresh bug.

@nextjs-bot
Copy link
Collaborator

nextjs-bot commented Mar 13, 2026

Tests Passed

@acdlite
Copy link
Contributor Author

acdlite commented Mar 13, 2026

Note: the diff for layout-router.tsx looks larger than it actually is — git's diff algorithm doesn't align the two scroll handlers well because the guard condition at the top changed shape. Here's what actually changed:

In both InnerScrollAndFocusHandlerOld and InnerScrollHandlerNew:

The old scroll-decision logic:

const { focusAndScrollRef, segmentPath } = this.props

if (focusAndScrollRef.apply) {
  if (
    focusAndScrollRef.segmentPaths.length !== 0 &&
    !focusAndScrollRef.segmentPaths.some((scrollRefSegmentPath) =>
      segmentPath.every((segment, index) =>
        matchSegment(segment, scrollRefSegmentPath[index])
      )
    )
  ) {
    return
  }
  // ... scroll execution (unchanged) ...
  focusAndScrollRef.apply = false
  focusAndScrollRef.hashFragment = null
  focusAndScrollRef.segmentPaths = []
}

Was replaced with:

const { focusAndScrollRef, cacheNode } = this.props

const scrollRef = focusAndScrollRef.scrollRef ?? cacheNode.scrollRef
if (scrollRef === null || !scrollRef.current) return
// ... scroll execution (unchanged) ...
scrollRef.current = false
focusAndScrollRef.hashFragment = null

Other changes:

  • segmentPath prop removed from ScrollAndMaybeFocusHandler (replaced by cacheNode)
  • matchSegment import removed (no longer needed)
  • OuterLayoutRouter passes cacheNode instead of segmentPath to the scroll handler

Everything else in the file (the scroll execution logic inside disableSmoothScrollDuringRouteTransition, focus handling, DOM node finding) is unchanged.

@acdlite acdlite force-pushed the scroll-ref-on-cache-node branch from 2618f59 to b9e4866 Compare March 13, 2026 23:57
@acdlite acdlite marked this pull request as ready for review March 14, 2026 00:02
@acdlite acdlite requested review from eps1lon and timneutkens March 14, 2026 00:02
@acdlite acdlite force-pushed the scroll-ref-on-cache-node branch from b9e4866 to ad4256d Compare March 14, 2026 00:55
@acdlite acdlite marked this pull request as draft March 14, 2026 01:16
@acdlite acdlite force-pushed the scroll-ref-on-cache-node branch 4 times, most recently from a1ea28c to 0090f51 Compare March 14, 2026 03:22
@acdlite acdlite marked this pull request as ready for review March 14, 2026 03:22
The scroll system previously relied on accumulating segment paths during
navigation, then matching those paths in layout-router to decide which
segments should scroll. This was necessary when CacheNodes were created
lazily during render, since we couldn't mark scroll targets on the nodes
themselves. But now that we construct the entire CacheNode tree
immediately upon navigation, we can use a much simpler approach.

Each navigation creates a single shared mutable ref (ScrollRef) and
assigns it to every new leaf CacheNode. When any segment's scroll
handler fires, it sets the ref to false, preventing other segments from
scrolling. This replaces all the segment path accumulation and matching
logic with a straightforward per-node flag.

The motivation for this refactor was a bug where calling refresh() from
a server action would scroll the page to the top. The old path-based
system had a semantic gap between null (no segments to scroll) and an
empty array (scroll everything) — when a server action triggered a
refresh with no new segments, the null value fell through to a code path
that scrolled unconditionally. The new model avoids this entirely:
refresh creates no new CacheNodes, so no ScrollRef is assigned, and
nothing scrolls.

There is extensive existing test coverage for scroll restoration
behavior. This adds one additional test for the server action refresh
bug.
@acdlite acdlite force-pushed the scroll-ref-on-cache-node branch from 0090f51 to 96fc02e Compare March 14, 2026 04:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants