Skip to content

fix(scroll-restoration): capture restoreKey at scroll-event time to prevent throttle race condition#7046

Closed
sleitor wants to merge 2 commits intoTanStack:mainfrom
sleitor:fix-7040
Closed

fix(scroll-restoration): capture restoreKey at scroll-event time to prevent throttle race condition#7046
sleitor wants to merge 2 commits intoTanStack:mainfrom
sleitor:fix-7040

Conversation

@sleitor
Copy link
Copy Markdown
Contributor

@sleitor sleitor commented Mar 26, 2026

Summary

Fixes #7040

Fixes a race condition in where the throttle read router.state.location at execution time (up to 100ms after the scroll event fired), rather than at the time of the event.

Root Cause

// Before fix: restoreKey captured when throttle fires, not when scroll occurs
const onScroll = (event: Event) => {
  // ...
  const restoreKey = getKey(router.stores.location.state) // ← read at EXECUTION time
  scrollRestorationCache.set(...)
}
document.addEventListener('scroll', throttle(onScroll, 100), true)

If a user scrolled and then immediately clicked a <Link> within the 100ms throttle window, router.state.location could have already changed to the new route when the throttle finally fired. The old page's scroll position would then be saved under the new page's cache key, causing the new page to scroll partway down on arrival.

Fix 1: Capture restoreKey at event time

Split onScroll into an immediate event listener and a throttled save function. The event listener captures restoreKey eagerly, then passes it as an argument to the throttled save. Since the throttle preserves the arguments from the first call in each window, the key always reflects the route active at the time of the scroll.

Fix 2: Clear stale cache before restoreScroll()

In the onRendered subscriber, delete any existing cache entry for the destination key before calling restoreScroll(). This guards against browser-generated scroll events that can fire during DOM transitions after router.state.location has already advanced to the new route.

Testing

The reporter confirmed the fix with a Playwright test suite. Before the fix, dumped sessionStorage showed game page keys with home page scroll positions. After the fix, the destination key always has scrollY: 0 for fresh forward navigations.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed scroll position restoration race condition that could occur during navigation, preventing scroll positions from being incorrectly saved or restored when page transitions happen rapidly.

…ttle race condition

When the onScroll throttle executed, it read router.state.location at
execution time (up to 100ms after the scroll event fired). If the user
scrolled and navigated within that 100ms window, the old page's scroll
position was saved under the new page's cache key.

Fix 1: Capture restoreKey eagerly at scroll-event time by separating the
event listener from a dedicated throttled save function that receives the
key as an argument.

Fix 2: Clear any stale cache entry for the destination key before calling
restoreScroll() in the onRendered subscriber, guarding against
browser-generated scroll events during DOM transitions.

Fixes TanStack#7040
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 26, 2026

📝 Walkthrough

Walkthrough

This PR fixes a race condition in scroll restoration where throttled scroll event handlers read route keys at execution time (up to 100ms later) instead of at event time, causing old page scroll positions to be saved under new route keys. Two complementary fixes address this: capturing the key immediately when the scroll event fires, and clearing stale cache entries before scroll restoration executes.

Changes

Cohort / File(s) Summary
Scroll Restoration Race Fix
packages/router-core/src/scroll-restoration.ts
Refactored scroll handler to capture restoreKey and elementSelector at scroll event time and pass them to a throttled save function, preventing stale route keys from being read during delayed throttle execution. Added cache clearing in onRendered subscriber to remove stale entries before restoreScroll() executes, preventing post-navigation scroll events from polluting the new route's cache.
Changeset Documentation
.changeset/fix-scroll-restoration-race-7040.md
Added patch-level changeset documenting the scroll restoration fix for @tanstack/router-core.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A scroll state race had tangled our hops,
When throttle delays let wrong keys unwrap,
Now we capture the key where the scroll-event drops,
And clear stale cache—no more overlap!
The rabbit routes true, from old page to new. ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main fix: capturing restoreKey at scroll-event time to prevent a throttle race condition. It directly reflects the primary change in both files.
Linked Issues check ✅ Passed The PR addresses all coding objectives from issue #7040: capturing restoreKey at scroll-event time [#7040], clearing stale cache entries before restoreScroll() [#7040], and preventing scroll positions from being saved under wrong route keys [#7040].
Out of Scope Changes check ✅ Passed All changes are within scope: the changeset documents the fix, and scroll-restoration.ts implements the two targeted solutions (event-time key capture and cache clearing) with no unrelated modifications.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@nx-cloud
Copy link
Copy Markdown
Contributor

