Leave and Enter Transitions
A town notice board that anyone pins to and tears from. A notice you tear down slides away. Notes that another visitor tears, or a filter hides, are gone at once. The motion is plain CSS, modulized as one import.
The town notice board
# frozen_string_literal: true
# One visitor's reading of the town notice board. Nothing here mentions
# motion. The channel renders what the board holds now, and the marks in
# the partials say how what is gone should leave.
class Recipes::Motion::MotionChannel < ApplicationCable::Channel
include Hibiki::Rails::Channel
Notice = Recipes::Motion::Notice
Board = Recipes::Motion::Board
VIEWS = "app/recipes/motion"
# The notice being written, and a line said once after it is pinned.
Draft = Data.define(:kind, :body)
Flash = Data.define(:id, :text)
def build_graph
@version = Hibiki::State.new(0)
@kind = Hibiki::State.new(Board::ALL)
@draft = Hibiki::State.new(nil)
@flash = Hibiki::State.new(nil)
@pins = 0
@board = Hibiki::Derived.new { Board.new(kind: @kind.value) }
kinds = Hibiki::Derived.new { @version.value; @board.value.kinds }
notices = Hibiki::Derived.new { @version.value; @board.value.notices }
# The filter and the rows are one fragment, so a notice torn down is
# one render, and it is the first to follow the click.
Hibiki::Effect.new do
broadcast_morph target: "motion_board", partial: "#{VIEWS}/board",
locals: { kinds: kinds.value, kind: @board.value.kind, notices: notices.value }
end
Hibiki::Effect.new do
broadcast_morph target: "motion_pin", partial: "#{VIEWS}/pin", locals: { draft: @draft.value, flash: @flash.value }
end
end
def filter(data) = @kind.value = data["kind"].to_s
def open
@flash.value = nil
@draft.value ||= draft(Notice::KINDS.first)
end
def cancel = @draft.value = nil
# Another wording, of the kind asked for or the kind it has.
def reword(data)
return unless (current = @draft.value)
kind = Notice::KINDS.include?(data["kind"]) ? data["kind"] : current.kind
@draft.value = draft(kind, except: current.body)
end
def pin
return unless (draft = @draft.value)
Notice.pin(draft.kind, draft.body)
@draft.value = nil
@flash.value = Flash.new(id: @pins += 1, text: "Pinned under #{Board::NAMES[draft.kind]}.")
@version.value += 1
end
# The row goes in this reply. The model's ping follows, finds the same
# board and sends nothing.
def tear(data)
Notice.tear(data["id"])
@version.value += 1
end
private
def draft(kind, except: nil) = Draft.new(kind:, body: Notice.compose(kind, except:))
def subscribed
super
return if subscription_rejected?
# A write by anyone. The block runs on a cable thread, so it hops onto
# the graph thread before touching a signal.
stream_from Notice::CHANGED do
graph_actor&.post { @version.value += 1 }
end
end
end
# frozen_string_literal: true
# The town board as one visitor reads it: every notice, or those of one
# kind. The page renders its first board from this, and the channel every
# board after. Both answers are plain values.
class Recipes::Motion::Board < Data.define(:kind)
Notice = Recipes::Motion::Notice
ALL = Notice::ALL
# What each kind is called on the page.
NAMES = { "wanted" => "Wanted", "sale" => "For sale", "lost" => "Lost" }.freeze
# A kind the board does not have falls back to everything.
def initialize(kind: ALL) = super(kind: Notice::KINDS.include?(kind) ? kind : ALL)
# How many notices of each kind are pinned. The filter is not asked.
def kinds = Notice::KINDS.index_with(0).merge(Notice.group(:kind).count)
# The notices this visitor looks at, the newest first.
def notices = Notice.of(kind).order(id: :desc).map(&:entry)
end
# frozen_string_literal: true
# A notice on the town board in the Leave and Enter Transitions recipe.
# There is one board, and every visitor pins to it and tears from it. No
# page is mentioned here. A commit sends an empty ping, and whoever is
# reading the board reads it again.
#
# A notice is never typed. It is put together from the phrases in
# db/seeds/notices.yml, so nothing a visitor writes is shown to another.
# Recipes::Reset puts the starting board back each night, and `pin` caps
# the table in between.
class Recipes::Motion::Notice < ApplicationRecord
self.table_name = "recipes_motion_notices"
# A line of the board: a plain copy of a row, compared by what it says.
Entry = Data.define(:id, :kind, :body)
KINDS = %w[wanted sale lost].freeze
ALL = "all"
# How many notices the board starts with, and how many `pin` leaves.
START = 12
KEEP = 24
SEED_FILE = Rails.root.join("db/seeds/notices.yml")
# Every open board listens here for a write by anyone.
CHANGED = "recipes:motion:notices"
after_commit { self.class.announce }
# The notices of one kind, or all of them.
def self.of(kind) = KINDS.include?(kind) ? where(kind:) : all
# A ping with nothing in it. Whoever hears it reads the board again.
def self.announce = ActionCable.server.broadcast(CHANGED, {})
def self.phrases = @phrases ||= YAML.load_file(SEED_FILE).freeze
# A wording for a notice of this kind, never the one in `except`.
def self.compose(kind, random: Random, except: nil)
loop do
body = phrases.fetch(kind).map { it.sample(random:) }.then { |what, where, word| "#{what} #{where}. #{word}" }
return body unless body == except
end
end
# Pins a notice. The oldest go in the same transaction, so there is one
# ping.
def self.pin(kind, body)
return unless KINDS.include?(kind)
transaction do
create!(kind:, body:).tap { prune! }
end
end
# Takes a notice off the board, if it is still there.
def self.tear(id) = find_by(id:)&.destroy!
# Fills an empty board with the starting notices. The random numbers are
# the same every time, so the board is too. Safe to run again.
def self.seed!
return if exists?
random = Random.new(16)
insert_all(Array.new(START) { KINDS[it % KINDS.size] }.map { |kind| { kind:, body: compose(kind, random:) } })
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(KEEP).select(:id)).delete_all
def entry = Entry.new(id:, kind:, body:)
end
<%# The page reads the same Board the channel does. The heading and its
button are rendered here and never again. %>
<% board = Recipes::Motion::Board.new %>
<%= island Recipes::Motion::MotionChannel, class: "panel mx-auto w-full max-w-2xl p-4 sm:p-5" do %>
<header class="mb-3 flex items-center gap-2">
<h2 class="text-xl font-bold">The town notice board</h2>
<span class="loading loading-spinner loading-xs hidden in-data-hibiki-busy:inline-block" aria-hidden="true"></span>
<%= tag.button "Pin a notice", id: "motion_open", type: "button", class: "btn btn-primary btn-sm ml-auto", **on(:open) %>
</header>
<%= render "app/recipes/motion/pin", draft: nil, flash: nil %>
<%= render "app/recipes/motion/board", kinds: board.kinds, kind: board.kind, notices: board.notices %>
<% end %>
<%# locals: (kinds:, kind:, notices:) -%>
<%# Morphed by the channel when the board changes or this tab picks a
kind. Rows keep their ids, so the ones that stay are left alone. The
counts are of the whole board, whatever the tab is looking at. %>
<div id="motion_board">
<div id="motion_kinds" class="flex flex-wrap gap-1.5" role="group" aria-label="Show a kind">
<% { Recipes::Motion::Board::ALL => kinds.values.sum, **kinds }.each do |name, count| %>
<%= tag.button id: "motion_kind_#{name}", type: "button", "aria-pressed": kind == name,
class: [ "btn btn-xs", { "btn-primary": kind == name } ], **on(:filter, with: { kind: name }) do %>
<%= Recipes::Motion::Board::NAMES.fetch(name, "All") %> <b class="tabular-nums"><%= count %></b>
<% end %>
<% end %>
</div>
<ul id="motion_notices" class="mt-3 max-h-128 overflow-x-hidden overflow-y-auto border-t-2 border-base-300">
<% notices.each do |notice| %>
<%= render "app/recipes/motion/notice", notice: %>
<% end %>
</ul>
<% if notices.empty? %>
<p id="motion_empty" data-motion class="hbk-fade px-2 py-6 text-center text-sm opacity-70">
Nothing is pinned here. The board is bare wood.
</p>
<% end %>
</div>
<%# locals: (notice:) -%>
<%# `own`: the row slides away only when the click that removed it was
inside it. That is the Tear down button and nothing else. %>
<li id="motion_notice_<%= notice.id %>" data-motion="own" class="hbk-slide-x">
<div class="hbk-slide-x-body">
<div class="flex items-center gap-3 border-b-2 border-base-300 px-2 py-2 text-sm">
<span class="badge badge-sm w-18 shrink-0 whitespace-nowrap"><%= Recipes::Motion::Board::NAMES[notice.kind] %></span>
<span class="min-w-0 flex-1"><%= notice.body %></span>
<%= tag.button "Tear down", id: "motion_tear_#{notice.id}", type: "button", class: "btn btn-xs shrink-0",
**on(:tear, with: { id: notice.id }) %>
</div>
</div>
</li>
<%# locals: (draft:, flash:) -%>
<%# Morphed by the channel. The form and the line each have an id of their
own, so each is a real insertion and a real removal: the form slides
open and shut, the line flies in from above and back. %>
<% names = Recipes::Motion::Board::NAMES %>
<div id="motion_pin">
<% if draft %>
<div id="motion_form" data-motion class="hbk-slide">
<div class="hbk-slide-body">
<div class="pb-3">
<div class="rounded-box border-2 border-base-300 bg-base-200 p-3">
<div class="flex flex-wrap gap-1.5" role="group" aria-label="The kind of notice">
<% names.each do |kind, name| %>
<%= tag.button name, id: "motion_draft_#{kind}", type: "button", "aria-pressed": draft.kind == kind,
class: [ "btn btn-xs", { "btn-primary": draft.kind == kind } ], **on(:reword, with: { kind: }) %>
<% end %>
</div>
<p id="motion_wording" class="mt-3 min-h-10 font-semibold"><%= draft.body %></p>
<div class="mt-3 flex flex-wrap gap-2">
<%= tag.button "Another wording", id: "motion_reword", type: "button", class: "btn btn-sm", **on(:reword) %>
<%= tag.button "Cancel", id: "motion_cancel", type: "button", class: "btn btn-ghost btn-sm ml-auto", **on(:cancel) %>
<%= tag.button "Pin it", id: "motion_pin_it", type: "button", class: "btn btn-primary btn-sm", **on(:pin) %>
</div>
</div>
</div>
</div>
</div>
<% elsif flash %>
<p id="motion_flash_<%= flash.id %>" data-motion role="status"
class="hbk-fly [--hbk-fly-y:-1rem] mb-3 rounded-box bg-success/20 px-3 py-2 text-sm font-semibold"><%= flash.text %></p>
<% end %>
</div>
The board above is the town notice board. Press Tear down on a row to see the row slide off the board, followed by the rows below smoothly closing the gap. A leave like this is awkward in stock Rails: every change to the page is HTML from the server, and the swap takes the old row out before any animation can be applied. Hibiki provides a module you can import that makes the swap wait for the animation. The board is shared with every visitor and resets each night.
How it fits together
An island is the part of the page Hibiki looks after, here the whole board. 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 that matter are which kind is shown and a version that goes up when anyone writes to the board.
An effect is a block that runs again when a state it read
changes. The channel has two: one renders the kinds and the rows as
one fragment, the other renders the box above the board that holds the
form or the line. Neither mentions motion. The motion is in the
browser, where hibiki-rails/motion is imported once and
meets every render on its way in. This is what happens when you press
Tear down:
-
The button calls
tearwith the notice's id. - The action deletes the notice and bumps the version. The effect that renders the board read the version, so it runs again and sends the board without that row.
- Before the new HTML goes in, the module finds the row, since the new HTML does not have it. It marks the row as leaving, waits for its transition to end, and then lets the swap run.
Animating an element as it leaves
Every change on this page comes from the server as new HTML, which
the client swaps in for the old. An element that is new in that HTML
can animate in with CSS alone, using @starting-style to
set how it looks before it appears. Removing an element from
the HTML is a different matter. The swap removes it straight
away, and a removed element can't run a transition.
To animate removals, the swap has to wait until the element has finished leaving. Hibiki's motion module does the waiting, and you can add it with one import:
// app/javascript/application.js
import "@hotwired/turbo-rails"
import "./controllers"
import "hibiki-rails/motion"
Before each swap, the module looks for marked elements that the new
HTML doesn't have. It sets data-motion-leaving on each
one, waits for its CSS transitions to finish, and then lets the swap
go ahead. How the element leaves is up to your CSS (or you can use the transitions Hibiki provides).
To animate an element, give it the data-motion
attribute, an id, and a class that styles the leave. The module
matches elements by id, so the id has to stay the same from one
render to the next.
<%# Leaves only when a click inside it removed it. %>
<li id="motion_notice_7" data-motion="own" class="hbk-slide-x">
<div class="hbk-slide-x-body">…</div>
</li>
<%# Leaves whenever a render no longer has it. %>
<div id="motion_form" data-motion class="hbk-slide">
<div class="hbk-slide-body">…</div>
</div>
<%# One class, and one number changed on this element only. %>
<p id="motion_flash_1" data-motion class="hbk-fly [--hbk-fly-y:-1rem]">Pinned under Lost.</p>
Animating only your own removals
On this board, a row can disappear in three ways: you tear it down, another visitor tears it down, or you pick a kind that hides it. When you tear a notice down, the slide shows you that your click worked. When a row goes for any other reason, the slide only gets in the way, most of all when a filter hides a dozen rows at once.
That's the reason the rows are marked data-motion="own". With
own, the module animates an element only when the click
that led to the new HTML came from inside that element. The Tear down
button is inside its row, so a tear slides. The kind buttons sit
above the rows, so a filter removes rows at once. Another visitor's
tear started on their page, so on yours the row simply disappears.
The channel sends every tab the same HTML, so working out whose click it was happens in the browser. The island already remembers which button started each request, which is how a pressed button shows that it is busy. The module reads the same record.
The form and the "Pinned" message above the board use plain
data-motion. Only your own clicks open and close them,
so they always animate.
Animating an element as it enters
The channel updates the page with a morph. Instead of replacing the
old HTML, a morph compares it with the new HTML and changes only what
differs. If an element is in both, the morph edits it in place. The
browser never sees it inserted, so its @starting-style
never runs.
An element animates in only when it is new to the page, which means its id must be one the page didn't have a moment ago. The form slides open because nothing with its id was on the page before. The "Pinned" message puts a counter in its id. When you pin a second notice, the message gets a new id, so the browser treats it as a new element and it flies in again.
The own mark has no effect on entrances. Entrances run in
CSS, and CSS animates every new element, whatever caused it to
appear. If the rows had an entrance, picking All after a filter would
slide every hidden row back onto the board. That is why the rows use
hbk-slide-x, the only one of the six effects with a leave
and no entrance.
The six effects Hibiki offers
The signwriter's yard
This line is below the sign. Only a slide moves it gently.
# frozen_string_literal: true
# The signwriter's yard: one sign, hung and taken down with the effect the
# visitor picks. The stage is sent over the channel itself, not a Turbo
# stream, and the same marks hold that swap too.
class Recipes::Motion::GalleryChannel < ApplicationCable::Channel
include Hibiki::Rails::Channel
VIEWS = "app/recipes/motion"
EFFECTS = %w[slide slide-x fade fly scale blur].freeze
# Every effect but the sideways slide also runs on the way in.
ENTERS = EFFECTS - %w[slide-x]
def build_graph
@effect = Hibiki::State.new(EFFECTS.first)
@shown = Hibiki::State.new(true)
Hibiki::Effect.new do
transmit({ html: render("stage", effect: @effect.value, shown: @shown.value) })
end
end
# The sign's id has the effect in it, so a pick takes the old sign down
# its own way and hangs the new one.
def pick(data)
return unless EFFECTS.include?(data["effect"])
@effect.value = data["effect"]
@shown.value = true
end
def toggle = @shown.value = !@shown.value
private
def render(partial, **locals) = ApplicationController.render(partial: "#{VIEWS}/#{partial}", locals:)
end
<%# The tabs and the button are rendered here and never again. A radio
keeps its own mark, so the channel only sends the stage. %>
<% effects = Recipes::Motion::GalleryChannel::EFFECTS %>
<%= island Recipes::Motion::GalleryChannel, transport: :transmit, class: "panel mx-auto w-full max-w-2xl p-4 sm:p-5" do %>
<header class="flex items-center gap-2">
<h2 class="text-xl font-bold">The signwriter's yard</h2>
<%= tag.button "Hang it or take it down", id: "gallery_toggle", type: "button", class: "btn btn-primary btn-sm ml-auto", **on(:toggle) %>
</header>
<div class="tabs tabs-box tabs-sm mt-3" role="radiogroup" aria-label="The effect">
<% effects.each do |effect| %>
<%= tag.input type: "radio", name: "gallery_effect", id: "gallery_effect_#{effect}", class: "tab",
"aria-label": "hbk-#{effect}", checked: effect == effects.first,
**on(:pick, event: :change, with: { effect: }) %>
<% end %>
</div>
<%= render "app/recipes/motion/stage", effect: effects.first, shown: true %>
<% end %>
<%# locals: (effect:, shown:) -%>
<%# Sent whole by the channel. The two slides are pairs: a class on the
box and one on its only child. The other four are one class. %>
<% box, body = { "slide" => %w[hbk-slide hbk-slide-body], "slide-x" => %w[hbk-slide-x hbk-slide-x-body],
"fade" => %w[hbk-fade], "fly" => %w[hbk-fly], "scale" => %w[hbk-scale], "blur" => %w[hbk-blur] }.fetch(effect) %>
<div id="gallery_stage" class="mt-3 min-h-44 overflow-hidden rounded-box border-2 border-base-300 bg-base-200 p-3">
<% if shown %>
<div id="gallery_sign_<%= effect %>" data-motion class="<%= box %>">
<div class="<%= body %>">
<div class="pb-3">
<div class="panel px-4 py-5 text-center">
<p class="text-lg font-bold">The Blue Boar</p>
<p class="text-sm opacity-70">Ale, stew and a dry bed</p>
</div>
</div>
</div>
</div>
<% end %>
<p id="gallery_note" class="text-sm opacity-70">
<% if Recipes::Motion::GalleryChannel::ENTERS.include?(effect) %>
This line is below the sign. Only a slide moves it gently.
<% else %>
This line is below the sign. <code>hbk-slide-x</code> is for leaving only, so the sign comes back at once.
<% end %>
</p>
</div>
The yard above hangs a sign. Pick an effect, then press "Hang it or take it down" to see the animation.
This island uses no Turbo Stream. Its channel sends the stage over the
subscription with transmit, and the client swaps it in by
id.
The effects are Tailwind utilities in one stylesheet,
hibiki_motion.css (the Hibiki scaffold generator writes this file for you).
The same feature in stock Rails
A Turbo Stream removes its target at once, as any swap does, so the
stock build needs the same wait. Turbo gives the place to put it, the
turbo:before-stream-render event, and the rest is written
by hand. Here is a sketch. It is not run here, and it leaves out the
form, the partials for the kinds and the line, and the CSS, which is
the same as above with a class in place of the attribute.
// Imported once from application.js. A Turbo Stream takes an element out
// before the browser has drawn it leaving, so every stream is met on its
// way in and held while what it removes plays its transition.
// Whose click was it? A broadcast does not say, so the page remembers:
// the row whose own form was submitted is stamped before the request goes.
document.addEventListener("turbo:submit-start", (event) => {
event.target.closest("[data-leave]")?.setAttribute("data-mine", "")
})
// What a stream is about to remove: its target for a remove, and for an
// update or a replace the marked elements the new content does not have.
const leaving = (stream) => {
if (stream.action === "remove") return stream.targetElements.filter((element) => element.matches("[data-leave]"))
if (!["update", "replace"].includes(stream.action)) return []
const kept = new Set([...stream.templateContent.querySelectorAll("[id]")].map((element) => element.id))
return stream.targetElements
.flatMap((target) => [...target.querySelectorAll("[data-leave]")])
.filter((element) => !kept.has(element.id))
}
const mine = (element) => element.dataset.leave !== "own" || element.hasAttribute("data-mine")
// A transition that never ends must not hold the page for good.
const finished = (element) =>
Promise.race([
Promise.allSettled(element.getAnimations().map((animation) => animation.finished)),
new Promise((resolve) => setTimeout(resolve, 1000))
])
// One queue, so a stream that is held is not overtaken by the next one.
let queue = Promise.resolve()
document.addEventListener("turbo:before-stream-render", (event) => {
const stream = event.detail.newStream
const render = event.detail.render
event.detail.render = (element) =>
(queue = queue.then(async () => {
const going = leaving(stream).filter(mine)
going.forEach((row) => row.classList.add("leaving"))
await new Promise(requestAnimationFrame)
await Promise.all(going.map(finished))
await render(element)
}))
})
class Notice < ApplicationRecord
KINDS = %w[wanted sale lost].freeze
# Every open board gets the new row, loses the torn one, and is sent the
# counts again. The remove reaches the visitor who tore it down as well
# as everyone else, and nothing in it says which of them they are.
after_create_commit -> { broadcast_prepend_to :notices, target: "notices" }
after_destroy_commit -> { broadcast_remove_to :notices }
after_commit -> { broadcast_replace_to :notices, target: "kinds", partial: "notices/kinds",
locals: { kinds: Notice.group(:kind).count } }
def self.pin(kind, body)
# ...as in the Hibiki version
end
end
class NoticesController < ApplicationController
def index
@notices = Notice.of(params[:kind]).order(id: :desc)
@kinds = Notice.group(:kind).count
end
# The form arrives in a turbo-frame. It is new to the page, so it can
# slide open with CSS alone.
def new
@draft = Draft.compose(params[:kind])
end
# The row comes by broadcast. This answer shuts the form and says so:
# two streams that each remove something, so the listener in leave.js
# holds both.
def create
notice = Notice.pin(params[:kind], params[:body])
render turbo_stream: [
turbo_stream.update("pin_form"),
turbo_stream.update("flash", partial: "notices/flash", locals: { notice: })
]
end
# The row goes by broadcast, for this visitor too.
def destroy
Notice.find_by(id: params[:id])&.destroy!
head :no_content
end
end
<%= turbo_stream_from :notices %>
<%= link_to "Pin a notice", new_notice_path, data: { turbo_frame: "pin_form" } %>
<%= turbo_frame_tag "pin_form" %>
<div id="flash"></div>
<%# A kind is a visit to another address. The broadcasts above are not
filtered: a new Lost notice is prepended to a board showing Wanted
unless each kind gets a stream of its own, as in Aggregate Stats. %>
<%= render "notices/kinds", kinds: @kinds %>
<ul id="notices">
<%= render @notices %>
</ul>
<%# The model cannot broadcast "the list is now empty", so the line is
always here and CSS shows it: #notices:empty + #empty { display: block } %>
<p id="empty" hidden>Nothing is pinned here.</p>
<%# `data-leave` is read by leave.js. The class is the same CSS as before,
written against `.leaving` in place of `data-motion-leaving`. %>
<li id="<%= dom_id(notice) %>" data-leave="own" class="slide-x">
<div class="slide-x-body">
<span><%= notice.kind %></span>
<span><%= notice.body %></span>
<%= button_to "Tear down", notice, method: :delete %>
</div>
</li>
resources :notices, only: %i[index new create destroy]
Most of the listing is leave.js. It is the motion module
again, written by hand: what a stream removes, a wait, a queue, a
ceiling. The hard part is whose click it was. A broadcast is the same
for everyone, so the page has to remember by itself, and it stamps the
row when its form is submitted.
The table lists each behaviour of the demo above and where it comes from.
| Stock Rails | Hibiki | |
|---|---|---|
| An element is seen leaving | A listener that works out what each stream removes, marks it and waits | One import, and data-motion on the element |
| Only for the click that removed it | A second listener stamps the row on submit, and the first reads the stamp | data-motion="own" |
| A held render is not overtaken | A queue of your own | The module's queue |
| A transition that never ends | A timeout of your own | The one-second ceiling |
| The form shuts after a save | The same listener, since the answer is a stream | The same mark |
| An element is seen arriving | CSS, @starting-style |
The same CSS |
| What the motion looks like | CSS written by hand | Six utilities in a file, or CSS written by hand |
| JavaScript written | About forty lines, once for the app | None |
This is a narrower win than most on this site. The stock listener is written once and then serves every page, and an entrance costs the same in both. Turbo can also run the browser's View Transitions, but for a visit to a page, and a stream is not a visit. In the Hibiki version the listener is already written, and it can tell your click from everyone else's because the island was already keeping track.