The problem of routing in single page applications

If you have been working with single page applications (SPA), such as React, you might have came across the following scenario in your app.

Pretend your app is on hosted on https://foo.com, which has a long scrollable list of items, maybe even infinite scroll.

You scroll down 500px -> Click a button -> url changes to https://foo.com/item/1 -> https://foo.com/item/1 is showing content 500px from the top.

But if I type in https://foo.com/item/1 into the url and visit it, I'm at the top. Why, what, and how?

Welcome to SPA routing and Scroll Restoration.

Introduction to Single Page Applications (SPA)

If you ever worked with an SPA, e.g React, Angular, Vue, Ember, you will notice that your app usually have a root.

In React, it can look like this.

import React from "react";
import { render } from "react-dom";
const App = () => (
<h1>This is my app!<h1/>
);
render(<App />, document.getElementById("root"));

This allows React to inject the the App component into a corresponding HTML element that has the id root, which is almost always located within the body.

<body>
<div id="root">{/* App will be injected here */}</div>
</body>

Because of this architecture, most if not all of your React app will be rendering components within root. I say most here because you can modify the DOM outside of the root, for example, React Helmet. However most of the time, you don't, so your components will render and re-render within the root, which effectively stays as the same page.

So how does it work if I want urls/routes for my app? This is where the History API comes into play.

Using https://foo.com as an example, if I wanted to add a new route, namely, https://foo.com/item, I would use pushState to change the url and render the corresponding component. This is a tip of what React Router does under the hood.

Routing problems

Ok routing sounds good, so what's the problem?

Go to this playground that I made and perform the following actions:

  1. At the homepage, scroll down to the bottom
  2. Click About in the navigation bar.
  3. See About is rendered with the scroll position at the bottom.

Now try this:

  1. At the homepage, scroll down to the bottom
  2. Append about to the url bar, e.g https://wzzbm.csb.app/about and enter.
  3. See About is rendered at the top.

Why is the behavior different?

In the first example, the page itself never changed. It was only the Home component and the url that changed. So since the page stayed the same, its scroll position stayed the same and was carried over to the new route. This is a good reason as to why these type of frameworks are called single page applications.

In the second example, we are hitting a new page, which essentially reinitialized our entire app. So the page's scroll position was reset back to the top.

Now you might be wondering, "hey why don't we just reset the scroll to the top on every navigation?". That almost works but scroll restoration would like to say hi.

Scroll restoration

The browser stores a lot of information regarding its history. The browser's back and forward buttons can navigate to your previously visited page and restore its scroll position back to what it was before you left, thanks to scroll restoration. For example

  1. Go to https://foo.com, scroll down 500px
  2. Go to https://bar.com
  3. Click the browser's back button
  4. Back at https://foo.com, 500px scrolled from the top.

So if we were to reset a page's scroll position to the top on every navigation, you will override the scroll restoration behavior. That would not make for a good user experience. So what can we do?

Solutions?

Unfortunately, I have not found any satsificatory ways to solve this problem at scale yet.

The browser history does not expose the actual history of urls as part of its public API. This is probably due to security concerns to protect users against malicious intentions.

Here is one imperfect solution, quoted from React router's documentation on scroll restoration

For a generic solution (and what browsers are starting to implement natively) we’re talking about two things: Scrolling up on navigation so you don’t start a new screen scrolled to the bottom Restoring scroll positions of the window and overflow elements on “back” and “forward” clicks (but not Link clicks!) At one point we were wanting to ship a generic API. Here’s what we were headed toward:

First, ScrollRestoration would scroll the window up on navigation. Second, it would use location.key to save the window scroll position and the scroll positions of RestoredScroll components to sessionStorage. Then, when ScrollRestoration or RestoredScroll components mount, they could look up their position from sessionsStorage.The tricky part was defining an “opt-out” API for when you don’t want the window scroll to be managed. For example, if you have some tab navigation floating inside the content of your page you probably don’t want to scroll to the top (the tabs might be scrolled out of view!).

I've given this similar approach a try in Ember, but with some modifications.

My modifications:

  1. All new route transitions scroll to top by default.
  2. Define an explicit mapping of routes, namely toRoutes and fromRoutes, which could also be bidirectionally dependent.
  3. On every transition, when the from route and the to route match, I record the from scroll position on itself, prevent scroll to top, and restore to route's scroll position if it has any recorded scroll position.
  4. Since scroll restoration needs to both behaviors, prevent scroll to top (1) and restore scroll position(2) to work, I introduced an opt out list to disable behavior (2) but still allow behavior (1).

There are still cons regarding these modifications such as dynamic segments in the url, so I'm all ears if people have better ideas!