@@ -121,6 +121,7 @@ export const createAwarenessStore = (): AwarenessStore => {
121121 rawAwareness : null ,
122122 isConnected : false ,
123123 lastUpdated : null ,
124+ userCache : new Map ( ) ,
124125 } as AwarenessState ,
125126 // No initial transformations needed
126127 draft => draft
@@ -129,11 +130,15 @@ export const createAwarenessStore = (): AwarenessStore => {
129130 const listeners = new Set < ( ) => void > ( ) ;
130131 let awarenessInstance : Awareness | null = null ;
131132 let lastSeenTimer : NodeJS . Timeout | null = null ;
133+ let cacheCleanupTimer : NodeJS . Timeout | null = null ;
134+
135+ // Cache configuration
136+ const CACHE_TTL = 60 * 1000 ; // 1 minute in milliseconds
132137
133138 // Redux DevTools integration
134139 const devtools = wrapStoreWithDevTools ( {
135140 name : 'AwarenessStore' ,
136- excludeKeys : [ 'rawAwareness' ] , // Exclude Y.js Awareness object
141+ excludeKeys : [ 'rawAwareness' , 'userCache' ] , // Exclude Y.js Awareness object and Map cache
137142 maxAge : 200 , // Higher limit since awareness changes are frequent
138143 } ) ;
139144
@@ -210,6 +215,7 @@ export const createAwarenessStore = (): AwarenessStore => {
210215
211216 state = produce ( state , draft => {
212217 const awarenessStates = awareness . getStates ( ) ;
218+ const now = Date . now ( ) ;
213219
214220 // Track which clientIds we've seen in this update
215221 const seenClientIds = new Set < number > ( ) ;
@@ -285,12 +291,18 @@ export const createAwarenessStore = (): AwarenessStore => {
285291 lastSeen,
286292 } ) ;
287293 }
294+
295+ // Update cache for this active user
296+ draft . userCache . set ( userData . id , {
297+ user : draft . cursorsMap . get ( clientId ) ! ,
298+ cachedAt : now ,
299+ } ) ;
288300 } catch ( error ) {
289301 logger . warn ( 'Invalid user data for client' , clientId , error ) ;
290302 }
291303 } ) ;
292304
293- // Remove users that are no longer in awareness
305+ // Remove users that are no longer in awareness from cursorsMap
294306 const entriesToDelete : number [ ] = [ ] ;
295307 draft . cursorsMap . forEach ( ( _ , clientId ) => {
296308 if ( ! seenClientIds . has ( clientId ) ) {
@@ -301,9 +313,27 @@ export const createAwarenessStore = (): AwarenessStore => {
301313 draft . cursorsMap . delete ( clientId ) ;
302314 } ) ;
303315
304- // Rebuild users array from cursorsMap for compatibility
305- // Sort by name for consistent ordering
306- draft . users = Array . from ( draft . cursorsMap . values ( ) ) . sort ( ( a , b ) =>
316+ // Rebuild users array from cursorsMap
317+ const liveUsers = Array . from ( draft . cursorsMap . values ( ) ) ;
318+
319+ // Merge with cached users (inactive collaborators within cache TTL)
320+ const liveUserIds = new Set ( liveUsers . map ( u => u . user . id ) ) ;
321+ const cachedUsers : AwarenessUser [ ] = [ ] ;
322+
323+ draft . userCache . forEach ( ( cachedUser , userId ) => {
324+ if ( ! liveUserIds . has ( userId ) ) {
325+ // Only add if cache is still valid
326+ if ( now - cachedUser . cachedAt <= CACHE_TTL ) {
327+ cachedUsers . push ( cachedUser . user ) ;
328+ } else {
329+ // Clean up expired cache entry
330+ draft . userCache . delete ( userId ) ;
331+ }
332+ }
333+ } ) ;
334+
335+ // Combine live and cached users, then sort by name for consistent ordering
336+ draft . users = [ ...liveUsers , ...cachedUsers ] . sort ( ( a , b ) =>
307337 a . user . name . localeCompare ( b . user . name )
308338 ) ;
309339
@@ -316,6 +346,36 @@ export const createAwarenessStore = (): AwarenessStore => {
316346 // PATTERN 2: Direct Immer → Notify + Awareness Update (Local Commands)
317347 // =============================================================================
318348
349+ /**
350+ * Set up periodic cache cleanup
351+ */
352+ const setupCacheCleanup = ( ) => {
353+ if ( cacheCleanupTimer ) {
354+ clearInterval ( cacheCleanupTimer ) ;
355+ }
356+
357+ // Clean up expired cache entries every 30 seconds
358+ cacheCleanupTimer = setInterval ( ( ) => {
359+ const now = Date . now ( ) ;
360+ const newCache = new Map ( state . userCache ) ;
361+ let hasChanges = false ;
362+
363+ newCache . forEach ( ( cachedUser , userId ) => {
364+ if ( now - cachedUser . cachedAt > CACHE_TTL ) {
365+ newCache . delete ( userId ) ;
366+ hasChanges = true ;
367+ }
368+ } ) ;
369+
370+ if ( hasChanges ) {
371+ state = produce ( state , draft => {
372+ draft . userCache = newCache ;
373+ } ) ;
374+ notify ( 'cacheCleanup' ) ;
375+ }
376+ } , 30000 ) ; // Check every 30 seconds
377+ } ;
378+
319379 /**
320380 * Initialize awareness instance and set up observers
321381 */
@@ -334,6 +394,9 @@ export const createAwarenessStore = (): AwarenessStore => {
334394 // Set up awareness observer for Pattern 1 updates
335395 awareness . on ( 'change' , handleAwarenessChange ) ;
336396
397+ // Set up cache cleanup
398+ setupCacheCleanup ( ) ;
399+
337400 // Update local state
338401 state = produce ( state , draft => {
339402 draft . localUser = userData ;
@@ -366,6 +429,11 @@ export const createAwarenessStore = (): AwarenessStore => {
366429 lastSeenTimer = null ;
367430 }
368431
432+ if ( cacheCleanupTimer ) {
433+ clearInterval ( cacheCleanupTimer ) ;
434+ cacheCleanupTimer = null ;
435+ }
436+
369437 devtools . disconnect ( ) ;
370438
371439 state = produce ( state , draft => {
@@ -376,6 +444,7 @@ export const createAwarenessStore = (): AwarenessStore => {
376444 draft . isInitialized = false ;
377445 draft . isConnected = false ;
378446 draft . lastUpdated = Date . now ( ) ;
447+ draft . userCache = new Map ( ) ;
379448 } ) ;
380449 notify ( 'destroyAwareness' ) ;
381450 } ;
@@ -467,13 +536,14 @@ export const createAwarenessStore = (): AwarenessStore => {
467536
468537 /**
469538 * Update last seen timestamp
539+ * @param forceTimestamp - Optional timestamp to use instead of Date.now()
470540 */
471- const updateLastSeen = ( ) => {
541+ const updateLastSeen = ( forceTimestamp ?: number ) => {
472542 if ( ! awarenessInstance ) {
473543 return ;
474544 }
475545
476- const timestamp = Date . now ( ) ;
546+ const timestamp = forceTimestamp ?? Date . now ( ) ;
477547 awarenessInstance . setLocalStateField ( 'lastSeen' , timestamp ) ;
478548
479549 // Note: We don't update local state here as awareness observer will handle it
@@ -483,19 +553,96 @@ export const createAwarenessStore = (): AwarenessStore => {
483553 * Set up automatic last seen updates
484554 */
485555 const setupLastSeenTimer = ( ) => {
486- if ( lastSeenTimer ) {
487- clearInterval ( lastSeenTimer ) ;
556+ let frozenTimestamp : number | null = null ;
557+
558+ const startTimer = ( ) => {
559+ if ( lastSeenTimer ) {
560+ clearInterval ( lastSeenTimer ) ;
561+ }
562+ lastSeenTimer = setInterval ( ( ) => {
563+ // If page is hidden, use frozen timestamp, otherwise use current time
564+ if ( frozenTimestamp ) frozenTimestamp ++ ; // This is to make sure that state is updated and data gets transmitted
565+ updateLastSeen ( frozenTimestamp ?? undefined ) ;
566+ } , 10000 ) ; // Update every 10 seconds
567+ } ;
568+
569+ const getVisibilityProps = ( ) => {
570+ if ( typeof document . hidden !== 'undefined' ) {
571+ return { hidden : 'hidden' , visibilityChange : 'visibilitychange' } ;
572+ }
573+
574+ if (
575+ // @ts -expect-error webkitHidden not defined
576+ typeof ( document as unknown as Document ) . webkitHidden !== 'undefined'
577+ ) {
578+ return {
579+ hidden : 'webkitHidden' ,
580+ visibilityChange : 'webkitvisibilitychange' ,
581+ } ;
582+ }
583+ // @ts -expect-error mozHidden not defined
584+ if ( typeof ( document as unknown as Document ) . mozHidden !== 'undefined' ) {
585+ return { hidden : 'mozHidden' , visibilityChange : 'mozvisibilitychange' } ;
586+ }
587+ // @ts -expect-error msHidden not defined
588+ if ( typeof ( document as unknown as Document ) . msHidden !== 'undefined' ) {
589+ return { hidden : 'msHidden' , visibilityChange : 'msvisibilitychange' } ;
590+ }
591+ return null ;
592+ } ;
593+
594+ const visibilityProps = getVisibilityProps ( ) ;
595+
596+ const handleVisibilityChange = ( ) => {
597+ if ( ! visibilityProps ) return ;
598+
599+ const isHidden = ( document as unknown as Document ) [
600+ visibilityProps . hidden as keyof Document
601+ ] ;
602+
603+ if ( isHidden ) {
604+ // Page is hidden, freeze the current timestamp
605+ frozenTimestamp = Date . now ( ) ;
606+ } else {
607+ // Page is visible, unfreeze and update immediately
608+ frozenTimestamp = null ;
609+ updateLastSeen ( ) ;
610+ }
611+ } ;
612+
613+ // Set up visibility change listener if supported
614+ if ( visibilityProps ) {
615+ document . addEventListener (
616+ visibilityProps . visibilityChange ,
617+ handleVisibilityChange
618+ ) ;
619+
620+ // Check initial visibility state
621+ const isHidden = ( document as unknown as Document ) [
622+ visibilityProps . hidden as keyof Document
623+ ] ;
624+ if ( isHidden ) {
625+ // Start with frozen timestamp if already hidden
626+ frozenTimestamp = Date . now ( ) ;
627+ }
488628 }
489629
490- lastSeenTimer = setInterval ( ( ) => {
491- updateLastSeen ( ) ;
492- } , 10000 ) ; // Update every 10 seconds
630+ // Always start the timer (whether visible or hidden)
631+ startTimer ( ) ;
493632
633+ // cleanup
494634 return ( ) => {
495635 if ( lastSeenTimer ) {
496636 clearInterval ( lastSeenTimer ) ;
497637 lastSeenTimer = null ;
498638 }
639+
640+ if ( visibilityProps ) {
641+ document . removeEventListener (
642+ visibilityProps . visibilityChange ,
643+ handleVisibilityChange
644+ ) ;
645+ }
499646 } ;
500647 } ;
501648
0 commit comments