Published at
Updated at
Reading time
4min

I've been doing Angular development lately, and as with any single-page app, the "Let's do everything in JS" approach breaks basic web functionality. Scroll handling is only one example.

If you're looking for a way to alter URL query params without having Angular scroll to the top of the page, this post is for you.

But before we get to the problem, let's recognize what browsers are good at — doing web things. If your site relies on server-rendered HTML you get well-working scroll handling out of the box.

If you link to an anchor element (/something#foo), the browser scrolls to it. Do you hit the back button? The browser instantly shows the last page, thanks to the BF cache. And the page is even in the correct scroll position. Browsers are pretty good at handling websites. One might think they're built for this.

Sometimes (often?) SPA frameworks break these standard web features. And of course, then there's more JS added to fix and reimplement what broke.

Here's Angular's RouterModule configured to fix scroll handling.

@NgModule({
  imports: [RouterModule.forRoot(routes, {
    // handle back and forward navigations
    scrollPositionRestoration: 'enabled',
    // handle anchor links
    anchorScrolling: 'enabled'
  })],
  exports: [RouterModule]
})
export class AppRoutingModule { }

anchorScrolling scrolls to anchors if they're available, and scrollPositionRestoration scrolls the previous page to its last position when hitting the back button. Ignoring the fact that there must be an array storing all navigation's scroll positions to reapply them, this works reasonably well. Forward navigations, on the other hand, are scrolled to the top.

But could there be situations when you want to update the URL without scrolling around? You bet!

If you're building a shop search and want to apply filters or sorting (?filter=shoes&order=asc), chances are high that you just want to update the URL and keep the current scroll position. Or, like in my case, maybe you want to open a modal or sidebar, and it should have a URL so you can link to it (?sidebar=reviews). If you're rendering UI in JS, there are plenty of cases in which you just want to update the URL.

So I thought I could slap an attribute onto a [routerlink] and call it a day.

<!-- 
  ⚠️ Before you copy and paste this code ⚠️
  Note that I hoped for the `scrollPositionRestoration` 
  attribute but unfortunately it's not a thing.
-->
<a
  [routerLink]="[]"
  [queryParams]="{sidebar: 'reviews', productId: productId}"
  [scrollPositionRestoration]="false">
  Show all reviews
</a>

But now it gets fun. Let me repeat the previous fact: forward navigations are scrolled to the top. Always? Yes always (unless you work around the core router behavior).

Here's the scroll logic from Angular 17.3 core if you're curious.

private consumeScrollEvents() {
  return this.transitions.events.subscribe((e) => {
    if (!(e instanceof Scroll)) return;
    
    // this part handles back navigations
    if (e.position) {
      if (this.options.scrollPositionRestoration === 'top') {
        this.viewportScroller.scrollToPosition([0, 0]);
      } else if (this.options.scrollPositionRestoration === 'enabled') {
        this.viewportScroller.scrollToPosition(e.position);
      }
      // imperative navigation "forward"
    } else {
      if (e.anchor && this.options.anchorScrolling === 'enabled') {
        this.viewportScroller.scrollToAnchor(e.anchor);
      } else if (this.options.scrollPositionRestoration !== 'disabled') {
        // You'll end up here if you have `scrollPositionRestoration`
        // enabled and navigate forward
        this.viewportScroller.scrollToPosition([0, 0]);
      }
    }
  });
}

Discovering this Angular core code took me a few hours already, but I can't be alone with this issue, can I? Of course not. Here's the GitHub issue from 2018 asking for a way to temporarily disable scrollPositionRestoration.

The proposed framework solution is what I have hoped for: allow setting scrollPositionRestoration on each navigation. But no one seemed to have PR'ed, reacted or cared about the issue.

I tried a few solutions and here are the ones that worked for me:

  1. Disable scroll position restoration entirely and roll your own scroll handling. Ufff.
  2. Link to a fragment that doesn't exist and trick Angular into not scrolling. Also, ufff.

I was almost leaning into the ugly fragment hack but asked a colleague for a rubber duck session. And looking at the Angular core code, he came up with a dynamic JavaScript getter for the scrollPositionRestoration option.

RouterModule.forRoot(routes, {
  get scrollPositionRestoration() {
    const params = new URLSearchParams(window.location.search);
    if (params.get('sidebar')) {
      return 'disabled' as const;
    }
    return 'enabled' as const;
  },
  scrollOffset: [0, 164],
  anchorScrolling: 'enabled',
});

Whenever Angular updates the URL and checks if it should scroll around by accessing this.options.scrollPositionRestoration, the current URL is checked. If it includes a query param that shouldn't trigger scrolling (sidebar), scrollPositionRestoration returns disabled. Otherwise, it'll be enabled, and Angular will do its "scroll magic".

Is this a perfect solution? I doubt it. Will there be edge cases where this approach leads to bugs? Most likely. Does it do the trick for me right now? Absolutely, because it's way better than rolling my own scroll handling or adding ugly URLs to the app.

Was this snippet helpful?
Yes? Cool! You might want to check out Web Weekly for more snippets. The last edition went out 5 days ago.
Stefan standing in the park in front of a green background

About Stefan Judis

Frontend nerd with over ten years of experience, freelance dev, "Today I Learned" blogger, conference speaker, and Open Source maintainer.

Related Topics