diff --git a/frontend/src/components/CollaborativeCanvas.tsx b/frontend/src/components/CollaborativeCanvas.tsx index 37555550..026451fc 100644 --- a/frontend/src/components/CollaborativeCanvas.tsx +++ b/frontend/src/components/CollaborativeCanvas.tsx @@ -1,10 +1,8 @@ 'use client'; -import { - useAwareness, - useCanvasCollaboration, - useSharedCanvas, -} from '@/hooks/useCanvasCollaboration'; +import { useCollaborativeEditor } from '@/hooks/useCollaborativeEditor'; +import { useAwareness, useSharedCanvas } from '@/hooks/useCanvasCollaboration'; +import { ReconnectBanner } from '@/components/collaboration/ReconnectBanner'; import html2canvas from 'html2canvas'; import jsPDF from 'jspdf'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -29,8 +27,20 @@ interface CollaborativeCanvasProps { export function CollaborativeCanvas({ roomId, userId, onCanvasReady }: CollaborativeCanvasProps) { const canvasRef = useRef(null); const [isExporting, setIsExporting] = useState(false); + const [showConflictResolver, setShowConflictResolver] = useState(false); + + // Use the safe reconnect hook instead of the plain useCanvasCollaboration. + const { + doc, + awareness, + isConnected, + reconnectStatus, + hasConflict, + pendingUpdateCount, + reconnect, + dismissConflict, + } = useCollaborativeEditor(roomId, userId); - const { doc, awareness, isConnected } = useCanvasCollaboration(roomId, userId); const { nodes, edges, @@ -40,6 +50,7 @@ export function CollaborativeCanvas({ roomId, userId, onCanvasReady }: Collabora addEdge: addCanvasEdge, deleteEdge, } = useSharedCanvas(doc); + const remoteUsers = useAwareness(awareness); useEffect(() => { @@ -226,6 +237,21 @@ export function CollaborativeCanvas({ roomId, userId, onCanvasReady }: Collabora return (
+ {/* ----------------------------------------------------------------- */} + {/* Reconnect / conflict banner (zero-height when not needed) */} + {/* ----------------------------------------------------------------- */} + setShowConflictResolver(true)} + onDismissConflict={dismissConflict} + /> + + {/* ----------------------------------------------------------------- */} + {/* Toolbar */} + {/* ----------------------------------------------------------------- */}
@@ -234,7 +260,8 @@ export function CollaborativeCanvas({ roomId, userId, onCanvasReady }: Collabora

@@ -298,6 +325,9 @@ export function CollaborativeCanvas({ roomId, userId, onCanvasReady }: Collabora

+ {/* ----------------------------------------------------------------- */} + {/* Collaborator presence bar */} + {/* ----------------------------------------------------------------- */}
{remoteUsers.length > 0 @@ -311,6 +341,7 @@ export function CollaborativeCanvas({ roomId, userId, onCanvasReady }: Collabora className="inline-flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold text-white" style={{ backgroundColor: user.color }} title={user.name} + aria-label={`Collaborator: ${user.name}`} > {user.name.charAt(0)} @@ -318,6 +349,9 @@ export function CollaborativeCanvas({ roomId, userId, onCanvasReady }: Collabora
+ {/* ----------------------------------------------------------------- */} + {/* Canvas */} + {/* ----------------------------------------------------------------- */}
{doc ? ( )}
+ + {/* ----------------------------------------------------------------- */} + {/* Conflict resolver modal (lazy – only rendered when triggered) */} + {/* ----------------------------------------------------------------- */} + {showConflictResolver && doc && ( +
+
+
+

Review Remote Changes

+ +
+

+ The shared canvas was updated while you were disconnected. Your pending edits have + been merged automatically using Yjs CRDT. If anything looks wrong, you can undo + recent changes from the canvas toolbar. +

+
+ +
+
+
+ )} ); } diff --git a/frontend/src/components/collaboration/ReconnectBanner.tsx b/frontend/src/components/collaboration/ReconnectBanner.tsx new file mode 100644 index 00000000..c5950ccd --- /dev/null +++ b/frontend/src/components/collaboration/ReconnectBanner.tsx @@ -0,0 +1,193 @@ +'use client'; + +/** + * ReconnectBanner + * + * A user-facing status banner that surfaces three collaboration states: + * + * • disconnected / reconnecting – tells the student their edits are being + * saved locally and will sync once the connection is restored. Includes a + * manual "Reconnect" action. + * + * • conflict detected – lets the student know remote changes + * arrived while they were offline and gives them the option to review the + * diff (via onReviewConflict) or dismiss the notice. + * + * Accessibility: + * – Uses role="status" (polite live region) for non-disruptive announcements + * and role="alert" (assertive) for conflict warnings. + * – Buttons carry descriptive aria-labels so screen-reader users know exactly + * what each action does. + * – The banner is keyboard-focusable via normal tab order. + * – No internal error codes or stack traces are surfaced to the user. + * + * Layout: + * – Positioned at the top of its nearest positioned ancestor so it overlays + * the canvas without pushing content down. + * – Uses responsive flex layout that stacks on narrow viewports. + */ + +import type { ReconnectStatus } from '@/hooks/useCollaborativeEditor'; + +export interface ReconnectBannerProps { + /** Current connection status from useCollaborativeEditor. */ + status: ReconnectStatus; + /** Number of local edits buffered while offline. */ + pendingUpdateCount: number; + /** Whether a merge conflict was detected on reconnect. */ + hasConflict: boolean; + /** Called when the user presses the "Reconnect" button. */ + onReconnect: () => void; + /** Called when the user presses "Review changes". */ + onReviewConflict: () => void; + /** Called when the user presses "Dismiss" on the conflict banner. */ + onDismissConflict: () => void; +} + +export function ReconnectBanner({ + status, + pendingUpdateCount, + hasConflict, + onReconnect, + onReviewConflict, + onDismissConflict, +}: ReconnectBannerProps) { + const showReconnectBanner = status === 'disconnected' || status === 'reconnecting'; + + if (!showReconnectBanner && !hasConflict) { + return null; + } + + // Conflict banner takes precedence — render it above the reconnect notice. + if (hasConflict) { + return ( +
+
+ {/* Warning icon */} + +

+ Remote changes arrived while you were offline.{' '} + Review your edits before continuing. +

+
+ +
+ + +
+
+ ); + } + + // Reconnect / reconnecting banner + const isReconnecting = status === 'reconnecting'; + + return ( +
+
+ {isReconnecting ? ( + /* Animated spinner */ + + ) : ( + /* Disconnected icon */ + + )} + +
+

+ {isReconnecting ? 'Reconnecting…' : 'Collaboration disconnected'} +

+

+ {pendingUpdateCount > 0 + ? `${pendingUpdateCount} edit${pendingUpdateCount !== 1 ? 's' : ''} saved locally – will sync when reconnected` + : 'Your edits are saved locally and will sync when the connection is restored.'} +

+
+
+ + {!isReconnecting && ( + + )} +
+ ); +} diff --git a/frontend/src/hooks/__tests__/reconnect.integration.test.ts b/frontend/src/hooks/__tests__/reconnect.integration.test.ts new file mode 100644 index 00000000..461b71a1 --- /dev/null +++ b/frontend/src/hooks/__tests__/reconnect.integration.test.ts @@ -0,0 +1,310 @@ +/** + * Integration test: WebSocket disconnect → local edits → reconnect → replay + * + * This test exercises the full flow that the acceptance criteria require: + * + * 1. Provider connects successfully. + * 2. Provider disconnects (network failure simulation). + * 3. Student makes several local canvas edits while offline. + * 4. Provider reconnects. + * 5. Pending updates are replayed into the shared Y.Doc without duplicates. + * 6. pendingUpdateCount returns to 0. + * 7. If the remote document advanced while offline, hasConflict is set. + * 8. dismissConflict() clears the flag. + * + * Because these are integration tests the mock is intentionally thinner than + * in the unit-test file: we let most of the hook logic run as-is and only + * stub the actual WebSocket transport. + */ + +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as Y from 'yjs'; + +// --------------------------------------------------------------------------- +// Controllable WebsocketProvider mock +// --------------------------------------------------------------------------- + +interface StatusListener { + (arg: { status: string }): void; +} + +class MockProvider { + doc: Y.Doc; + awareness = { + setLocalState: vi.fn(), + on: vi.fn(), + off: vi.fn(), + getStates: vi.fn().mockReturnValue(new Map()), + clientID: 42, + }; + + wsconnected = false; + private statusListeners: StatusListener[] = []; + private allListeners = new Map void>>(); + + on(event: string, cb: (arg: any) => void) { + if (!this.allListeners.has(event)) this.allListeners.set(event, new Set()); + this.allListeners.get(event)!.add(cb); + if (event === 'status') this.statusListeners.push(cb as StatusListener); + } + + off(event: string, cb: (arg: any) => void) { + this.allListeners.get(event)?.delete(cb); + if (event === 'status') { + this.statusListeners = this.statusListeners.filter((h) => h !== cb); + } + } + + connect = vi.fn(() => { + this.wsconnected = true; + this._emit('connected'); + }); + + disconnect = vi.fn(() => { + this.wsconnected = false; + this._emit('disconnected'); + }); + + destroy = vi.fn(); + + constructor(_url: string, _room: string, doc: Y.Doc) { + this.doc = doc; + } + + _emit(status: string) { + this.statusListeners.forEach((h) => h({ status })); + } +} + +let currentProvider: MockProvider | null = null; + +vi.mock('y-websocket', () => ({ + WebsocketProvider: class extends MockProvider { + constructor(url: string, room: string, doc: Y.Doc) { + super(url, room, doc); + currentProvider = this; + } + }, +})); + +// --------------------------------------------------------------------------- +// Clear registry between tests +// --------------------------------------------------------------------------- +afterEach(async () => { + const { _providerRegistry } = await import('@/hooks/useCollaborativeEditor'); + _providerRegistry.clear(); + currentProvider = null; + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Helper: perform a Y.Doc edit that originates locally (not from provider) +// --------------------------------------------------------------------------- +function applyLocalEdit(doc: Y.Doc, nodeId: string) { + doc.transact(() => { + doc.getArray('nodes').push([{ id: nodeId, position: { x: 0, y: 0 } }]); + }); + // origin is undefined (local) so the hook's update handler will queue it +} + +// --------------------------------------------------------------------------- +// Integration scenarios +// --------------------------------------------------------------------------- + +describe('Collaborative editor reconnect integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ------------------------------------------------------------------------- + it('scenario: connect → edit online → no queue growth', async () => { + const { useCollaborativeEditor } = await import('@/hooks/useCollaborativeEditor'); + const { result } = renderHook(() => useCollaborativeEditor('int-room-1', 'int-user-1')); + + // Connect + await act(async () => { + currentProvider!._emit('connected'); + }); + + expect(result.current.isConnected).toBe(true); + + // Make an edit while connected — should NOT enter the queue + await act(async () => { + applyLocalEdit(result.current.doc!, 'node-online-1'); + }); + + expect(result.current.pendingUpdateCount).toBe(0); + }); + + // ------------------------------------------------------------------------- + it('scenario: disconnect → local edits queue → reconnect → queue drained', async () => { + const { useCollaborativeEditor } = await import('@/hooks/useCollaborativeEditor'); + const { result } = renderHook(() => useCollaborativeEditor('int-room-2', 'int-user-2')); + + // 1. Connect + await act(async () => { + currentProvider!._emit('connected'); + }); + + // 2. Drop connection + await act(async () => { + currentProvider!._emit('disconnected'); + }); + + expect(result.current.isConnected).toBe(false); + + // 3. Make local edits while offline + await act(async () => { + applyLocalEdit(result.current.doc!, 'node-offline-A'); + applyLocalEdit(result.current.doc!, 'node-offline-B'); + }); + + expect(result.current.pendingUpdateCount).toBeGreaterThanOrEqual(1); + + // 4. Reconnect + await act(async () => { + currentProvider!._emit('connected'); + }); + + // 5. Queue should be drained + expect(result.current.pendingUpdateCount).toBe(0); + expect(result.current.isConnected).toBe(true); + }); + + // ------------------------------------------------------------------------- + it('scenario: duplicate updates from same edit are not replayed twice', async () => { + const { useCollaborativeEditor } = await import('@/hooks/useCollaborativeEditor'); + const { result } = renderHook(() => useCollaborativeEditor('int-room-3', 'int-user-3')); + + await act(async () => { currentProvider!._emit('connected'); }); + await act(async () => { currentProvider!._emit('disconnected'); }); + + const doc = result.current.doc!; + const nodesStart = doc.getArray('nodes').length; + + // Enqueue the same edit twice via PendingUpdateQueue (simulated by emitting + // the exact same encoded update bytes). + const { PendingUpdateQueue } = await import('@/lib/collaboration/PendingUpdateQueue'); + const q = new PendingUpdateQueue(); + + // Construct a single update + const before = Y.encodeStateAsUpdate(doc); + doc.transact(() => { + doc.getArray('nodes').push([{ id: 'dedup-node', position: { x: 1, y: 1 } }]); + }); + const after = Y.encodeStateAsUpdate(doc); + + // Compute delta (simplified: just use after as the update) + q.enqueue(after); + q.enqueue(after); // duplicate — should be ignored + + // Queue should have exactly 1 entry + expect(q.size).toBe(1); + + // Apply to a fresh doc to confirm only one mutation + const testDoc = new Y.Doc(); + Y.applyUpdate(testDoc, before); + const applied = q.replayInto(testDoc); + + expect(applied).toBe(1); + // After applying the single update the node should exist exactly once + const nodes = testDoc.getArray('nodes').toArray(); + const dedupNodes = nodes.filter((n: any) => n.id === 'dedup-node'); + expect(dedupNodes.length).toBe(1); + }); + + // ------------------------------------------------------------------------- + it('scenario: reconnect() helper triggers provider.connect()', async () => { + const { useCollaborativeEditor } = await import('@/hooks/useCollaborativeEditor'); + const { result } = renderHook(() => useCollaborativeEditor('int-room-4', 'int-user-4')); + + await act(async () => { currentProvider!._emit('disconnected'); }); + + act(() => { result.current.reconnect(); }); + + expect(currentProvider!.connect).toHaveBeenCalled(); + }); + + // ------------------------------------------------------------------------- + it('scenario: teardown stops sync – provider destroyed after unmount', async () => { + const { useCollaborativeEditor } = await import('@/hooks/useCollaborativeEditor'); + const { unmount } = renderHook(() => useCollaborativeEditor('int-room-5', 'int-user-5')); + + const provider = currentProvider!; + + unmount(); + + expect(provider.disconnect).toHaveBeenCalled(); + expect(provider.destroy).toHaveBeenCalled(); + }); + + // ------------------------------------------------------------------------- + it('scenario: conflict flag raised when remote doc advanced while offline', async () => { + const { useCollaborativeEditor } = await import('@/hooks/useCollaborativeEditor'); + const { result } = renderHook(() => useCollaborativeEditor('int-room-6', 'int-user-6')); + + await act(async () => { currentProvider!._emit('connected'); }); + + // Go offline + await act(async () => { currentProvider!._emit('disconnected'); }); + + const doc = result.current.doc!; + + // Simulate a local edit while offline so the queue is non-empty + await act(async () => { + applyLocalEdit(doc, 'my-offline-node'); + }); + + // Simulate a remote peer advancing the doc (using provider as origin + // so the hook's update listener ignores it for queueing) + doc.transact(() => { + doc.getArray('nodes').push([{ id: 'remote-peer-node' }]); + }, currentProvider); + + // Reconnect — hook should detect that the remote vector changed + await act(async () => { + currentProvider!._emit('connected'); + }); + + // hasConflict should be true because the remote doc advanced while we were offline + expect(result.current.hasConflict).toBe(true); + + // Dismiss the conflict + act(() => { result.current.dismissConflict(); }); + expect(result.current.hasConflict).toBe(false); + }); + + // ------------------------------------------------------------------------- + it('scenario: PendingUpdateQueue caps at MAX_QUEUE_SIZE=100 without throwing', async () => { + const { PendingUpdateQueue } = await import('@/lib/collaboration/PendingUpdateQueue'); + const q = new PendingUpdateQueue(); + + // Enqueue 150 distinct updates (each with a unique first byte to avoid dedup) + for (let i = 0; i < 150; i++) { + const update = new Uint8Array(32); + update[0] = i % 256; + update[1] = Math.floor(i / 256); + update[2] = i; + q.enqueue(update); + } + + // Size should be capped at 100 + expect(q.size).toBe(100); + }); + + // ------------------------------------------------------------------------- + it('scenario: PendingUpdateQueue.clear() empties the queue', async () => { + const { PendingUpdateQueue } = await import('@/lib/collaboration/PendingUpdateQueue'); + const q = new PendingUpdateQueue(); + + q.enqueue(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16])); + q.enqueue(new Uint8Array([17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32])); + + expect(q.size).toBe(2); + + q.clear(); + + expect(q.size).toBe(0); + }); +}); diff --git a/frontend/src/hooks/__tests__/useCollaborativeEditor.test.ts b/frontend/src/hooks/__tests__/useCollaborativeEditor.test.ts new file mode 100644 index 00000000..478b5755 --- /dev/null +++ b/frontend/src/hooks/__tests__/useCollaborativeEditor.test.ts @@ -0,0 +1,301 @@ +/** + * Unit tests for useCollaborativeEditor + * + * Covers: + * 1. Singleton provider – only one registry entry per (roomId, userId) pair. + * 2. Pending-update queue – local updates enqueued while disconnected. + * 3. Conflict detection – hasConflict raised when remote state advanced while offline. + * 4. Teardown – provider destroyed and registry cleaned up when last consumer unmounts. + * 5. reconnect() helper – calls provider.connect(). + * 6. dismissConflict() – resets hasConflict. + */ + +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as Y from 'yjs'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +// We need to control status events and track provider creation count. +let providerInstances: MockWebsocketProvider[] = []; + +class MockWebsocketProvider { + doc: Y.Doc; + roomName: string; + wsUrl: string; + wsconnected = false; + awareness = { + setLocalState: vi.fn(), + on: vi.fn(), + off: vi.fn(), + getStates: vi.fn().mockReturnValue(new Map()), + clientID: Math.floor(Math.random() * 9999), + }; + + private statusHandlers: Array<(arg: { status: string }) => void> = []; + + on = vi.fn((event: string, cb: any) => { + if (event === 'status') { + this.statusHandlers.push(cb); + } + }); + + off = vi.fn((event: string, cb: any) => { + if (event === 'status') { + this.statusHandlers = this.statusHandlers.filter((h) => h !== cb); + } + }); + + connect = vi.fn(() => { + this.wsconnected = true; + this._triggerStatus('connected'); + }); + + disconnect = vi.fn(() => { + this.wsconnected = false; + this._triggerStatus('disconnected'); + }); + + destroy = vi.fn(); + + constructor(wsUrl: string, roomName: string, doc: Y.Doc) { + this.wsUrl = wsUrl; + this.roomName = roomName; + this.doc = doc; + providerInstances.push(this); + } + + /** Test helper: emit a status event to all registered handlers. */ + _triggerStatus(status: string) { + this.statusHandlers.forEach((h) => h({ status })); + } +} + +vi.mock('y-websocket', () => ({ + WebsocketProvider: MockWebsocketProvider, +})); + +// --------------------------------------------------------------------------- +// Import the module under test AFTER mocks are set up +// --------------------------------------------------------------------------- +// We import lazily via dynamic import inside each test because the registry is +// module-level and we need it cleared between suites. + +async function loadHook() { + // Re-import to reset module-level state between test files. + const mod = await import('@/hooks/useCollaborativeEditor'); + return mod; +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function getProvider() { + return providerInstances[providerInstances.length - 1]; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('useCollaborativeEditor', () => { + beforeEach(() => { + providerInstances = []; + vi.clearAllMocks(); + }); + + afterEach(() => { + // Force-clean the registry so tests don't bleed into each other. + import('@/hooks/useCollaborativeEditor').then(({ _providerRegistry }) => { + _providerRegistry.clear(); + }); + }); + + // ------------------------------------------------------------------------- + it('initialises with disconnected state', async () => { + const { useCollaborativeEditor } = await loadHook(); + const { result } = renderHook(() => useCollaborativeEditor('room-1', 'user-1')); + + expect(result.current.isConnected).toBe(false); + expect(result.current.reconnectStatus).toBe('disconnected'); + expect(result.current.hasConflict).toBe(false); + expect(result.current.pendingUpdateCount).toBe(0); + expect(result.current.doc).toBeInstanceOf(Y.Doc); + expect(result.current.awareness).toBeDefined(); + }); + + // ------------------------------------------------------------------------- + it('transitions to connected after provider emits connected status', async () => { + const { useCollaborativeEditor } = await loadHook(); + const { result } = renderHook(() => useCollaborativeEditor('room-2', 'user-2')); + + const provider = getProvider(); + + await act(async () => { + provider._triggerStatus('connected'); + }); + + expect(result.current.isConnected).toBe(true); + expect(result.current.reconnectStatus).toBe('connected'); + }); + + // ------------------------------------------------------------------------- + it('shows reconnecting status while connecting', async () => { + const { useCollaborativeEditor } = await loadHook(); + const { result } = renderHook(() => useCollaborativeEditor('room-3', 'user-3')); + + const provider = getProvider(); + + await act(async () => { + provider._triggerStatus('connecting'); + }); + + expect(result.current.isConnected).toBe(false); + expect(result.current.reconnectStatus).toBe('reconnecting'); + }); + + // ------------------------------------------------------------------------- + it('singleton: two hook invocations for the same room share one provider', async () => { + const { useCollaborativeEditor } = await loadHook(); + + const { result: r1 } = renderHook(() => useCollaborativeEditor('room-same', 'user-same')); + const { result: r2 } = renderHook(() => useCollaborativeEditor('room-same', 'user-same')); + + // Both hooks should share the same Y.Doc reference. + expect(r1.current.doc).toBe(r2.current.doc); + // Only one provider should have been created. + expect(providerInstances).toHaveLength(1); + }); + + // ------------------------------------------------------------------------- + it('different rooms create separate providers', async () => { + const { useCollaborativeEditor } = await loadHook(); + + renderHook(() => useCollaborativeEditor('room-A', 'user-1')); + renderHook(() => useCollaborativeEditor('room-B', 'user-1')); + + expect(providerInstances).toHaveLength(2); + }); + + // ------------------------------------------------------------------------- + it('reconnect() calls provider.connect()', async () => { + const { useCollaborativeEditor } = await loadHook(); + const { result } = renderHook(() => useCollaborativeEditor('room-r', 'user-r')); + + const provider = getProvider(); + + act(() => { + result.current.reconnect(); + }); + + expect(provider.connect).toHaveBeenCalled(); + }); + + // ------------------------------------------------------------------------- + it('dismissConflict() clears hasConflict', async () => { + const { useCollaborativeEditor } = await loadHook(); + const { result } = renderHook(() => useCollaborativeEditor('room-cf', 'user-cf')); + + const provider = getProvider(); + + // Simulate: go offline, make a local change, reconnect to trigger conflict + await act(async () => { + provider._triggerStatus('disconnected'); + }); + + // Emit an update from the "local" side while offline so the queue has items + const doc = result.current.doc!; + const update = Y.encodeStateAsUpdate(doc); + doc.emit('update', [update, null]); // origin != provider → gets queued + + // Simulate remote vector changing by modifying the doc from the "remote" side + // before reconnecting. + doc.transact(() => { + const arr = doc.getArray('__test__'); + arr.push(['remote-change']); + }, provider /* mark as remote origin */); + + await act(async () => { + provider._triggerStatus('connected'); + }); + + act(() => { + result.current.dismissConflict(); + }); + + expect(result.current.hasConflict).toBe(false); + }); + + // ------------------------------------------------------------------------- + it('pendingUpdateCount increments while disconnected and resets after reconnect', async () => { + const { useCollaborativeEditor } = await loadHook(); + const { result } = renderHook(() => useCollaborativeEditor('room-pq', 'user-pq')); + + const provider = getProvider(); + const doc = result.current.doc!; + + // Go offline + await act(async () => { + provider._triggerStatus('disconnected'); + }); + + // Fire two distinct local updates + await act(async () => { + doc.transact(() => { + doc.getArray('nodes').push([{ id: 'n1' }]); + }); // origin = undefined → captured by queue + }); + + await act(async () => { + doc.transact(() => { + doc.getArray('nodes').push([{ id: 'n2' }]); + }); + }); + + // Queue count should be positive (at least 1 — dedup may merge them) + expect(result.current.pendingUpdateCount).toBeGreaterThanOrEqual(1); + + // Reconnect — queue should drain + await act(async () => { + provider._triggerStatus('connected'); + }); + + expect(result.current.pendingUpdateCount).toBe(0); + }); + + // ------------------------------------------------------------------------- + it('teardown: destroys provider and removes registry entry on last unmount', async () => { + const { useCollaborativeEditor, _providerRegistry } = await loadHook(); + + const { unmount } = renderHook(() => useCollaborativeEditor('room-td', 'user-td')); + + const provider = getProvider(); + + unmount(); + + expect(provider.destroy).toHaveBeenCalled(); + expect(provider.disconnect).toHaveBeenCalled(); + expect(_providerRegistry.has('room-td::user-td')).toBe(false); + }); + + // ------------------------------------------------------------------------- + it('teardown: provider kept alive while second consumer still mounted', async () => { + const { useCollaborativeEditor, _providerRegistry } = await loadHook(); + + const { unmount: unmount1 } = renderHook(() => + useCollaborativeEditor('room-shared-td', 'user-s') + ); + renderHook(() => useCollaborativeEditor('room-shared-td', 'user-s')); + + const provider = getProvider(); + + // Unmount the first consumer — provider should stay alive + unmount1(); + + expect(provider.destroy).not.toHaveBeenCalled(); + expect(_providerRegistry.has('room-shared-td::user-s')).toBe(true); + }); +}); diff --git a/frontend/src/hooks/useCollaborativeEditor.ts b/frontend/src/hooks/useCollaborativeEditor.ts new file mode 100644 index 00000000..a32a31de --- /dev/null +++ b/frontend/src/hooks/useCollaborativeEditor.ts @@ -0,0 +1,276 @@ +'use client'; + +/** + * useCollaborativeEditor + * + * A React hook that manages a Yjs collaborative session with: + * + * 1. Singleton provider – one Y.Doc + WebsocketProvider per (roomId, userId) + * pair; calling the hook a second time from the same component tree reuses + * the existing instance instead of opening a second connection. + * + * 2. Pending-update queue – while the provider is disconnected every local + * document mutation is captured and stored in a PendingUpdateQueue. + * + * 3. Reconnect replay – once the provider reconnects, all queued updates are + * replayed into the shared doc in FIFO order so no local work is lost. + * + * 4. Observable conflicts – the hook emits `hasConflict: true` when the + * shared state changed remotely while local updates were pending. + * + * 5. Teardown on unmount – the provider is disconnected and the Y.Doc is + * destroyed when the component unmounts, preventing memory leaks. + * + * Auth / privacy notes: + * - No user-identifying data beyond userId is broadcast to remote peers. + * - The hook never logs internal error details to the browser console + * when running in production; it surfaces a user-friendly message only. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { WebsocketProvider } from 'y-websocket'; +import * as Y from 'yjs'; +import { PendingUpdateQueue } from '@/lib/collaboration/PendingUpdateQueue'; + +// --------------------------------------------------------------------------- +// Singleton registry (module-level, cleared on full page unload) +// --------------------------------------------------------------------------- + +interface RegistryEntry { + doc: Y.Doc; + provider: WebsocketProvider; + queue: PendingUpdateQueue; + /** Number of active consumers that share this entry. */ + refCount: number; +} + +const registry = new Map(); + +function registryKey(roomId: string, userId: string): string { + return `${roomId}::${userId}`; +} + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export type ReconnectStatus = + | 'connected' + | 'disconnected' + | 'reconnecting'; + +export interface CollaborativeEditorState { + /** The shared Y.Doc (null while initialising). */ + doc: Y.Doc | null; + /** The awareness channel for cursor / presence data. */ + awareness: WebsocketProvider['awareness'] | null; + /** Whether the WebSocket provider is currently connected. */ + isConnected: boolean; + /** Richer connection status. */ + reconnectStatus: ReconnectStatus; + /** True when remote state changed while we were offline — user should review. */ + hasConflict: boolean; + /** Number of local updates still waiting to be replayed on the next connect. */ + pendingUpdateCount: number; + /** Manually trigger a reconnect attempt. */ + reconnect: () => void; + /** Dismiss the conflict flag (e.g. after the user reviews the diff). */ + dismissConflict: () => void; +} + +// --------------------------------------------------------------------------- +// Hook implementation +// --------------------------------------------------------------------------- + +export function useCollaborativeEditor( + roomId: string, + userId: string +): CollaborativeEditorState { + const [isConnected, setIsConnected] = useState(false); + const [reconnectStatus, setReconnectStatus] = useState('disconnected'); + const [hasConflict, setHasConflict] = useState(false); + const [pendingUpdateCount, setPendingUpdateCount] = useState(0); + + // Track which registry entry this mount "owns" so we can release the ref. + const keyRef = useRef(null); + const entryRef = useRef(null); + + // Stable snapshot of prev remote state for conflict detection. + const prevRemoteVectorRef = useRef(null); + + // ----------------------------------------------------------------------- + // Initialise (or reuse) the singleton entry + // ----------------------------------------------------------------------- + useEffect(() => { + if (!roomId || !userId) return; + + const key = registryKey(roomId, userId); + keyRef.current = key; + + let entry = registry.get(key); + + if (!entry) { + const doc = new Y.Doc(); + + const wsUrl = process.env.NEXT_PUBLIC_WS_URL ?? 'ws://localhost:1234'; + const provider = new WebsocketProvider(wsUrl, `canvas-${roomId}`, doc, { + connect: true, + }); + + const queue = new PendingUpdateQueue(); + + entry = { doc, provider, queue, refCount: 0 }; + registry.set(key, entry); + } + + entry.refCount += 1; + entryRef.current = entry; + + const { doc, provider, queue } = entry; + + // Set up awareness presence (idempotent — later mounts overwrite the + // local state, which is fine because userId is stable). + provider.awareness.setLocalState({ + user: { + id: userId, + name: `User ${userId.slice(0, 8)}`, + color: `hsl(${(userId.charCodeAt(0) * 47) % 360}, 65%, 55%)`, + }, + }); + + // ------------------------------------------------------------------ + // Capture local updates while disconnected + // ------------------------------------------------------------------ + const handleLocalUpdate = (update: Uint8Array, origin: unknown) => { + // origin === provider means the update came from the network — ignore. + if (origin === provider) return; + + if (!provider.wsconnected) { + queue.enqueue(update); + setPendingUpdateCount(queue.size); + } + }; + + doc.on('update', handleLocalUpdate); + + // ------------------------------------------------------------------ + // React to provider status changes + // ------------------------------------------------------------------ + const handleStatus = ({ status }: { status: string }) => { + const connected = status === 'connected'; + + setIsConnected(connected); + setReconnectStatus( + connected ? 'connected' : status === 'connecting' ? 'reconnecting' : 'disconnected' + ); + + if (connected && queue.size > 0) { + // Check whether the remote state advanced while we were offline — + // if it did that indicates a potential conflict. + const remoteVector = Y.encodeStateVector(doc); + if (prevRemoteVectorRef.current) { + const prev = prevRemoteVectorRef.current; + const different = + prev.length !== remoteVector.length || + prev.some((byte, i) => byte !== remoteVector[i]); + if (different) { + setHasConflict(true); + } + } + + // Replay pending updates into the shared doc. + const applied = queue.replayInto(doc); + setPendingUpdateCount(0); + + if (process.env.NODE_ENV !== 'production' && applied > 0) { + console.debug(`[useCollaborativeEditor] Replayed ${applied} pending update(s) for room "${roomId}".`); + } + } + + if (!connected) { + // Snapshot the current remote state vector so we can compare later. + prevRemoteVectorRef.current = Y.encodeStateVector(doc); + } + }; + + provider.on('status', handleStatus); + + // Sync initial connection state (provider might already be connected when + // the hook mounts via the singleton path). + const alreadyConnected = (provider as unknown as { wsconnected: boolean }).wsconnected ?? false; + if (alreadyConnected) { + setIsConnected(true); + setReconnectStatus('connected'); + } + + // ------------------------------------------------------------------ + // Teardown + // ------------------------------------------------------------------ + return () => { + doc.off('update', handleLocalUpdate); + provider.off('status', handleStatus); + + const current = registry.get(key); + if (!current) return; + + current.refCount -= 1; + + if (current.refCount <= 0) { + // Last consumer — fully tear down. + try { + provider.awareness.setLocalState(null); + provider.disconnect(); + provider.destroy(); + doc.destroy(); + } catch { + // Ignore errors during cleanup. + } + registry.delete(key); + } + + keyRef.current = null; + entryRef.current = null; + }; + // roomId and userId are the stable identity for this session. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [roomId, userId]); + + // ----------------------------------------------------------------------- + // Stable callbacks + // ----------------------------------------------------------------------- + const reconnect = useCallback(() => { + const entry = entryRef.current; + if (!entry) return; + setReconnectStatus('reconnecting'); + try { + entry.provider.connect(); + } catch { + setReconnectStatus('disconnected'); + } + }, []); + + const dismissConflict = useCallback(() => { + setHasConflict(false); + }, []); + + // ----------------------------------------------------------------------- + // Derive values from the current registry entry + // ----------------------------------------------------------------------- + const entry = entryRef.current; + + return { + doc: entry?.doc ?? null, + awareness: entry?.provider.awareness ?? null, + isConnected, + reconnectStatus, + hasConflict, + pendingUpdateCount, + reconnect, + dismissConflict, + }; +} + +// --------------------------------------------------------------------------- +// Exported for testing only +// --------------------------------------------------------------------------- +export { registry as _providerRegistry }; diff --git a/frontend/src/lib/collaboration/PendingUpdateQueue.ts b/frontend/src/lib/collaboration/PendingUpdateQueue.ts new file mode 100644 index 00000000..ac964cbd --- /dev/null +++ b/frontend/src/lib/collaboration/PendingUpdateQueue.ts @@ -0,0 +1,105 @@ +/** + * PendingUpdateQueue + * + * Buffers local collaborative operations that were made while the WebSocket + * provider was disconnected. On reconnect, every queued update is replayed + * into the shared Y.Doc in the order it was enqueued, then the queue is + * cleared. + * + * Design notes: + * - Each entry is an opaque `Uint8Array` (a Yjs encoded state-vector diff + * or a full document update) plus the wall-clock timestamp it was captured. + * - We use a content-hash to deduplicate identical byte sequences so that + * rapid offline edits that produce the same binary update are not applied + * twice. + * - The queue is capped at MAX_QUEUE_SIZE; once full the oldest entry is + * evicted so that memory pressure stays bounded. + */ + +import * as Y from 'yjs'; + +export interface PendingUpdate { + /** Yjs encoded document update (output of Y.encodeStateAsUpdate). */ + update: Uint8Array; + /** Wall-clock ms when the update was captured. */ + capturedAt: number; + /** Cheap dedup key derived from the first 16 bytes. */ + contentKey: string; +} + +const MAX_QUEUE_SIZE = 100; + +function deriveContentKey(update: Uint8Array): string { + // Use first 16 bytes as a cheap hash for deduplication. + return Array.from(update.slice(0, 16)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +export class PendingUpdateQueue { + private queue: PendingUpdate[] = []; + private seenKeys = new Set(); + + /** Number of updates currently in the queue. */ + get size(): number { + return this.queue.length; + } + + /** Returns a shallow copy of all pending updates in FIFO order. */ + get entries(): Readonly { + return [...this.queue]; + } + + /** + * Enqueue a Yjs document update captured while offline. + * + * Duplicate updates (same content key) are silently dropped. + * When the queue exceeds MAX_QUEUE_SIZE the oldest entry is evicted. + */ + enqueue(update: Uint8Array): void { + const contentKey = deriveContentKey(update); + + if (this.seenKeys.has(contentKey)) { + return; // deduplicate + } + + if (this.queue.length >= MAX_QUEUE_SIZE) { + const evicted = this.queue.shift(); + if (evicted) this.seenKeys.delete(evicted.contentKey); + } + + this.queue.push({ update, capturedAt: Date.now(), contentKey }); + this.seenKeys.add(contentKey); + } + + /** + * Replay every queued update into `doc` in the order they were captured, + * then clear the queue. + * + * Returns the number of updates that were applied. + */ + replayInto(doc: Y.Doc): number { + const pending = this.queue.splice(0); + this.seenKeys.clear(); + + let applied = 0; + for (const entry of pending) { + try { + Y.applyUpdate(doc, entry.update); + applied++; + } catch (err) { + // A malformed or already-applied update should not prevent the rest + // from replaying. Log a warning and continue. + console.warn('[PendingUpdateQueue] Failed to replay update', err); + } + } + + return applied; + } + + /** Discard all queued updates without applying them. */ + clear(): void { + this.queue = []; + this.seenKeys.clear(); + } +}