Add collaborative editing to the Wiki rich text editor
What does this MR do and why?
First step in delivering #14367
Adds real-time collaborative editing to wiki pages in the rich text (Markdown) editor, behind the wiki_collaborative_editing feature flag (type: wip, default_enabled: false, milestone 19.4).
Yjs, a CRDT library, runs in the browser. All merging of concurrent edits happens client-side. The server never decodes an update.
A new ActionCable channel, CollaborativeEditing::WikiPageChannel (a subclass of CollaborativeEditing::BaseChannel), relays opaque base64 Yjs updates between browsers editing the same page. It authorises the subscriber, appends the update to a Redis list, and rebroadcasts it.
Gitlab::CollaborativeEditing::DocumentStore holds that update log in Gitlab::Redis::SharedState, one list per document, with a 1 hour TTL refreshed on every write. Once a log passes 500 entries, the server asks a client for a compacted snapshot.
The durable copy of a wiki page is still the Git commit written on save. The Redis log is ephemeral. It exists so a late joiner can catch up, and so a session survives every client briefly disconnecting.
A CollaboratorsIndicator component shows avatars of the other people editing. Remote cursors come from the tiptap collaboration-cursor extension.
An earlier attempt at this feature relied on Rust bindings for a Ruby-side CRDT, which proved unsustainable to maintain. Keeping CRDT logic out of Ruby is a deliberate choice.
Guardrails in the channel:
- Subscription is rejected unless the flag is on, the page exists, and the user has
:create_wikion the container. - Payloads are capped at 1 MB per message.
- Updates are rate limited to 600 per minute via
Gitlab::ApplicationRateLimiter, keyed in Redis on[current_user, document_key]. Keying in Redis rather than on the channel instance stops a user multiplying their allowance by opening extra subscriptions to the same document.
Collaborator identity
Display identity — name and avatar — is stamped by the server on each awareness relay, taken from current_user. It is never read from the Yjs payload.
The payload is opaque binary the server cannot inspect, so any name or avatar carried inside it would be whatever the sender chose. A user with wiki write access could impersonate a colleague, or supply an arbitrary avatarUrl that every peer's browser then loads, which is an IP and timing beacon.
Clients look identity up by clientId, which is already on the message envelope. Cursor colour is derived from the server-supplied user id via assignUserColor, so it is consistent across peers.
This was raised in review by GitLab Duo.
References
Screenshots or screen recordings
Collaborative session
Two users editing the same page. Each sees the other's avatar in the collaborators indicator and their labelled cursor caret in the text.
| Administrator's window | Bertha's window |
|---|---|
![]() |
![]() |
How to set up and validate locally
You need two browser sessions signed in as two different users. A private window for the second is fine. Both users need at least Developer role on the project, as the ability checked is :create_wiki.
- Enable the flag in the Rails console:
Feature.enable(:wiki_collaborative_editing). - Create a wiki page and save it. The page must already exist — a brand-new unsaved page has no slug to key the shared document on, so collaboration does not start.
- Set the page format to Markdown.
- Open
http://gdk.test:3000/<group>/<project>/-/wikis/<slug>/editin both sessions, and put the editor in rich text mode in each. Plain text and source mode are not collaborative. - Type in one window. The text should appear in the other within a moment.
- Check that each window shows the other user's avatar in the collaborators indicator above the editor, and the other user's labelled cursor caret in the text. Both are driven by Yjs awareness updates, so they appear once the other person moves their cursor or types.
- Confirm identity cannot be forged. In window A's devtools console, claim a false identity:
Type a character in window A to send an awareness update. Window B should still show the real name and real avatar, and should make no request to the attacker domain.
// In window A's console, find the provider and claim a false identity const vm = [...document.querySelectorAll('*')] .map((el) => el.__vue__) .find((i) => i?.collaborationProvider); vm.collaborationProvider.awareness.setLocalStateField('user', { id: 1, name: 'GitLab Admin (SPOOFED)', avatarUrl: 'https://attacker.example.com/beacon.png', }); - Save the page. Confirm the commit message in the "Add a commit message" modal, then check the wiki page history shows a normal Git commit with the saving user as the author.
- Disable the flag and reload. The editor should behave as it did before: no websocket, no collaborators indicator.
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.

