Skip to content

Instantly share code, notes, and snippets.

@secretpray
Last active June 26, 2022 09:05
Show Gist options
  • Save secretpray/d0569e1986f0e794a22f6ddcf0160f5f to your computer and use it in GitHub Desktop.
Save secretpray/d0569e1986f0e794a22f6ddcf0160f5f to your computer and use it in GitHub Desktop.
Infinity scroll (Endless) on Ruby on Rails 7 Hotwire (Turbo)

Edition 1 simple (on Observer)

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="infinite-scroll"
export default class extends Controller {

  initialize() {
    this.observer = new IntersectionObserver(entries => this.callback(entries))
  }

  connect() {
    this.observer.observe(this.element)
  }

  disconnect() {
    this.observer.unobserve(this.element)
  }

  callback(entries, _observer) {
    entries.forEach(entry => {
      if (!entry.isIntersecting) return

      fetch(this.element.href, {
        headers: {
          Accept: "text/vnd.turbo-stream.html"
        }
      })
        .then(r => r.text())
        .then(html => Turbo.renderStreamMessage(html))
        .then(_ => history.replaceState(history.state, "", this.element.href))
    })
  }
}

views/posts/index.html.erb

<h1>Post lists</h1>
<div id="posts">
  <%= render @posts %>
</div>

<div id="pagination">
  <%== pagy_next_link(@pagy, link_extra: 'data-controller="infinite-scroll"') unless @pagy.page == @pagy.last %>
</div>

controllers/posts_controller.rb

class PostsController < ApplicationController
  before_action :set_post, only: %i[ show edit update destroy ]

  # GET /posts
  def index
    @pagy, @posts = pagy_countless(Post.all)
  end
  ...

Edition 2 (on observer)

import { Controller } from "@hotwired/stimulus"
import { get } from "@rails/request.js"

let count = null,             // timer duartion
    running = true,           // loader status
    mOld = null,              // old timer value
    mNew = null,              // new timer value
    options = {               // options for Observer
                root: null,
                rootMargins: "0px",
                threshold: 0.7 // Target overlap percentage (1.0 - 100%)
    }

const intersections = new Map() // map set for Observer

export default class extends Controller {

  static targets = [
                     "footer",
                     "current",
                     "next",
                     "pagination",
                     "pagination_prev",
                     "loader",
                     "seconds",
                     "milliseconds"
                   ]
  static values = {
                    request: Boolean
                   }

  connect() {
    if (document.body.dataset.actionName == 'index') {
      this.createObserver()
    }
  }

  createObserver() {
    const observer = new IntersectionObserver(this.callback, options)

    if (this.hasFooterTarget) { observer.observe(this.footerTarget) }
  }


  draw = () => {
    if (count > 0 && running) {
      requestAnimationFrame(() => {
        this.draw()
      })
      mNew = new Date().getTime();
      count = count - mNew + mOld;
      count = count >= 0 ? count : 0;
      mOld = mNew;
      this.secondsTarget.innerText = Math.floor(count / 1000);
      this.millisecondsTarget.innerText = count % 1000;
    } else {
      this.secondsTarget.innerText = 0
      this.millisecondsTarget.innerText = 0
    }
  }

  // little usability hack: add page 1 link if all page added and prevPage.href = null
  firstLinkAddIfNeeded = () => {
    let service = this.nextTarget.dataset['service']
    let result = Number(this.currentTarget.dataset['totalpage']) - Number(service)
    if (result < 2 && !this.hasPagination_prevTarget) {
      const firstUrl = this.currentTarget.dataset['prevpageLinkCurrent']
      const prevElement = document.getElementById('pagination-prev')
      prevElement.removeAttribute('disabled')
      prevElement.href = firstUrl
      prevElement.innerText = '1'
      prevElement.classList.remove('hover:opacity-25', 'cursor-default')
      prevElement.classList.add('hover:opacity-100')
    }
  }

