Skip to content

fix(drafts): seed Y.Doc only for brand-new drafts (slice 3a duplication) - #224

Merged
prellr merged 1 commit into
mainfrom
fix/drafts-yjs-seed-duplication
Jun 30, 2026
Merged

prellr merged 1 commit into
mainfrom
fix/drafts-yjs-seed-duplication

Conversation

@prellr

@prellr prellr commented Jun 30, 2026

Copy link
Copy Markdown
Owner

Problem

Drafts body text duplicates and compounds on every open (slice 3a Yjs co-editing). Observed up to 4× repetition.

Root cause

packages/views/drafts/annotations/annotation-draft-editor.tsx seeded the Y.Doc from draft.body markdown as soon as the draft query resolved. But useDraftYjs connects a DraftYjsProvider that replays the persisted Yjs update log asynchronously on connect. For a draft that ALREADY has persisted Yjs state:

  1. The seed effect runs first (provider hasn't replayed → fragment empty → seed proceeds → it writes the body as FRESH Yjs items under the local client id).
  2. The provider replays the persisted log on top → the body now exists twice (seeded items don't dedupe against replayed ones — different client id/clock).
  3. DraftYjsProvider.startSync sends Y.encodeStateAsUpdate(doc) to the server, which persists the duplication. Every subsequent open re-seeds → compounding.

The provider's isSynced flips the instant the handshake is sent, not when server state is received, so it can't gate the seed. The deterministic fix is to seed only when the draft has no persisted Yjs state.

The fix

Backend — expose whether a draft has persisted Yjs state:

  • New DraftHasYjsState query (EXISTS on draft_yjs_update, short-circuits cheaper than COUNT).
  • has_yjs_state (bool) added to the draft GET response, populated from the loader-resolved draft.ID. A query failure is non-fatal (defaults false).

Frontend — seed only a brand-new draft:

  • has_yjs_state threaded through DraftSchema (optional, default false for API-compat — older servers omit it), the Draft type, EMPTY_DRAFT, and the optimistic-create literal.
  • The seed effect now skips seedYDocFromMarkdown entirely when has_yjs_state === true (the provider's replay is authoritative) while still priming lastSavedBodyRef to suppress a redundant initial PUT. Seeds only when there's no persisted state. The internal fragment.length > 0 guard stays as defense-in-depth.

API compatibility

has_yjs_state is optional with a false default via parseWithFallback + DraftSchema, so an older backend that omits it is treated as "brand-new draft" (seed runs) — no white-screen. A schema test pins this.

Tests

  • Backend (draft_yjs_test.go): a draft with ≥1 draft_yjs_update row → has_yjs_state=true; a fresh draft → false.
  • Core schema (schemas.test.ts): missing field defaults to false; true preserved.
  • Views (collab-editor-wiring.test.tsx): the seed RUNS when has_yjs_state=false, SKIPS when true (body present exactly once, no duplication); plus a defense-in-depth check on the internal guard.

Verification

  • go build ./... && go vet ./... clean; go test ./internal/handler/... green (Draft tests pass); make sqlc regenerated cleanly.
  • pnpm typecheck green across all 6 packages/apps; core + views vitest green; lint shows only pre-existing warnings.

Note

Already-duplicated drafts have corrupted persisted Yjs state and need a separate data reset — handled out of band by the coordinator.

🤖 Generated with Claude Code

The annotation draft editor seeded the Y.Doc from draft.body as soon as the
draft query resolved. For a draft that already has persisted Yjs state this
seeded the body as a fresh set of CRDT items BEFORE the networked provider
replayed the persisted log on connect — so the body ended up twice (the seeded
items don't dedupe against the replayed ones), and startSync persisted the
duplication, compounding on every open.

Fix: expose whether a draft has persisted Yjs state and seed only a brand-new
draft (no state); the provider's replay is authoritative otherwise.

Backend:
- Add DraftHasYjsState (EXISTS on draft_yjs_update) and wire has_yjs_state into
  the draft GET response. draft.ID is the loader-resolved UUID; a query failure
  is non-fatal (defaults false).

Frontend:
- Thread has_yjs_state through DraftSchema (optional, default false for
  API-compat), the Draft type, and EMPTY_DRAFT / optimistic create.
- Gate the seed effect: skip seedYDocFromMarkdown when has_yjs_state is true
  (still prime the snapshot baseline), seed only when false. Keep the internal
  fragment.length guard as defense-in-depth.

Tests:
- Go: a draft with >=1 update row reports has_yjs_state=true; a fresh draft false.
- Core schema: missing field defaults to false (older backend), true preserved.
- Views: the seed RUNS when has_yjs_state=false, SKIPS when true (no duplication).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@prellr
prellr merged commit e9294c1 into main Jun 30, 2026
2 checks passed
@prellr
prellr deleted the fix/drafts-yjs-seed-duplication branch June 30, 2026 00:02
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