Collaborative script editing: Vue 3 Yjs foundation (Phase 2) - #1366
Collaborative script editing: Vue 3 Yjs foundation (Phase 2)#1366Tim020 wants to merge 2 commits into
Conversation
Phase 2 of the collab v3 rebuild (issue #889, plans/COLLABORATIVE_EDITING_V3_PLAN.md), following Phase 1's backend port (#1359, merged to dev). Builds the isolated Yjs foundation client-v3 needs before any editor UI wires into it: a Y.Doc can now be joined, synced, and observed reactively — no script editor UI wired up yet, matching this phase's exit criteria. New files under client-v3/src/: - js/yjs/base64.ts — bytesToBase64/base64ToBytes for the WS protocol's base64-in-JSON binary transport - js/yjs/ScriptDocProvider.ts — thin wrapper over a caller-supplied send function; applies incoming YJS_SYNC (step 0 full state, step 2 diff)/YJS_UPDATE with a SERVER_ORIGIN echo guard so server-applied updates are never re-sent back to the server (a save's ID-patch broadcast goes to *all* clients including the saver, so without this guard it would loop) - js/yjs/yjsSnapshot.ts — one-way Y.Doc -> plain-object snapshot builder for read paths (editing, in Phase 3, writes directly to the live Y.Map/Y.Text instead) - stores/scriptDraft.ts — new Pinia store; the Y.Doc/provider live in a plain module-level holder entirely outside state(), so nothing needs markRaw — Pinia only wraps what state() returns, and a module-level `let` is never touched by its reactivity at all - composables/useScriptDraft.ts — join/leave lifecycle via onMounted/onBeforeUnmount All 9 collab WS actions are camelCase methods on scriptDraft.ts, picked up automatically by useWebSocket's existing ACTION -> camelCase dispatch convention (no changes to that file). GET_SCRIPT_CONFIG_STATUS and REQUEST_EDIT_FAILURE stay owned by scriptConfig.ts, extended to keep editors/cutters/hasDraft instead of discarding them (dispatch is first-match-wins across every instantiated store, so a second store defining the same action name would silently steal the message) -- scriptDraft.ts exposes editors/cutters as getters reading scriptConfig instead of duplicating the fetch. Three real bugs this phase's own tests caught before merge, not from review: - parseDbId used parseInt, which reads a numeric prefix out of a UUID like "3fa85f64-..." and misreports a brand-new line as DB id 3. Switched to Number(), which requires the whole string to be numeric, matching the server's int(float(str(line_id))). - The snapshot refresh was pages-only (observeDeep on the pages map), but deleted_line_ids and meta are separate top-level shared types a pages-only observer can't see -- a save that only clears deleted_line_ids would leave that state stale. Switched to a doc-wide doc.on('update', ...) listener. - Two same-store naming collisions between a state field and a WS-dispatched action sharing a name (saveProgress, collabError) -- Pinia silently breaks on this and tsc doesn't catch it, only Vitest did. Renamed the state fields (pageSaveProgress, lastCollabError) and kept the action names exact. Verified rather than assumed that isDraftDirty ends up correct after a save: the ID-patch YJS_UPDATE broadcast in save_room runs before the SCRIPT_SAVED broadcast, and per-connection WS delivery is ordered, so a yjsUpdate flipping the dirty flag back to true is always overwritten by the scriptSaved that follows -- confirmed against script_room_manager.py and locked in with a regression test. yjs added as a dependency; nothing in the entry graph imports these files yet, so it tree-shakes out of the production build entirely (confirmed via npm run build) -- expected per this phase's exit criteria, not a bug. Tests: 24 new (10 yjsSnapshot, 11 ScriptDocProvider incl. the echo-guard cases, 15 scriptDraft store), 75/75 client-v3 Vitest passing (was 51). tsc --noEmit and eslint both clean. Full chromium E2E (221/221) green as an inert-change check -- nothing is wired in yet, so this mainly confirms Phase 1's backend and the existing frontend still work. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TjTnfchFvvQvKZUZv2jpT8
Client V3 Test Results90 tests 90 ✅ 0s ⏱️ Results for commit ac5c6e0. ♻️ This comment has been updated with latest results. |
Client Test Results132 tests 132 ✅ 0s ⏱️ Results for commit ac5c6e0. ♻️ This comment has been updated with latest results. |
Python Test Results 1 files 1 suites 2m 20s ⏱️ Results for commit ac5c6e0. ♻️ This comment has been updated with latest results. |
Playwright E2E Results (firefox)221 tests 221 ✅ 2m 11s ⏱️ Results for commit ac5c6e0. ♻️ This comment has been updated with latest results. |
Playwright E2E Results (chromium)221 tests 221 ✅ 2m 18s ⏱️ Results for commit ac5c6e0. ♻️ This comment has been updated with latest results. |
🤖 Automated PR Review (Claude Code)This is an automated review generated by Claude Code's Critical Issues
Important Issues
Suggestions
Strengths
Recommended Action
Generated by Claude Code's PR review toolkit (5 parallel specialized review agents). Not a human review — please verify findings before acting on them. |
Every Critical and Important finding in this round checked out as real on verification -- unlike prior rounds, nothing here was a false positive. Full triage in plans/collab_v3_pr1366_review_response.md. Critical fixes: - draftYdoc was a Pinia getter (a computed()) reading a plain module-level variable with no reactive dependency, so it cached its first value forever. Verified empirically with a throwaway test before fixing. Converted to a plain action getDraftYdoc(), which always reads fresh. - The module-level ydoc/provider holder could leak across store instances: joinScriptRoom's re-entry guard checked per-instance isDraftActive, not the actual module-level resource, so a stale instance could leave a live doc orphaned. Landed a 3-case guard (duplicate call: ignore: stale resource: tear down and retry: nothing yet: proceed normally) rather than the reviewer's own suggested "always tear down and rejoin," which would have discarded un-checkpointed edits and conflicted with the refcount fix below. - A rejected JOIN_SCRIPT_ROOM (COLLAB_ERROR for no show/no revision/live session/a Y.Doc build failure) left isDraftActive stuck true forever, since collabError never tore anything down. Now tears down only when mid-join (active but never synced), leaving a post-join error on an otherwise-healthy room alone. - A corrupt YJS_SYNC payload was silently swallowed and the store marked itself synced anyway, hiding a stale/empty doc behind a healthy UI. applySync/applyUpdate now return success booleans; the store only marks itself synced when the apply actually succeeded. requestSync()'s reconnect wiring stays deferred to Phase 5 per the plan, as building it now would be premature without that phase's broader design. Important fixes: - useScriptDraft.ts had no join refcount -- two simultaneous consumers (an editor plus a presence panel, the shape Phase 3 anticipates) would have the first to unmount tear the room down for both. Added a module-level refcount. - useScriptDraft.ts had zero tests; added 6 covering join/leave, the refcount behaviour, and the doc/snapshot passthrough, using @vue/test-utils. - The "doc-wide vs pages-scoped listener" fix from the prior round wasn't actually pinned -- every test only mutated pages, so reverting to a pages-scoped observer would still pass. Added a test mutating deleted_line_ids alone. - The save-ordering regression test was tautological and missed the real risk: a local edit landing between saveDraft() and scriptSaved had its dirty flag silently wiped, hiding genuinely unsaved work. Fixed with a local-edit counter (server-originated updates, like the save's own ID-patch broadcast, don't count) and a regression test for the race. - yjsSync/yjsUpdate now log when dropped due to no active provider. - SaveProgressMessage was missing percent, which the server actually sends; added it. Removed RequestEditFailureMessage, an unused type modelling a payload nothing consumes. - Comments pointing at the gitignored plan doc or a local-machine-only Claude Code memory file were rewritten to be self-contained. - Fire-and-forget WS sends (join/save/discard) now bail early with a toast when the socket isn't connected, rather than entering an optimistic state the server will never resolve. The full stall-watchdog handling stays Phase 3 scope, per the plan's existing phasing. Also corrected two overclaims this phase's own commit/plan doc made before this review: the "doc-wide listener" and "save-ordering" fixes were described as verified/pinned by tests when neither test actually exercised the failing scenario. Walked the plan doc's Phase 2 section back to describe what was actually true at each point rather than leave the overclaim standing. Tests: 90/90 client-v3 Vitest passing (was 75 -- 15 new). tsc --noEmit and eslint both clean. npm run build succeeds (still tree-shakes to zero bytes, unchanged -- nothing from this phase is wired into the app yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TjTnfchFvvQvKZUZv2jpT8
|
|
Thanks — this was a much sharper review than the earlier round on this feature. Went through all 4 Critical and 8 Important findings; every one checked out as real once verified against the code, no false positives this time. Fixed in ac5c6e0: Fixed (verified real):
Suggestions landed: Suggestions deferred, with reasons (Symbol sentinel, a Also corrected two overclaims my own Phase 2 commit/plan-doc text made before this review — I'd described the doc-wide-listener and save-ordering fixes as "verified"/"locked in with a regression test" when neither test actually exercised the failing scenario. Walked the plan doc back to describe what was actually true at each point. 90/90 Vitest passing (was 75), |



Summary
Phase 2 of the collaborative script editing rebuild (issue #889), following Phase 1's backend port (#1359, merged to
dev). Full design/rationale:plans/COLLABORATIVE_EDITING_V3_PLAN.md(gitignored — ask if you want it pasted).This phase builds the isolated Vue 3 / Yjs foundation
client-v3needs before any editor UI wires into it. Per the plan's exit criteria: a Y.Doc can be joined, synced, and observed reactively — no script editor UI wired up yet. That's deliberate, not incomplete — wiring it intoScriptEditor.vueis Phase 3.New files under
client-v3/src/:js/yjs/base64.ts— base64⇄Uint8Arrayfor the WS protocol's binary-in-JSON transportjs/yjs/ScriptDocProvider.ts— thin wrapper over a caller-suppliedsendfunction; applies incomingYJS_SYNC/YJS_UPDATEwith aSERVER_ORIGINecho guard so server-applied updates are never re-sent back to the server (a save's ID-patch broadcast goes to all clients including the saver — without this guard that loops)js/yjs/yjsSnapshot.ts— one-way Y.Doc → plain-object snapshot builder for read pathsstores/scriptDraft.ts— new Pinia store; the Y.Doc/provider live in a plain module-level holder entirely outsidestate(), so nothing needsmarkRaw— Pinia only wraps whatstate()returnscomposables/useScriptDraft.ts— join/leave lifecycle viaonMounted/onBeforeUnmount, with a join refcount so multiple simultaneous consumers share one roomAll 9 collab WS actions are camelCase methods on
scriptDraft.ts, picked up automatically byuseWebSocket's existing dispatch convention (no changes to that file).GET_SCRIPT_CONFIG_STATUS/REQUEST_EDIT_FAILUREstay owned byscriptConfig.ts(extended to keepeditors/cutters/hasDraftinstead of discarding them) — dispatch is first-match-wins across every instantiated store, soscriptDraft.tsexposeseditors/cuttersas getters readingscriptConfigrather than duplicating the fetch.Bugs caught by this phase's own tests, pre-push
parseDbIdusedparseInt, which reads a numeric prefix out of a UUID like"3fa85f64-..."and misreports a brand-new line as DB id3. Fixed withNumber(), matching the server'sint(float(str(line_id))).pages-only (observeDeepon the pages map), butdeleted_line_ids/metaare separate top-level shared types apages-only observer can't see. Switched to a doc-widedoc.on('update', ...)listener.saveProgress,collabError) — Pinia silently breaks on this andtscdoesn't catch it, only Vitest did. Renamed the state fields (pageSaveProgress,lastCollabError).Automated review response (2026-09-08)
A
pr-review-toolkitbot review found 4 Critical + 8 Important issues — every single one checked out as real on verification (unlike prior rounds on this feature, no false positives this time). Full ledger inplans/collab_v3_pr1366_review_response.md(gitignored — ask if you want it pasted). Fixed:draftYdocwas a broken Pinia getter. It read a plain module-level variable with no reactive dependency, so as acomputed()it cached its first value forever — verified empirically with a throwaway test before fixing. Converted to a plain actiongetDraftYdoc().joinScriptRoomsets. Now tears down, but only when genuinely mid-join.useScriptDraft.ts(two simultaneous consumers previously meant one's unmount could kill the room for both),useScriptDraft.tsgoing from 0 to 6 tests, a dirty-flag race where an edit made during a save could be silently marked saved and lost, and two of this phase's own claimed "regression tests" (doc-wide listener, save-ordering) turning out not to actually pin what they claimed — both now do.I also corrected two overclaims this phase's own commit/plan doc made before this review landed, rather than leave them standing.
Test plan
client-v3Vitest passing (was 51 → 75 → 90 across the two rounds above)tsc --noEmit/eslint: cleannpm run build: succeeds;yjsis a new dependency but nothing in the entry graph imports these files yet, so it tree-shakes out of the production build entirely (bundle size unchanged) — expected per this phase's exit criteria, not an oversight🤖 Generated with Claude Code
https://claude.ai/code/session_01TjTnfchFvvQvKZUZv2jpT8