nx-cloud bot commented Mar 26, 2026

🤖 Nx Cloud AI Fix Eligible

An automatically generated fix could have helped fix failing tasks for this run, but Self-healing CI is disabled for this workspace. Visit workspace settings to enable it and get automatic fixes in future runs.

To disable these notifications, a workspace admin can disable them in workspace settings.


View your CI Pipeline Execution ↗ for commit f02c69b

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ❌ Failed 11m View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 1m 46s View ↗

☁️ Nx Cloud last updated this comment at 2026-03-26 10:52:23 UTC

@github-actions
Copy link
Copy Markdown
Contributor

Bundle Size Benchmarks

  • Commit: 87a51603981e
  • Measured at: 2026-03-26T10:42:15.732Z
  • Baseline source: history:5016e4e4c738
  • Dashboard: bundle-size history
Scenario Current (gzip) Delta vs baseline Raw Brotli Trend
react-router.minimal 87.69 KiB +14 B (+0.02%) 276.48 KiB 76.14 KiB ██▅▅▅▅▅▄▄▁▁▁
react-router.full 90.92 KiB +10 B (+0.01%) 287.47 KiB 78.95 KiB ██▄▄▅▅▅▄▄▁▁▁
solid-router.minimal 35.67 KiB 0 B (0.00%) 107.61 KiB 32.00 KiB ██▃▃▃▃▃▁▁▁▁
solid-router.full 40.09 KiB +15 B (+0.04%) 121.01 KiB 35.95 KiB ██▂▂▃▃▃▁▁▁▁▁
vue-router.minimal 53.43 KiB +10 B (+0.02%) 153.41 KiB 47.89 KiB ██▅▅▅▅▅▅▅▁▁▁
vue-router.full 58.32 KiB +10 B (+0.02%) 168.89 KiB 52.17 KiB ██▄▄▅▅▅▅▅▁▁▁
react-start.minimal 102.11 KiB +18 B (+0.02%) 324.50 KiB 88.26 KiB ██▄▄▅▅▅▃▃▁▁▁
react-start.full 105.49 KiB +13 B (+0.01%) 334.81 KiB 91.17 KiB ██▄▄▅▅▅▄▄▁▁▁
solid-start.minimal 49.74 KiB -3 B (-0.01%) 153.80 KiB 43.80 KiB ██▃▃▃▃▃▁▁▁▁▁
solid-start.full 55.23 KiB +15 B (+0.03%) 169.89 KiB 48.51 KiB ██▂▂▃▃▃▁▁▁▁▁

Trend sparkline is historical gzip bytes ending with this PR measurement; lower is better.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 26, 2026

More templates

@tanstack/arktype-adapter

npm i https://pkg.pr.new/@tanstack/arktype-adapter@7046

@tanstack/eslint-plugin-router

npm i https://pkg.pr.new/@tanstack/eslint-plugin-router@7046

@tanstack/history

npm i https://pkg.pr.new/@tanstack/history@7046

@tanstack/nitro-v2-vite-plugin

npm i https://pkg.pr.new/@tanstack/nitro-v2-vite-plugin@7046

@tanstack/react-router

npm i https://pkg.pr.new/@tanstack/react-router@7046

@tanstack/react-router-devtools

npm i https://pkg.pr.new/@tanstack/react-router-devtools@7046

@tanstack/react-router-ssr-query

