Pagy, Infinite Scroll, and GET/POST requests

I’m currently moving over an app from Turbolinks to Turbo.

I’m struggling to figure out the best way to migrate a common pattern I have in my current Turbolinks setup:

  • I have infinite scroll set up with the Pagy gem and it grabs the next page via a GET request
  • I have many filter menus, which filter down results via a form using method: :get, responding with format.js
  • After the filter button is pressed, the paginated filtered results replace the current set, and scrolling down triggers the js response, which appends the new records, updates the infinite-scroll div, and triggers a few other DOM changes.

With the migration:

  • In order to get the forms to work with Turbo, I’ve had to change them to submit via a POST request and respond with format.turbo_stream
  • The turbo-stream response updates the target div with the first page of results and an updated infinite-scroll div
  • Now infinite scroll fails with 404s because a GET route no longer exists

Here’s what I need to happen:

  • When I click to filter a list, the first page of results are returned and the infinite-scroll div is updated to load the second page
  • Scrolling down eventually triggers the second page to append to the first page, the current infinite-scroll div is then removed and updated or replaced with one for the third page.

What would be the idiomatic way to do this using Pagy and Hotwire?

Is there a way to trigger a Stimulus controller after my Rails controller has finished processing? If there is, I might be able to just move over the JS I already have that’s working well.

I’m hoping for high-level tips on which direction to take but if I’ve not given enough information or seeing particular parts of the code would be helpful, just let me know. Thank you very much!

Here’s the general setup of my code right now:

First, I have infinite scroll set up site-wide via JS like so:

$(document).on('turbo:load', function() {
  var isLoading = false
  if ($('#infinite-scroll', this).size() > 0) {
    $(window).on('scroll', function() {
      var more_posts_url = $('.page.next a').attr('href');
      var threshold_passed = $(window).scrollTop() > $(document).height() - $(window).height() - 200;
      if (!isLoading && more_posts_url && threshold_passed) {
        isLoading = true;
        $('.page.next').html('<%= image_tag("loading-more-posts.gif", alt: "Loading...", title: "Loading...") %>')
        $.getScript(more_posts_url).done(function (data,textStatus,jqxhr) {
          isLoading = false;
        }).fail(function() {
          isLoading = false;
        });
      }
    });
  }
});

In general, index view pages are like this:

    <div class="posts">
      <div class="posts-panes">
        <% @posts.each do |post| %>
          <%= render post %>
        <% end %>
      </div>
      <div id="infinite-scroll">
        <%== pagy_next_link(@pagy, 'More...', 'id="next_link"') %>
      </div>
    </div>

And the respond_to block for the index action in the controller is like this:

    respond_to do |format|
      format.html
      format.js
    end

The index.js view are like so:

$('.posts-panes').append("<%= j render @posts %>");
<% if pagy_next_url(@pagy) %>
    $('.page.next').replaceWith('<%== j pagy_next_link(@pagy, 'More...', 'id="next_link"') %>');
<% else %>
    $(window).off('scroll');
    $('.page.next').remove();
<% end %>

For each index view, there is a filter form, which via GET, filters down the records.

The respond_to blocks for the filter methods are like so:

    respond_to do |format|
      format.js
    end

And the js views are something like so:

  ...

  $('.posts-panes').append("<%= j render @posts %>");
  $('#infinite-scroll').replaceWith('<div id=\"infinite-scroll\"><%== j pagy_next_link(@pagy, 'id="next_link"') %></div>');

<% if pagy_next_url(@pagy) %>
  $('.page.next').replaceWith('<%== j pagy_next_link(@pagy, 'more...', 'id="next_link"') %>');
<% else %>
  $(window).off('scroll');
  $('.page.next').remove();
<% end %>

So far, I’ve updated the filter action to be a POST request (so that it works with Turbo), and now the respond_to blocks for the filter methods read like:

    respond_to do |format|
      format.turbo_stream
    end

The turbo_stream views are like so:

<turbo-stream action="append" target="posts-panes">
  <template>
    <%= render partial:'posts/post', collection: @posts, as: :post, formats: [:html] %>
  </template>
</turbo-stream>

I can successfully return the first page of records — that’s all hooked up correctly — but I am struggling to hook up Pagy.

What would be the best way to refactor/rejig my existing code so that I have a pattern I can use across all of my views and controllers?

Thank you very much!

Have you seen this video?

1 Like

Yes, I have!

That solution focuses on GET requests, and I have a unique issue of sometimes needing pagination to work with GET and other times needing it to work with POST requests.

I’ve tried adapting that code regardless, but to no avail thus far.

In the meantime, I wanted to post here in case anybody had other tips or approaches for me given my current setup.

Thank you.

I’ve decided to go down a different direction for now using GET requests and turbo-frames. Working well so far!

Just curious, in your new implementation, did you use ‘loading: lazy’ attribute on turbo-frame tag?

In the end, I had to ditch Turbo Frames entirely. I had only one section of my form that needed updating alongside the results frame, and Turbo wouldn’t play nice with the nested frames. On top of that, there’s a known bug relating to a frame not updating if the same src is set and I was looking into hacky ways to solve that.

I ended up re-introducing UJS to the repo, which I’d originally stripped out based on the original (now outdated) advice to do so if using turbo.