Wire collaborative editing into the wiki form
What does this MR do and why?
This is slice 5 of 5 of a split feature: real-time collaborative editing of wiki pages in the rich text (Markdown) editor, behind the default-off WIP feature flag wiki_collaborative_editing.
It is the first slice a user can reach. Slices 1 to 4 landed the plumbing, but nothing constructed a provider, so the feature was inert.
app/assets/javascripts/wikis/components/wiki_form.vue now constructs an ActionCableProvider when the rich text editor becomes active, passes it to MarkdownEditor as the collaborationProvider prop, and destroys it when leaving rich text mode or when the form is destroyed. A session only starts when the flag is on, the format is Markdown, and the page is already saved. A new page has no slug to key the shared document on.
app/assets/javascripts/collaborative_editing/components/collaborators_indicator.vue is a new component. It shows avatars of the other people editing, collapsing to a "+N" badge above five.
app/controllers/concerns/wiki_actions.rb pushes the wiki_collaborative_editing flag to the frontend, and app/helpers/wiki_helper.rb adds container_full_path to wiki_page_info, which is the channel subscription parameter.
Two design points for reviewers
Feature flag actor. The flag is pushed with container.root_ancestor as the actor, matching CollaborativeEditing::WikiPageChannel#feature_enabled?. A per-user actor would let one participant edit through the CRDT while another edited the same page through the plain form, and the two would silently overwrite each other. A shared controller example asserts the exact actor, and it runs for both project and group wikis.
Collaborator identity. Names and avatars come only from the identity the server stamps on each awareness relay, read through provider.identityFor(clientId). They are never read from the Yjs awareness state, so a peer cannot supply an arbitrary avatarUrl that every browser in the session would then fetch, and the IP and timing beacon that would create does not apply. The same rule already governs the cursor labels added in slice 1.
The binding is weaker than that, and an earlier version of this description overstated it. The clientId a peer sends is peer-authored: CollaborativeEditing::BaseChannel#broadcast slices clientId from client-supplied data before attaching the server-derived user. A peer can therefore claim another peer's clientId and have the server stamp its own identity onto it. Names and avatar URLs stay genuine server-derived values, but a cursor or avatar can be mislabelled with another real user's identity. Tracked at #629400, and raised by @kivikakk in review.
Implementation note
content_editor.vue reads collaborationProvider once in created() and passes it into a non-reactive editor instance, so a provider arriving later would be ignored. WikiForm therefore starts the session in its own created(), using the shared getInitialEditingMode() helper at app/assets/javascripts/vue_shared/components/markdown/utils.js to decide whether the editor opens in rich text. A provider exists before any child renders, whether or not the async chunk is already cached. The event-driven start remains for the case where the author switches into rich text, and the existing guard prevents a second connection.
An earlier version of this description argued that defineAsyncComponent made the ordering safe on its own. That only held on the first open. Once the chunk is cached, Edit, Cancel, then Edit again runs ContentEditor.created() before MarkdownEditor.mounted() emits the editing mode event, so the editor never joined the shared document. Raised by @vanessaotto in review.
Deliberate omission
A watcher that tore the session down when the page format changed was written and then removed. The #wiki_format select is disabled while the rich text editor is active, so the format cannot change while a session is live. It was dead code.
Testing
14 new Jest cases in spec/frontend/wikis/components/wiki_form_spec.js, 10 in the new spec/frontend/collaborative_editing/components/collaborators_indicator_spec.js, one new RSpec example in spec/helpers/wiki_helper_spec.rb, and one new shared controller example in spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb. Prettier, ESLint, RuboCop and rake gettext:lint are clean.
No changelog entry, because the feature is behind a default-off WIP flag.
References
- Original unsplit merge request: !250607 (closed)
- Review comment requesting the split: !250607 (comment 3708895581)
- Recorded slice breakdown and outstanding follow-ups: !250607 (comment 3802654087)
- Slice 1, merged, optional
collaborationProviderthreaded into ContentEditor: !251691 (merged) - Slice 2, merged,
DocumentStoreand the rate limit registration: !252572 (merged) - Store hardening, merged: !253421 (merged)
- Slice 3, merged,
BaseChannel,WikiPageChannel, flag definition, AppSec review: !253403 (merged) - Slice 4, merged,
ActionCableProvider: !254876 (merged) - Feature work item: #14367
- Flag rollout work item: #616943
Seventeen items are outstanding before the flag can be enabled, tracked in one place at #629400. An earlier version of this description said two, which undercounted: it named only the per-document byte budget, still waiting on a #g_durability PREP review, and the UX sign-off on the coloured cursors asked of @nickleonard and @nickbrandt at !250607 (comment 3738038618), which has not been answered. The rest were recorded across two other places. @kivikakk asked for a single document at !256514 (comment 3895758001), so everything has been consolidated into #629400 and the other lists now point at it.
Screenshots or screen recordings
These are from the original unsplit merge request, and show the state this slice makes reachable.
| 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 is fine for the second. Both users need at least the Developer role on the project, because the ability checked is :create_wiki.
- In the Rails console, enable the feature flag:
Feature.enable(:wiki_collaborative_editing) - Create and save a wiki page. It must already exist, because a new page has no slug.
- Set the page format to Markdown.
- Open
http://gdk.test:3000/<group>/<project>/-/wikis/<slug>/editin both sessions, and put each one in rich text mode. - Type in one window and confirm the text appears in the other.
- Confirm each window shows the other user's avatar in the collaborators indicator, and their labelled cursor caret.
- Check identity spoofing. In window A's console, run:
Window B should still show the real name and the 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 and confirm the wiki history shows a normal Git commit, with the saving user as the author.
- Disable the flag and reload. Confirm there is no websocket and 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.

