skip to content

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?

level: seniorimportance: should knowfreq 38%

answer

  1. one function on createRouter
  2. savedPosition on popstate and reload
  3. query-only change: same path
  4. falsy return means no scroll
  5. runs before the data arrives

basics

~10 s

The 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 s

In 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 lines
ts
import { 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

for a junior

Recall that scrolling is configured once, with the scrollBehavior option of createRouter, and that it receives to, from and savedPosition.

for a middle

Explain when savedPosition is set, what each return value does, and why a falsy return leaves the page where it is.

for a senior

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.

for a principal

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