I'm trying to implement infinite scrolling in a cocktail recipe app using Rails 7, Stimulus/Turbo and the Kaminari gem for pagination. Everything functions as it should when I am simply filtering all records sent from the controller. Meaning, the page numbers increment properly and each new page of records updates in the correct order as I scroll down the page.
However, when I send filtering or sorting parameters to the controller, the behavior gets undesirably wonky. New pages of records continue to get sent from the controller but it sends two or three duplicate pages or advances the page number ahead by a few pages.
I've spent too many hours trying to figure out what I've done wrong here and would appreciate it if any gracious soul out there might have an idea what boneheaded thing I'm missing.
Here is my Cocktail Recipes controller#index where I am feeding records based on a sort option (All ingredients, Any Ingredient, etc.), cocktail category, and/or a group of specifically selected ingredients to filter by (Gin, Mint, Applejack, etc.)
def index
@page = params[:page] || 1
category_id = params[:categoryId]
ingredient_ids = params[:ingredientIds] ? [*params[:ingredientIds]].map(&:to_i) : nil
@recipes = Recipe.alphabetical.page(@page)
case params[:sort_option]
when ''
params[:ingredientIds] ?
@recipes = Recipe.alphabetical.filter_all_recipes(ingredient_ids, category_id).page(@page) :
@recipes = Recipe.filter_all_by_category(category_id).page(@page)
respond_to do |format|
# needed to explicitly call formats: [:html] after adding turbo_stream option
format.html { render partial: 'recipe_cards', formats: [:html] }
format.turbo_stream
end
when 'All Recipes'
params[:ingredientIds] ?
@recipes = Recipe.alphabetical.filter_all_recipes(ingredient_ids, category_id).page(@page) :
@recipes = Recipe.filter_all_by_category(category_id).page(@page)
respond_to do |format|
format.html { render partial: 'recipe_cards', formats: [:html] }
format.turbo_stream
end
when 'Any Ingredient'
params[:ingredientIds] ?
@recipes = Recipe.alphabetical.match_any_subset(ingredient_ids, current_user.ingredients, category_id).page(@page) :
@recipes = Recipe.alphabetical.match_any_ingredient(current_user.ingredients, category_id).page(@page)
respond_to do |format|
format.html { render partial: 'recipe_cards', formats: [:html] }
format.turbo_stream
end
when 'All Ingredients'
params[:ingredientIds] ?
@possible_recipes = Recipe.match_all_subset(params[:recipeIds], ingredient_ids, category_id).page(@page) :
@possible_recipes = Recipe.alphabetical.match_all_ingredients(current_user.ingredients, category_id).page(@page)
respond_to do |format|
format.html { render partial: 'recipe_cards', formats: [:html] }
format.turbo_stream
end
end
end
Here is my pagination Stimulus controller:
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
// gets/sets record fetching flag
static get fetching() { return this.fetching; }
static set fetching(bool) {
this.fetching = bool;
}
// gets url and page number from target element
static get values() { return {
url: String,
page: { type: Number, default: 1 },
};}
// adds the scroll event listener and sets fetching flag to false
connect() {
console.log("Pagination Controller Loaded");
document.addEventListener('scroll', this.scroll);
this.fetching = false;
}
// binds this to the controller rather than document
initialize() {
this.scroll = this.scroll.bind(this);
}
// calls loadRecords() when scroll reaches the bottom of the page
scroll() {
if (this.pageEnd && !this.fetching) {
this.loadRecords();
}
}
// record fetching function
async loadRecords() {
// get pre-configured url from helper method
const url = getUrl(this.urlValue, this.pageValue);
// sets fetching flag to true
this.fetching = true;
// sends a turbo_stream fetch request to the recipes controller
await fetch(url.toString(), {
headers: {
Accept: 'text/vnd.turbo-stream.html',
},
}).then(r => r.text())
.then(html => Turbo.renderStreamMessage(html));
// sets fetching flag to false
this.fetching = false;
// increments the target element's
this.pageValue += 1;
}
// sets the boundary where the loadRecords() function gets called
get pageEnd() {
const { scrollHeight, scrollTop, clientHeight } = document.documentElement;
return scrollHeight - scrollTop - clientHeight < 40; // can adjust to desired limit
}
}
// ------------- HELPER FUNCTIONS ----------------
// gets selected ingredient ids from liquor cabinet display
// options and returns them in an array
function getIngredientIds() {
var ingredientIds = [...$('.cabinet-spirits').val(),
...$('.cabinet-modifiers').val(),
...$('.cabinet-sugars').val(),
...$('.cabinet-garnishes').val()];
return ingredientIds;
}
// if there are ingredientIds, appends them as an array to searchParams
function appendIngredientIds(url) {
var ingredientIds = getIngredientIds();
if (ingredientIds.length != 0) {
ingredientIds.map(i => url.searchParams.append('ingredientIds', i));
}
return url;
}
// configures url searchParams and returns the url
function getUrl(urlValue, pageValue) {
var url = new URL(urlValue);
url.searchParams.set('page', pageValue);
url.searchParams.append('sort_option', $('.sort-options').val());
url = appendIngredientIds(url);
return url;
}
Here is the index.turbo.erb:
<%= turbo_stream.append 'recipes' do %>
<% @recipes.each do |recipe| %>
<%= render partial: "recipe_card", locals: { recipe: recipe } %>
<% end %>
<% end %>
And finally, the target div I am appending the new records to:
<div class="container-fluid mt-2 mx-3">
<div id="recipes" class="row row-cols-lg-5 row-cols-md-4 row-cols-sm-3 g-2"
data-controller='pagination'
data-pagination-target='recipes'
data-pagination-url-value='<%= recipes_url %>'
data-pagination-page-value='<%= 2 %>'>
<% @recipes.each do |recipe| %>
<%= render partial: "recipe_card", locals: { recipe: recipe } %>
<% end %>
</div>
</div>
I've monitored the page incrementation in devTools and it looks like for each additional ajax call to the recipes controller, the pagination controller gets called an extra time. So, if I sort results by 'Any Ingredient', I start to get duplicate pages as I scroll. If I then filter those results by Bourbon drinks, 3 pages (not necessarily in order), start to get loaded on scroll. I feel like there's probably something obvious I'm missing.
In case anyone else encounters a similar problem, I figured this out. Probably a rookie mistake.
The issue came from where I located the data-pagination attributes. Initially, I placed them in a div in a partial that gets replaced when sorting or filtering options are sent to the controller. The partial looked like this:
As mentioned, the infinite scrolling/pagination worked fine until a sorting or filtering option was sent to the controller. Afterwards, the scrolling function started adding duplicate pages or pages out of order.
When I moved the data-pagination attributes to the div containing the partial, the unwanted behavior stopped. Here's the outer div:
I believe the data attributes were getting persisted after the partial was updated so that the pagination ended up responding to multiple targets.
I also needed to create a simple javascript function to reset the data-pagination-page-value each time a new sorting option was selected now.