The JavaScript client
yrby-client is the browser half of yrby. It has a provider for Action Cable
and AnyCable, plus the pieces that provider is built from: a protocol session
that works over any transport, and a queue that resends edits until the server
confirms them. It's written in TypeScript, includes its own types, and works from
plain JavaScript as ESM or CommonJS.
npm install yrby-client
yjs and y-protocols are optional peer dependencies, so install them
alongside it. If you already use a Yjs editor binding, you have both.
The <yrby-document> element
yrby-rails' collaborative_document_tag renders a <yrby-document> element
with a signed token in it, much like turbo_stream_from renders a
<turbo-cable-stream-source>. Import the element once and it connects by
itself:
import "yrby-client/element";
document.addEventListener("yrby:synced", ({ target, detail }) => {
const editor = bindYourEditor(target, detail.doc, detail.provider);
detail.signal.addEventListener("abort", () => editor.destroy(), { once: true });
});
bindYourEditor stands for however you attach your editor. When the signal
aborts, remove the Yjs listeners and disable or remove the editor. Don't
destroy the document, the provider, or the shared consumer, because yrby
manages those. The signal fires before yrby sends any final update. Handle it even if the
editor is already gone from the page.
Behind each element is a document session. It holds the Y.Doc, the provider,
and any edits the server hasn't confirmed. When the last editor detaches, the
session clears your presence, and it closes if nothing is waiting to be sent.
If edits are still waiting, it keeps sending them with the original token
until the server confirms them. If the server rejects the subscription, the
session stops and keeps the unsent edits in memory so you can recover them.
The element works with Turbo and Turbolinks 5. A cached preview doesn't create a document or connect. When you go back to a page, the element picks up its old session if that session still has unsent edits. Otherwise it loads the saved content from Rails. If the page renders a new token, the element gets a new session, and the old session's edits reach it through the server like anyone else's. This all happens within one tab. Nothing is stored for offline use, so closing or reloading the tab loses edits the server hasn't confirmed.
Moving the element in the DOM keeps its editor and document, as long as you
remove and reinsert it in the same synchronous block of code. If you put it
back later, it reloads the saved content, and the old Y.Doc and undo history
are gone. Changing the token, name, or channel tears down the old editor right
away and connects to the new document. The old session still finishes sending
its unsent edits with its original token.
The element exposes its current session, doc, and provider. They're
undefined until it connects and while it switches documents, and reading them
never creates one. whenSynced is always a promise, even before the consumer
is set up. It resolves when the document first catches up with the server, and
never resolves if the element gives up on that document. The yrby:synced
event bubbles, fires once each time the element connects to a document, and
includes detail.signal for cleanup. Synced doesn't mean the connection is up
right now, or that every edit is confirmed. Check provider.synced and
session.hasPending for those.
If the import fails or the server rejects the subscription, the element fires
yrby:error with detail.error. A rejection also includes detail.session,
so you can retry it. While the session is blocked, the element does nothing.
After you retry, call element.activate() or remount the element to attach
again. element.destroy() disconnects the element until it's put back on the
page. It doesn't throw away unsent edits.
The refresh attribute is a same-origin URL that returns a new token for this
document as { "grant": "..." }. When the server rejects the subscription,
usually because the token expired, the session fetches that URL once and
subscribes again with the new token. It keeps its document and unsent edits.
See Grant lifetime and
refresh in the
main README. The element reads this attribute when it connects, so changing it
later has no effect on the current editor.
By default the element needs @rails/actioncable, yjs, and y-protocols,
and every element on the page shares one consumer. For AnyCable, set the
consumer before adding any elements:
import { YrbyDocumentElement } from "yrby-client/element";
import { createConsumer } from "@anycable/web";
YrbyDocumentElement.consumer = createConsumer();
Importing the module registers the element, and any <yrby-document> tags
already on the page start right away. So if you set a custom consumer on a
page that's already rendered, put the tag inside a <template>. Set the
consumer and add your yrby:synced listener first, then insert the template's
content. The working example does it in that order, and
its source is in
site/frontend/src/document.js.
Document sessions and navigation
Each consumer has its own session store. Elements with the same channel,
token, and name share one document and one queue of unsent edits. The store
compares tokens as plain strings and never decodes them. Each session adds a
random session_id to its subscription, so its acks don't get mixed up with
another session's. The server doesn't use it to pick or authorize a document.
Code without an editor can hold a lease for as long as it needs the document:
import { DocumentSessionStore } from "yrby-client";
const store = DocumentSessionStore.for(consumer);
const lease = store.acquire({ grant, name: "body" });
const { session } = lease;
await session.whenSynced;
// Work with session.doc, and hold the lease until the workflow finishes.
lease.release(); // safe to call more than once; pending edits keep sending
Call lease.setPresence(state) when an editor gets focus and
lease.setPresence(null) when it loses it. Every view of a session shares one
presence, so the last call wins. Your editor code gets its lease from
detail.lease on yrby:synced.
session.state is open, blocked, or closed. It doesn't tell you
whether the provider is connected. The store fires change with the session
in event.detail. Use it to report edits that couldn't be delivered, even
after the page that made them is gone.
store.addEventListener("change", ({ detail: session }) => {
if (session.state === "blocked") reportDeliveryFailure(session.error, session);
});
Sessions keep their queues while the consumer is down and send them when it reconnects. A new consumer gets a new store and doesn't pick up another consumer's queued work.
A blocked session keeps its document and unsent edits in memory. retry()
reconnects with its current token, which is the original or the last one its
refresh URL returned. discard() throws the edits away. A token that
arrives any other way, such as a new element attribute, starts a separate
session and leaves the blocked one alone.
ActionCableProvider
The element uses this provider. Use it directly when you wire things up yourself. With a channel you wrote, the params are whatever that channel reads, such as a document key or a room token.
const provider = new ActionCableProvider(
ydoc,
createConsumer(),
"DocumentChannel",
{ id: "post/1/body" },
)
The constructor takes the document, the consumer, the channel name, and the
channel params. Consumers from @rails/actioncable and @anycable/web both
work without adapters or type casts. On AnyCable, the provider sends presence
as whispers, so cursor updates go straight between browsers through AnyCable
and never reach your Ruby code. This site's demos are public, so they turn
that off and send presence through the server, where it's rate-limited and
checked. See Presence.
Bind after the first sync
provider.connect()
await provider.whenSynced
// now hand ydoc to the editor binding
whenSynced resolves when the document first catches up with the server. Most
editor bindings add an empty paragraph when they start. If you attach the
editor before the server's copy arrives, every client adds its own empty
paragraph to the shared document.
If that already happened, whenSynced resolves right away, even while
disconnected. It resolves once, and later reconnects don't trigger it again.
That makes it a good place to add starter content, since it won't refill a
document someone emptied on purpose.
Connection status
The provider reports four states through one listener:
| Status | Meaning |
|---|---|
connecting |
Subscription created, transport not up yet |
connected |
Transport up and exchanging sync steps (show it as "syncing") |
synced |
Caught up with the server |
disconnected |
You called disconnect() or destroy() |
When the connection drops and Action Cable retries, the status goes back to
connecting. You only see disconnected after you disconnect or destroy the
provider yourself.
const off = provider.onStatusChange(({ status }) => {
statusEl.textContent = status
})
onStatusChange returns an unsubscribe function. provider.status is the
current value, and provider.synced is true once the document has caught up.
Reliable delivery
provider.hasPending is true while local edits are waiting to be confirmed.
The provider merges everything in its queue into one update and tags it with
the highest sequence number in the batch. When the server replies
{ ack: id }, every edit up to that id is confirmed.
If the server gets an update it already has, nothing changes. On reconnect, the provider sends its whole queue again. That's also how the server fills gaps. If it's missing an update, the client that made it still hasn't had it confirmed, so that client keeps resending it.
Seeding from an HTTP response
applyRemoteUpdate loads state into the document without sending it back to
the server as a new edit. Call it for each piece of state the server already
has, before connect().
provider.applyRemoteUpdate(fromBase64(initialState))
priorUpdates.forEach((u) => provider.applyRemoteUpdate(fromBase64(u)))
provider.connect()
If you call Y.applyUpdate directly, the provider treats the update as a local
change and sends it to the server again.
Teardown
disconnect() closes the subscription and clears this client's presence.
destroy() does the same and then releases the provider.
If you reconnect by hand, set your presence again. disconnect() clears it,
and setLocalStateField does nothing while it's null. So after disconnect()
and connect(), call setLocalState again, or other people won't see you.
provider.onStatusChange(({ status }) => {
if (status !== "disconnected" && !provider.awareness.getLocalState()) {
provider.awareness.setLocalState({ user })
}
})
Bundling: one copy of yjs
Make sure your bundle has only one copy of yjs. With two, the provider and
the editor binding each get their own. Yjs warns that it was already imported,
instanceof checks fail, and y-prosemirror throws "Method unimplemented" on
remote updates. The editor never shows other people's changes, and your next
keystroke overwrites them. None of these errors point at the duplicate
import, so it's hard to track down.
Pin the shared packages (yjs, y-protocols, lib0) to one path in your
bundler config. This site's
build.mjs
does it with a small Bun resolve plugin. In Vite, use resolve.dedupe. In
webpack, use resolve.alias.
This page is copied from the repo README. If the two disagree, go by the README.