fix(files): stop the collaborative editor rewriting and reflowing a document on open - #6652
fix(files): stop the collaborative editor rewriting and reflowing a document on open#6652icecrasher321 wants to merge 1 commit into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryHigh Risk Overview CRDT / markdown parity: Shared documents are seeded, merged, streamed, and cached through Persist: Skips writes when the projected markdown already matches durable bytes (avoids rotating storage keys and 404ing in-flight reads on a mere open). Still refreshes canonical cached snapshots on no-op. Markdown parse: Preserves authored interior blank lines with per-gap and per-doc caps instead of collapsing everything; agent stream path uses the same bounds and normal form. Collaboration UX: Readiness revokes on fatal join (editable UI on an abandoned provider). Live editor reveal waits one settled frame after sync so in-flight CRDT updates don’t flash wrong layout. File detail route gets layout-level file-list prefetch, detail Content reads: 404 on content URL triggers record re-resolution; collab surfaces disable focus refetch of durable bytes when the relay owns durability. Reviewed by Cursor Bugbot for commit 6cb2a7b. Configure here. |
Greptile SummaryThis PR normalizes collaborative documents at CRDT boundaries, avoids byte-identical persistence writes, improves collaboration readiness and stale-key recovery, and aligns initial file-route rendering with the hydrated client.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| apps/sim/lib/collab-doc/converter.ts | Introduces a shared editor normal form and canonicalizes detached collaborative snapshots against their durable markdown projection. |
| apps/sim/lib/collab-doc/persist.ts | Canonicalizes cached snapshots and skips persistence when projected bytes already match durable content. |
| apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts | Preserves representable authored blank lines while enforcing per-gap and per-document paragraph bounds. |
| apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/readiness.ts | Revokes editor readiness when the collaborative provider enters a fatal state. |
| apps/sim/app/workspace/[workspaceId]/files/[fileId]/page.tsx | Prefetches workspace file records for consistent server and client detail-route rendering. |
| apps/sim/hooks/queries/workspace-files.ts | Re-resolves workspace file records after failed content reads to recover from rotated storage keys. |
Reviews (3): Last reviewed commit: "fix(files): stop the collaborative edito..." | Re-trigger Greptile
361f0f7 to
9674e20
Compare
9674e20 to
105051b
Compare
|
bugbot run |
105051b to
9a6c25d
Compare
|
bugbot run |
…ocument on open Opening a file rewrote it. Binding an editor to a seeded document emits a Yjs update of its own — ProseMirror appends an empty paragraph to any doc that does not end in one — which the relay saw as a real edit and persisted. Every open therefore uploaded the file under a FRESH storage key and deleted the old one, 404ing the page's own in-flight content read, bumping "Last Updated" just from viewing, and churning a blob per open. Worse, a trailing blank line cannot serialize, so the file never recorded that paragraph and nothing reconciled the two: each client that seeded without seeing another's contribution stacked one more. A real document reached 18 against the placeholder's 1 — measured as the pane growing several hundred pixels the instant the live editor took over. - Seed and merge through the editor's own normal form (`editorNormalForm`), so binding is a no-op and `canonicalizeYDoc` collapses an accumulated run back to one. Placed at the collab boundary, not in `parseMarkdownToDoc`: only the CRDT has to agree with the editor — every other consumer of the parse renders through a real editor that normalizes itself. - Skip a persist whose projection already matches the durable bytes. Byte length is the free reject, so the compare read only happens when a no-op write is actually on the table. - Revoke collaborative readiness on a fatal join. The sticky `syncedOnce` latch outlived the document: after a readiness timeout the provider drops `synced` so the gate closes, but the latch re-opened it on the offline fallback's seed flag — handing back an EDITABLE editor on a document the provider had abandoned, with client autosave gated off because collaboration is nominally on. Keystrokes went nowhere and vanished on reload, with no error shown. - Recover from a superseded storage key instead of stranding the reader: a 404 re-resolves the file record, so the read re-keys onto the current object. And do not focus-refetch durable bytes while the relay owns durability. - Prefetch the workspace file list in the layout, where the sidebar already reads it. `HydrationBoundary` defers an already-seen query to an effect that SSR never runs, so a page-level prefetch of that key could not reach the server render — the file route rendered a spinner and disagreed with the client about the header's markup (a hydration mismatch). - Load the document font with `display: block`. A swap repaints prose in metric-adjusted Arial first, so paragraphs re-wrap when the real face lands. Also: a detail-route `loading.tsx` (the segment was inheriting the list chrome), `normalize.ts` renamed to `field.ts` now that it holds only the field constant, and the duplicate `COLLAB_DOC_FIELD` in the streaming path folded into it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9a6c25d to
6cb2a7b
Compare
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 6cb2a7b. Configure here.
Opening a file rewrote it, and the pane visibly re-laid-out a beat after it painted. Both came from the live document drifting out of the shape its own markdown can describe.
What was happening
ProseMirror appends an empty paragraph to any document that does not end in one. The server seeded the CRDT with the raw parse, so the first client to bind wrote that paragraph back into the shared document — which the relay saw as a real edit and persisted. Every open therefore:
FileNotFoundErroron a file you are looking at),contentUpdatedAt, so "Last Updated" moved just from viewing a file,And because a trailing blank line cannot serialize (
postProcessSerializedMarkdowncollapses it), the file never recorded that paragraph, so nothing ever reconciled the two — each client that seeded without seeing another's contribution stacked one more. Measured on a real document: 18 stacked empty paragraphs in the live doc against the placeholder's 1, i.e. the pane growing several hundred pixels the instant the live editor took over.Changes
editorNormalFormincollab-doc/converter.ts), so binding is a no-op andcanonicalizeYDoc— which is parse ∘ serialize — collapses an accumulated run back to one. Placed at the collab boundary rather than inparseMarkdownToDoc: only the CRDT has to agree with the editor. Every other consumer of the parse (paste, the round-trip probe, the read-only placeholder, note blocks, skills) renders through a real editor that normalizes itself — putting it in the parse broke 17 tests on exactly those surfaces.syncedOncelatch outlived the document: after a readiness timeout the provider dropssyncedso the gate closes, but the latch re-opened it on the offline fallback's seed flag — handing back an editable editor on a document the provider had abandoned, with client autosave gated off because collaboration is nominally on. Keystrokes went nowhere and vanished on reload, with no error shown.HydrationBoundaryhands an already-seen query to auseEffectthat SSR never runs, so a page-level prefetch of that key could not reach the server render. The file route rendered a spinner and disagreed with the client about the header's markup — a hydration mismatch. Verified: the SSR probe went fromfilesLen:0, hasSelected:falsetofilesLen:19, hasSelected:true, and the served HTML now carries the real breadcrumb trail instead of the…placeholder.display: block. A swap repaints prose in metric-adjusted Arial first, so paragraphs re-wrap when the real face lands — visible on every hard refresh, since that bypasses the font cache.Also: a detail-route
loading.tsx(the segment was inheriting the list chrome — an options bar and table header a document page doesn't have),normalize.tsrenamed tofield.tsnow that it holds only the field constant, and the duplicateCOLLAB_DOC_FIELDin the streaming path folded into it.Verification
Measured in a headless browser against real documents, not just unit tests:
POST /api/internal/file-doc/seedunaffected at ~25ms.New regression tests: 6 "binding an editor to the seed changes nothing" cases (list/heading/table/rule/paragraph/blank-line endings), 6 no-op-persist cases including the fail-open paths, 4 stale-storage-key cases, and 2 readiness cases that both return
ready: trueunder the old formula. The parity helper now compares against the shared normal form — it had been asserting a shape neither side renders.markdown-parse.test.ts's two 400-seed property tests were re-budgeted from 30s to 60s: this branch adds the second one, and at 30s both timed out under whole-suite parallelism while passing standalone.Full
apps/simsuite: 1825 files / 23948 tests, 0 failures.tsc, api-validation, react-query, client-boundary, utils, import-specifiers all clean.Reveal the live document only once it has settled
collabReadyflipped the moment the provider reportedsynced, but a room's remaining updates can still be in flight — reconnecting to a room edited moments ago, the handshake lands on the base state and the edit arrives milliseconds later. The live editor was therefore un-hidden onto an intermediate CRDT state and corrected itself in view.Measured on a reload right after a
Mod-Shift-ArrowDownblock move:The placeholder was right; the swap was the only reason anything moved. Readiness now waits for one animation frame with no update to the shared document, and any update restarts the wait. Going NOT-ready stays immediate, so nothing keeps a fatal or unsynced document editable a moment longer than before. After the fix the same repro absorbs the stale state while still hidden:
Cost is at most one frame of a placeholder that is already showing the correct content.
Ruled out along the way, by measurement rather than reasoning: the move does reach the durable markdown (within 6s, and the typing control too), so this was never a lost write.
Unrelated flake seen while validating
lib/workflows/diff/diff-engine.test.tstimes out at 10s under whole-suite parallelism (2.4s standalone) in roughly two of three full runs. Untouched by this branch — flagging because CI may hit it.