Skip to content

feat: realtime collaboration PoC (codemirror + tiptap)#447

Closed
dschmidt wants to merge 5 commits into
opencloud-eu:mainfrom
dschmidt:feat/realtime-collaboration-poc
Closed

feat: realtime collaboration PoC (codemirror + tiptap)#447
dschmidt wants to merge 5 commits into
opencloud-eu:mainfrom
dschmidt:feat/realtime-collaboration-poc

Conversation

@dschmidt
Copy link
Copy Markdown
Contributor

Description

THIS IS A PoC EXPERIMENT. NOT READY FOR MERGING OR PRODUCTION.

To be honest I started this before I saw the standard editor in Web was ported to TipTap already - on the pro side it was easier to experiment with a very simple and basic editor not integrated with anything yet :)

Yjs/Hocuspocus-based collaborative editing for OpenCloud as a generic wrapper, validated with two apps over markdown (CodeMirror, Tiptap).

  • Hocuspocus sidecar: token validation via Graph /me, effective ACL via Graph /permissions allowedValues (same Reva PermissionSet that backs WebDAV oc:permissions, so owner/share/space-role are merged). Anti-spoof identity stamp on awareness updates.
  • CollaborativeWrapper: provider lifecycle, empty-user awareness bootstrap, etag save loop without soft-lock (412 -> HEAD probe -> retry), hydration election by lowest awareness clientId.
  • Adapter contract decouples wrapper from editor: hasContent, hydrate, serialize. CodeMirror and Tiptap apps each provide one small adapter against the same wrapper.
  • E2E Playwright suites per app, integration suite for two-peer Yjs sync via the sidecar.

Related Issue

How Has This Been Tested?

  • test environment:
  • test case 1:
  • test case 2:
  • ...

