editing: type into a document, reload, your words are there closed
Sixteen commits. You can open a document, type into it, reload, and your words are there — and the same document still renders for a reader with no JavaScript at all.
What this required
Not an editor bolted onto the read view. The document's CRDT root moved from a Y.Text named "content" to a ProseMirror-shaped XmlFragment named "prosemirror", because that is what y-prosemirror uses. That change reaches the Rust core, the ABI, the seeded documents, the read view and the wire gate. Discovering it in task four of six would have been expensive; it surfaced while writing the plan, from reading the code rather than recalling it.
The Rust core now walks that fragment and renders it to text and to HTML, so the server keeps rendering documents without running JavaScript. The browser loads a document by fetching its raw update log, and edits POST back as Yjs updates, validated through the same core and appended to doc_updates.
Four sequencing decisions
Plain HTTP, not a WebSocket. §7.1 specifies a socket for live collaboration between people. This is one person editing one document durably, for which a POST suffices and a socket is a liability — §7.3's hibernation problem exists precisely because Docs would hold a socket per open editor. The update-log format established here is what a socket will stream.
No pagination, no toolbar. show.html's own comment already sequenced pagination to a later layout plan, and its shape is what lets that land without restructuring. Keyboard bold and italic are the editing scope.
The Y.Text wire fixtures stay. They test the update encoding — that Rust and JS agree on bytes — which is §0's actual contract and is independent of which shared type an application puts in a document. Fragment fixtures were added alongside.
The sandbox stopped being a claim
§5.2 has argued all along that the per-document WebAssembly instance is a security boundary, not merely portability, because yrs calls from_utf8_unchecked on wire data. Until this branch, nothing on the network could reach it. Now a POST body can.
So it was measured. Every single-byte flip of a valid fixture — all 291 — POSTed through the real router: 194 accepted, 97 rejected, zero read or render errors after any accept. One flip triggered a genuine trap inside yrs (panic_const_div_by_zero in block_store::find_pivot) and was contained: instance discarded and rebuilt, 400 returned, nothing persisted, document still readable, no hang. The whole sweep took 4.1 seconds.
The security surface composes: session → origin check → owner-scoped 404-not-403 → size cap → ownership re-derived from the database → sandboxed yrs, with escaping done once in Rust against a fixed tag allowlist that emits no attributes.
Six bugs, one shape, none found by tests
Every defect of consequence on this branch was the same thing wearing different clothes: something reporting success about work that never landed. All six were found by using the application. The suite found none of them.
EditorViewappends a sibling rather than replacing its container, so the editor rendered beside the server's HTML.GET /updatesreturns concatenated updates, and a singleY.applyUpdatedecodes only the first — so a second edit vanished on reload. This required two edits to appear; every single-edit test passed.- A failed flush was retried forever and later edits were merged into the doomed batch, so a rejected 1.2MB paste silently took everything typed after it.
- Dropping a batch left the local document diverged, so subsequent edits returned 204 — success — while never integrating.
resync()'s own failure path could never retry, because a diverged document produces 204s and never the 4xx that triggers a retry. One transient blip recreated the silent loss for the whole session.- The
beforeunloadkeepalive fallback sharessendBeacon's ~64KB quota, so it could not carry the paste-sized case its comment claimed it covered. Verified: 100KB refused, 30KB through.
Each was reproduced before being fixed, and the fix confirmed against the reproduction. The queue and resync state machines were extracted into testable modules with regression tests wired into the CI gate.
Two of the defects on this branch were authored by the plan rather than the implementation: a test asserting nothing, and copy telling a person to "copy anything important before leaving this page" after resync() had already wiped the screen. Advice you cannot follow is worse than none.
Known and deliberate
The update log grows unbounded until §4.1's snapshots land, and the debounce move from 400ms to 2s pushes the 4096-row cap from ~27 minutes of continuous typing to ~2h16m — headroom, labelled as headroom, with the real fix named at the cap. The server can still return 204 for an update it cannot integrate; that class belongs to the snapshot plan. Two tabs on one document diverge until the collaboration plan adds a socket.
Reviewed as a single diff by the highest-capability model. Full CI gate green, exit 0.