Repository navigation
fix(drafts): seed Y.Doc only for brand-new drafts (slice 3a duplication) - #224
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tsxseeded theY.Docfromdraft.bodymarkdown as soon as the draft query resolved. ButuseDraftYjsconnects aDraftYjsProviderthat replays the persisted Yjs update log asynchronously on connect. For a draft that ALREADY has persisted Yjs state:DraftYjsProvider.startSyncsendsY.encodeStateAsUpdate(doc)to the server, which persists the duplication. Every subsequent open re-seeds → compounding.The provider's
isSyncedflips 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:
DraftHasYjsStatequery (EXISTSondraft_yjs_update, short-circuits cheaper thanCOUNT).has_yjs_state(bool) added to the draftGETresponse, populated from the loader-resolveddraft.ID. A query failure is non-fatal (defaultsfalse).Frontend — seed only a brand-new draft:
has_yjs_statethreaded throughDraftSchema(optional, default false for API-compat — older servers omit it), theDrafttype,EMPTY_DRAFT, and the optimistic-create literal.seedYDocFromMarkdownentirely whenhas_yjs_state === true(the provider's replay is authoritative) while still priminglastSavedBodyRefto suppress a redundant initial PUT. Seeds only when there's no persisted state. The internalfragment.length > 0guard stays as defense-in-depth.API compatibility
has_yjs_stateis optional with afalsedefault viaparseWithFallback+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
draft_yjs_test.go): a draft with ≥1draft_yjs_updaterow →has_yjs_state=true; a fresh draft →false.schemas.test.ts): missing field defaults tofalse;truepreserved.collab-editor-wiring.test.tsx): the seed RUNS whenhas_yjs_state=false, SKIPS whentrue(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 sqlcregenerated cleanly.pnpm typecheckgreen 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