-
Notifications
You must be signed in to change notification settings - Fork 61
fix: flickering of inactive collaborator #4053
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -93,50 +93,84 @@ 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 60-second duration looks arbitrary. If that's the case, shouldn't we consider documenting why this specific value was chosen. Is it related to typical browser throttling behavior? Should it be configurable? |
||
|
|
||
| /** | ||
| * 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(); | ||
|
|
||
| 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<string, AwarenessUser>(); | ||
| const statePriority = { active: 3, away: 2, idle: 1 }; | ||
| const userMap = new Map<string, AwarenessUser[]>(); | ||
| const connectionCounts = new Map<string, number>(); | ||
|
|
||
| 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This looks dense. No way to simplify ? |
||
| }); | ||
|
|
||
| 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); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
| }; | ||
|
Comment on lines
+369
to
+378
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What happens when |
||
| } | ||
| }; | ||
|
|
||
| // ============================================================================= | ||
| // PATTERN 2: Direct Immer → Notify + Awareness Update (Local Commands) | ||
| // ============================================================================= | ||
|
|
@@ -723,6 +760,7 @@ export const createAwarenessStore = (): AwarenessStore => { | |
| _internal: { | ||
| handleAwarenessChange, | ||
| setupLastSeenTimer, | ||
| initActivityStateChange, | ||
| }, | ||
| }; | ||
| }; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am wondering if we shouldn't move this into
AwarenessStoreso that we can properly scope it and clean it up to avoid cache pollutions and memory leaks when users open workflows in multiple tabs. Wouldn't the fact that it's module-level Map make it shared across all component instances and persists across React re-renders ?