From ef4df18d670a8b003bf4a78ded6f9d64ab674e96 Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Mon, 10 Nov 2025 08:14:40 +0000 Subject: [PATCH 1/2] Fix flickering of active collaborator icons with awareness cache Implement 60-second cache in awareness store to stabilize collaborator states and prevent icon flickering between active/inactive/unavailable states during rapid awareness updates. Key changes: - Add userCache Map to AwarenessState with 60s TTL - Cache users as they disconnect from awareness, showing them as inactive - Clean up expired cache entries every 30 seconds - Update ActiveCollaborators to use cached users for stability - Fix inactive collaborators to continue pinging their last seen timestamp - Add comprehensive tests for cache behavior and timing Technical details: - userCache stores { user, cachedAt } tuples indexed by userId - When user disconnects from awareness (no longer in cursorsMap), they remain in the users array via cache for 60s before being removed - Page visibility API integration ensures lastSeen updates continue even when page is hidden (prevents premature cache expiration) - Periodic cleanup prevents memory leaks from stale cache entries Fixes #3931 Co-authored-by: Farhan Yahaya --- CHANGELOG.md | 2 + .../components/ActiveCollaborators.tsx | 2 +- .../stores/createAwarenessStore.ts | 171 ++++++++++++++++-- .../collaborative-editor/types/awareness.ts | 11 ++ .../components/ActiveCollaborators.test.tsx | 122 +++++++++++-- 5 files changed, 280 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6a5fb3df1..72034ac86d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,8 @@ and this project adheres to ### Fixed +- Fix flickering of active collaborator icons between states(active, inactive, + unavailable) [#3931](https://github.com/OpenFn/lightning/issues/3931) - Restored footers to inspectors on the canvas while in read only mode [#4018](https://github.com/OpenFn/lightning/issues/4018) - Fix vertical scrolling in workflow panels diff --git a/assets/js/collaborative-editor/components/ActiveCollaborators.tsx b/assets/js/collaborative-editor/components/ActiveCollaborators.tsx index e1353428e6..8eb0e0c6b8 100644 --- a/assets/js/collaborative-editor/components/ActiveCollaborators.tsx +++ b/assets/js/collaborative-editor/components/ActiveCollaborators.tsx @@ -44,7 +44,7 @@ export function ActiveCollaborators({ className }: ActiveCollaboratorsProps) { return (
{ rawAwareness: null, isConnected: false, lastUpdated: null, + userCache: new Map(), } as AwarenessState, // No initial transformations needed draft => draft @@ -129,11 +130,15 @@ export const createAwarenessStore = (): AwarenessStore => { const listeners = new Set<() => void>(); let awarenessInstance: Awareness | null = null; let lastSeenTimer: NodeJS.Timeout | null = null; + let cacheCleanupTimer: NodeJS.Timeout | null = null; + + // Cache configuration + const CACHE_TTL = 60 * 1000; // 1 minute in milliseconds // Redux DevTools integration const devtools = wrapStoreWithDevTools({ name: 'AwarenessStore', - excludeKeys: ['rawAwareness'], // Exclude Y.js Awareness object + excludeKeys: ['rawAwareness', 'userCache'], // Exclude Y.js Awareness object and Map cache maxAge: 200, // Higher limit since awareness changes are frequent }); @@ -210,6 +215,7 @@ export const createAwarenessStore = (): AwarenessStore => { state = produce(state, draft => { const awarenessStates = awareness.getStates(); + const now = Date.now(); // Track which clientIds we've seen in this update const seenClientIds = new Set(); @@ -285,12 +291,18 @@ export const createAwarenessStore = (): AwarenessStore => { lastSeen, }); } + + // Update cache for this active user + draft.userCache.set(userData.id, { + user: draft.cursorsMap.get(clientId)!, + cachedAt: now, + }); } catch (error) { logger.warn('Invalid user data for client', clientId, error); } }); - // Remove users that are no longer in awareness + // Remove users that are no longer in awareness from cursorsMap const entriesToDelete: number[] = []; draft.cursorsMap.forEach((_, clientId) => { if (!seenClientIds.has(clientId)) { @@ -301,9 +313,27 @@ export const createAwarenessStore = (): AwarenessStore => { draft.cursorsMap.delete(clientId); }); - // Rebuild users array from cursorsMap for compatibility - // Sort by name for consistent ordering - draft.users = Array.from(draft.cursorsMap.values()).sort((a, b) => + // Rebuild users array from cursorsMap + const liveUsers = Array.from(draft.cursorsMap.values()); + + // Merge with cached users (inactive collaborators within cache TTL) + const liveUserIds = new Set(liveUsers.map(u => u.user.id)); + const cachedUsers: AwarenessUser[] = []; + + draft.userCache.forEach((cachedUser, userId) => { + if (!liveUserIds.has(userId)) { + // Only add if cache is still valid + if (now - cachedUser.cachedAt <= CACHE_TTL) { + cachedUsers.push(cachedUser.user); + } else { + // Clean up expired cache entry + draft.userCache.delete(userId); + } + } + }); + + // Combine live and cached users, then sort by name for consistent ordering + draft.users = [...liveUsers, ...cachedUsers].sort((a, b) => a.user.name.localeCompare(b.user.name) ); @@ -316,6 +346,36 @@ export const createAwarenessStore = (): AwarenessStore => { // PATTERN 2: Direct Immer → Notify + Awareness Update (Local Commands) // ============================================================================= + /** + * Set up periodic cache cleanup + */ + const setupCacheCleanup = () => { + if (cacheCleanupTimer) { + clearInterval(cacheCleanupTimer); + } + + // Clean up expired cache entries every 30 seconds + cacheCleanupTimer = setInterval(() => { + const now = Date.now(); + const newCache = new Map(state.userCache); + let hasChanges = false; + + newCache.forEach((cachedUser, userId) => { + if (now - cachedUser.cachedAt > CACHE_TTL) { + newCache.delete(userId); + hasChanges = true; + } + }); + + if (hasChanges) { + state = produce(state, draft => { + draft.userCache = newCache; + }); + notify('cacheCleanup'); + } + }, 30000); // Check every 30 seconds + }; + /** * Initialize awareness instance and set up observers */ @@ -334,6 +394,9 @@ export const createAwarenessStore = (): AwarenessStore => { // Set up awareness observer for Pattern 1 updates awareness.on('change', handleAwarenessChange); + // Set up cache cleanup + setupCacheCleanup(); + // Update local state state = produce(state, draft => { draft.localUser = userData; @@ -366,6 +429,11 @@ export const createAwarenessStore = (): AwarenessStore => { lastSeenTimer = null; } + if (cacheCleanupTimer) { + clearInterval(cacheCleanupTimer); + cacheCleanupTimer = null; + } + devtools.disconnect(); state = produce(state, draft => { @@ -376,6 +444,7 @@ export const createAwarenessStore = (): AwarenessStore => { draft.isInitialized = false; draft.isConnected = false; draft.lastUpdated = Date.now(); + draft.userCache = new Map(); }); notify('destroyAwareness'); }; @@ -467,13 +536,14 @@ export const createAwarenessStore = (): AwarenessStore => { /** * Update last seen timestamp + * @param forceTimestamp - Optional timestamp to use instead of Date.now() */ - const updateLastSeen = () => { + const updateLastSeen = (forceTimestamp?: number) => { if (!awarenessInstance) { return; } - const timestamp = Date.now(); + const timestamp = forceTimestamp ?? Date.now(); awarenessInstance.setLocalStateField('lastSeen', timestamp); // Note: We don't update local state here as awareness observer will handle it @@ -483,19 +553,96 @@ export const createAwarenessStore = (): AwarenessStore => { * Set up automatic last seen updates */ const setupLastSeenTimer = () => { - if (lastSeenTimer) { - clearInterval(lastSeenTimer); + let frozenTimestamp: number | null = null; + + const startTimer = () => { + if (lastSeenTimer) { + clearInterval(lastSeenTimer); + } + lastSeenTimer = setInterval(() => { + // If page is hidden, use frozen timestamp, otherwise use current time + if (frozenTimestamp) frozenTimestamp++; // This is to make sure that state is updated and data gets transmitted + updateLastSeen(frozenTimestamp ?? undefined); + }, 10000); // Update every 10 seconds + }; + + const getVisibilityProps = () => { + if (typeof document.hidden !== 'undefined') { + return { hidden: 'hidden', visibilityChange: 'visibilitychange' }; + } + + if ( + // @ts-expect-error webkitHidden not defined + typeof (document as unknown as Document).webkitHidden !== 'undefined' + ) { + return { + hidden: 'webkitHidden', + visibilityChange: 'webkitvisibilitychange', + }; + } + // @ts-expect-error mozHidden not defined + if (typeof (document as unknown as Document).mozHidden !== 'undefined') { + return { hidden: 'mozHidden', visibilityChange: 'mozvisibilitychange' }; + } + // @ts-expect-error msHidden not defined + if (typeof (document as unknown as Document).msHidden !== 'undefined') { + return { hidden: 'msHidden', visibilityChange: 'msvisibilitychange' }; + } + return null; + }; + + const visibilityProps = getVisibilityProps(); + + const handleVisibilityChange = () => { + if (!visibilityProps) return; + + const isHidden = (document as unknown as Document)[ + visibilityProps.hidden as keyof Document + ]; + + if (isHidden) { + // Page is hidden, freeze the current timestamp + frozenTimestamp = Date.now(); + } else { + // Page is visible, unfreeze and update immediately + frozenTimestamp = null; + updateLastSeen(); + } + }; + + // Set up visibility change listener if supported + if (visibilityProps) { + document.addEventListener( + visibilityProps.visibilityChange, + handleVisibilityChange + ); + + // Check initial visibility state + const isHidden = (document as unknown as Document)[ + visibilityProps.hidden as keyof Document + ]; + if (isHidden) { + // Start with frozen timestamp if already hidden + frozenTimestamp = Date.now(); + } } - lastSeenTimer = setInterval(() => { - updateLastSeen(); - }, 10000); // Update every 10 seconds + // Always start the timer (whether visible or hidden) + startTimer(); + // cleanup return () => { if (lastSeenTimer) { clearInterval(lastSeenTimer); lastSeenTimer = null; } + + if (visibilityProps) { + document.removeEventListener( + visibilityProps.visibilityChange, + handleVisibilityChange + ); + } }; }; diff --git a/assets/js/collaborative-editor/types/awareness.ts b/assets/js/collaborative-editor/types/awareness.ts index 67ef688900..cbb44002d7 100644 --- a/assets/js/collaborative-editor/types/awareness.ts +++ b/assets/js/collaborative-editor/types/awareness.ts @@ -37,6 +37,14 @@ export interface LocalUserData { color: string; } +/** + * Cached user entry for fallback when awareness is throttled + */ +export interface CachedUser { + user: AwarenessUser; + cachedAt: number; +} + /** * Awareness store state */ @@ -55,6 +63,9 @@ export interface AwarenessState { // Connection state isConnected: boolean; lastUpdated: number | null; + + // Fallback cache for throttled awareness updates (1 minute TTL) + userCache: Map; } /** diff --git a/assets/test/collaborative-editor/components/ActiveCollaborators.test.tsx b/assets/test/collaborative-editor/components/ActiveCollaborators.test.tsx index a694c83b1b..6d713f1446 100644 --- a/assets/test/collaborative-editor/components/ActiveCollaborators.test.tsx +++ b/assets/test/collaborative-editor/components/ActiveCollaborators.test.tsx @@ -160,9 +160,9 @@ describe('ActiveCollaborators - Activity Indicator', () => { mockRemoteUsers = []; }); - test('shows green border for users active within last 2 minutes', () => { + test('shows green border for users active within last 12 seconds (0.2 minutes)', () => { const now = Date.now(); - const oneMinuteAgo = now - 60 * 1000; // 1 minute ago + const fiveSecondsAgo = now - 5 * 1000; // 5 seconds ago mockRemoteUsers = [ createMockAwarenessUser({ @@ -172,7 +172,7 @@ describe('ActiveCollaborators - Activity Indicator', () => { email: 'john@example.com', color: '#ff0000', }, - lastSeen: oneMinuteAgo, + lastSeen: fiveSecondsAgo, }), ]; @@ -182,9 +182,9 @@ describe('ActiveCollaborators - Activity Indicator', () => { expect(borderDiv).toBeInTheDocument(); }); - test('shows gray border for users inactive for more than 2 minutes', () => { + test('shows gray border for users inactive for more than 12 seconds (0.2 minutes)', () => { const now = Date.now(); - const threeMinutesAgo = now - 3 * 60 * 1000; // 3 minutes ago + const thirtySecondsAgo = now - 30 * 1000; // 30 seconds ago mockRemoteUsers = [ createMockAwarenessUser({ @@ -194,7 +194,7 @@ describe('ActiveCollaborators - Activity Indicator', () => { email: 'john@example.com', color: '#ff0000', }, - lastSeen: threeMinutesAgo, + lastSeen: thirtySecondsAgo, }), ]; @@ -223,12 +223,12 @@ describe('ActiveCollaborators - Activity Indicator', () => { expect(borderDiv).toBeInTheDocument(); }); - test('correctly implements the 2-minute threshold (120,000ms)', () => { + test('correctly implements the 12-second threshold (0.2 minutes = 12,000ms)', () => { const now = Date.now(); - const justUnderTwoMinutes = now - (2 * 60 * 1000 - 1000); // 1 second before threshold - const justOverTwoMinutes = now - (2 * 60 * 1000 + 1000); // 1 second after threshold + const justUnderThreshold = now - (0.2 * 60 * 1000 - 1000); // 1 second before threshold (11 seconds ago) + const justOverThreshold = now - (0.2 * 60 * 1000 + 1000); // 1 second after threshold (13 seconds ago) - // User just under 2 minutes should have green border + // User just under 12 seconds should have green border mockRemoteUsers = [ createMockAwarenessUser({ clientId: 1, @@ -238,7 +238,7 @@ describe('ActiveCollaborators - Activity Indicator', () => { email: 'active@example.com', color: '#ff0000', }, - lastSeen: justUnderTwoMinutes, + lastSeen: justUnderThreshold, }), createMockAwarenessUser({ clientId: 2, @@ -248,7 +248,7 @@ describe('ActiveCollaborators - Activity Indicator', () => { email: 'inactive@example.com', color: '#00ff00', }, - lastSeen: justOverTwoMinutes, + lastSeen: justOverThreshold, }), ]; @@ -261,7 +261,7 @@ describe('ActiveCollaborators - Activity Indicator', () => { expect(grayBorder).toBeInTheDocument(); }); - test('border color updates when user crosses the 2-minute threshold', () => { + test('border color updates when user crosses the 12-second threshold', () => { vi.useFakeTimers(); const now = Date.now(); @@ -282,8 +282,8 @@ describe('ActiveCollaborators - Activity Indicator', () => { // Initially should have green border expect(container.querySelector('.border-green-500')).toBeInTheDocument(); - // Advance time by 3 minutes - vi.advanceTimersByTime(3 * 60 * 1000); + // Advance time by 15 seconds (beyond the 12-second threshold) + vi.advanceTimersByTime(15 * 1000); // Re-render to trigger the component to re-evaluate rerender(); @@ -379,6 +379,98 @@ describe('ActiveCollaborators - Store Integration', () => { }); }); +describe('ActiveCollaborators - Cache Behavior', () => { + beforeEach(() => { + mockRemoteUsers = []; + }); + + test('continues showing users from cache when awareness is throttled', () => { + const now = Date.now(); + + // Initial user list + mockRemoteUsers = [ + createMockAwarenessUser({ + user: { + id: 'user-1', + name: 'John Doe', + email: 'john@example.com', + color: '#ff0000', + }, + lastSeen: now, + }), + ]; + + const { rerender } = render(); + expect(screen.getByText('JD')).toBeInTheDocument(); + + // Simulate throttling - user still in cache (via merged users from store) + // The cache keeps users for 1 minute, so they should still appear + mockRemoteUsers = [ + createMockAwarenessUser({ + user: { + id: 'user-1', + name: 'John Doe', + email: 'john@example.com', + color: '#ff0000', + }, + lastSeen: now, + }), + ]; + + rerender(); + expect(screen.getByText('JD')).toBeInTheDocument(); + }); + + test('cached users appear with their last known state', () => { + const now = Date.now(); + const fiveSecondsAgo = now - 5 * 1000; + + // User active 5 seconds ago + mockRemoteUsers = [ + createMockAwarenessUser({ + user: { + id: 'user-1', + name: 'Jane Smith', + email: 'jane@example.com', + color: '#00ff00', + }, + lastSeen: fiveSecondsAgo, + }), + ]; + + render(); + + // Should still show as active (green border) because < 12 seconds + expect(screen.getByText('JS')).toBeInTheDocument(); + expect(screen.getByText('JS').closest('div')?.parentElement).toHaveClass( + 'border-green-500' + ); + }); + + test('cached users eventually expire after threshold', () => { + const now = Date.now(); + const thirtySecondsAgo = now - 30 * 1000; // Well over 12 seconds + + mockRemoteUsers = [ + createMockAwarenessUser({ + user: { + id: 'user-1', + name: 'Old User', + email: 'old@example.com', + color: '#ff0000', + }, + lastSeen: thirtySecondsAgo, + }), + ]; + + const { container } = render(); + + // Should show with gray border (inactive) because > 12 seconds + expect(screen.getByText('OU')).toBeInTheDocument(); + expect(container.querySelector('.border-gray-500')).toBeInTheDocument(); + }); +}); + describe('ActiveCollaborators - Edge Cases', () => { beforeEach(() => { mockRemoteUsers = []; From 2483a1e72edf68de5ed310565e9b1156c970e36f Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Mon, 24 Nov 2025 07:59:54 +0200 Subject: [PATCH 2/2] Add unified useAwareness() hook with simplified API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a new unified useAwareness() hook that consolidates useRemoteUsers(), useLiveRemoteUsers(), and useUserCursors() into a single flexible API. Key changes: - Remove excludeLocal option - hook always excludes local user - Support cached mode for smooth avatar transitions (60s TTL) - Support map format for efficient Monaco cursor CSS generation - Add comprehensive test coverage (26 tests) API: - useAwareness() - live remote users (for cursors) - useAwareness({ cached: true }) - cached remote users (for avatars) - useAwareness({ format: 'map' }) - Map format (for Monaco CSS) Legacy hooks remain available but are marked as deprecated. Migrate components to unified useAwareness() API Migrate all collaborative editor components to use the new simplified useAwareness() hook and update test mocks. Component migrations: - ActiveCollaborators: useRemoteUsers() → useAwareness({ cached: true }) - Cursors: useUserCursors() → useAwareness({ format: 'map' }) - CollaborationWidget: useAwarenessUsers() → useAwareness({ cached: true }) - RemoteCursor: useRemoteUsers() → useAwareness() Test updates: - Update mocks to use useAwareness instead of legacy hooks UX improvements: - CollaborationWidget now shows "You + N others" for clarity - RemoteCursor bug fix: cursors now disappear immediately on disconnect Optimize remote cursor rendering to prevent jumpy movement Fixes browser-specific issue where remote cursors appeared jumpy when viewport updates were frequent (during panning/zooming). The problem was render storms interrupting CSS transitions. Changes: - Add position rounding to reduce micro-pixel updates - Replace left/top positioning with CSS transform for better GPU acceleration - Wrap RemoteCursor in React.memo with custom comparison to skip re-renders when position changes are <1px This prevents frequent viewport updates from recreating cursor elements and interrupting their CSS transitions. --- .../components/ActiveCollaborators.tsx | 4 +- .../components/CollaborationWidget.tsx | 20 +- .../components/Cursors.tsx | 14 +- .../components/diagram/RemoteCursor.tsx | 99 +- .../hooks/useAwareness.ts | 191 ++- .../components/ActiveCollaborators.test.tsx | 8 +- .../ide/FullScreenIDE.keyboard.test.tsx | 2 +- .../yaml-import/YAMLImportPanel.test.tsx | 2 +- .../hooks/useAwareness.test.tsx | 1354 +++++++++++++++++ 9 files changed, 1618 insertions(+), 76 deletions(-) create mode 100644 assets/test/collaborative-editor/hooks/useAwareness.test.tsx diff --git a/assets/js/collaborative-editor/components/ActiveCollaborators.tsx b/assets/js/collaborative-editor/components/ActiveCollaborators.tsx index 8eb0e0c6b8..7987db6219 100644 --- a/assets/js/collaborative-editor/components/ActiveCollaborators.tsx +++ b/assets/js/collaborative-editor/components/ActiveCollaborators.tsx @@ -1,5 +1,5 @@ import { cn } from '../../utils/cn'; -import { useRemoteUsers } from '../hooks/useAwareness'; +import { useAwareness } from '../hooks/useAwareness'; import { getAvatarInitials } from '../utils/avatar'; import { Tooltip } from './Tooltip'; @@ -15,7 +15,7 @@ interface ActiveCollaboratorsProps { } export function ActiveCollaborators({ className }: ActiveCollaboratorsProps) { - const remoteUsers = useRemoteUsers(); + const remoteUsers = useAwareness({ cached: true }); if (remoteUsers.length === 0) { return null; diff --git a/assets/js/collaborative-editor/components/CollaborationWidget.tsx b/assets/js/collaborative-editor/components/CollaborationWidget.tsx index e282cc59fd..1d859c0201 100644 --- a/assets/js/collaborative-editor/components/CollaborationWidget.tsx +++ b/assets/js/collaborative-editor/components/CollaborationWidget.tsx @@ -5,14 +5,15 @@ import { useSocket } from '../../react/contexts/SocketProvider'; import { cn } from '../../utils/cn'; -import { useAwarenessUsers } from '../hooks/useAwareness'; +import { useAwareness } from '../hooks/useAwareness'; import { useSession } from '../hooks/useSession'; export function CollaborationWidget() { const { isConnected: socketConnected, connectionError } = useSocket(); const { isConnected: yjsConnected, isSynced } = useSession(); - const users = useAwarenessUsers(); + // Get remote users only (local user is always excluded) + const remoteUsers = useAwareness({ cached: true }); const getStatusColor = () => { if (socketConnected && yjsConnected && isSynced) return 'bg-green-500'; @@ -40,19 +41,20 @@ export function CollaborationWidget() {
{/* Separator */} - {users.length > 0 &&
} + {remoteUsers.length > 0 &&
} {/* Online users */} - {users.length > 0 && ( + {remoteUsers.length > 0 && (
- {users.length} user{users.length !== 1 ? 's' : ''}: + You + {remoteUsers.length} other + {remoteUsers.length !== 1 ? 's' : ''}:
- {users.slice(0, 3).map(user => ( + {remoteUsers.slice(0, 3).map(user => (
@@ -65,9 +67,9 @@ export function CollaborationWidget() {
))} - {users.length > 3 && ( + {remoteUsers.length > 3 && ( - +{users.length - 3} more + +{remoteUsers.length - 3} more )}
diff --git a/assets/js/collaborative-editor/components/Cursors.tsx b/assets/js/collaborative-editor/components/Cursors.tsx index b813463535..1a2a5f188d 100644 --- a/assets/js/collaborative-editor/components/Cursors.tsx +++ b/assets/js/collaborative-editor/components/Cursors.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo } from 'react'; -import { useUserCursors, useRemoteUsers } from '../hooks/useAwareness'; +import { useAwareness } from '../hooks/useAwareness'; function BaseStyles() { const baseStyles = ` @@ -46,16 +46,14 @@ function BaseStyles() { * Cursors component using awareness hooks for better performance and maintainability * * Key improvements: - * - Uses useUserCursors() hook with memoized Map for efficient lookups - * - Uses useRemoteUsers() for selection data (referentially stable) + * - Uses useAwareness() hook with Map format for efficient clientId lookups + * - Returns referentially stable data that only changes when users change * - Eliminates manual awareness state management and reduces re-renders */ export function Cursors() { // Get cursor data as a Map for efficient clientId lookups - const cursorsMap = useUserCursors(); - - // Get remote users for selection data - const remoteUsers = useRemoteUsers(); + // Note: Uses live users only (not cached), always excludes local user + const cursorsMap = useAwareness({ format: 'map' }); // Dynamic user-specific cursor styles - now using Map entries const userStyles = useMemo(() => { @@ -127,7 +125,7 @@ export function Cursors() { return () => { editorElement?.removeEventListener('scroll', checkCursorPositions); }; - }, [remoteUsers.length]); // Only re-run when remote users change + }, [cursorsMap.size]); // Only re-run when live users change return ( <> diff --git a/assets/js/collaborative-editor/components/diagram/RemoteCursor.tsx b/assets/js/collaborative-editor/components/diagram/RemoteCursor.tsx index d41c1ef050..c58762aba0 100644 --- a/assets/js/collaborative-editor/components/diagram/RemoteCursor.tsx +++ b/assets/js/collaborative-editor/components/diagram/RemoteCursor.tsx @@ -1,8 +1,8 @@ import { useViewport } from '@xyflow/react'; -import { useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { cn } from '../../../utils/cn'; -import { useRemoteUsers } from '../../hooks/useAwareness'; +import { useAwareness } from '../../hooks/useAwareness'; import { denormalizePointerPosition } from './normalizePointer'; @@ -15,7 +15,7 @@ interface RemoteCursor { } export function RemoteCursors() { - const remoteUsers = useRemoteUsers(); + const remoteUsers = useAwareness(); const { x: tx, y: ty, zoom: tzoom } = useViewport(); const cursors = useMemo(() => { @@ -30,12 +30,14 @@ export function RemoteCursors() { [tx, ty, tzoom] ); + // Round to nearest pixel to reduce micro-updates + // that trigger unnecessary re-renders return { clientId: user.clientId, name: user.user.name, color: user.user.color, - x: screenPos.x, - y: screenPos.y, + x: Math.round(screenPos.x), + y: Math.round(screenPos.y), }; }); }, [remoteUsers, tx, ty, tzoom]); @@ -66,42 +68,57 @@ interface RemoteCursorProps { y: number; } -function RemoteCursor({ name, color, x, y }: RemoteCursorProps) { - return ( -
- {/* Cursor pointer (SVG arrow) */} - - - - {/* User name label */} +// Memoize RemoteCursor to prevent re-renders when position hasn't changed +// significantly. This prevents CSS transitions from being interrupted by +// frequent viewport updates. +const RemoteCursor = memo( + function RemoteCursor({ name, color, x, y }: RemoteCursorProps) { + return (
- {name} + {/* Cursor pointer (SVG arrow) */} + + + + {/* User name label */} +
+ {name} +
-
- ); -} + ); + }, + // Custom comparison: only re-render if position changed by >1px + // This prevents render storms from micro viewport changes + (prev, next) => { + return ( + prev.name === next.name && + prev.color === next.color && + Math.abs(prev.x - next.x) < 1 && + Math.abs(prev.y - next.y) < 1 + ); + } +); diff --git a/assets/js/collaborative-editor/hooks/useAwareness.ts b/assets/js/collaborative-editor/hooks/useAwareness.ts index d99498e43d..18f33a6b35 100644 --- a/assets/js/collaborative-editor/hooks/useAwareness.ts +++ b/assets/js/collaborative-editor/hooks/useAwareness.ts @@ -4,33 +4,63 @@ * Provides React hooks for consuming awareness data with maximum referential stability. * These hooks use useSyncExternalStore with memoized selectors to minimize re-renders. * - * ## Core Hooks: - * - `useAwareness()`: Access to awareness store methods + * ## Unified API (Recommended): + * - `useAwareness(options?)`: Flexible hook with options for all use cases + * + * ## Legacy Hooks (Deprecated - use useAwareness instead): * - `useAwarenessUsers()`: All connected users - * - `useRemoteUsers()`: Remote users (excluding local user) + * - `useRemoteUsers()`: Remote users with deduplication + * - `useLiveRemoteUsers()`: Live remote users only + * - `useUserCursors()`: Cursor map for rendering + * + * ## Other Hooks: * - `useLocalUser()`: Current user data - * - `useUserCursors()`: Cursor data for rendering * - `useRawAwareness()`: Raw awareness instance (for Monaco bindings) + * - `useAwarenessCommands()`: Command functions * * ## Usage Examples: * * ```typescript - * // Get all users - * const users = useAwarenessUsers(); + * // Live remote users only (default) - for cursor rendering + * const users = useAwareness(); + * + * // Include cached users (recently disconnected) - for avatar lists + * const users = useAwareness({ cached: true }); * - * // Get only remote users for cursor rendering - * const remoteUsers = useRemoteUsers(); + * // Return as Map keyed by clientId - for Monaco CSS generation + * const usersMap = useAwareness({ format: 'map' }); + * + * // Combination: cached + map format + * const usersMap = useAwareness({ + * cached: true, + * format: 'map' + * }); * * // Get local user info * const localUser = useLocalUser(); * * // Access store commands - * const awareness = useAwareness(); - * awareness.updateLocalCursor({ x: 100, y: 200 }); + * const { updateLocalCursor } = useAwarenessCommands(); + * updateLocalCursor({ x: 100, y: 200 }); * * // Raw awareness for Monaco (referentially stable) * const rawAwareness = useRawAwareness(); * ``` + * + * ## Migration Guide: + * ```typescript + * // Old: useLiveRemoteUsers() + * // New: useAwareness() + * const users = useAwareness(); + * + * // Old: useRemoteUsers() + * // New: useAwareness({ cached: true }) + * const users = useAwareness({ cached: true }); + * + * // Old: useUserCursors() + * // New: useAwareness({ format: 'map' }) + * const usersMap = useAwareness({ format: 'map' }); + * ``` */ import { useSyncExternalStore, useContext } from 'react'; @@ -166,6 +196,28 @@ export const useUserCursors = (): Map => { return useSyncExternalStore(awarenessStore.subscribe, selectCursors); }; +/** + * Hook to get only live/active remote users (excluding cached/inactive users) + * Use this for cursor rendering where you only want to show active users + * Use useRemoteUsers() for avatar lists where you want to show cached users too + */ +export const useLiveRemoteUsers = (): AwarenessUser[] => { + const awarenessStore = useAwarenessStore(); + + const selectLiveRemoteUsers = awarenessStore.withSelector(state => { + if (!state.localUser) { + return Array.from(state.cursorsMap.values()); + } + + // Filter out local user from cursorsMap + return Array.from(state.cursorsMap.values()).filter( + user => user.user.id !== state.localUser?.id + ); + }); + + return useSyncExternalStore(awarenessStore.subscribe, selectLiveRemoteUsers); +}; + /** * Hook to get the raw awareness instance (for Monaco editor bindings) * This is referentially stable - only changes when awareness is initialized/destroyed @@ -211,3 +263,122 @@ export const useAwarenessCommands = () => { setConnected: awarenessStore.setConnected, }; }; + +/** + * Options for the unified useAwareness hook + */ +export interface UseAwarenessOptions { + /** + * Use cached users (live + recently disconnected within 60s TTL) + * Default: false (live only from cursorsMap) + */ + cached?: boolean; + /** + * Return format + * Default: 'array' + */ + format?: 'array' | 'map'; +} + +// TypeScript overloads for type-safe return types +export function useAwareness( + options: { format: 'map' } & Omit +): Map; +export function useAwareness(options?: UseAwarenessOptions): AwarenessUser[]; + +/** + * Unified hook for accessing awareness data with flexible options + * + * This hook consolidates the functionality of useRemoteUsers(), + * useLiveRemoteUsers(), and useUserCursors() into a single, + * flexible API. + * + * The hook always excludes the local user from results, as no + * component needs to render the current user's cursor or avatar. + * + * @example + * // Live remote users only (default) - for cursor rendering + * const users = useAwareness(); + * + * @example + * // Include cached users (recently disconnected) - for avatar lists + * const users = useAwareness({ cached: true }); + * + * @example + * // Return as Map keyed by clientId - for Monaco CSS generation + * const usersMap = useAwareness({ format: 'map' }); + * + * @example + * // Combination: cached + map format + * const usersMap = useAwareness({ + * cached: true, + * format: 'map' + * }); + */ +export function useAwareness( + options?: UseAwarenessOptions +): AwarenessUser[] | Map { + const awarenessStore = useAwarenessStore(); + + // Normalize options with defaults + const opts = { + cached: options?.cached ?? false, + format: options?.format ?? 'array', + } as const; + + const selectUsers = awarenessStore.withSelector(state => { + // 1. Choose data source + let users: AwarenessUser[]; + + if (opts.cached) { + // Use state.users (live + cached users within 60s TTL) + users = state.users; + + // Apply deduplication (same logic as useRemoteUsers) + const userMap = new Map(); + const connectionCounts = new Map(); + + users.forEach(user => { + const userId = user.user.id; + const count = connectionCounts.get(userId) || 0; + connectionCounts.set(userId, count + 1); + + const existingUser = userMap.get(userId); + if (!existingUser) { + userMap.set(userId, user); + } else { + // Keep the user with the latest lastSeen timestamp + const existingLastSeen = existingUser.lastSeen || 0; + const currentLastSeen = user.lastSeen || 0; + if (currentLastSeen > existingLastSeen) { + userMap.set(userId, user); + } + } + }); + + // Add connection counts + users = Array.from(userMap.values()).map(user => ({ + ...user, + connectionCount: connectionCounts.get(user.user.id) || 1, + })); + } else { + // Use cursorsMap (live users only, no deduplication needed) + users = Array.from(state.cursorsMap.values()); + } + + // 2. Always filter out local user + if (state.localUser) { + users = users.filter(u => u.user.id !== state.localUser?.id); + } + + // 3. Return in requested format + if (opts.format === 'map') { + // Convert array to Map keyed by clientId + return new Map(users.map(user => [user.clientId, user])); + } + + return users; + }); + + return useSyncExternalStore(awarenessStore.subscribe, selectUsers); +} diff --git a/assets/test/collaborative-editor/components/ActiveCollaborators.test.tsx b/assets/test/collaborative-editor/components/ActiveCollaborators.test.tsx index 6d713f1446..ac4bcc518d 100644 --- a/assets/test/collaborative-editor/components/ActiveCollaborators.test.tsx +++ b/assets/test/collaborative-editor/components/ActiveCollaborators.test.tsx @@ -7,7 +7,7 @@ * Test categories: * 1. Basic Rendering - Component visibility and avatar count * 4. Activity Indicator - Border colors based on lastSeen timestamp - * 6. Store Integration - Integration with useRemoteUsers hook + * 6. Store Integration - Integration with useAwareness hook * 7. Edge Cases - Empty states and state transitions */ @@ -17,11 +17,11 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { ActiveCollaborators } from '../../../js/collaborative-editor/components/ActiveCollaborators'; import type { AwarenessUser } from '../../../js/collaborative-editor/types/awareness'; -// Mock the useRemoteUsers hook +// Mock the useAwareness hook let mockRemoteUsers: AwarenessUser[] = []; vi.mock('../../../js/collaborative-editor/hooks/useAwareness', () => ({ - useRemoteUsers: () => mockRemoteUsers, + useAwareness: () => mockRemoteUsers, })); /** @@ -300,7 +300,7 @@ describe('ActiveCollaborators - Store Integration', () => { mockRemoteUsers = []; }); - test('uses useRemoteUsers hook correctly (excludes local user)', () => { + test('uses useAwareness hook correctly (excludes local user)', () => { // The hook should already filter out local users, so we only set remote users mockRemoteUsers = [ createMockAwarenessUser({ diff --git a/assets/test/collaborative-editor/components/ide/FullScreenIDE.keyboard.test.tsx b/assets/test/collaborative-editor/components/ide/FullScreenIDE.keyboard.test.tsx index a0c8800f56..525ea88354 100644 --- a/assets/test/collaborative-editor/components/ide/FullScreenIDE.keyboard.test.tsx +++ b/assets/test/collaborative-editor/components/ide/FullScreenIDE.keyboard.test.tsx @@ -331,7 +331,7 @@ vi.mock('../../../../js/collaborative-editor/hooks/useAwareness', () => ({ })), subscribe: vi.fn(), }), - useRemoteUsers: () => [], + useAwareness: () => [], })); // Create stable function references that persist across test renders diff --git a/assets/test/collaborative-editor/components/yaml-import/YAMLImportPanel.test.tsx b/assets/test/collaborative-editor/components/yaml-import/YAMLImportPanel.test.tsx index 9aeb30dbed..1dbf19f923 100644 --- a/assets/test/collaborative-editor/components/yaml-import/YAMLImportPanel.test.tsx +++ b/assets/test/collaborative-editor/components/yaml-import/YAMLImportPanel.test.tsx @@ -12,7 +12,7 @@ import type { StoreContextValue } from '../../../../js/collaborative-editor/cont // Mock the awareness hook vi.mock('../../../../js/collaborative-editor/hooks/useAwareness', () => ({ - useRemoteUsers: () => [], + useAwareness: () => [], })); const validYAML = ` diff --git a/assets/test/collaborative-editor/hooks/useAwareness.test.tsx b/assets/test/collaborative-editor/hooks/useAwareness.test.tsx new file mode 100644 index 0000000000..4a1996e1c7 --- /dev/null +++ b/assets/test/collaborative-editor/hooks/useAwareness.test.tsx @@ -0,0 +1,1354 @@ +/** + * useRemoteUsers() Hook Tests + * + * Tests the deduplication and connection counting behavior of useRemoteUsers(). + * This hook filters out the local user, deduplicates users by user.id (when they have + * multiple tabs/clientIds), keeps the latest cursor/selection, and adds connectionCount. + */ + +import { act, renderHook } from '@testing-library/react'; +import type React from 'react'; +import { beforeEach, describe, expect, test } from 'vitest'; + +import type { StoreContextValue } from '../../../js/collaborative-editor/contexts/StoreProvider'; +import { StoreContext } from '../../../js/collaborative-editor/contexts/StoreProvider'; +import { + useAwareness, + useRemoteUsers, +} from '../../../js/collaborative-editor/hooks/useAwareness'; +import type { AwarenessStoreInstance } from '../../../js/collaborative-editor/stores/createAwarenessStore'; +import { createAwarenessStore } from '../../../js/collaborative-editor/stores/createAwarenessStore'; +import type { + AwarenessUser, + LocalUserData, +} from '../../../js/collaborative-editor/types/awareness'; + +// ============================================================================= +// TEST SETUP & FIXTURES +// ============================================================================= + +function createWrapper( + awarenessStore: AwarenessStoreInstance +): React.ComponentType<{ children: React.ReactNode }> { + const mockStoreValue: StoreContextValue = { + awarenessStore, + sessionContextStore: + {} as unknown as StoreContextValue['sessionContextStore'], + adaptorStore: {} as unknown as StoreContextValue['adaptorStore'], + credentialStore: {} as unknown as StoreContextValue['credentialStore'], + workflowStore: {} as unknown as StoreContextValue['workflowStore'], + historyStore: {} as unknown as StoreContextValue['historyStore'], + uiStore: {} as unknown as StoreContextValue['uiStore'], + editorPreferencesStore: + {} as unknown as StoreContextValue['editorPreferencesStore'], + }; + + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); +} + +function createMockLocalUser( + overrides: Partial = {} +): LocalUserData { + return { + id: 'local-user-1', + name: 'Local User', + email: 'local@example.com', + color: '#ff0000', + ...overrides, + }; +} + +function createMockAwarenessUser( + overrides: Partial = {} +): AwarenessUser { + return { + clientId: 100, + user: { + id: 'user-1', + name: 'Test User', + email: 'test@example.com', + color: '#00ff00', + }, + cursor: { x: 100, y: 200 }, + selection: null, + lastSeen: Date.now(), + ...overrides, + }; +} + +/** + * Creates a mutable mock awareness instance that can simulate user states + */ +function createMutableMockAwareness() { + let currentStates = new Map>(); + + return { + getLocalState: () => null, + setLocalState: () => {}, + setLocalStateField: () => {}, + getStates: () => currentStates, + on: () => {}, + off: () => {}, + _updateStates: (newStates: Map>) => { + currentStates = newStates; + }, + }; +} + +/** + * Helper to simulate awareness state changes. + * This triggers the awareness change handler in the store. + */ +function simulateAwarenessUpdate( + store: AwarenessStoreInstance, + users: AwarenessUser[] +): void { + // Create a map of awareness states keyed by clientId + const awarenessStates = new Map>(); + + users.forEach(user => { + awarenessStates.set(user.clientId, { + user: user.user, + cursor: user.cursor, + selection: user.selection, + lastSeen: user.lastSeen, + }); + }); + + // Replace the awareness instance and trigger change handler + // We need to access the internal handler + const storeInternal = store as unknown as { + _internal: { handleAwarenessChange: () => void }; + }; + + // Update the raw awareness reference + const currentState = store.getSnapshot(); + if (currentState.rawAwareness) { + // Update the mock awareness states + const mockAwareness = currentState.rawAwareness as unknown as ReturnType< + typeof createMutableMockAwareness + >; + mockAwareness._updateStates(awarenessStates); + } + + // Trigger the awareness change handler + storeInternal._internal.handleAwarenessChange(); +} + +describe('useRemoteUsers()', () => { + let store: AwarenessStoreInstance; + + beforeEach(() => { + store = createAwarenessStore(); + }); + + // =========================================================================== + // BASIC FILTERING + // =========================================================================== + + describe('basic filtering', () => { + test('returns empty array when no users exist', () => { + const { result } = renderHook(() => useRemoteUsers(), { + wrapper: createWrapper(store), + }); + + expect(result.current).toEqual([]); + }); + + test('excludes local user and returns only remote users', () => { + const localUser = createMockLocalUser({ id: 'local-user-1' }); + + // Create mock awareness and initialize + const mockAwareness = createMutableMockAwareness(); + + act(() => { + store.initializeAwareness(mockAwareness as never, localUser); + }); + + const { result } = renderHook(() => useRemoteUsers(), { + wrapper: createWrapper(store), + }); + + // Initially empty + expect(result.current).toEqual([]); + + // Add users including one with local user ID + const localUserAsRemote = createMockAwarenessUser({ + clientId: 99, + user: { + id: 'local-user-1', + name: 'Local User', + email: 'local@example.com', + color: '#ff0000', + }, + }); + const remoteUser1 = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'remote-1', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + }); + const remoteUser2 = createMockAwarenessUser({ + clientId: 101, + user: { + id: 'remote-2', + name: 'Bob', + email: 'bob@example.com', + color: '#00ff00', + }, + }); + + act(() => { + simulateAwarenessUpdate(store, [ + localUserAsRemote, + remoteUser1, + remoteUser2, + ]); + }); + + expect(result.current).toHaveLength(2); + expect(result.current.map(u => u.user.id).sort()).toEqual([ + 'remote-1', + 'remote-2', + ]); + }); + + test('returns all users when localUser is null', () => { + const user1 = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + }); + const user2 = createMockAwarenessUser({ + clientId: 101, + user: { + id: 'user-2', + name: 'Bob', + email: 'bob@example.com', + color: '#00ff00', + }, + }); + + // Don't initialize awareness - localUser will be null + const { result } = renderHook(() => useRemoteUsers(), { + wrapper: createWrapper(store), + }); + + // Manually set users without initialization + // Since we can't set users without awareness, we'll initialize with a mock awareness + const mockAwareness = createMutableMockAwareness(); + act(() => { + store.initializeAwareness(mockAwareness as never, null as never); + }); + + act(() => { + simulateAwarenessUpdate(store, [user1, user2]); + }); + + // With null localUser, all users should be returned + expect(result.current).toHaveLength(2); + expect(result.current.map(u => u.user.id).sort()).toEqual([ + 'user-1', + 'user-2', + ]); + }); + }); + + // =========================================================================== + // DEDUPLICATION WITH CONNECTION COUNT + // =========================================================================== + + describe('deduplication with multiple tabs', () => { + test('deduplicates user with multiple connections and adds connectionCount', () => { + const localUser = createMockLocalUser({ id: 'local-user-1' }); + const mockAwareness = createMutableMockAwareness(); + + act(() => { + store.initializeAwareness(mockAwareness as never, localUser); + }); + + const { result } = renderHook(() => useRemoteUsers(), { + wrapper: createWrapper(store), + }); + + // Alice has 3 tabs open (3 different clientIds, same user.id) + const aliceTab1 = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + cursor: { x: 100, y: 100 }, + lastSeen: 1000, + }); + const aliceTab2 = createMockAwarenessUser({ + clientId: 101, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + cursor: { x: 200, y: 200 }, + lastSeen: 2000, // Most recent + }); + const aliceTab3 = createMockAwarenessUser({ + clientId: 102, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + cursor: { x: 150, y: 150 }, + lastSeen: 1500, + }); + + act(() => { + simulateAwarenessUpdate(store, [aliceTab1, aliceTab2, aliceTab3]); + }); + + expect(result.current).toHaveLength(1); + const deduplicatedAlice = result.current[0]; + + // Should have connection count of 3 + expect(deduplicatedAlice.connectionCount).toBe(3); + + // Should keep the cursor/selection from the tab with latest lastSeen (tab2) + expect(deduplicatedAlice.cursor).toEqual({ x: 200, y: 200 }); + expect(deduplicatedAlice.lastSeen).toBe(2000); + + // User data should be preserved + expect(deduplicatedAlice.user.id).toBe('alice'); + expect(deduplicatedAlice.user.name).toBe('Alice'); + }); + + test('handles mix of single and multi-connection users', () => { + const localUser = createMockLocalUser({ id: 'local-user-1' }); + const mockAwareness = createMutableMockAwareness(); + + act(() => { + store.initializeAwareness(mockAwareness as never, localUser); + }); + + const { result } = renderHook(() => useRemoteUsers(), { + wrapper: createWrapper(store), + }); + + // Alice has 2 connections + const aliceTab1 = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + lastSeen: 1000, + }); + const aliceTab2 = createMockAwarenessUser({ + clientId: 101, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + lastSeen: 2000, + }); + + // Bob has 1 connection + const bob = createMockAwarenessUser({ + clientId: 102, + user: { + id: 'bob', + name: 'Bob', + email: 'bob@example.com', + color: '#00ff00', + }, + lastSeen: 1500, + }); + + // Charlie has 3 connections + const charlieTab1 = createMockAwarenessUser({ + clientId: 103, + user: { + id: 'charlie', + name: 'Charlie', + email: 'charlie@example.com', + color: '#0000ff', + }, + lastSeen: 1200, + }); + const charlieTab2 = createMockAwarenessUser({ + clientId: 104, + user: { + id: 'charlie', + name: 'Charlie', + email: 'charlie@example.com', + color: '#0000ff', + }, + lastSeen: 1800, + }); + const charlieTab3 = createMockAwarenessUser({ + clientId: 105, + user: { + id: 'charlie', + name: 'Charlie', + email: 'charlie@example.com', + color: '#0000ff', + }, + lastSeen: 2500, // Most recent + }); + + act(() => { + simulateAwarenessUpdate(store, [ + aliceTab1, + aliceTab2, + bob, + charlieTab1, + charlieTab2, + charlieTab3, + ]); + }); + + expect(result.current).toHaveLength(3); + + const userMap = new Map(result.current.map(u => [u.user.id, u])); + + // Alice: 2 connections, latest lastSeen = 2000 + expect(userMap.get('alice')?.connectionCount).toBe(2); + expect(userMap.get('alice')?.lastSeen).toBe(2000); + + // Bob: 1 connection + expect(userMap.get('bob')?.connectionCount).toBe(1); + expect(userMap.get('bob')?.lastSeen).toBe(1500); + + // Charlie: 3 connections, latest lastSeen = 2500 + expect(userMap.get('charlie')?.connectionCount).toBe(3); + expect(userMap.get('charlie')?.lastSeen).toBe(2500); + }); + + test('keeps latest cursor position when deduplicating', () => { + const localUser = createMockLocalUser({ id: 'local-user-1' }); + const mockAwareness = createMutableMockAwareness(); + + act(() => { + store.initializeAwareness(mockAwareness as never, localUser); + }); + + const { result } = renderHook(() => useRemoteUsers(), { + wrapper: createWrapper(store), + }); + + // Alice has 2 tabs, but one has undefined lastSeen + const aliceTab1 = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + cursor: { x: 100, y: 100 }, + lastSeen: undefined, + }); + const aliceTab2 = createMockAwarenessUser({ + clientId: 101, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + cursor: { x: 200, y: 200 }, + lastSeen: 2000, + }); + + act(() => { + simulateAwarenessUpdate(store, [aliceTab1, aliceTab2]); + }); + + expect(result.current).toHaveLength(1); + const deduplicatedAlice = result.current[0]; + + // Should prefer the one with defined lastSeen + expect(deduplicatedAlice.lastSeen).toBe(2000); + expect(deduplicatedAlice.cursor).toEqual({ x: 200, y: 200 }); + expect(deduplicatedAlice.connectionCount).toBe(2); + }); + }); + + // =========================================================================== + // EDGE CASES + // =========================================================================== + + describe('edge cases', () => { + test('handles null cursor and selection gracefully', () => { + const localUser = createMockLocalUser({ id: 'local-user-1' }); + const mockAwareness = createMutableMockAwareness(); + + act(() => { + store.initializeAwareness(mockAwareness as never, localUser); + }); + + const { result } = renderHook(() => useRemoteUsers(), { + wrapper: createWrapper(store), + }); + + const userWithNullCursor = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'user-1', + name: 'User 1', + email: 'user1@example.com', + color: '#ff0000', + }, + cursor: null, + selection: null, + lastSeen: Date.now(), + }); + + act(() => { + simulateAwarenessUpdate(store, [userWithNullCursor]); + }); + + expect(result.current).toHaveLength(1); + expect(result.current[0].cursor).toBeNull(); + expect(result.current[0].selection).toBeNull(); + }); + + test('handles user with no lastSeen timestamp', () => { + const localUser = createMockLocalUser({ id: 'local-user-1' }); + const mockAwareness = createMutableMockAwareness(); + + act(() => { + store.initializeAwareness(mockAwareness as never, localUser); + }); + + const { result } = renderHook(() => useRemoteUsers(), { + wrapper: createWrapper(store), + }); + + const userWithoutLastSeen = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'user-1', + name: 'User 1', + email: 'user1@example.com', + color: '#ff0000', + }, + lastSeen: undefined, + }); + + act(() => { + simulateAwarenessUpdate(store, [userWithoutLastSeen]); + }); + + expect(result.current).toHaveLength(1); + expect(result.current[0].lastSeen).toBeUndefined(); + }); + + test('deduplication prefers entry with lastSeen when other has undefined', () => { + const localUser = createMockLocalUser({ id: 'local-user-1' }); + const mockAwareness = createMutableMockAwareness(); + + act(() => { + store.initializeAwareness(mockAwareness as never, localUser); + }); + + const { result } = renderHook(() => useRemoteUsers(), { + wrapper: createWrapper(store), + }); + + // Two entries for same user, one without lastSeen + const userTab1 = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'user-1', + name: 'User 1', + email: 'user1@example.com', + color: '#ff0000', + }, + cursor: { x: 100, y: 100 }, + lastSeen: undefined, + }); + + const userTab2 = createMockAwarenessUser({ + clientId: 101, + user: { + id: 'user-1', + name: 'User 1', + email: 'user1@example.com', + color: '#ff0000', + }, + cursor: { x: 200, y: 200 }, + lastSeen: 5000, + }); + + act(() => { + simulateAwarenessUpdate(store, [userTab1, userTab2]); + }); + + expect(result.current).toHaveLength(1); + expect(result.current[0].lastSeen).toBe(5000); + expect(result.current[0].cursor).toEqual({ x: 200, y: 200 }); + expect(result.current[0].connectionCount).toBe(2); + }); + + test('sets connectionCount to 1 for single-connection users', () => { + const localUser = createMockLocalUser({ id: 'local-user-1' }); + const mockAwareness = createMutableMockAwareness(); + + act(() => { + store.initializeAwareness(mockAwareness as never, localUser); + }); + + const { result } = renderHook(() => useRemoteUsers(), { + wrapper: createWrapper(store), + }); + + const bob = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'bob', + name: 'Bob', + email: 'bob@example.com', + color: '#00ff00', + }, + }); + + act(() => { + simulateAwarenessUpdate(store, [bob]); + }); + + expect(result.current).toHaveLength(1); + expect(result.current[0].connectionCount).toBe(1); + }); + }); + + // =========================================================================== + // REACTIVITY + // =========================================================================== + + describe('reactivity', () => { + test('updates when users are added or removed', () => { + const localUser = createMockLocalUser({ id: 'local-user-1' }); + const mockAwareness = createMutableMockAwareness(); + + act(() => { + store.initializeAwareness(mockAwareness as never, localUser); + }); + + const { result } = renderHook(() => useRemoteUsers(), { + wrapper: createWrapper(store), + }); + + // Initially empty + expect(result.current).toHaveLength(0); + + // Add a user + const alice = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + }); + + act(() => { + simulateAwarenessUpdate(store, [alice]); + }); + + expect(result.current).toHaveLength(1); + expect(result.current[0].user.id).toBe('alice'); + + // Add another user + const bob = createMockAwarenessUser({ + clientId: 101, + user: { + id: 'bob', + name: 'Bob', + email: 'bob@example.com', + color: '#00ff00', + }, + }); + + act(() => { + simulateAwarenessUpdate(store, [alice, bob]); + }); + + expect(result.current).toHaveLength(2); + + // Remove a user (bob disconnects) + // Note: Bob will still appear in the cached users list for 60s after disconnect + act(() => { + simulateAwarenessUpdate(store, [alice]); + }); + + // Bob is still cached (within 60s TTL), so both users appear + expect(result.current).toHaveLength(2); + const userIds = result.current.map(u => u.user.id).sort(); + expect(userIds).toEqual(['alice', 'bob']); + }); + }); +}); + +// ============================================================================= +// NEW UNIFIED useAwareness() API TESTS +// ============================================================================= + +describe('useAwareness() - unified API with options', () => { + let store: AwarenessStoreInstance; + + beforeEach(() => { + store = createAwarenessStore(); + }); + + // =========================================================================== + // DEFAULT BEHAVIOR: cached: false, always excludes local, format: 'array' + // =========================================================================== + + describe('default behavior (live, always excludes local)', () => { + test('returns live remote users only (from cursorsMap)', () => { + const localUser = createMockLocalUser({ id: 'local-user-1' }); + const mockAwareness = createMutableMockAwareness(); + + act(() => { + store.initializeAwareness(mockAwareness as never, localUser); + }); + + // Default behavior: live users only, always excludes local + const { result } = renderHook(() => useAwareness(), { + wrapper: createWrapper(store), + }); + + // Add live users + const alice = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + lastSeen: Date.now(), + }); + const bob = createMockAwarenessUser({ + clientId: 101, + user: { + id: 'bob', + name: 'Bob', + email: 'bob@example.com', + color: '#00ff00', + }, + lastSeen: Date.now(), + }); + + act(() => { + simulateAwarenessUpdate(store, [alice, bob]); + }); + + expect(result.current).toHaveLength(2); + expect(result.current.map(u => u.user.id).sort()).toEqual([ + 'alice', + 'bob', + ]); + }); + + test('excludes local user from results', () => { + const localUser = createMockLocalUser({ id: 'local-user-1' }); + const mockAwareness = createMutableMockAwareness(); + + act(() => { + store.initializeAwareness(mockAwareness as never, localUser); + }); + + const { result } = renderHook(() => useAwareness(), { + wrapper: createWrapper(store), + }); + + // Add local user and remote user + const localUserAsRemote = createMockAwarenessUser({ + clientId: 99, + user: { + id: 'local-user-1', + name: 'Local', + email: 'local@example.com', + color: '#ff0000', + }, + }); + const alice = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + }); + + act(() => { + simulateAwarenessUpdate(store, [localUserAsRemote, alice]); + }); + + expect(result.current).toHaveLength(1); + expect(result.current[0].user.id).toBe('alice'); + }); + + test('does not deduplicate - shows all clientIds for same user', () => { + const localUser = createMockLocalUser({ id: 'local-user-1' }); + const mockAwareness = createMutableMockAwareness(); + + act(() => { + store.initializeAwareness(mockAwareness as never, localUser); + }); + + // cached: false means no deduplication - each clientId is separate + const { result } = renderHook(() => useAwareness(), { + wrapper: createWrapper(store), + }); + + // Alice with 2 tabs - should show both tabs (no deduplication) + const aliceTab1 = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + cursor: { x: 100, y: 100 }, + lastSeen: 1000, + }); + const aliceTab2 = createMockAwarenessUser({ + clientId: 101, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + cursor: { x: 200, y: 200 }, + lastSeen: 2000, + }); + + act(() => { + simulateAwarenessUpdate(store, [aliceTab1, aliceTab2]); + }); + + // With cached: false, no deduplication - both tabs appear + expect(result.current).toHaveLength(2); + expect(result.current[0].clientId).toBe(100); + expect(result.current[1].clientId).toBe(101); + }); + + test('does not include recently disconnected users', () => { + const localUser = createMockLocalUser({ id: 'local-user-1' }); + const mockAwareness = createMutableMockAwareness(); + + act(() => { + store.initializeAwareness(mockAwareness as never, localUser); + }); + + // cached: false means live users only + const { result } = renderHook(() => useAwareness(), { + wrapper: createWrapper(store), + }); + + // Add two users + const alice = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + }); + const bob = createMockAwarenessUser({ + clientId: 101, + user: { + id: 'bob', + name: 'Bob', + email: 'bob@example.com', + color: '#00ff00', + }, + }); + + act(() => { + simulateAwarenessUpdate(store, [alice, bob]); + }); + + expect(result.current).toHaveLength(2); + + // Bob disconnects (remove from cursorsMap) + act(() => { + simulateAwarenessUpdate(store, [alice]); + }); + + // With cached: false (default), bob should NOT appear (live only) + expect(result.current).toHaveLength(1); + expect(result.current[0].user.id).toBe('alice'); + }); + }); + + // =========================================================================== + // OPTION: cached: true + // =========================================================================== + + describe('cached: true option', () => { + test('includes recently disconnected users within 60s TTL', () => { + const localUser = createMockLocalUser({ id: 'local-user-1' }); + const mockAwareness = createMutableMockAwareness(); + + act(() => { + store.initializeAwareness(mockAwareness as never, localUser); + }); + + const { result } = renderHook(() => useAwareness({ cached: true }), { + wrapper: createWrapper(store), + }); + + // Add two users + const alice = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + }); + const bob = createMockAwarenessUser({ + clientId: 101, + user: { + id: 'bob', + name: 'Bob', + email: 'bob@example.com', + color: '#00ff00', + }, + }); + + act(() => { + simulateAwarenessUpdate(store, [alice, bob]); + }); + + // Bob disconnects + act(() => { + simulateAwarenessUpdate(store, [alice]); + }); + + // Both users should appear (bob is cached) + expect(result.current).toHaveLength(2); + const userIds = result.current.map(u => u.user.id).sort(); + expect(userIds).toEqual(['alice', 'bob']); + }); + + test('deduplicates by user.id and adds connectionCount', () => { + const localUser = createMockLocalUser({ id: 'local-user-1' }); + const mockAwareness = createMutableMockAwareness(); + + act(() => { + store.initializeAwareness(mockAwareness as never, localUser); + }); + + const { result } = renderHook(() => useAwareness({ cached: true }), { + wrapper: createWrapper(store), + }); + + // Alice with 3 tabs + const aliceTab1 = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + cursor: { x: 100, y: 100 }, + lastSeen: 1000, + }); + const aliceTab2 = createMockAwarenessUser({ + clientId: 101, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + cursor: { x: 200, y: 200 }, + lastSeen: 2000, + }); + const aliceTab3 = createMockAwarenessUser({ + clientId: 102, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + cursor: { x: 150, y: 150 }, + lastSeen: 1500, + }); + + act(() => { + simulateAwarenessUpdate(store, [aliceTab1, aliceTab2, aliceTab3]); + }); + + expect(result.current).toHaveLength(1); + expect(result.current[0].connectionCount).toBe(3); + expect(result.current[0].cursor).toEqual({ x: 200, y: 200 }); // Latest lastSeen + }); + + test('keeps cursor from latest lastSeen when deduplicating', () => { + const localUser = createMockLocalUser({ id: 'local-user-1' }); + const mockAwareness = createMutableMockAwareness(); + + act(() => { + store.initializeAwareness(mockAwareness as never, localUser); + }); + + const { result } = renderHook(() => useAwareness({ cached: true }), { + wrapper: createWrapper(store), + }); + + // Alice with 2 tabs, different cursors and timestamps + const aliceTab1 = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + cursor: { x: 100, y: 100 }, + lastSeen: 1000, + }); + const aliceTab2 = createMockAwarenessUser({ + clientId: 101, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + cursor: { x: 500, y: 600 }, + lastSeen: 3000, // Latest + }); + + act(() => { + simulateAwarenessUpdate(store, [aliceTab1, aliceTab2]); + }); + + expect(result.current).toHaveLength(1); + expect(result.current[0].cursor).toEqual({ x: 500, y: 600 }); + expect(result.current[0].lastSeen).toBe(3000); + }); + }); + + // =========================================================================== + // OPTION: format: 'map' + // =========================================================================== + + describe('format: map option', () => { + test('returns Map', () => { + const localUser = createMockLocalUser({ id: 'local-user-1' }); + const mockAwareness = createMutableMockAwareness(); + + act(() => { + store.initializeAwareness(mockAwareness as never, localUser); + }); + + const { result } = renderHook(() => useAwareness({ format: 'map' }), { + wrapper: createWrapper(store), + }); + + const alice = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + }); + const bob = createMockAwarenessUser({ + clientId: 101, + user: { + id: 'bob', + name: 'Bob', + email: 'bob@example.com', + color: '#00ff00', + }, + }); + + act(() => { + simulateAwarenessUpdate(store, [alice, bob]); + }); + + expect(result.current).toBeInstanceOf(Map); + expect(result.current.size).toBe(2); + expect(result.current.get(100)?.user.id).toBe('alice'); + expect(result.current.get(101)?.user.id).toBe('bob'); + }); + + test('always excludes local user when format is map', () => { + const localUser = createMockLocalUser({ id: 'local-user-1' }); + const mockAwareness = createMutableMockAwareness(); + + act(() => { + store.initializeAwareness(mockAwareness as never, localUser); + }); + + // Always excludes local user + const { result } = renderHook(() => useAwareness({ format: 'map' }), { + wrapper: createWrapper(store), + }); + + const localUserAsRemote = createMockAwarenessUser({ + clientId: 99, + user: { + id: 'local-user-1', + name: 'Local', + email: 'local@example.com', + color: '#ff0000', + }, + }); + const alice = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + }); + + act(() => { + simulateAwarenessUpdate(store, [localUserAsRemote, alice]); + }); + + // Local user should be excluded + expect(result.current.size).toBe(1); + expect(result.current.has(99)).toBe(false); + expect(result.current.get(100)?.user.id).toBe('alice'); + }); + + test('works with cached mode when format is map', () => { + const localUser = createMockLocalUser({ id: 'local-user-1' }); + const mockAwareness = createMutableMockAwareness(); + + act(() => { + store.initializeAwareness(mockAwareness as never, localUser); + }); + + const { result } = renderHook( + () => useAwareness({ format: 'map', cached: true }), + { wrapper: createWrapper(store) } + ); + + const alice = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + }); + + act(() => { + simulateAwarenessUpdate(store, [alice]); + }); + + expect(result.current.size).toBe(1); + expect(result.current.get(100)?.user.id).toBe('alice'); + + // Alice disconnects + act(() => { + simulateAwarenessUpdate(store, []); + }); + + // With cached: true, alice should still appear + expect(result.current.size).toBe(1); + expect(result.current.get(100)?.user.id).toBe('alice'); + }); + }); + + // =========================================================================== + // OPTION COMBINATIONS + // =========================================================================== + + describe('option combinations', () => { + test('cached: true + format: map', () => { + const localUser = createMockLocalUser({ id: 'local-user-1' }); + const mockAwareness = createMutableMockAwareness(); + + act(() => { + store.initializeAwareness(mockAwareness as never, localUser); + }); + + const { result } = renderHook( + () => useAwareness({ cached: true, format: 'map' }), + { wrapper: createWrapper(store) } + ); + + // Alice with 2 tabs - should be deduplicated + const aliceTab1 = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + cursor: { x: 100, y: 100 }, + lastSeen: 1000, + }); + const aliceTab2 = createMockAwarenessUser({ + clientId: 101, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + cursor: { x: 200, y: 200 }, + lastSeen: 2000, + }); + const bob = createMockAwarenessUser({ + clientId: 102, + user: { + id: 'bob', + name: 'Bob', + email: 'bob@example.com', + color: '#00ff00', + }, + }); + + act(() => { + simulateAwarenessUpdate(store, [aliceTab1, aliceTab2, bob]); + }); + + expect(result.current).toBeInstanceOf(Map); + expect(result.current.size).toBe(2); + + // Convert Map to array to verify contents + const usersArray = Array.from(result.current.values()); + const userMap = new Map(usersArray.map(u => [u.user.id, u])); + + // Should be deduplicated - alice should have connectionCount of 2 + const aliceResult = userMap.get('alice'); + expect(aliceResult?.user.id).toBe('alice'); + expect(aliceResult?.connectionCount).toBe(2); + expect(aliceResult?.cursor).toEqual({ x: 200, y: 200 }); // Latest cursor + expect(aliceResult?.lastSeen).toBe(2000); + + // Bob should have connectionCount of 1 + const bobResult = userMap.get('bob'); + expect(bobResult?.user.id).toBe('bob'); + expect(bobResult?.connectionCount).toBe(1); + }); + }); + + // =========================================================================== + // EDGE CASES + // =========================================================================== + + describe('edge cases', () => { + test('returns empty array when no users and no options specified', () => { + const { result } = renderHook(() => useAwareness(), { + wrapper: createWrapper(store), + }); + + expect(result.current).toEqual([]); + }); + + test('returns empty Map when format: map and no users', () => { + const { result } = renderHook(() => useAwareness({ format: 'map' }), { + wrapper: createWrapper(store), + }); + + expect(result.current).toBeInstanceOf(Map); + expect(result.current.size).toBe(0); + }); + + test('handles null localUser (includes all users when local is null)', () => { + const mockAwareness = createMutableMockAwareness(); + + // Initialize with null local user + act(() => { + store.initializeAwareness(mockAwareness as never, null as never); + }); + + const { result } = renderHook(() => useAwareness(), { + wrapper: createWrapper(store), + }); + + const alice = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + }); + + act(() => { + simulateAwarenessUpdate(store, [alice]); + }); + + // Should include alice since localUser is null + expect(result.current).toHaveLength(1); + expect(result.current[0].user.id).toBe('alice'); + }); + + test('returns consistent data across multiple calls', () => { + const localUser = createMockLocalUser({ id: 'local-user-1' }); + const mockAwareness = createMutableMockAwareness(); + + act(() => { + store.initializeAwareness(mockAwareness as never, localUser); + }); + + const { result } = renderHook(() => useAwareness(), { + wrapper: createWrapper(store), + }); + + const alice = createMockAwarenessUser({ + clientId: 100, + user: { + id: 'alice', + name: 'Alice', + email: 'alice@example.com', + color: '#ff0000', + }, + }); + + act(() => { + simulateAwarenessUpdate(store, [alice]); + }); + + // Data should be consistent + expect(result.current).toHaveLength(1); + expect(result.current[0].user.id).toBe('alice'); + }); + }); +});