Skip to content

fix(collab): stop content duplication + fix false unreachable probe#186

Merged
thetechjon merged 1 commit into
devfrom
fix/collab-duplication-and-probe
Jun 12, 2026
Merged

fix(collab): stop content duplication + fix false unreachable probe#186
thetechjon merged 1 commit into
devfrom
fix/collab-duplication-and-probe

Conversation

@thetechjon

Copy link
Copy Markdown
Collaborator

Two runtime bugs in the live-collaboration feature, exposed now that beta sets NEXT_PUBLIC_YJS_WS_URL.

BUG 1 — false "Live: unreachable" pill (src/hooks/useCollaboration.ts)

Root cause. The Phase-A connectivity probe opened new WebSocket(url) against the bare configured URL (wss://<host>/<token>, no room). The Cloudflare worker (collab-server/src/index.ts) reads the last path segment as the room and the second-to-last as the auth token. A single-segment path therefore has no token -> the worker returns 403 -> the probe reported the server unreachable, even though the real document binding (which dials <url>/<room> via WebsocketProvider) authenticates fine.

Fix. The probe now dials a dedicated __probe__ room (buildProbeUrl), so it hits the /<token>/<room> shape the worker requires and the socket is accepted. The worker's auth-token check is untouched. The pill's exposed url stays the bare configured value (used in the tooltip).

BUG 2 — content DUPLICATION / data integrity (src/components/editor/CodeMirrorEditor.tsx)

Root cause. The CodeMirror editor was created with value={initialContent} and the collab binding seeded the same note body into the Y.Text. yCollab's ySync plugin only registers an observer on construction — it never reconciles the editor's existing doc against the (empty) Y.Text. So when the seed-on-empty inserted the body into Y.Text after the first sync, the observer replayed that insert on top of the body already in the editor -> the body rendered twice, and the doubled text was what got saved back to the note (and would sync to GitHub = corruption).

Fix. When collab is enabled (getConfiguredUrl() != null — the exact gate the collab effect uses), the editor is built empty and the Y.Text becomes the single source of content. The seeder sees its body exactly once; a joiner (empty local note) receives the shared body over the wire exactly once and never re-seeds. With collab off (NEXT_PUBLIC_YJS_WS_URL unset) the editor is byte-for-byte unchanged. The seed-on-empty logic in collabExtension.ts was already correct and is left as-is.

Tests

  • useCollaboration.test.ts: buildProbeUrl produces /<token>/<room>; the hook dials /__probe__, not the bare URL.
  • collabNoDoubling.test.tsx (new): mounts a real EditorView + the real yCollab binding (only the websocket transport faked). After binding + sync the editor holds the body exactly once for the seeder (fresh room) and a joiner (content arriving over the wire), with a regression guard proving that initializing the editor with the body reproduces the doubling.

Playwright verification (two contexts, same room)

Ran the dev server with the real worker (NEXT_PUBLIC_YJS_WS_URL=wss://collab.noteser.app/<token>) and drove two browser contexts on the same room via the share link (?collab=<collabId>) — see e2e/_verify_collab.spec.ts.

  • Before (root cause / unit regression guard): editor initialized with the body doubles it post-sync; bare-URL probe -> 403 -> pill "unreachable".
  • After (observed, green run): pill shows "Live: on"; opening the note shows the body exactly once in both clients; typing in context A appears live in context B; the content saved back to the store is the single correct body; no CSP/WebSocket console errors.

Gates

npm run lint clean · npx tsc --noEmit zero errors · full jest --ci green (3008 passed, no flake).

🤖 Generated with Claude Code

Two runtime bugs surfaced now that beta sets NEXT_PUBLIC_YJS_WS_URL.

BUG 1 — false "Live: unreachable" pill. The Phase-A connectivity probe
opened `new WebSocket(url)` against the BARE configured URL
(wss://<host>/<token>, no room). The worker reads the last path segment
as the room and the one before it as the auth token, so a single-segment
path has no token -> 403 -> the probe reported the server unreachable
even though the real document binding (which dials <url>/<room>)
authenticates fine. Fix: the probe now dials a dedicated `__probe__`
room (buildProbeUrl) so it hits the `/<token>/<room>` shape the worker
requires. Worker auth is untouched.

BUG 2 — content DOUBLING (data integrity). The editor was created with
value={initialContent} AND the collab binding seeded the same body into
the Y.Text. yCollab's ySync plugin only registers an observer; it never
reconciles the editor's initial doc against the (empty) Y.Text, so the
post-sync seed replayed the body on top of the existing text -> the body
rendered twice and the doubled text was saved back to the note (and would
sync to GitHub). Fix: when collab is enabled the editor is built EMPTY
and the Y.Text is the single content source. The seeder sees its body
once; a joiner receives the shared body over the wire once. With collab
off the editor is byte-for-byte unchanged.

Tests: probe builds a /<token>/<room> URL (not the bare url); a real
EditorView + yCollab binding holds the note body exactly once after
binding for both seeder and joiner (collabNoDoubling), with a regression
guard documenting that initializing the editor WITH the body doubles it.
Verified end-to-end with two Playwright browser contexts on the same
room (e2e/_verify_collab): pill "Live: on", body once in both clients,
live A->B sync, single saved content, no CSP/WS console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thetechjon thetechjon merged commit 7b4a986 into dev Jun 12, 2026
3 checks passed
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