Skip to content

feat(canvas): add frontend structural document replica and op queue#13

Open
FuJacob wants to merge 3 commits into
mainfrom
codex/frontend-canvas-op-queue
Open

feat(canvas): add frontend structural document replica and op queue#13
FuJacob wants to merge 3 commits into
mainfrom
codex/frontend-canvas-op-queue

Conversation

@FuJacob
Copy link
Copy Markdown
Owner

@FuJacob FuJacob commented Apr 10, 2026

What does this PR do?

Briefly describe the changes. (e.g., "Added a user authentication form" or "Refactored the API fetching logic.")

  • Adds a Zustand-backed canvas document store and a separate structural operation queue so the client can keep committed canvas state distinct from pending optimistic edits.
  • Reworks the canvas collaboration flow to bootstrap from committed server state, buffer structural events during join, and reconcile committed events back into the local replica.
  • Updates the websocket contract so structural messages carry clientOperationId metadata and the sender receives the committed structural event back as the queue acknowledgment path.

Why is this necessary?

Explain the problem you are solving. If you look back at this in 6 months, will you know why you wrote this code?

  • The old frontend flow mutated local canvas state and sent websocket events in the same hook, which worked for a peer relay but broke down once structural truth moved behind the durable persist-first backend.
  • Without a dedicated client replica and send queue, the frontend had no clean way to hydrate from 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!)

Did you figure out a new CSS trick? Finally understand Promises? Make a note of it here.

  • Keeping committed base state and optimistic projected state as separate layers is much easier to reason about than trying to mutate one store in place and patch over reconciliation afterward.

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 10, 2026

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

Project Deployment Actions Updated (UTC)
leet-canvas Ready Ready Preview, Comment Apr 10, 2026 1:47am

@FuJacob FuJacob marked this pull request as ready for review April 10, 2026 01:07
Copilot AI review requested due to automatic review settings April 10, 2026 01:07
@FuJacob FuJacob changed the title feat(canvas): add frontend structural document replica and op queue feat(canvas): add structural op queue and smooth remote cursors Apr 10, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment on lines 360 to 364
String committedPayload = objectMapper.writeValueAsString(
structuralBroadcastMessage(canvasId, committed)
);
sendToSession(session, new TextMessage(committedPayload));
broadcastStructuralToCanvas(canvasId, session.getId(), committed.version(), committedPayload);
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +116 to +119
} catch (error) {
console.error("Failed to dispatch queued structural operation", error);
operationQueueStore.getState().setDispatchReady(false);
}
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

On purpose, we'll follow up with a robust recovery handling

Comment on lines +137 to +141
documentStore.getState().applyOptimisticOperations(events);
const queue = operationQueueStore.getState();
for (const event of events) {
queue.enqueue(event);
}
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +49
// 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]);
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines 9 to 13
- 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.
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
@FuJacob FuJacob force-pushed the codex/frontend-canvas-op-queue branch from 3caac69 to 26a5da8 Compare April 10, 2026 01:11
@FuJacob FuJacob changed the title feat(canvas): add structural op queue and smooth remote cursors feat(canvas): add frontend structural document replica and op queue Apr 10, 2026
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.

2 participants