Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ 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)

## [2.15.0-pre] - 2025-11-20

### Added
Expand Down
192 changes: 182 additions & 10 deletions assets/js/collaborative-editor/stores/createAwarenessStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export const createAwarenessStore = (): AwarenessStore => {
rawAwareness: null,
isConnected: false,
lastUpdated: null,
userCache: new Map(),
} as AwarenessState,
// No initial transformations needed
draft => draft
Expand All @@ -128,11 +129,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
});

Expand Down Expand Up @@ -195,6 +200,53 @@ export const createAwarenessStore = (): AwarenessStore => {
return users;
};

/**
* Update cache with current users
*/
const updateCache = (users: AwarenessUser[]) => {
const now = Date.now();
const newCache = new Map(state.userCache);

// Add/update users in cache
users.forEach(user => {
newCache.set(user.user.id, {
user,
cachedAt: now,
});
});

// Clean up expired entries (older than 1 minute)
newCache.forEach((cachedUser, userId) => {
if (now - cachedUser.cachedAt > CACHE_TTL) {
newCache.delete(userId);
}
});

return newCache;
};

/**
* Merge live users with cached users
* Cached users are used as fallback when they're missing from live awareness
*/
const mergeUsersWithCache = (liveUsers: AwarenessUser[]): AwarenessUser[] => {
const liveUserIds = new Set(liveUsers.map(u => u.user.id));
const mergedUsers = [...liveUsers];

// Add cached users that aren't in the live set
state.userCache.forEach((cachedUser, userId) => {
if (!liveUserIds.has(userId)) {
// Only add if cache is still valid
const now = Date.now();
if (now - cachedUser.cachedAt <= CACHE_TTL) {
mergedUsers.push(cachedUser.user);
}
}
});

return mergedUsers;
};

/**
* Handle awareness state changes - core collaborative data update pattern
*/
Expand All @@ -204,10 +256,13 @@ export const createAwarenessStore = (): AwarenessStore => {
return;
}

const users = extractUsersFromAwareness(awarenessInstance);
const liveUsers = extractUsersFromAwareness(awarenessInstance);
const mergedUsers = mergeUsersWithCache(liveUsers);
const updatedCache = updateCache(liveUsers);

state = produce(state, draft => {
draft.users = users;
draft.users = mergedUsers;
draft.userCache = updatedCache;
draft.lastUpdated = Date.now();
});
notify('awarenessChange');
Expand All @@ -217,6 +272,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
*/
Expand All @@ -235,6 +320,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;
Expand Down Expand Up @@ -267,6 +355,11 @@ export const createAwarenessStore = (): AwarenessStore => {
lastSeenTimer = null;
}

if (cacheCleanupTimer) {
clearInterval(cacheCleanupTimer);
cacheCleanupTimer = null;
}

devtools.disconnect();

state = produce(state, draft => {
Expand All @@ -276,6 +369,7 @@ export const createAwarenessStore = (): AwarenessStore => {
draft.isInitialized = false;
draft.isConnected = false;
draft.lastUpdated = Date.now();
draft.userCache = new Map();
});
notify('destroyAwareness');
};
Expand Down Expand Up @@ -367,13 +461,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
Expand All @@ -383,19 +478,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
);
}
};
};

Expand Down
11 changes: 11 additions & 0 deletions assets/js/collaborative-editor/types/awareness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -52,6 +60,9 @@ export interface AwarenessState {
// Connection state
isConnected: boolean;
lastUpdated: number | null;

// Fallback cache for throttled awareness updates (1 minute TTL)
userCache: Map<string, CachedUser>;
}

/**
Expand Down
Loading