Skip to content

Commit 1ae1aae

Browse files
doc-hanstuartc
authored andcommitted
Fix flickering of active collaborator icons with awareness cache
Implement 60-second cache in awareness store to stabilize collaborator states and prevent icon flickering between active/inactive/unavailable states during rapid awareness updates. Key changes: - Add userCache Map to AwarenessState with 60s TTL - Cache users as they disconnect from awareness, showing them as inactive - Clean up expired cache entries every 30 seconds - Update ActiveCollaborators to use cached users for stability - Fix inactive collaborators to continue pinging their last seen timestamp - Add comprehensive tests for cache behavior and timing Technical details: - userCache stores { user, cachedAt } tuples indexed by userId - When user disconnects from awareness (no longer in cursorsMap), they remain in the users array via cache for 60s before being removed - Page visibility API integration ensures lastSeen updates continue even when page is hidden (prevents premature cache expiration) - Periodic cleanup prevents memory leaks from stale cache entries Fixes #3931 Co-authored-by: Farhan Yahaya <yahyafarhan48@gmail.com>
1 parent 5e85e2f commit 1ae1aae

File tree

5 files changed

+280
-28
lines changed

5 files changed

+280
-28
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ and this project adheres to
6262

6363
### Fixed
6464

65+
- Fix flickering of active collaborator icons between states(active, inactive,
66+
unavailable) [#3931](https://github.com/OpenFn/lightning/issues/3931)
6567
- Restored footers to inspectors on the canvas while in read only mode
6668
[#4018](https://github.com/OpenFn/lightning/issues/4018)
6769
- Fix vertical scrolling in workflow panels

assets/js/collaborative-editor/components/ActiveCollaborators.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export function ActiveCollaborators({ className }: ActiveCollaboratorsProps) {
4444
return (
4545
<Tooltip key={user.clientId} content={tooltipContent} side="right">
4646
<div
47-
className={`relative inline-flex items-center justify-center rounded-full border-2 ${user.lastSeen && lessthanmin(user.lastSeen, 2) ? 'border-green-500' : 'border-gray-500 '}`}
47+
className={`relative inline-flex items-center justify-center rounded-full border-2 ${user.lastSeen && lessthanmin(user.lastSeen, 0.2) ? 'border-green-500' : 'border-gray-500 '}`}
4848
>
4949
<div
5050
className="w-5 h-5 rounded-full flex items-center justify-center font-normal text-[9px] font-semibold text-white cursor-default"

assets/js/collaborative-editor/stores/createAwarenessStore.ts

Lines changed: 159 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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

assets/js/collaborative-editor/types/awareness.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ export interface LocalUserData {
3737
color: string;
3838
}
3939

40+
/**
41+
* Cached user entry for fallback when awareness is throttled
42+
*/
43+
export interface CachedUser {
44+
user: AwarenessUser;
45+
cachedAt: number;
46+
}
47+
4048
/**
4149
* Awareness store state
4250
*/
@@ -55,6 +63,9 @@ export interface AwarenessState {
5563
// Connection state
5664
isConnected: boolean;
5765
lastUpdated: number | null;
66+
67+
// Fallback cache for throttled awareness updates (1 minute TTL)
68+
userCache: Map<string, CachedUser>;
5869
}
5970

6071
/**

0 commit comments

Comments
 (0)