npm i https://pkg.pr.new/@tanstack/react-router-ssr-query@7046

@tanstack/react-start

npm i https://pkg.pr.new/@tanstack/react-start@7046

@tanstack/react-start-client

npm i https://pkg.pr.new/@tanstack/react-start-client@7046

@tanstack/react-start-server

npm i https://pkg.pr.new/@tanstack/react-start-server@7046

@tanstack/router-cli

npm i https://pkg.pr.new/@tanstack/router-cli@7046

@tanstack/router-core

npm i https://pkg.pr.new/@tanstack/router-core@7046

@tanstack/router-devtools

npm i https://pkg.pr.new/@tanstack/router-devtools@7046

@tanstack/router-devtools-core

npm i https://pkg.pr.new/@tanstack/router-devtools-core@7046

@tanstack/router-generator

npm i https://pkg.pr.new/@tanstack/router-generator@7046

@tanstack/router-plugin

npm i https://pkg.pr.new/@tanstack/router-plugin@7046

@tanstack/router-ssr-query-core

npm i https://pkg.pr.new/@tanstack/router-ssr-query-core@7046

@tanstack/router-utils

npm i https://pkg.pr.new/@tanstack/router-utils@7046

@tanstack/router-vite-plugin

npm i https://pkg.pr.new/@tanstack/router-vite-plugin@7046

@tanstack/solid-router

npm i https://pkg.pr.new/@tanstack/solid-router@7046

@tanstack/solid-router-devtools

npm i https://pkg.pr.new/@tanstack/solid-router-devtools@7046

@tanstack/solid-router-ssr-query

npm i https://pkg.pr.new/@tanstack/solid-router-ssr-query@7046

@tanstack/solid-start

npm i https://pkg.pr.new/@tanstack/solid-start@7046

@tanstack/solid-start-client

npm i https://pkg.pr.new/@tanstack/solid-start-client@7046

@tanstack/solid-start-server

npm i https://pkg.pr.new/@tanstack/solid-start-server@7046

@tanstack/start-client-core

npm i https://pkg.pr.new/@tanstack/start-client-core@7046

@tanstack/start-fn-stubs

npm i https://pkg.pr.new/@tanstack/start-fn-stubs@7046

@tanstack/start-plugin-core

npm i https://pkg.pr.new/@tanstack/start-plugin-core@7046

@tanstack/start-server-core

npm i https://pkg.pr.new/@tanstack/start-server-core@7046

@tanstack/start-static-server-functions

npm i https://pkg.pr.new/@tanstack/start-static-server-functions@7046

@tanstack/start-storage-context

npm i https://pkg.pr.new/@tanstack/start-storage-context@7046

@tanstack/valibot-adapter

npm i https://pkg.pr.new/@tanstack/valibot-adapter@7046

@tanstack/virtual-file-routes

npm i https://pkg.pr.new/@tanstack/virtual-file-routes@7046

@tanstack/vue-router

npm i https://pkg.pr.new/@tanstack/vue-router@7046

@tanstack/vue-router-devtools

npm i https://pkg.pr.new/@tanstack/vue-router-devtools@7046

@tanstack/vue-router-ssr-query

npm i https://pkg.pr.new/@tanstack/vue-router-ssr-query@7046

@tanstack/vue-start

npm i https://pkg.pr.new/@tanstack/vue-start@7046

@tanstack/vue-start-client

npm i https://pkg.pr.new/@tanstack/vue-start-client@7046

@tanstack/vue-start-server

npm i https://pkg.pr.new/@tanstack/vue-start-server@7046

@tanstack/zod-adapter

npm i https://pkg.pr.new/@tanstack/zod-adapter@7046

commit: f02c69b

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
packages/router-core/src/scroll-restoration.ts (3)

414-417: Remove duplicate JSDoc comment.

There are two JSDoc comments for handleHashScroll. Keep the shorter one since the longer one is also marked @private.

