Add ActionCableProvider for collaborative editing

What does this MR do and why?

Slice 4 of 5. Adds the client-side Yjs provider that relays collaborative-editing updates between browsers over the wiki page ActionCable channel.

Files added:

  • app/assets/javascripts/collaborative_editing/action_cable_provider.js
  • app/assets/javascripts/collaborative_editing/encoding.js
  • app/assets/javascripts/collaborative_editing/index.js
  • spec/frontend/collaborative_editing/action_cable_provider_spec.js
  • spec/frontend/collaborative_editing/encoding_spec.js

It also modifies two files merged in slice 3:

  • app/channels/collaborative_editing/base_channel.rb
  • spec/channels/collaborative_editing/wiki_page_channel_spec.rb

Nothing constructs a provider yet. The wiki form is not wired up, so the module is unreachable from the UI. The whole feature sits behind the default-off WIP feature flag wiki_collaborative_editing, so there is no changelog entry.

Design

The provider knows nothing about wikis. It takes a channel name and channel params, so another document surface can reuse it by pointing at a different server-side channel.

The server is not a CRDT peer. It authorises the subscriber, appends opaque base64 updates to a Redis log and rebroadcasts them. All merging happens in the browser.

Remote user identities live in a Map keyed by clientId, populated only from the identity the server stamps on each awareness relay. They are held outside the Y.Doc and outside the awareness state, because both are client-authored and a peer could claim any name or avatar. Callers read them through identityFor(clientId).

One client is elected by the server to populate an empty document. seed() runs the populate callback under a fixed clientID (0), so the resulting updates are byte-identical whichever client produces them. If the server's claim were ever bypassed, for example by a network partition, duplicate seeds merge into one document rather than doubling its content.

Three things beyond a straight port

The channel and store gained protocol features while this slice waited. The original branch's provider did not know about them.

1. Compaction token. A snapshot message must now carry the token from the server's request_snapshot message. Without it the store rejects the snapshot and the log never compacts. #sendSnapshot echoes the token back, and returns early without one because the server would reject it anyway.

2. document_full handling. This is a new server message meaning the log was at its ceiling and this client's update was dropped. It is transient, not terminal: the same append that reports it also claims a compaction, so the log usually drains within a round trip.

The dropped update is still a real problem. doc.on('update') yields incremental deltas, so no later delta carries the lost edit. Left alone, that client's edit never reaches its peers while its save still passes the conflict check. The provider sets a flag that makes the next sync send full state (Y.encodeStateAsUpdate) instead of the delta. The flag clears only when the message actually left the client, so a disconnection, or a snapshot request arriving mid-disconnect, no longer loses the pending state. The same flag covers offline edits: #send reports whether it sent, #onDocUpdate arms the flag when a delta is dropped, and the connected callback flushes full state on reconnect. A flag armed while offline survives until a send succeeds.

3. Malformed payload handling. Payloads are opaque to the server, which cannot inspect them, so a peer can relay bytes that fail to decode or fail to apply. atob throws on invalid base64, so the decode needs guarding as well as the apply. Both are wrapped for sync, for awareness, and for each entry in the initial state. Guarding per entry means one bad log entry no longer aborts the whole initial state and leaves the user with an empty editor whose save would then overwrite the page.

Server-side change

Review found that handle_snapshot persisted the compacted snapshot without relaying it, so live peers never received an update the store had dropped, and later deltas from that client arrived with a clock gap. handle_snapshot now checks the result of store.replace and relays a successful snapshot to peers as a sync. This touches app/channels/collaborative_editing/base_channel.rb and spec/channels/collaborative_editing/wiki_page_channel_spec.rb, both merged in slice 3, so this MR is no longer purely additive.

For reviewer attention

Tradeoff. The per-entry guard sits inside the doc.transact call, so a payload that fails midway could leave a partially applied update in the document. The alternative is an unguarded throw that aborts the initial state entirely, leaving the user with an empty editor whose save overwrites the page. The partial apply was judged the lesser risk. This is a real tradeoff, not an obvious win.

Answered in review. The document_full recovery behaviour has now been reviewed. It found that a compacted snapshot was persisted but never relayed, so the recovery flag was being cleared before it could fire. The server now relays the snapshot, and the same flag was extended to cover offline edits. See the two threads on action_cable_provider.js.

Not included

CollaboratorsIndicator is deliberately left out. The agreed split puts user-facing parts in the wiring slice, and the component's only mount point is the wiki form. The provider contract it depends on (awareness, doc.clientID, identityFor) is already covered by this slice's specs and by create_collaboration_extensions.js, which is on master.

Prerequisites already on master

From slice 1: the collaborationProvider prop chain through markdown_editor.vue and content_editor.vue, the whenSynced and seed calls in content_editor.js, create_collaboration_extensions.js, user_colors.js, the yjs and y-protocols dependencies, and the webpack rule for the untranspiled Yjs stack.

References

Slice list:

  • Slice 1, merged: !251691 (merged) — optional collaborationProvider threaded into ContentEditor
  • Slice 2, merged: !252572 (merged)DocumentStore and the rate limit registration
  • Store hardening, merged: !253421 (merged) — stored-size ceiling, compaction claim tokens, LTRIM-based replace
  • Slice 3, merged: !253403 (merged)BaseChannel, WikiPageChannel, feature flag definition, AppSec review
  • Slice 4: this MR
  • Slice 5: wiring — wiki_form.vue, CollaboratorsIndicator, and making the feature reachable

Screenshots or screen recordings

None. This slice adds no UI and the module is unreachable from the interface.

How to set up and validate locally

Nothing to validate through the UI, because no client subscribes to the channel yet.

Run the specs:

yarn jest spec/frontend/collaborative_editing/
bundle exec rspec spec/channels/collaborative_editing/

39 Jest tests and 62 RSpec examples, 0 failures. RuboCop, ESLint and Prettier are clean.

The specs cover the subscription params, the initial state, seeding determinism, local and remote updates, identity stamping, the compaction token, document_full recovery, offline edits recovered on reconnect, malformed payloads, subscription rejection, and teardown. On the server they cover the snapshot relay and a rejected snapshot broadcasting nothing.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.


🤖 This content was generated by GitLab Duo.

Edited by Lee Tickett

Merge request reports

Loading
Loading