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..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; @@ -44,7 +44,7 @@ export function ActiveCollaborators({ className }: ActiveCollaboratorsProps) { return (
{ 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/js/collaborative-editor/stores/createAwarenessStore.ts b/assets/js/collaborative-editor/stores/createAwarenessStore.ts index e4e21bcc91..0f04cc3cc3 100644 --- a/assets/js/collaborative-editor/stores/createAwarenessStore.ts +++ b/assets/js/collaborative-editor/stores/createAwarenessStore.ts @@ -121,6 +121,7 @@ export const createAwarenessStore = (): AwarenessStore => { 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..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, })); /** @@ -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(); @@ -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({ @@ -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 = []; 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'); + }); + }); +});