Skip to content

fix(files): stop the collaborative editor rewriting and reflowing a document on open - #6652

Open
icecrasher321 wants to merge 1 commit into
stagingfrom
fix/files-editor-open-fidelity
Open

fix(files): stop the collaborative editor rewriting and reflowing a document on open#6652
icecrasher321 wants to merge 1 commit into
stagingfrom
fix/files-editor-open-fidelity

Conversation

@icecrasher321

@icecrasher321 icecrasher321 commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

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:

  • uploaded the file under a fresh storage key and deleted the old one, 404ing the page's own in-flight content read (FileNotFoundError on a file you are looking at),
  • bumped contentUpdatedAt, so "Last Updated" moved just from viewing a file,
  • churned a blob write + delete + DB transaction per open.

And because a trailing blank line cannot serialize (postProcessSerializedMarkdown collapses 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

  • Seed and merge through the editor's own normal form (editorNormalForm in collab-doc/converter.ts), so binding is a no-op and canonicalizeYDoc — which is parse ∘ serialize — collapses an accumulated run back to one. Placed at the collab boundary rather than in parseMarkdownToDoc: 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.
  • 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. On a no-op it reports the file's current version, resyncing a stale If-Match instead of conflicting.
  • 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. A 404 from a content read means the pointer is stale, not that the file is gone: re-resolve the record so the read re-keys onto the current object. Invalidates the record, never the failed query, which is what keeps it loop-proof. And don't focus-refetch durable bytes while the relay owns durability.
  • Prefetch the workspace file list in the layout. The sidebar reads that query on every workspace route, so it registers before any page renders; HydrationBoundary hands an already-seen query to a useEffect 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. Verified: the SSR probe went from filesLen:0, hasSelected:false to filesLen:19, hasSelected:true, and the served HTML now carries the real breadcrumb trail instead of the placeholder.
  • 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 — 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.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.

Verification

Measured in a headless browser against real documents, not just unit tests:

  • placeholder vs live editor on a cold room: 18 blocks vs 18, zero differing positions, no geometry change at the swap (previously 52 vs 69).
  • a warm, edited room joined by a second client: identical again — the accumulation path is closed, not just healed.
  • POST /api/internal/file-doc/seed unaffected 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: true under 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/sim suite: 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

collabReady flipped the moment the provider reported synced, 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-ArrowDown block move:

t=1484ms editor#1 (placeholder)   T|BBB|AAA|CCC   ← correct the whole time
t=1570ms editor#2 (live) REVEALED T|AAA|BBB|CCC   ← pre-move state
t=1577ms editor#2 (live)          T|BBB|AAA|CCC   ← corrects 7ms later

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:

t=2154ms editor#2 hidden=true   (empty)
t=2246ms editor#2 hidden=true   T|AAA|BBB|CCC   ← stale, but not on screen
t=2256ms editor#2 hidden=false  T|BBB|AAA|CCC   ← revealed already correct

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.ts times 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.

@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Aug 13, 2026 3:16am

Request Review

@cursor

cursor Bot commented Aug 13, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Touches core collaborative file editing, markdown round-trip, and server persist/seed paths; regressions could cause data loss, read-only files, or silent edit drops, though coverage is extensive.

Overview
Fixes opening a collaborative markdown file rewriting storage, visibly reflowing the pane after paint, and sometimes leaving users typing into a dead session.

CRDT / markdown parity: Shared documents are seeded, merged, streamed, and cached through editorNormalForm and canonicalizeYDoc so the live Yjs doc matches what markdown can round-trip. That stops the first client bind from appending trailing empty paragraphs that persist as fake edits and stack (placeholder vs live block count drift).

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 loading.tsx, and display: block for the Season font to reduce prose reflow on hard refresh.

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-apps

greptile-apps Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This 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.

  • Preserves authored markdown spacing while bounding pathological empty-paragraph growth.
  • Canonicalizes seeded and persisted Yjs snapshots against durable markdown.
  • Keeps fatal collaborative sessions read-only and delays live-document reveal until updates settle.
  • Prefetches workspace files for detail-route SSR and adds matching loading chrome.
  • Prevents document-font swaps from visibly reflowing prose.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

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

Comment thread apps/sim/lib/collab-doc/persist.ts
Comment thread apps/sim/lib/collab-doc/converter.ts
@icecrasher321
icecrasher321 force-pushed the fix/files-editor-open-fidelity branch from 361f0f7 to 9674e20 Compare August 13, 2026 02:15
@icecrasher321
icecrasher321 force-pushed the fix/files-editor-open-fidelity branch from 9674e20 to 105051b Compare August 13, 2026 02:29
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

bugbot run

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321
icecrasher321 force-pushed the fix/files-editor-open-fidelity branch from 105051b to 9a6c25d Compare August 13, 2026 02:51
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

bugbot run

Comment thread apps/sim/lib/collab-doc/converter.ts
…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>
@icecrasher321
icecrasher321 force-pushed the fix/files-editor-open-fidelity branch from 9a6c25d to 6cb2a7b Compare August 13, 2026 03:16
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

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