Pagination and Scroll Restoration with Turbolinks, but without Cookies
Well, Rich found the first post. Kinda cool đ
But then Brian had to go and ruin the ride.
Naive, doesnât actually solve it, PR rejection â vicious. Also, itâs my blog, mate. Iâll decide what the product rules are!
But, ugh, he has a point. Hereâs pass one shitting the bed just because you opened an email in a new tab:
We clear the cookie when you open an email in a new tab, so then when you click âShowâ and âBackâ in your original tab, we forget where we were in the list. Plus, itâd be extra broken if we had two inbox tabs open â theyâd overwrite each othersâ last page.
Ooofff we went from âhacky, but cleverâ to PRs getting rejected and âI donât consider this problem solvedâ.
Fine, letâs try again.
Ok, so the first solution isnât entirely correct. But everything down there đ I wrote as if you had read the first post. Heads up!
Take two, no cookies đȘ
So cookies are out. Theyâre the culprit here â theyâre not unique per tab. So what is? Two things I can think of: the URL and sessionStorage
.
In our first solution, we never changed the URL. The inbox was at /emails
and as you loaded more, you stayed at /emails
. Thatâs nice. Like I like that the URL stayed pretty. But what if we updated the URL as you loaded more?
You start at /emails
. You load page two by asynchronously requesting /emails?page=2
, and what if when that finished, your address bar changed to reflect it? That would mean if you load more, then click through to some other page, then click the browserâs back button, the browser would know where you were in the list â itâs in the URL. And the server wouldnât have to know, itâs going to get a request that tells it.
Keeping the page
in the url
If you remember, when we click the âLoad Moreâ button, we get some JavaScript back from the server that we want the browser to execute. In our first solution, that just appended rows to the table. We can use that same setup to update our url:
// app/views/emails/index.js.erb
(function() {
document.querySelector("tbody").
insertAdjacentHTML(
'beforeend',
'<%= j render(partial: "email", collection: @emails) %>'
);
<% if @pagy.next %>
document.querySelector("input[name=page]").value = '<%= @pagy.next %>'
<% else %>
document.querySelector("form").remove()
<% end %>
// -------- đold stuff, unchanged --------
// -------- đnew stuff --------
let url = '<%= current_page_path %>'
history.replaceState(
{ turbolinks: { restorationIdentifier: '<%= SecureRandom.uuid %>' } },
'',
url
)
})();
That shouldnât look horribly unfamiliar, itâs just history.replaceState
, but with some state that keeps Turbolinks in the loop.
Handling pagination
Ok, before when it came time to paginate we made a decision server-side between âjust load the page in the page
param of the URLâ or âload all the pages up to the one in the cookieâ. Every time we loaded a new page, we also had to update the cookie. In the first solution, we had a Paginator
class responsible for both deciding what to load, and persisting our max_page_loaded
.
Now, itâs simpler. We only ever read from the page
param. We persist nothing since itâs in the URL. We just make a decision, based on the request type, to load either just the next page or all pages up to this point. Take a look:
class EmailsController
include Pagination
def index
preserve_scroll
@pagy, @emails = paginates(Email.all.order(:predictably))
end
end
module Pagination
extend ActiveSupport::Concern
class Paginator
attr_reader :controller, :collection, :items, :pagy
delegate :request, to: :controller
def initialize(controller, collection)
@controller = controller
@collection = collection
@items = []
paginate
end
private
def paginate
if load_just_the_next_page?
single_page_of_items
else
all_previously_loaded_items
end
end
def load_just_the_next_page?
request.xhr?
end
def single_page_of_items
@pagy, @items = controller.send(:pagy, collection)
end
def all_previously_loaded_items
1.upto(page_to_restore_to).each do |page|
controller.send(:pagy, collection, page: page).
then do |(pagy_object, results)|
@pagy = pagy_object
@items += results
end
end
end
def page_to_restore_to
[
controller.send(:pagy_get_vars, collection, {})[:page].try(:to_i),
1
].compact.max
end
end
def paginates(collection)
Paginator.new(self, collection).
then { |paginator| [paginator.pagy, paginator.items] }
end
end
With that, we can load more, our URL updates, and we can restore scroll when you click the browserâs back button:
Refreshing shouldâŠwellâŠstart fresh
At this point, weâve lost something we had in the old version: if you refresh your inbox we should forget pagination and scroll, and start back at the top. Now that weâre tracking pagination state in the URL, when you click refresh, you get results up to that page.
So how can we detect a user refreshing? Letâs monkey patch again đ”
# config/initializers/monkey_patches.rb
module ActionDispatch
class Request
def full_page_refresh?
get_header("HTTP_CACHE_CONTROL") == "max-age=0" ||
(
user_agent.include?("Safari") &&
get_header("HTTP_TURBOLINKS_REFERRER").nil? &&
(referrer.nil? || referrer == url)
)
end
end
end
For Chrome and Firefox, when you refresh or navigate directly to a url they both set a Cache-Control
header to max-age=0
. So if we see that, we can treat this request as a full_page_refresh?
. Safari however does not set that header. So if it looks like itâs Safari weâre dealing with we uhâŠguestimate. Our âSafariâs refreshingâ guestimate is 1) thereâs no Turbolinks referrer i.e. we didnât get here form a Turbolink and 2) the browserâs referrer is either not set or set to this page youâre requesting.
Thereâs probably room for improvement here đŹ But armed with that helper method, we can make a server-side decision to ignore any page
params set in a userâs request. Hereâs what that looks like:
module Pagination
extend ActiveSupport::Concern
# ... Paginator class and paginates methods ...
def fresh_unpaginated_listing
url_for(only_path: true)
end
def redirecting_to_fresh_unpaginated_listing?
if request.full_page_refresh? && params[:page]
redirect_to fresh_unpaginated_listing
return true
end
false
end
end
class EmailsController < ApplicationController
def index
preserve_scroll
return if redirecting_to_fresh_unpaginated_listing?
@pagy, @emails = paginates(Email.all.order(:predictably))
end
end
Now when we refresh the page â even if weâre at a URL like /emails?page=2
â or navigate to it directly, the server can instead give us the fresh, unpaginated list of emails:
Our appâs links should work
So far, we have pagination and scroll restoration working with the browserâs back button â what about our âBackâ links. Our app has them both on the show page and on the edit page. In our original version, you could even go from inbox -> show -> edit -> click "Back"
, and have the inbox recover your scroll and pagination. Itâd be nice if that continues to work.
Hereâs where sessionStorage
comes in. Every time we load more emails, we update our URL to where in the list we are. Letâs also drop a little note in sessionStorage
. That way as we click into our application we can remember what our URL was.
First, letâs add some helpers to that Pagination
concern:
module Pagination
extend ActiveSupport::Concern
def clear_session_storage_when_fresh_unpaginated_listing_loaded
script = <<~JS
sessionStorage.removeItem('#{last_page_fetched_key}');
JS
helpers.content_tag(:script, script.html_safe, type: "text/javascript",
data: { turbolinks_eval: "false" })
end
def current_page_path
request.fullpath
end
def last_page_fetched_key
"#{controller_name}_index"
end
included do
# đ rails for "let me use this method in the controller and the view"
helper_method :clear_session_storage_when_fresh_unpaginated_listing_loaded,
:current_page_path,
:last_page_fetched_key
end
end
Next, letâs adjust that JavaScript response where we update the URL to also update sessionStorage
:
// app/views/emails/index.js.erb
(function() {
// ... adding table rows ....
let url = '<%= current_page_path %>'
// ... updating the address bar and turbolinks ...
sessionStorage.setItem('<%= last_page_fetched_key %>', url)
})();
Cool, now weâre updating both the URL and sessionStorage
. We just wrote some code for clearing the URL when you refresh, now we need to clear sessionStorage
, too.
Refreshing the URL serves our index.html.erb
view, so we can do it there:
<!-- app/views/index.html.erb -->
<!-- ... header ... -->
<!-- ... table ... -->
<!-- ... load more ... -->
<%= clear_session_storage_when_fresh_unpaginated_listing_loaded %>
clear_session_storage_when_fresh_unpaginated_listing_loaded
renders a script tag that has the actual JavaScript for clearing sessionStorage
, and it decorates that script tag with some data attributes that tells Turbolinks âlook if you ever load this page from a cache, donât run that script againâ. So itâs just our response to the userâs refresh thatâll run that script and clear the sessionStorge
key.
Ok, so weâve got the current pagination url in sessionStorage
; we clear it out when our URL resets â now weâve gotta use it when our links are clicked.
First, we decorate our links in app/views/emails/show.html.erb
and app/views/emails/edit.html.erb
like so:
<%= link_to 'Back', emails_path,
data: { restores_last_page_fetched: last_page_fetched_key } %>
Second, we need just a little more JavaScript to make that work. Turns out when a link is clicked, weâll see that click event before Turbolinks takes over. So we can listen for clicks on decorated links and tell Turbolinks where to go:
on(document, "click", "a[data-restores-last-page-fetched]", function(event) {
const { target: a } = event
const { restoresLastPageFetched: key } = a.dataset
const lastPageFetched = sessionStorage.getItem(key)
if (lastPageFetched) {
a.href = lastPageFetched
}
})
So, when a <a data-restores-last-page-fetched="key">
link is clicked, we grab that key, see if thereâs a value in sessionStorage
for it, and if so swap out the href
attribute before Turbolinks starts its thing. Check it out:
What about multiple tabs? Isnât that why weâre here?
Right you are!
What about the history stack is not unique by URL so you canât save scroll positions by URL?
Turbolinks is (mostly) bailing us out here. Turbolinks saves the scroll position before navigating away and restores that position when it loads the page from cache â i.e. you click the browserâs back button.
We scroll to #22, open it, click âBackâ, scroll to #44, and open it. Now, we click the back button, we get that cached page scrolled to #44. Click the back button twice more, we get that other cached page scrolled to #22.
Iâm not sure scroll is always restored if you mix using the browserâs back button and our appâs back link. That said, if Turbolinks has a scroll position and we donât, surely we could go get it. Alternatively, we could change our savedScrolls
object. What if it was a stack per URL? Then to restore scroll, we pop the last value we had for that URL (rather than the only value we have for that URL).
Anyway, I think thatâs everything. Opening it for re-review, anyway.
Conclusion
Restoring pagination and scroll position: I guess itâs more difficult than I thought. I definitely didnât think about multiple tabs. There might even be edge cases Iâm still not thinking about, but at this point, weâre well past âitâs impossible with Rails and Turbolinksâ.
So, I think itâs yâallâs turn. Letâs see that Sapper / Svelte, Next.js / React, SPA goodness.
Code can be found here.
Bonus
If scroll restoration and pagination is gonna become my thing, I wrote some tests for it. You can see the test cases â all the def test_x
methods â here. Itâs kinda readable even without Ruby / Rails experience. Let me know if Iâm missing any!
Also, itâs an impressive display of browser testing capabilities you get out-of-the-box with Rails. People forget the allure of Rails is so much more than just server-rendering. Itâs a vast tool set that makes everything so. god. damn. enjoyable.