Infinite Scroll
The town crier's chronicle, loading more as it is scrolled. Entries other visitors add wait, so the list never moves while you read, and the address bar remembers how far down you are.
The town chronicle
Showing 120 of 300
- Quests A novice mage swore to clear the mine before supper.
- Trade A Sunmere fishwife raised the price of ale at the market.
- Weather Thick snow lay along the Sunmere road.
- Trade The smith raised the price of ale at the east gate.
- Weather A hard frost lay on the castle roofs.
- Quests A lone squire came back from the cellar with three rat tails without a word.
- Weather Thick snow settled across the cabbage fields.
- Trade A Sunmere fishwife sold forty cabbages by the mill.
- Gossip The gravedigger reckons the bridge troll pays rent though nobody believes it.
- Weather Thick snow crept in around the campfire.
- Weather A light rain lay across the cabbage fields.
- Sightings A goblin was seen digging on the castle wall.
- Weather A hard frost crept in over the pond.
- Quests A lone squire went looking for a lost letter by lantern light.
- Gossip The baker claims a dragon was seen and will not be told otherwise.
- Trade The smith sold forty cabbages at the market.
- Quests A party of adventurers took the bounty on the troll in the pouring rain.
- Quests An old ranger returned empty-handed before supper.
- Quests A novice mage swore to clear the mine by lantern light.
- Quests An old ranger swore to clear the mine before supper.
- Sightings A white stag was seen digging beside the pond.
- Quests The cook's apprentice came back from the cellar with three rat tails by lantern light.
- Sightings A white stag was caught dancing under the old bridge.
- Gossip A boy from Mossford swears the cellar is haunted as everyone already knew.
- Quests A novice mage set out to find the cabbage thief by lantern light.
- Trade The smith traded a bronze dagger for a pie at the east gate.
- Sightings A white stag was spotted dozing in the cabbage patch.
- Quests A novice mage came back from the cellar with three rat tails to much applause.
- Weather Fog rattled across the cabbage fields.
- Gossip The baker heard the well grants wishes though nobody believes it.
- Quests An old ranger returned empty-handed to much applause.
- Gossip A boy from Mossford heard the well grants wishes and will not be told otherwise.
- Weather Hail blew along the Sunmere road.
- Gossip The baker says the king eats nothing but cabbage or so the cook says.
- Sightings The pond's old pike was spotted dozing beside the pond.
- Sightings A goblin was seen digging under the old bridge.
- Weather Thick snow crept in along the Sunmere road.
- Trade The smith raised the price of ale at the docks.
- Gossip The gravedigger heard the well grants wishes and half the town agrees.
- Quests A novice mage took the bounty on the troll at dawn.
- Quests A party of adventurers set out to find the cabbage thief by lantern light.
- Quests A novice mage went looking for a lost letter without a word.
- Weather Hail crept in on the market stalls.
- Quests The ratcatcher went looking for a lost letter to much applause.
- Quests A novice mage took the bounty on the troll at dawn.
- Gossip A boy from Mossford says the king eats nothing but cabbage though nobody believes it.
- Weather Thick snow rattled along the Sunmere road.
- Sightings Somebody's goat was caught dancing in the cabbage patch.
- Quests A lone squire returned empty-handed to much applause.
- Quests An old ranger came back from the cellar with three rat tails by lantern light.
- Weather A hard frost rattled on the castle roofs.
- Quests An old ranger returned empty-handed before supper.
- Quests The cook's apprentice set out to find the cabbage thief to much applause.
- Sightings A gnome was seen digging by the campfire.
- Trade A Sunmere fishwife traded a bronze dagger for a pie on the Sunmere road.
- Sightings The pond's old pike was caught dancing on the castle wall.
- Gossip The miller's wife swears the cellar is haunted though nobody believes it.
- Trade The miller raised the price of ale on the Sunmere road.
- Quests An old ranger swore to clear the mine by lantern light.
- Gossip The baker says the king eats nothing but cabbage though nobody believes it.
- Quests A party of adventurers swore to clear the mine in the pouring rain.
- Gossip The baker insists the smith cheats at dice as everyone already knew.
- Trade The innkeeper paid double for dry firewood at the market.
- Quests The ratcatcher came back from the cellar with three rat tails before supper.
- Weather A light rain lay on the market stalls.
- Quests A lone squire returned empty-handed to much applause.
- Quests A novice mage came back from the cellar with three rat tails without a word.
- Gossip The innkeeper reckons the bridge troll pays rent or so the cook says.
- Quests A party of adventurers took the bounty on the troll in the pouring rain.
- Gossip The gravedigger claims a dragon was seen as everyone already knew.
- Sightings A cloaked stranger was seen digging behind the inn.
- Weather Hail settled on the castle roofs.
- Weather A warm wind blew on the castle roofs.
- Quests An old ranger swore to clear the mine in the pouring rain.
- Trade A Sunmere fishwife raised the price of ale outside the general store.
- Weather A hard frost crept in on the market stalls.
- Quests An old ranger came back from the cellar with three rat tails to much applause.
- Sightings Somebody's goat was seen mining by the campfire.
- Quests A lone squire set out to find the cabbage thief to much applause.
- Sightings A goblin was seen mining behind the inn.
- Gossip The miller's wife says the king eats nothing but cabbage but only after two ales.
- Trade The innkeeper paid double for dry firewood outside the general store.
- Quests The ratcatcher took the bounty on the troll by lantern light.
- Sightings A goblin was caught dancing in the cabbage patch.
- Weather Fog lay across the cabbage fields.
- Sightings Somebody's goat was seen digging behind the inn.
- Weather A hard frost settled around the campfire.
- Trade The miller bought a cart of iron ore on the Sunmere road.
- Sightings A white stag was spotted dozing under the old bridge.
- Gossip A boy from Mossford insists the smith cheats at dice as everyone already knew.
- Quests A novice mage took the bounty on the troll before supper.
- Trade A travelling merchant traded a bronze dagger for a pie at the docks.
- Trade A travelling merchant raised the price of ale outside the general store.
- Sightings The pond's old pike was spotted dozing beside the pond.
- Sightings A white stag was caught dancing in the cabbage patch.
- Quests An old ranger came back from the cellar with three rat tails at dawn.
- Quests The ratcatcher set out to find the cabbage thief without a word.
- Sightings A cloaked stranger was caught dancing on the castle wall.
- Gossip The innkeeper claims a dragon was seen but only after two ales.
- Trade A travelling merchant bought a cart of iron ore outside the general store.
- Trade A Sunmere fishwife sold forty cabbages outside the general store.
- Trade The smith bought a cart of iron ore by the mill.
- Sightings A white stag was seen digging under the old bridge.
- Sightings A cloaked stranger was spotted dozing in the cabbage patch.
- Trade A Sunmere fishwife sold forty cabbages by the mill.
- Trade A gnome pedlar bought a cart of iron ore by the mill.
- Weather Hail came down along the Sunmere road.
- Quests The cook's apprentice swore to clear the mine in the pouring rain.
- Gossip The baker claims a dragon was seen but only after two ales.
- Trade A gnome pedlar raised the price of ale at the docks.
- Sightings A cloaked stranger was seen mining behind the inn.
- Quests An old ranger took the bounty on the troll before supper.
- Sightings The pond's old pike was heard arguing behind the inn.
- Gossip The gravedigger reckons the bridge troll pays rent or so the cook says.
- Sightings A gnome was found singing under the old bridge.
- Gossip A guard at the gate claims a dragon was seen and half the town agrees.
- Quests The ratcatcher set out to find the cabbage thief without a word.
- Weather A light rain came down on the market stalls.
- Weather A hard frost lay on the castle roofs.
- Trade The miller sold forty cabbages outside the general store.
# frozen_string_literal: true
# One visitor's reading of the town chronicle. The states are the kind, how
# many loads the box opened with, the newest entry it has caught up to, the
# last row shown when the sentinel last fired, and a number that goes up
# when anyone writes an entry. The box is rendered whole when the list
# starts over; a load renders only its twenty rows, in place of the
# sentinel, so the rows above it are never sent again.
class Recipes::Scroll::ScrollChannel < ApplicationCable::Channel
include Hibiki::Rails::Channel
Entry = Recipes::Scroll::Entry
Chronicle = Recipes::Scroll::Chronicle
VIEWS = "app/recipes/scroll"
def build_graph
# The tab subscribes with the reading its page was rendered from, head
# included, so the two agree on which entry is the newest.
start = Chronicle.from(params, head: params[:head])
@version = Hibiki::State.new(0)
@kind = Hibiki::State.new(start.kind)
@page = Hibiki::State.new(start.page)
@head = Hibiki::State.new(start.head)
@before = Hibiki::State.new(nil)
@jumps = Hibiki::State.new(0)
@chronicle = Hibiki::Derived.new { Chronicle.new(kind: @kind.value, page: @page.value, head: @head.value) }
# The rows the box opens with, and the twenty a load adds under them.
# Neither reads the version: rows already sent stay as they were.
first = Hibiki::Derived.new { @chronicle.value.lines }
load = Hibiki::Derived.new { (before = @before.value) && @chronicle.value.below(before) }
# The id of the last row on screen, which the next load starts below.
@last = Hibiki::Derived.new { load.value&.last&.id || @before.value || first.value.last&.id }
# These read the version, so a ping makes them ask again, and answer
# with plain values, so an answer equal to the last one stops there.
# An entry above the head changes `fresh` and nothing else.
matched = Hibiki::Derived.new { @version.value; @chronicle.value.matched }
shown = Hibiki::Derived.new { @version.value; @chronicle.value.shown(@last.value) }
loads = Hibiki::Derived.new { @version.value; @chronicle.value.loads(@last.value) }
fresh = Hibiki::Derived.new { @version.value; @chronicle.value.fresh }
# The whole box, when the list starts over. Its first run also puts a
# reconnected tab's box back to the reading the graph starts from.
Hibiki::Effect.new do
chronicle, lines = @chronicle.value, first.value
broadcast_morph target: "scroll_box", partial: "#{VIEWS}/box",
locals: { lines:, kind: chronicle.kind, page: chronicle.window,
remaining: chronicle.matched - lines.size, jumps: @jumps.value }
end
# One load, in place of the sentinel it answers. Its rows and a new
# sentinel go under the rows already shown. It must not read the
# version: run again for a ping, it would add the same rows twice.
Hibiki::Effect.new do
next unless (lines = load.value)
chronicle, last = @chronicle.value, @last.value
broadcast_replace target: "scroll_more", partial: "#{VIEWS}/load",
locals: { lines:, kind: chronicle.kind, page: chronicle.loads(last),
remaining: chronicle.matched - chronicle.shown(last) }
end
Hibiki::Effect.new do
broadcast_morph target: "scroll_fresh", partial: "#{VIEWS}/fresh", locals: { fresh: fresh.value }
end
transmit_value(:scroll_count) { "#{shown.value} of #{matched.value}" }
transmit_url do
urls.app_recipe_path("scroll", **@chronicle.value.with(page: loads.value).to_query)
end
end
# The sentinel names the last row above it. A load is taken only when
# that row is still the last: a sentinel that comes into view twice
# before the first reply lands, or one left over from a list that has
# since started over, names a row that no longer is.
def more(data)
before = data["before"].to_i
@before.value = before if before == @last.value
end
def set_kind(data)
@kind.value = data["kind"].to_s
start_over
end
# The bell. The tab that rang it catches up, so it sees its own entry;
# every other tab hears the ping and counts one more waiting.
def ring
Entry.ring!
catch_up
end
# Moves the head to the newest entry and goes back to the top.
def catch_up
@head.value = Entry.head
start_over
end
private
# The first load again, with the box scrolled to its top.
def start_over
@page.value = 1
@before.value = nil
@jumps.value += 1
end
def subscribed
super
return if subscription_rejected?
# An entry written by anyone. The block runs on a cable thread, so it
# hops onto the graph thread before touching a signal.
stream_from Entry::CHANGED do
graph_actor&.post { @version.value += 1 }
end
end
def urls = Rails.application.routes.url_helpers
end
# frozen_string_literal: true
# The chronicle as one visitor reads it: a kind, how many loads the page
# opened with, and the newest entry they have caught up to (the head).
# `from` reads a query string (or the params a tab subscribes with) and
# trusts none of it; `to_query` writes one back.
#
# The page opens with the first `page * PER` entries at or below the head.
# Each load after that is the twenty below the last row shown, so a row is
# found by its id, never by how many came before it. Entries above the head
# are only counted, so a list being read never moves.
class Recipes::Scroll::Chronicle < Data.define(:kind, :page, :head)
Entry = Recipes::Scroll::Entry
ALL = "all"
# The head is not in the address bar: a reload starts from the newest.
def self.from(params, head: Entry.head)
new(kind: params[:kind].to_s, page: params[:page].to_i, head: head.to_i.clamp(0, Entry.head))
end
# A kind the crier does not write falls back to everything.
def initialize(kind: ALL, page: 1, head: Entry.head)
super(kind: Entry::KINDS.key?(kind) ? kind : ALL, page: [ page, 1 ].max, head:)
end
# How many entries this reading can reach.
def matched = entries.count
# How many loads it takes to show them all, and the page clamped to that.
def pages = [ matched.fdiv(Entry::PER).ceil, 1 ].max
def window = page.clamp(1, pages)
# The rows the page opens with, newest first.
def lines = entries.newest.limit(window * Entry::PER).map(&:line)
# One load: the twenty rows below the row with this id.
def below(id) = entries.where(id: ...id).newest.limit(Entry::PER).map(&:line)
# How many rows are shown when the row with this id is the last, and how
# many loads that makes.
def shown(last) = last ? entries.where(id: last..).count : 0
def loads(last) = [ shown(last).fdiv(Entry::PER).ceil, 1 ].max
# Entries written since the head.
def fresh = Entry.of(kind).where(id: (head + 1)..).count
# The address of this reading, defaults left out.
def to_query = { kind:, page: window }.reject { |name, value| value == { kind: ALL, page: 1 }[name] }
private
def entries = Entry.of(kind).where(id: ..head)
end
# frozen_string_literal: true
# An entry in the town crier's chronicle in the Infinite Scroll recipe.
# There is one chronicle, read newest first, and every visitor who rings
# the bell adds an entry to it. No page is mentioned here. A commit sends an
# empty ping, and whoever is reading asks again.
#
# Recipes::Reset puts the starting chronicle back each night, and `ring!`
# caps the table in between. An entry holds nothing a visitor typed.
class Recipes::Scroll::Entry < ApplicationRecord
self.table_name = "recipes_scroll_entries"
# A line of the chronicle: a plain copy of a row, compared by what it says.
Line = Data.define(:id, :kind, :body, :at)
# What the crier writes about, and what each is called on the page.
KINDS = { "sighting" => "Sightings", "trade" => "Trade", "quest" => "Quests",
"weather" => "Weather", "gossip" => "Gossip" }.freeze
# How many entries one load adds, how many the chronicle starts with, and
# how many `ring!` leaves.
PER = 20
SEEDED = 300
CAP = 400
PARTS_FILE = Rails.root.join("db/seeds/chronicle.yml")
# Every open chronicle listens here for an entry written by anyone.
CHANGED = "recipes:scroll:entries"
after_commit { self.class.announce }
scope :newest, -> { order(id: :desc) }
# The entries of one kind, or all of them.
def self.of(kind) = KINDS.key?(kind) ? where(kind:) : all
# The id of the newest entry. Ids only go up, so this marks a place in
# the chronicle that later entries do not move.
def self.head = maximum(:id).to_i
# A ping with nothing in it. Whoever hears it asks again.
def self.announce = ActionCable.server.broadcast(CHANGED, {})
# An entry made up from db/seeds/chronicle.yml: a kind, and one phrase
# from each of its lists.
def self.compose(random = Random.new)
kind = KINDS.keys.sample(random:)
{ kind:, body: "#{parts.fetch(kind).values.map { it.sample(random:) }.join(" ")}." }
end
def self.parts = @parts ||= YAML.load_file(PARTS_FILE)
# The bell: one new entry. The oldest rows go in the same transaction, so
# there is one ping.
def self.ring!
transaction do
create!(**compose).tap { prune! }
end
end
# Writes the starting chronicle into an empty table, an hour between
# entries and the last of them an hour ago. The same seed composes the
# same entries. Safe to run again.
def self.seed!
return if exists?
random, now = Random.new(1), Time.current
insert_all(Array.new(SEEDED) do |n|
at = now - (SEEDED - n).hours
compose(random).merge(created_at: at, updated_at: at)
end)
end
# `delete_all` and `insert_all` run no callback, so the ping is sent by
# hand.
def self.reset!
transaction do
delete_all
seed!
end
announce
end
# Drops all but the newest rows.
def self.prune! = unscoped.where.not(id: unscoped.order(id: :desc).limit(CAP).select(:id)).delete_all
def line = Line.new(id:, kind:, body:, at: created_at)
end
<%# The page reads the same Chronicle the channel does, and hands it the
reading as it subscribes, head included. The filter and the bell are
rendered here and never again. %>
<% chronicle = Recipes::Scroll::Chronicle.from(params) %>
<% lines, matched = chronicle.lines, chronicle.matched %>
<%= island Recipes::Scroll::ScrollChannel, params: chronicle.to_query.merge(head: chronicle.head),
class: "panel mx-auto w-full max-w-2xl p-4 sm:p-5" do %>
<header class="flex items-baseline justify-between gap-2">
<h2 class="text-xl font-bold">The town chronicle</h2>
<p class="text-sm opacity-70">Showing <%= reactive :scroll_count, "#{lines.size} of #{matched}" %></p>
</header>
<div class="mt-3 flex flex-wrap items-center gap-2">
<%= tag.select name: "kind", "aria-label": "Kind", class: "select select-sm w-auto py-0",
**on(:set_kind, event: :change) do %>
<%= options_for_select [ [ "Everything", Recipes::Scroll::Chronicle::ALL ], *Recipes::Scroll::Entry::KINDS.invert.to_a ],
chronicle.kind %>
<% end %>
<span class="loading loading-spinner loading-xs hidden in-data-hibiki-busy:inline-block" aria-hidden="true"></span>
<%= tag.button "Ring the bell", id: "scroll_ring", type: "button", class: "btn btn-primary btn-sm ml-auto", **on(:ring) %>
</div>
<%= render "app/recipes/scroll/fresh", fresh: chronicle.fresh %>
<%= render "app/recipes/scroll/box", lines:, kind: chronicle.kind, page: chronicle.window,
remaining: matched - lines.size %>
<% end %>
<%# locals: (lines:, kind:, page:, remaining:, jumps: 0) -%>
<%# Rendered whole when the list starts over: the rows the page opens with,
as one load. Later loads go in at the sentinel, under these.
`props` puts the box back at its top when `jumps` goes up (a new kind,
or catching up) and leaves the visitor's scrolling alone otherwise. %>
<%= tag.div id: "scroll_box", tabindex: 0, role: "region", "aria-label": "Chronicle entries",
class: "mt-2 h-96 overflow-y-auto rounded-box border-2 border-base-300 bg-base-100",
**props(scroll_top: (0 if jumps.positive?), key: jumps) do %>
<% if lines.empty? %>
<p id="scroll_empty" class="px-3 py-14 text-center text-sm opacity-70">The crier has nothing of that kind to report.</p>
<% else %>
<div id="scroll_entries" class="divide-y-2 divide-base-300">
<%= render "app/recipes/scroll/load", lines:, kind:, page:, remaining: %>
</div>
<% end %>
<% end %>
<%# locals: (lines:, kind:, page:, remaining:) -%>
<%# One load: its rows, then the sentinel for the next or the end of the
chronicle. The channel renders it in place of the sentinel it answers,
so it lands under the rows already shown. %>
<% if lines.any? %>
<ul id="scroll_load_<%= lines.first.id %>" class="divide-y-2 divide-base-300">
<% lines.each do |line| %>
<li id="scroll_entry_<%= line.id %>" class="flex items-baseline gap-3 px-3 py-2 text-sm">
<span class="badge badge-sm badge-neutral w-20 shrink-0"><%= Recipes::Scroll::Entry::KINDS[line.kind] %></span>
<span class="min-w-0 flex-1"><%= line.body %></span>
<time class="shrink-0 text-xs whitespace-nowrap tabular-nums opacity-60"><%= line.at.strftime("%-d %b, %H:%M") %></time>
</li>
<% end %>
</ul>
<% end %>
<% if remaining.positive? && lines.any? %>
<%= render "app/recipes/scroll/more", kind:, page:, remaining:, last: lines.last %>
<% else %>
<p id="scroll_end" class="px-3 py-3 text-center text-xs opacity-60">That is as far back as the chronicle goes.</p>
<% end %>
<%# locals: (kind:, page:, remaining:, last:) -%>
<%# Two controls for one action. The wrapper is the sentinel: `visible`
fires when it scrolls into view, and names the last row above it. The
link inside is the click, and its href is what happens with no socket:
the same page with one more load, opened at the last row already read.
The sentinel itself must never fall back, or scrolling to it on a dead
socket would navigate. %>
<% href = app_recipe_path("scroll", kind: (kind unless kind == Recipes::Scroll::Chronicle::ALL), page: page + 1,
anchor: "scroll_entry_#{last.id}") %>
<%= tag.div id: "scroll_more", class: "flex justify-center p-2",
**on(:more, event: :visible, with: { before: last.id }) do %>
<%= link_to href, class: "btn btn-ghost btn-xs",
data: { turbo: false, **on(:more, with: { before: last.id }, fallback: true)[:data] } do %>
Load more <span class="opacity-60">· <%= remaining %> remaining</span>
<% end %>
<% end %>
<%# locals: (fresh:) -%>
<%# Morphed by the channel when the count of entries above this tab's head
changes. They wait here until the visitor asks for them. %>
<div id="scroll_fresh" class="mt-2 flex min-h-6 justify-center">
<% if fresh.positive? %>
<%= tag.button id: "scroll_catch_up", type: "button", class: "btn btn-accent btn-xs", **on(:catch_up) do %>
<%= pluralize fresh, "new entry" %>
<% end %>
<% end %>
</div>
The panel above is the town chronicle, a few hundred entries that auto-load as you scroll to the bottom. The count in the corner and the address bar update as you scroll, so a reload opens the chronicle as far down as you had read.
Try opening this page in a second window and ring the bell there. The list in this window does not move. A count of new entries appears above the box, and they come in when you press it. We wrote no JavaScript for any of this: everything works from reactivity defined in the channel. The chronicle is shared by every visitor and resets each night.
How it fits together
An island is the part of the page Hibiki looks after, here the whole panel. It belongs to a channel: the server side of an Action Cable connection, an object that stays alive while the page is open, one for each tab. The channel's public methods are its actions, the methods the page may call, and its states are values that remember who read them. Here the states are the kind; the newest entry this tab has caught up to, which we call the head; how many loads the box opened with; and the last row on screen when the box asked for more.
Chronicle is a plain value object built from the first
three. It asks the database for the rows, the count and the number of
entries waiting, and the page renders from it first, the channel after,
so the two agree. In the channel it is a derived: a value
computed from the states, computed again when one of them changes.
Three effects read from it, one rendering the whole box, one a
load of twenty rows, and one the count of new entries. This is what
happens when you scroll to the foot:
-
The foot of the list comes into view. The client calls
morewith the id of the last row above it. -
The action writes that id into the
@beforestate, if that row is still the last one on screen. It renders nothing. - A derived asks the database for the twenty rows below that id. The effect that read them renders those rows with a new sentinel under them, and sends them in place of the sentinel that asked. They land under the rows already shown, which are left as they are.
-
The count and the address bar read the last row shown and follow, by
transmit_valueandtransmit_url.
The sentinel
<%# The wrapper is the sentinel. It names the last row above it. %>
<%= tag.div id: "scroll_more", **on(:more, event: :visible, with: { before: last.id }) do %>
<%# The link is the click, and its href is the way with no socket. %>
<%= link_to "Load more", href, data: { turbo: false, **on(:more, with: { before: last.id }, fallback: true)[:data] } %>
<% end %>
Something has to notice that the visitor has reached the foot. Here it
is the wrapper around the "Load more" link, which sends its action on
visible. That is not a DOM event: the client watches the
element with an IntersectionObserver and performs the
action when it scrolls into view. It fires once, and the load that
answers brings a new sentinel under its rows, so if twenty more rows do
not push that one out of view, it fires too, and a tall box fills
itself.
A sentinel can come into view twice before the first answer lands, which is why it names the last row above it. The action takes a load only while that row is still the last one on screen. By the time the second request arrives, the first has put twenty rows under that row, so the request is ignored. A sentinel left over from a list that has since started over is turned away the same way.
The link inside is a second control for the same action. A click sends
it over the socket. With no socket, the browser follows the link
instead, and its address is this page with one more load, opened at
the last row already read. Only the link carries
fallback: true, because on the wrapper it would make the
browser follow the link and leave the page whenever the visitor
scrolled to the foot while offline.
Appending a load
# The value object. One load is the twenty rows below the last one shown.
def below(id) = entries.where(id: ...id).newest.limit(Entry::PER).map(&:line)
def entries = Entry.of(kind).where(id: ..head)
# The channel. The action takes a load only while the row it names is
# still the last on screen.
def more(data)
before = data["before"].to_i
@before.value = before if before == @last.value
end
load = Hibiki::Derived.new { (before = @before.value) && @chronicle.value.below(before) }
# The rows and a new sentinel replace the sentinel that asked for them.
Hibiki::Effect.new do
next unless (lines = load.value)
broadcast_replace target: "scroll_more", partial: "#{VIEWS}/load", locals: { lines:, ... }
end
A load is the twenty rows below the last one shown.
Chronicle#below finds them by the id the sentinel sent, and
the effect that reads them renders them with a new sentinel and puts
the lot where the old sentinel stood, by broadcast_replace.
(There is no append helper; replacing the sentinel is how a load adds
rows, as a lazy frame does in stock Rails.) The rows above are never
sent again, so a load far down the chronicle is the same size as the
first.
The rows are found by id, which serves as the cursor. Counting rows from the top would also work here, since the head keeps new entries out of the list, but the id does two more things. The database reaches a load deep in the chronicle as quickly as the first, and the action can tell a request for the current foot from a stale one, as the sentinel section showed.
The load effect leaves alone the @version state that goes
up when anyone writes an entry. If it read that too, each entry anyone
wrote would run it again and add the same twenty rows a second time.
The whole box is rendered only when the list starts over, on a new kind
or a catch-up: the first load is morphed into the box, and later loads
begin from its last row.
New entries wait
# The value object. Entries above the head are counted, not shown.
def fresh = Entry.of(kind).where(id: (head + 1)..).count
# The channel. A ping from anyone's bell makes the count ask again.
fresh = Hibiki::Derived.new { @version.value; @chronicle.value.fresh }
Hibiki::Effect.new do
broadcast_morph target: "scroll_fresh", partial: "#{VIEWS}/fresh", locals: { fresh: fresh.value }
end
# The pill's action. The rows follow, since they read the head.
def catch_up
@head.value = Entry.head
start_over
end
A row added at the top of a list pushes down whatever is being read. So each tab holds a head, the id of the newest entry it had when its page was rendered, and its rows are the ones at or below it. Ids only go up, so the head marks a place in the chronicle that later entries do not move. (It is not in the address bar, so a reload starts from the newest entry.)
When anyone writes an entry, the model's after_commit
broadcasts an empty message, and each channel that hears it adds one
to a @version state. The counts read that state too, so
they ask the database again; the rows never do. For a tab reading below
the new entry only fresh has changed, so the pill is
rendered and the box is left alone. Pressing it
moves the head to the newest entry and starts the list over. The tab
that rang the bell does the same on its own, so it sees its entry at
once.
Back to the top
<%# `jumps` goes up when the kind changes or the tab catches up. The client
sets scrollTop when this helper's output changes, and at no other time. %>
<%= tag.div id: "scroll_box", class: "h-96 overflow-y-auto",
**props(scroll_top: (0 if jumps.positive?), key: jumps) do %>
...
<% end %>
A new kind and a catch-up both start the list over, and the box should
show its top then. A scroll position is a DOM property rather than an
attribute, so a rendered fragment on its own cannot set it; the
props helper can. The client assigns
scrollTop when the helper's output changes and leaves the
visitor's own scrolling alone otherwise. Here jumps goes
up on a new kind or a catch-up. A load replaces the sentinel and never
renders the box, so the box stays where it was.
The same feature in stock Rails
Plain infinite scroll needs no JavaScript in stock Rails either. A
turbo-frame with loading="lazy" at the foot of
each page fetches the next one when it scrolls into view, and it works
with no socket at all. What costs more is everything around it in this
example. Here is a sketch. It is not run here, and it leaves out the
cap on the table.
class EntriesController < ApplicationController
def index
# The head and the cursor travel in every URL. Paging by offset would
# show a row twice each time an entry is added above it.
@kind = params[:kind].presence_in(Entry::KINDS)
@head = (params[:head] || Entry.maximum(:id)).to_i
reach = Entry.of(@kind).where(id: ..@head)
@entries = reach.then { params[:before] ? it.where(id: ...params[:before].to_i) : it }
.newest.limit(Entry::PER).to_a
@matched = reach.count
@shown = @entries.any? ? reach.where(id: @entries.last.id..).count : @matched
# A lazy frame asks for the next page only.
render partial: "page" if turbo_frame_request?
end
def create
Entry.create!(**Entry.compose)
redirect_to entries_path(kind: params[:kind]), status: :see_other
end
end
<%= turbo_stream_from :entries %>
<p>Showing <span id="shown"><%= @shown %></span> of <%= @matched %></p>
<%# Changing the kind is a visit, which starts the list again. %>
<%= form_with url: entries_path, method: :get do |form| %>
<%= form.select :kind, Entry::KINDS, { include_blank: "Everything", selected: @kind },
onchange: "this.form.requestSubmit()" %>
<% end %>
<%= button_to "Ring the bell", entries_path(kind: @kind) %>
<div data-controller="fresh" data-fresh-kind-value="<%= @kind %>">
<%# Catching up is the same visit: a new head, the first page. %>
<%= link_to entries_path(kind: @kind), hidden: true, data: { fresh_target: "pill" } do %>
<span data-fresh-target="count">0</span> new entries
<% end %>
<div id="arrivals" hidden></div>
</div>
<div class="h-96 overflow-y-auto">
<%= render "page" %>
</div>
<% @entries.each do |entry| %>
<div id="<%= dom_id entry %>"><%= entry.body %></div>
<% end %>
<%# A frame response cannot change anything outside its frame, except by
carrying a stream, which runs when it lands in the page. %>
<% if turbo_frame_request? %>
<%= turbo_stream.update "shown", @shown %>
<% end %>
<%# The next page is a lazy frame at the foot of this one: it loads when it
scrolls into view, and its answer holds the frame after it. No
JavaScript. The link inside is what shows until then. %>
<% if @shown < @matched %>
<% following = entries_path(kind: @kind, head: @head, before: @entries.last.id) %>
<%= turbo_frame_tag "page_#{@entries.last.id}", src: following, loading: :lazy do %>
<%= link_to "Load more", following %>
<% end %>
<% end %>
class Entry < ApplicationRecord
PER = 20
scope :newest, -> { order(id: :desc) }
scope :of, ->(kind) { kind.present? ? where(kind:) : all }
# Every reader has a head of their own, so no "3 new entries" can be
# rendered here and sent to all of them. What is sent is a hidden mark
# for the new entry, and each page counts the marks itself.
after_create_commit do
broadcast_append_to :entries, target: "arrivals",
html: ApplicationController.helpers.tag.i(hidden: true,
data: { fresh_target: "arrival", kind: })
end
end
import { Controller } from "@hotwired/stimulus"
// Counts the entries that arrived after this page was rendered, and shows
// the pill. The server cannot do it: the count depends on this tab's kind
// and on when this tab last caught up.
export default class extends Controller {
static targets = ["arrival", "pill", "count"]
static values = { kind: String }
// Runs for each mark the broadcast appends.
arrivalTargetConnected() {
const mine = this.arrivalTargets.filter((mark) => !this.kindValue || mark.dataset.kind === this.kindValue)
this.countTarget.textContent = mine.length
this.pillTarget.hidden = mine.length === 0
}
}
resources :entries, only: %i[index create]
The table lists each behaviour of the demo above and where it comes from.
| Stock Rails | Hibiki | |
|---|---|---|
| Load when the foot comes into view | A lazy turbo-frame in each page |
event: :visible on a wrapper |
| No row twice when an entry is added | A head and a cursor, carried in every frame URL | A head and a cursor, kept as states in the channel |
| "3 new entries" | A broadcast of a hidden mark for each entry, and a Stimulus controller that counts the ones of this tab's kind | A derived count and a partial |
| "Showing 40 of 300" | A stream inside each frame response; nothing moves it when a row is removed | transmit_value |
| Changing the kind | A form and a visit | An action that writes the kind and starts the list over |
| The address bar after scrolling | Not kept. A frame can advance the URL, but to its own cursor, which reloads as one page | transmit_url |
| With no socket | Scrolling still loads; only the new-entries count stops | Scrolling stops; the link loads one more by a visit |
| A load far down the list | One query for twenty rows, and twenty rows sent | The same: one query for twenty rows, sent in place of the sentinel |
Both versions append twenty rows a load, and both find them from a head and a cursor. What differs is where those two live. A lazy frame is plain HTTP, so it carries them in every URL, works out the count of new entries in the browser, and keeps loading with the socket down. Here they are states in the channel, and the count, the address bar and the pill are values derived from them.