Skip to content

Collaboration Provider

The SupabaseRealtimeProvider class provides real-time collaborative editing by combining Yjs CRDT synchronization with Supabase Realtime broadcast channels.

Location: src/lib/components/editor/collaboration-provider.ts

Architecture

Key Features

FeatureImplementation
CRDT SyncYjs document updates broadcast via Supabase Realtime
Offline SupportIndexedDB persistence via y-indexeddb
Cursor TrackingDedicated cursor-update broadcasts, rendered by CustomCursorExtension
PresenceSupabase Realtime Presence for user tracking
Debouncing100ms update batching, 50ms cursor throttling

Usage

Basic Initialization

typescript
import {
	createCollaborationProvider,
	generateUserColor
} from '$lib/components/editor/collaboration-provider';

const provider = createCollaborationProvider({
	supabase,
	sectionId: 'section-uuid',
	user: {
		id: currentUser.id,
		name: currentUser.full_name,
		color: generateUserColor(currentUser.id),
		avatarUrl: currentUser.avatar_url
	},
	onStatusChange: (status) => {
		// 'connecting' | 'connected' | 'disconnected'
		console.log('Connection status:', status);
	},
	onUsersChange: (users) => {
		// Array of CollaborationUser objects
		console.log('Active users:', users);
	},
	onSynced: (hasLocalContent, receivedRemoteSync) => {
		// Called once when initial sync completes
		if (receivedRemoteSync) {
			// Use remote state (another user shared their content)
		} else if (hasLocalContent) {
			// Use IndexedDB content (offline edits)
		} else {
			// Initialize from database
		}
	},
	onCursorChange: (userId, cursor) => {
		// Remote cursor position update
		if (cursor) {
			console.log(`${cursor.user.name} at position ${cursor.position.from}`);
		}
	}
});

TipTap Integration

Remote carets are not drawn by TipTap's stock CollaborationCursor extension. They use the repo's own CustomCursorExtension (src/lib/components/editor/cursor-extension.ts), which renders absolutely positioned overlays outside ProseMirror's transaction system and is driven by the provider's cursor-update broadcasts rather than by Yjs Awareness.

typescript
import { Editor } from '@tiptap/core';
import Collaboration from '@tiptap/extension-collaboration';
import {
	CustomCursorExtension,
	setRemoteCursor,
	removeRemoteCursor
} from '$lib/components/editor/cursor-extension';

const editor = new Editor({
	extensions: [
		// ... other extensions
		Collaboration.configure({
			document: provider.getDoc(),
			field: 'content'
		}),
		CustomCursorExtension.configure({
			currentUserId: currentUser.id,
			getContrastColor,
			// Fired only while THIS editor has focus
			onCursorUpdate: (position) => provider.updateCursorPosition(position),
			// Fired on blur, after a 200ms grace period
			onCursorClear: () => provider.clearCursor()
		})
	]
});

// Provider option: route every incoming cursor through the editor that owns
// its section. Passing any other editor is what issue #299 was.
const onCursorChange = (userId: string, cursor: RemoteCursor | null) => {
	if (cursor === null) {
		removeRemoteCursor(editor, userId);
	} else {
		setRemoteCursor(editor, userId, cursor);
	}
};

Section scoping (do not regress)

A page mounts one editor per show-notes section, all at the same time, so the lens switcher can keep Yjs connections alive. Two rules follow, and breaking either one reproduces #299:

  1. All cursor render state is per editor, held in editor.storage.customCursor via the extension's addStorage(). It was module-level once, which meant a single cursor map, a single overlay container and a single "active view" shared by every editor on the page: remote carets were drawn into whichever editor last updated, with a position index from one document re-resolved against another. That was a rendering defect, not a data leak, since each section has its own show-notes:{sectionId} channel and guests only subscribe to sections get_guest_show_notes grants them.
  2. The local caret is broadcast only while its editor has focus. The plugin's update hook also runs when a remote Yjs update is applied to a hidden, never-focused editor, where the default selection is position 1. Broadcasting from there parked the local user's marker on the first line of every other participant's section.

A lens-inactive editor is display: none, so coordsAtPos returns zero-height rects and nothing can be measured. Each editor keeps an IntersectionObserver on its own DOM to re-run the position pass when it becomes visible again, so an idle peer's caret still appears after a lens switch.

Cleanup

typescript
// On component destroy
onDestroy(() => {
	provider.destroy();
});

API Reference

CollaborationProviderOptions

typescript
interface CollaborationProviderOptions {
	supabase: SupabaseClient<Database>;
	sectionId: string;
	user: CollaborationUser;
	onStatusChange?: (status: 'connecting' | 'connected' | 'disconnected') => void;
	onUsersChange?: (users: CollaborationUser[]) => void;
	onSynced?: (hasLocalContent: boolean, receivedRemoteSync: boolean) => void;
	onCursorChange?: (userId: string, cursor: RemoteCursor | null) => void;
}

