In Vue Router 5, an app's `scrollBehavior` always returns `{ top: 0 }`; why does a job-search page jump on every filter change and lose its place on Back, and what fixes it?
answer
- one function on createRouter
- savedPosition on popstate and reload
- query-only change: same path
- falsy return means no scroll
- runs before the data arrives
basics
~10 sThe function treats every navigation alike and discards savedPosition. Return savedPosition on Back and Forward, after the list has rendered; return false when only the query changed; scroll to the top otherwise.
solid answer
~50 sIn Vue Router 5, `scrollBehavior(to, from, savedPosition)` runs after every navigation commits, on the next tick. `savedPosition` is set on back/forward navigations and on the first navigation after a reload, is `null` for pushes, and a falsy return means no scroll. Returning `{ top: 0 }` unconditionally jumps on filter changes, which are pushes to the same path with a new query, and throws the saved position away on Back. The fix: return `savedPosition` when it exists, `false` when `to.path === from.path`, `{ el: to.hash }` for anchors, and `{ top: 0 }` otherwise. One more trap: the list re-fetches on mount, so the saved position is applied to a short page and clamps; returning a Promise that resolves after the results render fixes it. Adding `scrollBehavior` also sets `history.scrollRestoration` to `'manual'`, so the router owns restoration.
code
ts · 19 linesimport { createRouter, createWebHistory } from 'vue-router'
import { routes } from './routes'
import { resultsReady } from './results-state'
const withTimeout = <T>(p: Promise<T>, ms: number) =>
Promise.race([p, new Promise<void>(resolve => setTimeout(resolve, ms))])
export const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return withTimeout(resultsReady(), 1500).then(() => savedPosition)
}
if (to.hash) return { el: to.hash, top: 72 }
if (to.path === from.path) return false
return { top: 0 }
},
})go deeper
Recall that scrolling is configured once, with the scrollBehavior option of createRouter, and that it receives to, from and savedPosition.
Explain when savedPosition is set, what each return value does, and why a falsy return leaves the page where it is.
Diagnose restore failures on data-driven pages: the scroll runs before async data renders, so return a Promise with a ceiling and separate query-only changes.
Set one app-wide scroll policy per kind of navigation, new page, query change, anchor and Back, instead of per-page patches that fight each other.
## Where `scrollBehavior` lives Vue Router 5 has one hook for scrolling: the `scrollBehavior` option of `createRouter()`. Without it the router never scrolls. With it, the router: - sets `history.scrollRestoration` to `'manual'`, taking restoration away from the browser; - saves each history entry's scroll position itself; - calls `scrollBehavior(to, from, savedPosition)` after each navigation commits, on the next tick, after the DOM update that follows the commit; - skips the scroll if another navigation has committed in the meantime. The third argument, **`savedPosition`**, is set on a **back/forward (popstate) navigation** to an entry whose position was saved. It is also set on the app's first navigation when the browser kept that entry's state, as after a reload, because the router writes the current position into `history.state` on `pagehide`. For `router.push()`, `router.replace()` and RouterLink clicks it is `null`. ## What the function can return | Return value | Effect | |---|---| | `{ top, left, behavior }` | scrolls the window to those coordinates | | `{ el: '#results', top: 72 }` | scrolls to the element, keeping `top`/`left` as an offset above/left of it | | `savedPosition` | restores the saved position, like a native Back | | `false`, `undefined` or `{}` | no scrolling | | a Promise of any of these | waits, then applies the result | A string `el` that starts with `#` is looked up with `document.getElementById`; any other string goes to `document.querySelector`. If nothing matches, no scroll happens and a development warning names the selector. ## Diagnosing the job-search page The symptoms come from a one-liner many apps start with, `scrollBehavior: () => ({ top: 0 })`: 1. **Every filter change jumps to the top.** A filter change is a push to the same path with a new query, and the function cannot tell it apart from moving to another page. 2. **Back lands at the top of the list.** The function ignores `savedPosition`, so the position the router saved is thrown away. 3. **After fixing (2), Back still lands too high.** The results component remounts and fetches the jobs on mount. `scrollBehavior` runs right after the route commits, before the data arrives, so the page is still short and the scroll is clamped to the end of the partial content. ## A fixed `scrollBehavior` ```ts scrollBehavior(to, from, savedPosition) { if (savedPosition) return resultsReady().then(() => savedPosition) if (to.hash) return { el: to.hash, top: 72 } if (to.path === from.path) return false return { top: 0 } } ``` - `savedPosition` comes first, so Back and Forward restore where the user was. - The Promise delays the restore until the list has rendered; `resultsReady()` stands for whatever your data layer exposes, such as a promise the results store resolves after its fetch. - The hash branch keeps in-page anchors working, offset below a 72px sticky header. - `to.path === from.path` catches query-only changes, so filters and page numbers keep the user where they were; return `{ el: '#results' }` there instead if the design wants the top of the list. - Everything else starts a new page at the top. The Promise needs a ceiling. If `resultsReady()` never settles the scroll never happens, so race it against a timeout; if it rejects, the error is reported through `router.onError()` handlers. ## One policy per kind of navigation | Navigation | What `to`, `from` and `savedPosition` show | Sensible result | |---|---|---| | Back, Forward or a reload | `savedPosition` is set | the saved position, once content is ready | | link to an anchor | `to.hash` is set | the element, offset below fixed headers | | filter or page change | `to.path === from.path` | no scroll, or the top of the list | | a different page | none of the above | the top of the page | Writing the function as this ordered list of checks keeps it readable as the app grows, and makes a new case, such as a modal route that must not scroll the page behind it, a single extra branch rather than a rewrite. ## Other details worth knowing - The router also calls `scrollBehavior` on a duplicated navigation, so clicking the same `#section` link twice scrolls again. - With a `<Transition mode="out-in">` around the route view, the new page mounts only after the old one has left, so an immediate scroll moves the leaving page; a Promise that resolves after the transition avoids it. - If computing positions is too costly for a page, leave `scrollBehavior` out and let the browser restore scroll natively. ## Common mistakes - Returning `{ top: 0 }` unconditionally. - Expecting `savedPosition` on a RouterLink click. - Returning `savedPosition` synchronously for a list that loads after mount. - Using an `el` id selector with unescaped special characters, which matches nothing.
- What changes if you delete `scrollBehavior` entirely?The router stops managing scroll: it leaves `history.scrollRestoration` alone, saves no positions and never scrolls. The browser then restores positions natively on Back and Forward, but a push to a new page keeps the old scroll offset, because in a single-page app no new document loads.
- How do you scroll to the results list, not the page top, when a filter changes under a sticky header?Return `{ el: '#results', top: 64 }` for query-only changes: `el` finds the list and `top` is kept as an offset above it, so the header does not cover the first job. For an offset each element controls, read its `scroll-margin-top` with `getComputedStyle` and pass that number as `top`.
saying these in an interview costs you the question
- savedPosition is available on every navigation, including router.push
- Returning false from scrollBehavior cancels the navigation
- Without scrollBehavior Vue Router scrolls to the top on every push
- scrollBehavior runs before the guards, so the new page is not rendered yet
- Returning savedPosition is enough even when the list loads after mount