  _loadMore = async () => {
  // _loadMore = () => {
    if (!this.hasPaginationTarget || this.requestValue) {
      return
    }

    this.requestValue = true
    const prevPageValue = this.currentTarget.dataset['prevpage']
    const endlessUrl = new URL(this.paginationTarget.href)
    // endlessUrl.searchParams.set("endless", prevPageValue)
    endlessUrl.searchParams.append("endless", prevPageValue)
    // async fetch
    await get(endlessUrl.toString(), {responseKind: 'turbo-stream'})
    this.firstLinkAddIfNeeded()
    this.requestValue = false
  }

  intersectionChanged = (entry) => {
    if (entry.isIntersecting && this.hasPaginationTarget) {
      this.loaderTarget.classList.add("show")
      mOld = new Date().getTime();
      running = true      // timer start
      // clearInterval(intersections.get(entry.target))
      count = 2500        // set timer duration (2500 -> 2.5 sec)
      this.draw()
      intersections.set(entry.target, setInterval(() => {
        this.loaderTarget.classList.remove("show");
        running = false   // timer stop
        // console.log('Infinity scroll fired!')
        this._loadMore()
      }, 2500))
    } else if (!entry.isIntersecting && intersections.get(entry.target) != null) {
      this.loaderTarget.classList.remove("show");
      running = false     // timer stop
      // console.log('Timeout infinity scroll disabled!')
      clearInterval(intersections.get(entry.target))
    }
  }

  callback = (entries, observer) => {
    entries.forEach(entry => this.intersectionChanged(entry))
  }
}

Edition 3 (lazy load)

app/views/posts/index.html.erb

  <div id="posts" class="min-w-full">
    <%= render @posts %>
  </div>

  <%= turbo_frame_tag "pagination", src: posts_path(format: :turbo_stream), loading: :lazy %>
</div>

app/views/posts/index.turbo_stream.erb

<%= turbo_stream.append "posts" do %>
   <%= render partial: "posts/post", collection: @posts %>
 <% end %>
<% unless @pagy.page == @pagy.last%>
  <%= turbo_stream.replace "pagination" do %>
    <%= turbo_frame_tag "pagination", src: posts_path(page: @pagy.next, format: :turbo_stream), loading: :lazy %>
  <% end %>
<% end %>

app/controllers/posts_controller.rb

  def index
    # @pagy, @posts = pagy(Post.order(created_at: :desc), items: 5)
    respond_to do |format|
      format.html { @pagy, @posts = pagy_countless(Post.all) }
      format.turbo_stream { @pagy, @posts = pagy_countless(Post.all) }
    end
  end

config/initializers/pagy.rb

require 'pagy/extras/support'
require 'pagy/extras/countless'

@secretpray
Copy link
Author

document.addEventListener("turbo:load", () => {
  document.addEventListener("turbo:before-stream-render", function (event) {
    debugger
    console.log('turbo:load fired! ', event.target)
    event.target.target === 'notifications_list'
    this.location.pathname === '/'
    this.activeElement === btn (a with href)
    this.activeElement.dispatchEvent
    this.activeElement.offsetParent
    this.activeElement.offsetParent.scrollHeight -> 650
    this.activeElement.offsetParent.clientHeight -> 650
    this.activeElement.offsetParent.offsetHeight -> 650
    this.activeElement.offsetParent.id -> 'notifications'
    this.activeElement.offsetParent.lastElementChild -> div.notification-ui_dd-footer
    this.activeElement.offsetParent.firstElementChild -> title
    this.activeElement.offsetParent.scroll
    this.activeElement.offsetParent.scrollBy
    this.activeElement.offsetParent.scrollHeight -> 650
    this.activeElement.offsetParent.scrollIntoView
    this.activeElement.offsetParent.scrollIntoViewIfNeeded
    this.activeElement.offsetParent.scrollTo
    this.activeElement.offsetParent.scrollTop -> 0

  })
  document.addEventListener("notificationsList:append", function (event) {
    const list = document.querySelector('#notifications_list')
    list.scrollTop += 140
  })
});

scroll_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = [ "notifications" ]

  connect() {
    const list = this.notificationsTarget  // document.querySelector('#chat-container')
    const observer = new ResizeObserver(() => {
      if (list) {
        // list.scrollTop = list.scrollHeight
        list.scrollTop += 140
      }
    })

    for (var i = 0; i < list.children.length; i++) {
      observer.observe(list.children[i])
    }
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment