feat(canvas): add frontend structural document replica and op queue#13
feat(canvas): add frontend structural document replica and op queue#13FuJacob wants to merge 3 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR introduces a client-side “committed vs optimistic” architecture for canvas structure by adding a Zustand-backed document replica plus a durable structural operation queue, and updates the WebSocket flow/contract to support server-echo acknowledgements via clientOperationId.
Changes:
- Add a client document store that maintains base (committed) and projected (base + pending ops) structural state.
- Add a FIFO structural operation queue with dispatch gating until
canvas_bootstrap, and reconcile on committed event echo. - Update the collaboration join/reconcile path and WebSocket handler so the sender also receives committed structural events as acknowledgements.
Reviewed changes
Copilot reviewed 12 out of 13 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| services/collab/src/main/java/com/leetdoodle/collab/handler/CanvasWebSocketHandler.java | Echo committed structural payload back to sender as an ack path. |
| frontend/src/shared/events.ts | Split structural vs immediate events; add clientOperationId and committed structural event types/guards. |
| frontend/src/canvas/ops/canvasOperationQueueStore.ts | New Zustand vanilla store for structural operation queueing/dispatch gating. |
| frontend/src/canvas/document/canvasDocumentStore.ts | New Zustand vanilla store for base vs projected structural document state and rebasing. |
| frontend/src/canvas/model/useCanvasDocument.ts | Route structural commands into optimistic projection + queue instead of immediate socket send. |
| frontend/src/canvas/hooks/useCanvasCollab.ts | Bootstrap from committed state, buffer structural events during join, dispatch queue, and reconcile on committed echoes. |
| frontend/src/canvas/Canvas.tsx | Wire the new document/queue stores into canvas UI + collab hook. |
| frontend/package.json | Add zustand dependency. |
| frontend/pnpm-lock.yaml | Lockfile updates for zustand. |
| docs/backend/contracts/websocket-events.md | Note that sender receives committed structural events as ack. |
Files not reviewed (1)
- frontend/pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| String committedPayload = objectMapper.writeValueAsString( | ||
| structuralBroadcastMessage(canvasId, committed) | ||
| ); | ||
| sendToSession(session, new TextMessage(committedPayload)); | ||
| broadcastStructuralToCanvas(canvasId, session.getId(), committed.version(), committedPayload); |
There was a problem hiding this comment.
sendToSession(session, ...) can throw and will prevent broadcastStructuralToCanvas(...) from running, which means a committed structural operation may not be delivered to other peers if the sender’s socket errors/closes between commit and ack send. Consider wrapping the ack send in a try/catch (or sending the broadcast first) so peer delivery isn’t coupled to the sender ack path.
| } catch (error) { | ||
| console.error("Failed to dispatch queued structural operation", error); | ||
| operationQueueStore.getState().setDispatchReady(false); | ||
| } |
There was a problem hiding this comment.
On send failure you set dispatchReady to false, which effectively stalls the structural operation queue with no recovery path unless a reconnect/bootstrap happens. Consider instead re-queueing the operation (status back to pending) and keeping dispatch enabled, or explicitly closing the socket to trigger a reconnect/rebootstrap so queued edits don’t get stuck indefinitely.
There was a problem hiding this comment.
On purpose, we'll follow up with a robust recovery handling
| documentStore.getState().applyOptimisticOperations(events); | ||
| const queue = operationQueueStore.getState(); | ||
| for (const event of events) { | ||
| queue.enqueue(event); | ||
| } |
There was a problem hiding this comment.
enqueueStructuralOperations enqueues each operation with a separate Zustand set via queue.enqueue(event). For multi-node drags this can cause N store updates (and subscribers firing N times) per pointer move. Consider adding a batched enqueue (e.g., enqueueMany(events)) or a single set that appends all operations at once to reduce render/subscribe churn.
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| const documentStore = useMemo(() => createCanvasDocumentStore(), [canvasId]); | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| const operationQueueStore = useMemo(() => createCanvasOperationQueueStore(), [canvasId]); |
There was a problem hiding this comment.
The react-hooks/exhaustive-deps disables look unnecessary here (the memo callbacks only reference imported factory functions). Keeping the disables can hide real dependency issues later; consider removing them and letting the linter enforce correctness.
| - Client sends JSON messages with a `type` discriminator. | ||
| - Ephemeral events are relayed directly in-memory. | ||
| - Durable structural events are committed through `canvas-service` first, then broadcast with committed metadata. | ||
| - The sender also receives the committed structural event back as the client-side ack path for optimistic queue reconciliation. | ||
| - `crdt_op` and `sync_request` are special-cased for replay/sync. |
There was a problem hiding this comment.
This doc now mentions the sender receiving the committed structural event as an ack, but the contract examples below still list structural outbound events without the required clientOperationId field (and imply the old relay-style payloads). Please update the “Event Types (Client -> Server)” section (and any examples) to reflect the new structural message shape with clientOperationId metadata.
3caac69 to
26a5da8
Compare
What does this PR do?
clientOperationIdmetadata and the sender receives the committed structural event back as the queue acknowledgment path.Why is this necessary?
canvas_bootstrap, survive the join race, or tell the difference between optimistic local intent and committed server state.🧠 What did I learn? (The most important part!)