CollaborationUser

typescript
interface CollaborationUser {
	id: string;
	name: string;
	color: string; // Hex color for cursor/avatar
	avatarUrl?: string;
}

RemoteCursor

typescript
interface RemoteCursor {
	user: CollaborationUser;
	position: {
		from: number;
		to: number;
	};
}

Instance Methods

MethodReturnsDescription
getDoc()Y.DocGet the Yjs document
getAwareness()AwarenessGet Yjs Awareness instance
getUsers()CollaborationUser[]Get all active users
getRemoteCursors()Map<string, RemoteCursor>Get remote cursor positions
isSyncComplete()booleanCheck if IndexedDB sync is done
whenSynced()Promise<boolean>Wait for IndexedDB sync
updateCursorPosition(pos)voidBroadcast cursor position (focused editors only)
clearCursor()voidRetract our caret: broadcasts position: null and drops lastCursorPosition
clearLocalCache()Promise<void>Clear IndexedDB cache
destroy()Promise<void>Clean up all resources

Utility Functions

generateUserColor(userId: string): string

Generates a deterministic color based on user ID. Same user always gets the same color.

typescript
const color = generateUserColor('user-123');
// Returns one of 10 predefined colors: #FF6B6B, #4ECDC4, etc.

Broadcast Events

The provider uses these Supabase Realtime broadcast events:

EventDirectionPayloadPurpose
doc-updateBidirectional{ update: number[] }Yjs document changes
sync-requestOut{ requesterId: string }Request full state
sync-responseIn{ state: number[], requesterId: string }Full state from peer
awareness-updateBidirectional{ update: number[] }Yjs Awareness changes
awareness-requestOut{ requesterId: string }Request awareness state
awareness-responseIn{ update: number[], requesterId: string }Awareness from peer
cursor-updateBidirectional{ userId, position, user }Cursor position
cursor-requestOut{ requesterId: string }Request cursor positions
cursor-responseIn{ userId, position, user, requesterId }Cursor from peer

Connection Lifecycle

Sync Strategy

The provider implements a careful sync strategy to avoid content conflicts:

  1. IndexedDB First: Local cache is loaded immediately for instant startup
  2. Remote Check: Request state from other active users
  3. Grace Period: 500ms wait for remote responses
  4. Decision Point: The onSynced callback provides flags:
    • receivedRemoteSync = true: Another user shared their content
    • hasLocalContent = true: IndexedDB had cached content
    • Both false: No local or remote content, initialize from database
typescript
onSynced: (hasLocalContent, receivedRemoteSync) => {
	if (receivedRemoteSync) {
		// Remote state is authoritative - already applied to Yjs doc
		return;
	}

	if (!hasLocalContent) {
		// No one has content - load from database
		const dbContent = await loadFromDatabase(sectionId);
		// Initialize Yjs document with database content
	}
};

Performance Optimizations

Update Batching

Document updates are debounced to 100ms and merged before broadcast:

typescript
// Multiple rapid edits become one broadcast
this.debounceTimer = setTimeout(() => {
	const mergedUpdate = Y.mergeUpdates(this.pendingUpdates);
	this.channel.send({
		type: 'broadcast',
		event: 'doc-update',
		payload: { update: Array.from(mergedUpdate) }
	});
}, 100);

Cursor Throttling

Cursor position updates are throttled to 50ms maximum frequency:

typescript
updateCursorPosition(position: CursorPosition): void {
  this.lastCursorPosition = position;

  if (this.cursorThrottleTimer) {
    return; // Already scheduled
  }

  this.cursorThrottleTimer = setTimeout(() => {
    this.cursorThrottleTimer = null;
    // Broadcast position
  }, 50);
}

Awareness Throttling

Yjs Awareness updates are also throttled to 50ms.

IndexedDB Persistence

Each section gets its own IndexedDB database:

typescript
const dbName = `show-notes-${sectionId}`;
this.indexeddbProvider = new IndexeddbPersistence(dbName, this.doc);

To clear cached content (e.g., after database reset):

typescript
await provider.clearLocalCache();

Error Handling

The provider handles these error scenarios:

ScenarioBehavior
Connection lostUpdates queued in pendingUpdates
ReconnectionQueued updates flushed automatically
Channel erroronStatusChange('disconnected') called
No peersFalls back to IndexedDB/database content

Integration with Presence Store

The provider automatically updates the global presence store:

typescript
// On connect
presenceStore.setCurrentUser(this.user);
presenceStore.setStatus('connected');

// On users change
presenceStore.updateSectionPresence(this.sectionId, deduplicatedUsers);

// On destroy
presenceStore.removeSectionPresence(this.sectionId);

Internal documentation - Not for public distribution