🧹 Proposed cleanup
-/**
- * `@private`
- * Handles hash-based scrolling after navigation completes.
- * To be used in framework-specific <Transitioner> components during the onResolved event.
- *
- * Provides hash scrolling for programmatic navigation when default browser handling is prevented.
- * `@param` router The router instance containing current location and state
- */
 /**
  * `@private`
  * Handles hash-based scrolling after navigation completes.
  * To be used in framework-specific Transitioners.
  */
 export function handleHashScroll(router: AnyRouter) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/router-core/src/scroll-restoration.ts` around lines 414 - 417, There
are duplicate JSDoc comments for the function handleHashScroll; remove the
longer/extra JSDoc block and keep the shorter `@private` comment so only one JSDoc
remains for handleHashScroll, ensuring references to handleHashScroll in
scroll-restoration.ts are left unchanged.

218-219: Remove duplicate JSDoc comment.

There are two identical JSDoc comments for setupScrollRestoration.

🧹 Proposed cleanup
-/** Setup global listeners and hooks to support scroll restoration. */
 /** Setup global listeners and hooks to support scroll restoration. */
 export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/router-core/src/scroll-restoration.ts` around lines 218 - 219, There
are two identical JSDoc comment lines above setupScrollRestoration; remove the
duplicate so only one JSDoc comment remains for the setupScrollRestoration
function (locate the duplicated /** Setup global listeners and hooks to support
scroll restoration. */ lines and delete the redundant one).

37-39: Remove duplicate JSDoc comments.

There are three identical/similar JSDoc comments for storageKey. Only one is needed.

🧹 Proposed cleanup
-/** SessionStorage key used to persist scroll restoration state. */
-/** SessionStorage key used to store scroll positions across navigations. */
 /** SessionStorage key used to store scroll positions across navigations. */
 export const storageKey = 'tsr-scroll-restoration-v1_3'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/router-core/src/scroll-restoration.ts` around lines 37 - 39, Remove
the duplicated JSDoc comments above the storageKey declaration: keep a single
clear JSDoc line (e.g., "SessionStorage key used to store scroll positions
across navigations.") and delete the other two redundant comment lines so only
one JSDoc comment remains immediately preceding the storageKey symbol.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/router-core/src/scroll-restoration.ts`:
- Around line 414-417: There are duplicate JSDoc comments for the function
handleHashScroll; remove the longer/extra JSDoc block and keep the shorter
`@private` comment so only one JSDoc remains for handleHashScroll, ensuring
references to handleHashScroll in scroll-restoration.ts are left unchanged.
- Around line 218-219: There are two identical JSDoc comment lines above
setupScrollRestoration; remove the duplicate so only one JSDoc comment remains
for the setupScrollRestoration function (locate the duplicated /** Setup global
listeners and hooks to support scroll restoration. */ lines and delete the
redundant one).
- Around line 37-39: Remove the duplicated JSDoc comments above the storageKey
declaration: keep a single clear JSDoc line (e.g., "SessionStorage key used to
store scroll positions across navigations.") and delete the other two redundant
comment lines so only one JSDoc comment remains immediately preceding the
storageKey symbol.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 812e567e-fe98-4f7f-bde7-8c9e4e6529cb

📥 Commits

Reviewing files that changed from the base of the PR and between 87a5160 and f02c69b.

📒 Files selected for processing (2)
  • .changeset/fix-scroll-restoration-race-7040.md
  • packages/router-core/src/scroll-restoration.ts

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq bot commented Mar 26, 2026

Merging this PR will not alter performance

✅ 6 untouched benchmarks


Comparing sleitor:fix-7040 (f02c69b) with main (5016e4e)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (87a5160) during the generation of this report, so 5016e4e was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@schiller-manuel
Copy link
Copy Markdown
Contributor

closed in favor of #7042

@sleitor sleitor deleted the fix-7040 branch March 26, 2026 19:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scroll restoration onScroll throttle saves scroll positions under wrong route key during SPA navigation

2 participants