fix(router-core): handle window and element scroll restoration independently#7807
Conversation
…ndently Window and element scroll targets are now handled independently. Restoring one target no longer suppresses resets for other uncached configured targets, and a restored element is no longer reset when the window has no cached position. Hash navigation no longer resets elements configured through `scrollToTopSelectors` and retains precedence over stale window positions through destination invalidations. Scroll positions are sampled when leaving a route, preserving live changes made after the most recent scroll event. This also prevents client hydration from undoing nested positions restored by the SSR script. Fixes #7687.
|
View your CI Pipeline Execution ↗ for commit b18d5ba
☁️ Nx Cloud last updated this comment at |
🚀 Changeset Version Preview1 package(s) bumped directly, 22 bumped as dependents. 🟩 Patch bumps
|
📝 WalkthroughWalkthroughChangesScroll restoration behavior
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Router
participant ScrollCache
participant Window
participant NestedTarget
Router->>ScrollCache: snapshot tracked positions
Router->>ScrollCache: load cached positions
ScrollCache->>Window: restore window position
ScrollCache->>NestedTarget: restore element position
Router->>NestedTarget: reset only uncached targets
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Bundle Size Benchmarks
Current gzip tracks all emitted client JS chunks. Initial gzip tracks only the entry/import graph. Trend sparkline is historical current gzip ending with this PR measurement; lower is better. |
Merging this PR will degrade performance by 10.76%
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes |
There was a problem hiding this comment.
Nx Cloud has identified a possible root cause for your failed CI:
We investigated both CI failures and determined neither is caused by the PR's changes. The tanstack-react-start-e2e-import-protection:test:e2e failure is a Chromium SIGSEGV browser crash in the CI environment, and the tanstack-start-example-rscs:build failure is a pre-existing nitro package export compatibility issue confirmed to also occur on unrelated branches.
No code changes were suggested for this issue.
Trigger a rerun:
🎓 Learn more about Self-Healing CI on nx.dev
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
e2e/react-start/scroll-restoration/tests/issue-7687.spec.ts (1)
4-10: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAvoid
waitForLoadState('networkidle').Playwright's own docs mark
'networkidle'as discouraged since background/analytics requests can keep the network "busy" indefinitely, causing flaky or slow tests; prefer relying on the subsequenttoBeVisible()web-first assertion alone.♻️ Proposed simplification
async function goToList(page: Page) { await page.goto('/issue-7687') - await page.waitForLoadState('networkidle') await expect( page.getByRole('heading', { name: 'issue-7687-list' }), ).toBeVisible() }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@e2e/react-start/scroll-restoration/tests/issue-7687.spec.ts` around lines 4 - 10, Remove the waitForLoadState('networkidle') call from goToList and rely on the existing heading toBeVisible() assertion to wait for the page readiness.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@e2e/react-start/scroll-restoration/src/router.tsx`:
- Around line 10-21: Update the scrollToTopSelectors entry for the nested hash
scroller in the router configuration to use that element’s data-attribute
selector instead of the ineffective ID selector, preserving its participation in
reset-to-top and cross-key carry-over handling.
In `@e2e/react-start/scroll-restoration/src/routes/`(tests)/hash-scroll-repro.tsx:
- Around line 26-35: Handle rejection from router.invalidate() within the
React.useLayoutEffect callback by adding a catch path to the existing promise
chain, preventing failed invalidations from becoming unhandled promise
rejections while preserving the success-only setInvalidateCount update.
---
Nitpick comments:
In `@e2e/react-start/scroll-restoration/tests/issue-7687.spec.ts`:
- Around line 4-10: Remove the waitForLoadState('networkidle') call from
goToList and rely on the existing heading toBeVisible() assertion to wait for
the page readiness.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 32d7ccab-0d1b-4628-82c8-d7b27eef8871
📒 Files selected for processing (15)
.changeset/fix-independent-scroll-targets.mde2e/react-start/scroll-restoration/src/routeTree.gen.tse2e/react-start/scroll-restoration/src/router.tsxe2e/react-start/scroll-restoration/src/routes/(tests)/hash-scroll-about.tsxe2e/react-start/scroll-restoration/src/routes/(tests)/hash-scroll-repro.tsxe2e/react-start/scroll-restoration/src/routes/(tests)/issue-7687.detail.tsxe2e/react-start/scroll-restoration/src/routes/(tests)/issue-7687.index.tsxe2e/react-start/scroll-restoration/src/routes/(tests)/issue-7687.tsxe2e/react-start/scroll-restoration/src/routes/(tests)/ssr-scroll-key.tsxe2e/react-start/scroll-restoration/tests/hash-scroll-repro.spec.tse2e/react-start/scroll-restoration/tests/issue-7687.spec.tse2e/react-start/scroll-restoration/tests/ssr-scroll-key.spec.tspackages/router-core/src/router.tspackages/router-core/src/scroll-restoration.tspackages/router-core/tests/scroll-restoration.test.ts
| scrollToTopSelectors: [ | ||
| '[data-scroll-restoration-id="carry-over-reset"]', | ||
| '#hash-scroll-nested', | ||
| '#hash-scroll-reset-target', | ||
| '#issue-7687-reset-probe', | ||
| '#issue-7687-scroller', | ||
| () => | ||
| typeof document === 'undefined' | ||
| ? undefined | ||
| : document.getElementById('issue-7687-stale-selector'), | ||
| '#ssr-scroll-key-nested', | ||
| ], |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Confirm no element anywhere defines id="hash-scroll-nested", and inspect getScrollToTopElements/getScrollRestorationSelector matching logic.
rg -n 'id="hash-scroll-nested"' e2e/react-start/scroll-restoration
rg -n 'getScrollToTopElements|getScrollRestorationSelector' packages/router-core/src/scroll-restoration.ts -A5 -B2Repository: TanStack/router
Length of output: 2021
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== router.tsx ==\n'
sed -n '1,80p' e2e/react-start/scroll-restoration/src/router.tsx
printf '\n== hash-scroll-repro.tsx (around nested target) ==\n'
sed -n '80,130p' e2e/react-start/scroll-restoration/src/routes/'(tests)'/hash-scroll-repro.tsx
printf '\n== scroll-restoration.ts (selector logic) ==\n'
sed -n '1,360p' packages/router-core/src/scroll-restoration.tsRepository: TanStack/router
Length of output: 13404
Use the data attribute selector for hash-scroll-nested.
scrollToTopSelectors are resolved with document.querySelector(), so #hash-scroll-nested never matches this element. That leaves the nested scroller out of both the reset-to-top path and the cross-key carry-over exclusion.
🐛 Proposed fix
- '`#hash-scroll-nested`',
+ '[data-scroll-restoration-id="hash-scroll-nested"]',📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| scrollToTopSelectors: [ | |
| '[data-scroll-restoration-id="carry-over-reset"]', | |
| '#hash-scroll-nested', | |
| '#hash-scroll-reset-target', | |
| '#issue-7687-reset-probe', | |
| '#issue-7687-scroller', | |
| () => | |
| typeof document === 'undefined' | |
| ? undefined | |
| : document.getElementById('issue-7687-stale-selector'), | |
| '#ssr-scroll-key-nested', | |
| ], | |
| scrollToTopSelectors: [ | |
| '[data-scroll-restoration-id="carry-over-reset"]', | |
| '[data-scroll-restoration-id="hash-scroll-nested"]', | |
| '`#hash-scroll-reset-target`', | |
| '`#issue-7687-reset-probe`', | |
| '`#issue-7687-scroller`', | |
| () => | |
| typeof document === 'undefined' | |
| ? undefined | |
| : document.getElementById('issue-7687-stale-selector'), | |
| '`#ssr-scroll-key-nested`', | |
| ], |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@e2e/react-start/scroll-restoration/src/router.tsx` around lines 10 - 21,
Update the scrollToTopSelectors entry for the nested hash scroller in the router
configuration to use that element’s data-attribute selector instead of the
ineffective ID selector, preserving its participation in reset-to-top and
cross-key carry-over handling.
| React.useLayoutEffect(() => { | ||
| if (!invalidateOnMount || invalidatedOnMount.current) { | ||
| return | ||
| } | ||
|
|
||
| invalidatedOnMount.current = true | ||
| void router.invalidate().then(() => { | ||
| setInvalidateCount((count) => count + 1) | ||
| }) | ||
| }, [invalidateOnMount, router]) |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Unhandled rejection if router.invalidate() fails.
void router.invalidate().then(...) has no .catch; a rejected invalidation becomes an unhandled promise rejection, which can produce noisy console errors or flaky Playwright failures if the test harness asserts on console output.
🛠️ Proposed fix
invalidatedOnMount.current = true
- void router.invalidate().then(() => {
- setInvalidateCount((count) => count + 1)
- })
+ void router.invalidate()
+ .then(() => {
+ setInvalidateCount((count) => count + 1)
+ })
+ .catch(() => {})📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| React.useLayoutEffect(() => { | |
| if (!invalidateOnMount || invalidatedOnMount.current) { | |
| return | |
| } | |
| invalidatedOnMount.current = true | |
| void router.invalidate().then(() => { | |
| setInvalidateCount((count) => count + 1) | |
| }) | |
| }, [invalidateOnMount, router]) | |
| React.useLayoutEffect(() => { | |
| if (!invalidateOnMount || invalidatedOnMount.current) { | |
| return | |
| } | |
| invalidatedOnMount.current = true | |
| void router.invalidate() | |
| .then(() => { | |
| setInvalidateCount((count) => count + 1) | |
| }) | |
| .catch(() => {}) | |
| }, [invalidateOnMount, router]) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@e2e/react-start/scroll-restoration/src/routes/`(tests)/hash-scroll-repro.tsx
around lines 26 - 35, Handle rejection from router.invalidate() within the
React.useLayoutEffect callback by adding a catch path to the existing promise
chain, preventing failed invalidations from becoming unhandled promise
rejections while preserving the success-only setInvalidateCount update.
Window and element scroll targets are now handled independently. Restoring one target no longer suppresses resets for other uncached configured targets, and a restored element is no longer reset when the window has no cached position.
Hash navigation no longer resets elements configured through
scrollToTopSelectorsand retains precedence over stale window positions through destination invalidations.Scroll positions are sampled when leaving a route, preserving live changes made after the most recent scroll event. This also prevents client hydration from undoing nested positions restored by the SSR script.
Fixes #7687.
Summary by CodeRabbit
Bug Fixes
Tests