Types of changes

  • Bugfix
  • Enhancement (a change that doesn't break existing code or deployments)
  • Breaking change (a modification that affects current functionality)
  • Technical debt (addressing code that needs refactoring or improvements)
  • Tests (adding or improving tests)
  • Documentation (updates or additions to documentation)
  • Maintenance (like dependency updates or tooling adjustments)

Yjs/Hocuspocus-based collaborative editing for OpenCloud as a generic
wrapper, validated with two apps over markdown (CodeMirror, Tiptap).

- Hocuspocus sidecar: token validation via Graph /me, effective ACL via
  Graph /permissions allowedValues (same Reva PermissionSet that backs
  WebDAV oc:permissions, so owner/share/space-role are merged). Anti-spoof
  identity stamp on awareness updates.
- CollaborativeWrapper: provider lifecycle, empty-user awareness
  bootstrap, etag save loop without soft-lock (412 -> HEAD probe ->
  retry), hydration election by lowest awareness clientId.
- Adapter contract decouples wrapper from editor: hasContent, hydrate,
  serialize. CodeMirror and Tiptap apps each provide one small adapter
  against the same wrapper.
- E2E Playwright suites per app, integration suite for two-peer Yjs
  sync via the sidecar.
@dschmidt dschmidt force-pushed the feat/realtime-collaboration-poc branch from d7bb5a3 to 449f08f Compare May 18, 2026 13:32
dschmidt added 4 commits May 19, 2026 17:39
PoC e2e + integration suites now pass green (codemirror 5/5, tiptap 4/4,
integration 8/8) against OC 6.2.0.

- web-pkg / web-client: ^3.0.0 → ^7.0.0 in both packages (mismatched
  major rejected by Module Federation as "Shared module must be provided
  by host"; all other apps in the repo were already on ^7)
- drop unused `storeToRefs` import (pinia named export is not federated
  in dev builds and broke the codemirror vite build); read serverUrl
  directly off the store inside the computed instead
- tiptap deps: ^2.10 → ^3.20.4 to align with what web-pkg's editor
  already pulls in transitively (avoids parallel v2+v3 in the store and
  the tiptap-markdown → @tiptap/pm/model resolution failure)
- replace community `tiptap-markdown` with the official `@tiptap/markdown`
  (same package web's editor uses); adapter switches to
  `setContent(md, { contentType: 'markdown' })` + `editor.getMarkdown()`
- drop `@tiptap/extension-collaboration-cursor@3.0.0`: it still imports
  `yCursorPlugin` from upstream `y-prosemirror`, while
  `@tiptap/extension-collaboration@3` has migrated to Tiptap's own
  `@tiptap/y-tiptap` fork — the two ship distinct `ySyncPluginKey`
  instances, so the cursor extension cannot find the sync state and
  throws "Cannot read properties of undefined (reading 'doc')" on init.
  A 12-line in-place Extension wires `@tiptap/y-tiptap`'s `yCursorPlugin`
  directly with a `cursorBuilder` that emits the same
  `.collaboration-cursor__caret` / `__label` DOM the consumer CSS and
  the e2e test expect.
- vite.config: dedupe `yjs` / `y-prosemirror` / `y-protocols` in the
  tiptap bundle so duplicated instances don't break `instanceof` checks
- CollaborativeWrapper: pass `provider` through to the editor component
  alongside `awareness` (the cursor wiring needs the provider for the
  underlying awareness; codemirror ignores the extra prop)
Drop the wrapper's own top-bar + Save button + 60s save loop + beforeUnload
hook. Declaring `update:currentContent` on the app entry component is enough
for AppWrapper to flip into `isEditor` mode, render the standard Save action
in the topbar, bind Ctrl+S, arm the unsaved-changes route-leave modal, and
run its own auto-save loop. Avoids the prior parallel-chrome look and lets
the collab wrapper focus on the Y.Doc / etag / lifecycle concerns that
AppWrapper has no view of.

- CollaborativeWrapper now emits debounced `update:currentContent` from
  Y.Doc updates (skipping our own internal-origin transactions so a
  fresh hydrate doesn't echo the loaded content back as a fake user edit)
- watch on `props.resource.etag` mirrors AppWrapper's post-save etag into
  `_oc_meta.etag` so the sidecar's next stale-state probe sees the
  current authoritative tag
- compact status strip (connection state + lifecycle errors) is all that's
  left of the wrapper's chrome; save / dirty / etag UX comes from AppWrapper
- App.vue declares the emit and forwards it from the wrapper
- save-back e2e drives Ctrl+S instead of the removed `collab-save` button
  and asserts persistence by polling WebDAV (no client-side "saving done"
  signal to latch on to)
…unset

The wrapper now branches on a new `realtimeUrl` prop:
- set: collab-mode as before (Hocuspocus provider, awareness from provider,
  hydration waits for `onSynced`)
- unset / null: local-mode — still a `Y.Doc` + `Awareness` pair so editor
  bindings (CodeMirror's yCollab, Tiptap's collaboration plugin) stay on
  a single codepath; hydration runs immediately from `currentContent`,
  no network, no peers, status reads `local`

`onProviderSynced` and `recoverFromStaleState` now take an `Awareness`
parameter directly so they're provider-tolerant; `lockForReload` accepts
a null provider. The standalone `Awareness` in local-mode degenerates the
hydration election to "we win unconditionally" (no peers in the room),
which is the desired behaviour.

`TiptapEditor` makes `provider` optional and only registers
`CollaborationCursor` when a provider is present — no peers to render
in local mode means no cursor extension to wire up. `CodeMirrorEditor`
needs no change (its `yCollab` binding only ever needed `awareness`).

App.vue forwards `applicationConfig?.realtimeUrl` from the app config
into the wrapper, so the dev compose's `opencloud.apps.yaml` is the
single source of truth for whether collab is wired up. The previous
auto-derivation from `configStore.serverUrl + /realtime` is gone — apps
that want collab now opt in via config.

`y-protocols` lifted from transitive to explicit dependency in both
package.json files to anchor the `Awareness` import. Dev-mode config
gains realtimeUrl entries for codemirror and tiptap so the e2e suites
keep exercising the collab path.
… suite

The wrapper's `watchEffect((onCleanup) => { ... })` re-ran whenever any
tracked prop changed inside the body — including `props.resource`
mutations from AppWrapper's post-save `resourcesStore.upsertResource`.
Each save would tear down and rebuild the Y.Doc, dropping any in-flight
peer edits. The shared-file e2e didn't catch it because the two peers'
saves are far apart in test time and the wrapper re-hydrates cleanly
from `currentContent`.

Replace with `watch(sessionKey, ..., { immediate: true })` where
sessionKey = `${documentName}::${realtimeUrl ?? 'local'}`. Vue's
computed equality check guarantees an identity-preserving resource
update (same id, different etag) leaves the watch dormant — the Y.Doc
and provider survive.

Adds the first wrapper unit suite (vitest + happy-dom + @vue/test-utils),
13 specs covering both modes (local + collab), the debounced
`update:currentContent` emit, the `_oc_meta.etag` mirror, the cleanup
contract, and an explicit regression test for the rebuild bug above.

- `HocuspocusProvider` is mocked via a class returned from `vi.hoisted`
  so `new HocuspocusProvider(...)` inside the wrapper finds a real
  constructable, and the test can fish out the instance to trigger
  `onSynced` / `onAuthenticationFailed` manually
- `useAuthStore` is mocked to a plain `{ accessToken }` object so the
  wrapper doesn't need a real pinia in the test
- The CodeMirror markdown adapter is reused as-is — pure, Y.Text-only,
  matches the contract a real adapter would honour
@dschmidt
Copy link
Copy Markdown
Contributor Author

Superseded by opencloud-eu/web#2561

@dschmidt dschmidt closed this May 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant