diff --git a/CHANGELOG.md b/CHANGELOG.md index 72034ac86d..2a9edaf4c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and this project adheres to ### Fixed +- Fix flickering of active collaborator icons between activity states(active, + away, offline) [#3931](https://github.com/OpenFn/lightning/issues/3931) - Put close button in top right [PR#4037](https://github.com/OpenFn/lightning/pull/4037) - Don't grey out in-progress runs diff --git a/assets/js/collaborative-editor/components/ActiveCollaborators.tsx b/assets/js/collaborative-editor/components/ActiveCollaborators.tsx index 7987db6219..5624af32dd 100644 --- a/assets/js/collaborative-editor/components/ActiveCollaborators.tsx +++ b/assets/js/collaborative-editor/components/ActiveCollaborators.tsx @@ -1,21 +1,15 @@ import { cn } from '../../utils/cn'; -import { useAwareness } from '../hooks/useAwareness'; +import { useRemoteUsers } from '../hooks/useAwareness'; import { getAvatarInitials } from '../utils/avatar'; import { Tooltip } from './Tooltip'; -function lessthanmin(val: number, mins: number) { - const now = Date.now(); - const threshold = now - mins * 60 * 1000; - return val > threshold; -} - interface ActiveCollaboratorsProps { className?: string; } export function ActiveCollaborators({ className }: ActiveCollaboratorsProps) { - const remoteUsers = useAwareness({ cached: true }); + const remoteUsers = useRemoteUsers(); if (remoteUsers.length === 0) { return null; @@ -44,7 +38,7 @@ export function ActiveCollaborators({ className }: ActiveCollaboratorsProps) { return (
{ if (socketConnected && yjsConnected && isSynced) return 'bg-green-500'; diff --git a/assets/js/collaborative-editor/contexts/StoreProvider.tsx b/assets/js/collaborative-editor/contexts/StoreProvider.tsx index 04ef560b40..8832969529 100644 --- a/assets/js/collaborative-editor/contexts/StoreProvider.tsx +++ b/assets/js/collaborative-editor/contexts/StoreProvider.tsx @@ -155,6 +155,11 @@ export const StoreProvider = ({ children }: StoreProviderProps) => { // Set up last seen timer const cleanupTimer = stores.awarenessStore._internal.setupLastSeenTimer(); + // set up activityState handler + stores.awarenessStore._internal.initActivityStateChange(state => { + session.awareness?.setLocalStateField('lastState', state); + }); + return cleanupTimer; } return undefined; diff --git a/assets/js/collaborative-editor/hooks/useAwareness.ts b/assets/js/collaborative-editor/hooks/useAwareness.ts index 18f33a6b35..70cc22eaf8 100644 --- a/assets/js/collaborative-editor/hooks/useAwareness.ts +++ b/assets/js/collaborative-editor/hooks/useAwareness.ts @@ -93,11 +93,20 @@ export const useAwarenessUsers = (): AwarenessUser[] => { return useSyncExternalStore(awarenessStore.subscribe, selectUsers); }; +const awayUserCache = new Map< + string, + { user: AwarenessUser; expiresAt: number } +>(); + +const AWAY_CACHE_DURATION = 60000; // 60 seconds + /** * Hook to get only remote users (excluding the local user) * Useful for cursor rendering where you don't want to show your own cursor - * Deduplicates users by keeping the one with the latest lastSeen timestamp - * and adds connectionCount to show how many connections they have + * Deduplicates users by keeping the one with the highest priority state + * (active > away > idle), then by latest lastSeen timestamp + * Caches away users for 60s to prevent flickering when presence is throttled + * Adds connectionCount to show how many connections they have */ export const useRemoteUsers = (): AwarenessUser[] => { const awarenessStore = useAwarenessStore(); @@ -105,38 +114,63 @@ export const useRemoteUsers = (): AwarenessUser[] => { const selectRemoteUsers = awarenessStore.withSelector(state => { if (!state.localUser) return state.users; - // Filter out local user + const now = Date.now(); const remoteUsers = state.users.filter( user => user.user.id !== state.localUser?.id ); - // Group users by user ID and deduplicate - const userMap = new Map(); + const statePriority = { active: 3, away: 2, idle: 1 }; + const userMap = new Map(); const connectionCounts = new Map(); remoteUsers.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); - } + connectionCounts.set(userId, (connectionCounts.get(userId) || 0) + 1); + (userMap.get(userId) || userMap.set(userId, []).get(userId)!).push(user); + }); + + const selectedUsers = Array.from(userMap.values()).map(users => { + const selected = users.sort((a, b) => { + const stateDiff = + (statePriority[b.lastState || 'idle'] || 0) - + (statePriority[a.lastState || 'idle'] || 0); + return stateDiff || (b.lastSeen || 0) - (a.lastSeen || 0); + })[0]; + + const userId = selected.user.id; + + // clear cache if user becomes active + if (selected.lastState === 'active') { + awayUserCache.delete(userId); + } + + // cache away users + if (selected.lastState === 'away') { + awayUserCache.set(userId, { + user: selected, + expiresAt: now + AWAY_CACHE_DURATION, + }); + } + + return { + ...selected, + connectionCount: connectionCounts.get(userId) || 1, + }; + }); + + // Add cached away users that aren't in current results + awayUserCache.forEach((cached, userId) => { + if (now > cached.expiresAt) { + awayUserCache.delete(userId); + } else if (!userMap.has(userId)) { + selectedUsers.push({ + ...cached.user, + connectionCount: 0, + }); } }); - // Add connection counts to users - return Array.from(userMap.values()).map(user => ({ - ...user, - connectionCount: connectionCounts.get(user.user.id) || 1, - })); + return selectedUsers; }); return useSyncExternalStore(awarenessStore.subscribe, selectRemoteUsers); diff --git a/assets/js/collaborative-editor/stores/createAwarenessStore.ts b/assets/js/collaborative-editor/stores/createAwarenessStore.ts index 0f04cc3cc3..95e67f6f6a 100644 --- a/assets/js/collaborative-editor/stores/createAwarenessStore.ts +++ b/assets/js/collaborative-editor/stores/createAwarenessStore.ts @@ -96,11 +96,14 @@ import type { Awareness } from 'y-protocols/awareness'; import _logger from '#/utils/logger'; import type { + ActivityState, AwarenessState, AwarenessStore, AwarenessUser, LocalUserData, + SetStateHandler, } from '../types/awareness'; +import { getVisibilityProps } from '../utils/visibility'; import { createWithSelector } from './common'; import { wrapStoreWithDevTools } from './devtools'; @@ -240,6 +243,9 @@ export const createAwarenessStore = (): AwarenessStore => { 'selection' ] as AwarenessUser['selection']; const lastSeen = awarenessState['lastSeen'] as number | undefined; + const lastState = awarenessState['lastState'] as + | ActivityState + | undefined; // Check if user data actually changed if (existingUser) { @@ -270,6 +276,11 @@ export const createAwarenessStore = (): AwarenessStore => { hasChanged = true; } + // compare lastState + if (existingUser.lastState !== lastState) { + hasChanged = true; + } + // Only update if something changed // If not changed, Immer preserves the existing reference if (hasChanged) { @@ -279,6 +290,7 @@ export const createAwarenessStore = (): AwarenessStore => { cursor, selection, lastSeen, + lastState, }); } } else { @@ -289,6 +301,7 @@ export const createAwarenessStore = (): AwarenessStore => { cursor, selection, lastSeen, + lastState, }); } @@ -342,6 +355,30 @@ export const createAwarenessStore = (): AwarenessStore => { notify('awarenessChange'); }; + const visibilityProps = getVisibilityProps(); + const activityStateChangeHandler = (setState: SetStateHandler) => { + const isHidden = document[visibilityProps?.hidden as keyof Document]; + if (isHidden) { + setState('away'); + } else { + setState('active'); + } + }; + + const initActivityStateChange = (setState: SetStateHandler) => { + if (visibilityProps) { + const handler = activityStateChangeHandler.bind(undefined, setState); + // initial call + handler(); + document.addEventListener(visibilityProps.visibilityChange, handler); + + // return cleanup function + return () => { + document.removeEventListener(visibilityProps.visibilityChange, handler); + }; + } + }; + // ============================================================================= // PATTERN 2: Direct Immer → Notify + Awareness Update (Local Commands) // ============================================================================= @@ -723,6 +760,7 @@ export const createAwarenessStore = (): AwarenessStore => { _internal: { handleAwarenessChange, setupLastSeenTimer, + initActivityStateChange, }, }; }; diff --git a/assets/js/collaborative-editor/types/awareness.ts b/assets/js/collaborative-editor/types/awareness.ts index cbb44002d7..2d985b46dd 100644 --- a/assets/js/collaborative-editor/types/awareness.ts +++ b/assets/js/collaborative-editor/types/awareness.ts @@ -4,6 +4,8 @@ import { z } from 'zod'; import type { WithSelector } from '../stores/common'; +export type ActivityState = 'active' | 'away' | 'idle'; + /** * User information stored in awareness */ @@ -24,6 +26,7 @@ export interface AwarenessUser { head: RelativePosition; } | null; lastSeen?: number; + lastState?: ActivityState; connectionCount?: number; } @@ -115,6 +118,8 @@ export interface AwarenessQueries { getRawAwareness: () => Awareness | null; } +export type SetStateHandler = (state: ActivityState) => void; + /** * Complete awareness store interface following CQS pattern */ @@ -128,5 +133,6 @@ export interface AwarenessStore extends AwarenessCommands, AwarenessQueries { _internal: { handleAwarenessChange: () => void; setupLastSeenTimer: () => () => void; + initActivityStateChange: (setState: SetStateHandler) => void; }; } diff --git a/assets/js/collaborative-editor/utils/visibility.ts b/assets/js/collaborative-editor/utils/visibility.ts new file mode 100644 index 0000000000..93666bbe37 --- /dev/null +++ b/assets/js/collaborative-editor/utils/visibility.ts @@ -0,0 +1,24 @@ +export 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; +}; diff --git a/assets/test/collaborative-editor/components/ActiveCollaborators.test.tsx b/assets/test/collaborative-editor/components/ActiveCollaborators.test.tsx index ac4bcc518d..cd3c61842d 100644 --- a/assets/test/collaborative-editor/components/ActiveCollaborators.test.tsx +++ b/assets/test/collaborative-editor/components/ActiveCollaborators.test.tsx @@ -6,9 +6,9 @@ * * Test categories: * 1. Basic Rendering - Component visibility and avatar count - * 4. Activity Indicator - Border colors based on lastSeen timestamp - * 6. Store Integration - Integration with useAwareness hook - * 7. Edge Cases - Empty states and state transitions + * 2. Activity Indicator - Border colors based on lastState (active/away/idle) + * 3. Store Integration - Integration with useRemoteUsers hook + * 4. Edge Cases - Empty states and state transitions */ import { render, screen } from '@testing-library/react'; @@ -160,10 +160,7 @@ describe('ActiveCollaborators - Activity Indicator', () => { mockRemoteUsers = []; }); - test('shows green border for users active within last 12 seconds (0.2 minutes)', () => { - const now = Date.now(); - const fiveSecondsAgo = now - 5 * 1000; // 5 seconds ago - + test('shows green border for active users', () => { mockRemoteUsers = [ createMockAwarenessUser({ user: { @@ -172,7 +169,7 @@ describe('ActiveCollaborators - Activity Indicator', () => { email: 'john@example.com', color: '#ff0000', }, - lastSeen: fiveSecondsAgo, + lastState: 'active', }), ]; @@ -182,10 +179,7 @@ describe('ActiveCollaborators - Activity Indicator', () => { expect(borderDiv).toBeInTheDocument(); }); - test('shows gray border for users inactive for more than 12 seconds (0.2 minutes)', () => { - const now = Date.now(); - const thirtySecondsAgo = now - 30 * 1000; // 30 seconds ago - + test('shows gray border for away users', () => { mockRemoteUsers = [ createMockAwarenessUser({ user: { @@ -194,7 +188,7 @@ describe('ActiveCollaborators - Activity Indicator', () => { email: 'john@example.com', color: '#ff0000', }, - lastSeen: thirtySecondsAgo, + lastState: 'away', }), ]; @@ -204,7 +198,7 @@ describe('ActiveCollaborators - Activity Indicator', () => { expect(borderDiv).toBeInTheDocument(); }); - test('shows gray border when lastSeen is undefined', () => { + test('shows gray border for idle users', () => { mockRemoteUsers = [ createMockAwarenessUser({ user: { @@ -213,7 +207,7 @@ describe('ActiveCollaborators - Activity Indicator', () => { email: 'john@example.com', color: '#ff0000', }, - lastSeen: undefined, + lastState: 'idle', }), ]; @@ -223,12 +217,26 @@ describe('ActiveCollaborators - Activity Indicator', () => { expect(borderDiv).toBeInTheDocument(); }); - test('correctly implements the 12-second threshold (0.2 minutes = 12,000ms)', () => { - const now = Date.now(); - 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) + test('shows gray border when lastState is undefined', () => { + mockRemoteUsers = [ + createMockAwarenessUser({ + user: { + id: 'user-1', + name: 'John Doe', + email: 'john@example.com', + color: '#ff0000', + }, + lastState: undefined, + }), + ]; + + const { container } = render(); + + const borderDiv = container.querySelector('.border-gray-500'); + expect(borderDiv).toBeInTheDocument(); + }); - // User just under 12 seconds should have green border + test('distinguishes between active and non-active states', () => { mockRemoteUsers = [ createMockAwarenessUser({ clientId: 1, @@ -238,17 +246,17 @@ describe('ActiveCollaborators - Activity Indicator', () => { email: 'active@example.com', color: '#ff0000', }, - lastSeen: justUnderThreshold, + lastState: 'active', }), createMockAwarenessUser({ clientId: 2, user: { id: 'user-2', - name: 'Inactive User', - email: 'inactive@example.com', + name: 'Away User', + email: 'away@example.com', color: '#00ff00', }, - lastSeen: justOverThreshold, + lastState: 'away', }), ]; @@ -261,10 +269,7 @@ describe('ActiveCollaborators - Activity Indicator', () => { expect(grayBorder).toBeInTheDocument(); }); - test('border color updates when user crosses the 12-second threshold', () => { - vi.useFakeTimers(); - const now = Date.now(); - + test('border color updates when user activity state changes', () => { mockRemoteUsers = [ createMockAwarenessUser({ user: { @@ -273,7 +278,7 @@ describe('ActiveCollaborators - Activity Indicator', () => { email: 'john@example.com', color: '#ff0000', }, - lastSeen: now, + lastState: 'active', }), ]; @@ -282,16 +287,23 @@ describe('ActiveCollaborators - Activity Indicator', () => { // Initially should have green border expect(container.querySelector('.border-green-500')).toBeInTheDocument(); - // Advance time by 15 seconds (beyond the 12-second threshold) - vi.advanceTimersByTime(15 * 1000); + // User goes away + mockRemoteUsers = [ + createMockAwarenessUser({ + user: { + id: 'user-1', + name: 'John Doe', + email: 'john@example.com', + color: '#ff0000', + }, + lastState: 'away', + }), + ]; - // Re-render to trigger the component to re-evaluate rerender(); // Now should have gray border expect(container.querySelector('.border-gray-500')).toBeInTheDocument(); - - vi.useRealTimers(); }); }); @@ -421,32 +433,6 @@ describe('ActiveCollaborators - Cache Behavior', () => { 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 diff --git a/assets/test/collaborative-editor/stores/createAwarenessStore.test.ts b/assets/test/collaborative-editor/stores/createAwarenessStore.test.ts new file mode 100644 index 0000000000..ac55750987 --- /dev/null +++ b/assets/test/collaborative-editor/stores/createAwarenessStore.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, test, vi } from 'vitest'; +import { Awareness } from 'y-protocols/awareness'; +import * as Y from 'yjs'; + +import { createAwarenessStore } from '../../../js/collaborative-editor/stores/createAwarenessStore'; +import type { + ActivityState, + LocalUserData, +} from '../../../js/collaborative-editor/types/awareness'; + +function createMockLocalUser( + overrides: Partial = {} +): LocalUserData { + return { + id: `user-${Math.random().toString(36).substring(7)}`, + name: 'Test User', + email: 'test@example.com', + color: '#ff0000', + ...overrides, + }; +} + +describe('AwarenessStore - Activity State Tracking', () => { + test('tracks lastState field in awareness users', () => { + const store = createAwarenessStore(); + const ydoc = new Y.Doc(); + const awareness = new Awareness(ydoc); + const localUser = createMockLocalUser({ id: 'local-user' }); + + store.initializeAwareness(awareness, localUser); + + // Add a remote user with lastState + const remoteClientId = 123; + const states = new Map(); + states.set(remoteClientId, { + user: { + id: 'remote-user', + name: 'Remote User', + email: 'remote@example.com', + color: '#00ff00', + }, + lastState: 'active' as ActivityState, + }); + + awareness.states = states; + awareness.emit('change', []); + + const state = store.getSnapshot(); + const remoteUser = state.users.find(u => u.user.id === 'remote-user'); + + expect(remoteUser?.lastState).toBe('active'); + }); + + test('updates user when lastState changes', () => { + const store = createAwarenessStore(); + const ydoc = new Y.Doc(); + const awareness = new Awareness(ydoc); + const localUser = createMockLocalUser({ id: 'local-user' }); + + store.initializeAwareness(awareness, localUser); + + const remoteClientId = 123; + + // First update - active state + const states1 = new Map(); + states1.set(remoteClientId, { + user: { + id: 'remote-user', + name: 'Remote User', + email: 'remote@example.com', + color: '#00ff00', + }, + lastState: 'active' as ActivityState, + }); + awareness.states = states1; + awareness.emit('change', []); + + const state1 = store.getSnapshot(); + const user1 = state1.cursorsMap.get(remoteClientId); + expect(user1?.lastState).toBe('active'); + + // Second update - away state + const states2 = new Map(); + states2.set(remoteClientId, { + user: { + id: 'remote-user', + name: 'Remote User', + email: 'remote@example.com', + color: '#00ff00', + }, + lastState: 'away' as ActivityState, + }); + awareness.states = states2; + awareness.emit('change', []); + + const state2 = store.getSnapshot(); + const user2 = state2.cursorsMap.get(remoteClientId); + + // Should be different references due to state change + expect(user1).not.toBe(user2); + expect(user2?.lastState).toBe('away'); + }); + + test('maintains referential stability when lastState unchanged', () => { + const store = createAwarenessStore(); + const ydoc = new Y.Doc(); + const awareness = new Awareness(ydoc); + const localUser = createMockLocalUser({ id: 'local-user' }); + + store.initializeAwareness(awareness, localUser); + + const remoteClientId = 123; + const remoteUserState = { + user: { + id: 'remote-user', + name: 'Remote User', + email: 'remote@example.com', + color: '#00ff00', + }, + lastState: 'active' as ActivityState, + }; + + // First update + const states1 = new Map(); + states1.set(remoteClientId, remoteUserState); + awareness.states = states1; + awareness.emit('change', []); + + const state1 = store.getSnapshot(); + const user1 = state1.cursorsMap.get(remoteClientId); + + // Second update with same data + const states2 = new Map(); + states2.set(remoteClientId, remoteUserState); + awareness.states = states2; + awareness.emit('change', []); + + const state2 = store.getSnapshot(); + const user2 = state2.cursorsMap.get(remoteClientId); + + // Should maintain referential stability (Immer won't create new object) + expect(user1).toBe(user2); + }); + + test('handles undefined lastState gracefully', () => { + const store = createAwarenessStore(); + const ydoc = new Y.Doc(); + const awareness = new Awareness(ydoc); + const localUser = createMockLocalUser({ id: 'local-user' }); + + store.initializeAwareness(awareness, localUser); + + const remoteClientId = 123; + const states = new Map(); + states.set(remoteClientId, { + user: { + id: 'remote-user', + name: 'Remote User', + email: 'remote@example.com', + color: '#00ff00', + }, + // lastState intentionally omitted + }); + + awareness.states = states; + awareness.emit('change', []); + + const state = store.getSnapshot(); + const remoteUser = state.users.find(u => u.user.id === 'remote-user'); + + // Should handle missing lastState without errors + expect(remoteUser).toBeDefined(); + expect(remoteUser?.lastState).toBeUndefined(); + }); + + test('supports all activity states: active, away, idle', () => { + const store = createAwarenessStore(); + const ydoc = new Y.Doc(); + const awareness = new Awareness(ydoc); + const localUser = createMockLocalUser({ id: 'local-user' }); + + store.initializeAwareness(awareness, localUser); + + const states = new Map(); + states.set(1, { + user: { + id: 'user-1', + name: 'Active User', + email: 'active@example.com', + color: '#ff0000', + }, + lastState: 'active' as ActivityState, + }); + states.set(2, { + user: { + id: 'user-2', + name: 'Away User', + email: 'away@example.com', + color: '#00ff00', + }, + lastState: 'away' as ActivityState, + }); + states.set(3, { + user: { + id: 'user-3', + name: 'Idle User', + email: 'idle@example.com', + color: '#0000ff', + }, + lastState: 'idle' as ActivityState, + }); + + awareness.states = states; + awareness.emit('change', []); + + const state = store.getSnapshot(); + + const activeUser = state.users.find(u => u.user.id === 'user-1'); + const awayUser = state.users.find(u => u.user.id === 'user-2'); + const idleUser = state.users.find(u => u.user.id === 'user-3'); + + expect(activeUser?.lastState).toBe('active'); + expect(awayUser?.lastState).toBe('away'); + expect(idleUser?.lastState).toBe('idle'); + }); +}); + +describe('AwarenessStore - Visibility API Integration', () => { + test('initActivityStateChange sets up visibility listener', () => { + const store = createAwarenessStore(); + const setStateMock = vi.fn(); + + store._internal.initActivityStateChange(setStateMock); + + // Initial call should happen + expect(setStateMock).toHaveBeenCalled(); + }); + + test('calls setState with away when document is hidden', () => { + const store = createAwarenessStore(); + + // Mock document visibility as hidden + Object.defineProperty(document, 'hidden', { + configurable: true, + get: () => true, + }); + + const setStateMock = vi.fn(); + store._internal.initActivityStateChange(setStateMock); + + // Should be called with 'away' when document is hidden + expect(setStateMock).toHaveBeenCalledWith('away'); + }); + + test('calls setState with active when document is visible', () => { + const store = createAwarenessStore(); + + // Mock document visibility as visible + Object.defineProperty(document, 'hidden', { + configurable: true, + get: () => false, + }); + + const setStateMock = vi.fn(); + store._internal.initActivityStateChange(setStateMock); + + // Should be called with 'active' when document is visible + expect(setStateMock).toHaveBeenCalledWith('active'); + }); + + test('handles missing visibility API gracefully', () => { + const store = createAwarenessStore(); + const setStateMock = vi.fn(); + + expect(() => { + store._internal.initActivityStateChange(setStateMock); + }).not.toThrow(); + }); +}); diff --git a/assets/test/collaborative-editor/utils/visibility.test.ts b/assets/test/collaborative-editor/utils/visibility.test.ts new file mode 100644 index 0000000000..d5e2a3e856 --- /dev/null +++ b/assets/test/collaborative-editor/utils/visibility.test.ts @@ -0,0 +1,166 @@ +/** + * Visibility API Utility Tests + * + * Tests for the visibility.ts utility that detects Page Visibility API + * support across different browser vendors. + * + * Test categories: + * 1. Standard API - Modern browsers with standard implementation + * 2. Vendor Prefixes - Legacy browser support (webkit, moz, ms) + * 3. No Support - Browsers without Page Visibility API + */ + +import { beforeEach, describe, expect, test } from 'vitest'; + +import { getVisibilityProps } from '../../../js/collaborative-editor/utils/visibility'; + +describe('getVisibilityProps - Standard API', () => { + beforeEach(() => { + // Clean up any vendor prefixes from previous tests + // @ts-expect-error - Deleting test properties + delete document.webkitHidden; + // @ts-expect-error - Deleting test properties + delete document.mozHidden; + // @ts-expect-error - Deleting test properties + delete document.msHidden; + }); + + test('returns standard properties when document.hidden is supported', () => { + // Standard API is already available in modern test environments + const result = getVisibilityProps(); + + expect(result).toEqual({ + hidden: 'hidden', + visibilityChange: 'visibilitychange', + }); + }); +}); + +describe('getVisibilityProps - Vendor Prefixes', () => { + beforeEach(() => { + // Save original property descriptor + const originalDescriptor = Object.getOwnPropertyDescriptor( + Document.prototype, + 'hidden' + ); + + // Remove standard property to test vendor prefixes + if (originalDescriptor) { + Object.defineProperty(Document.prototype, 'hidden', { + configurable: true, + get: () => undefined, + }); + } + }); + + test('returns webkit properties when webkitHidden is supported', () => { + // Mock webkit prefix + Object.defineProperty(document, 'webkitHidden', { + configurable: true, + value: false, + }); + + const result = getVisibilityProps(); + + expect(result).toEqual({ + hidden: 'webkitHidden', + visibilityChange: 'webkitvisibilitychange', + }); + + // Cleanup + // @ts-expect-error - Deleting test property + delete document.webkitHidden; + }); + + test('returns moz properties when mozHidden is supported', () => { + // Mock moz prefix + Object.defineProperty(document, 'mozHidden', { + configurable: true, + value: false, + }); + + const result = getVisibilityProps(); + + expect(result).toEqual({ + hidden: 'mozHidden', + visibilityChange: 'mozvisibilitychange', + }); + + // Cleanup + // @ts-expect-error - Deleting test property + delete document.mozHidden; + }); + + test('returns ms properties when msHidden is supported', () => { + // Mock ms prefix + Object.defineProperty(document, 'msHidden', { + configurable: true, + value: false, + }); + + const result = getVisibilityProps(); + + expect(result).toEqual({ + hidden: 'msHidden', + visibilityChange: 'msvisibilitychange', + }); + + // Cleanup + // @ts-expect-error - Deleting test property + delete document.msHidden; + }); +}); + +describe('getVisibilityProps - Browser Compatibility', () => { + test('returns properties in standard modern browsers', () => { + // The function should check in this order: + // 1. document.hidden (standard) + // 2. document.webkitHidden (webkit) + // 3. document.mozHidden (moz) + // 4. document.msHidden (ms) + + // Standard should take precedence + const result = getVisibilityProps(); + + // In modern test/browser environments, standard API is available + // So we expect either the standard API or null + expect(result === null || result?.hidden === 'hidden').toBe(true); + }); + + test('returns event name matching the property prefix', () => { + const result = getVisibilityProps(); + + if (result) { + // Event name should match the property prefix + if (result.hidden === 'hidden') { + expect(result.visibilityChange).toBe('visibilitychange'); + } else if (result.hidden === 'webkitHidden') { + expect(result.visibilityChange).toBe('webkitvisibilitychange'); + } else if (result.hidden === 'mozHidden') { + expect(result.visibilityChange).toBe('mozvisibilitychange'); + } else if (result.hidden === 'msHidden') { + expect(result.visibilityChange).toBe('msvisibilitychange'); + } + } + }); +}); + +describe('getVisibilityProps - Return Type', () => { + test('returns object with hidden and visibilityChange properties', () => { + const result = getVisibilityProps(); + + if (result) { + expect(result).toHaveProperty('hidden'); + expect(result).toHaveProperty('visibilityChange'); + expect(typeof result.hidden).toBe('string'); + expect(typeof result.visibilityChange).toBe('string'); + } + }); + + test('returns null or object, never undefined', () => { + const result = getVisibilityProps(); + + expect(result).not.toBe(undefined); + expect(result === null || typeof result === 'object').toBe(true); + }); +});