fix(presence): keep peers online across rApp navigation in same space

- Drop beforeunload leave broadcast — was flashing users offline during
  intra-space page nav between rApps
- Heartbeat 10s → 5s; GC stale threshold 15s → 12s
- Presence payload now carries userId; overlay keys peer map by userId
  so reconnects with a new WS peerId map back to the same row
- uniquePeers() prefers entries with module/context metadata so the panel
  shows which rApp a peer is in, even alongside cursor-only awareness
This commit is contained in:
Jeff Emmett 2026-04-17 11:23:50 -04:00
parent 06b84bf2d7
commit 6f8d938e1f
2 changed files with 39 additions and 18 deletions

View File

@ -57,6 +57,7 @@ export function broadcastPresence(opts: PresenceOpts): void {
noteId: opts.noteId,
itemId: opts.itemId,
username: session.username,
userId: session.userId,
color: userColor(session.userId),
});
}
@ -75,21 +76,17 @@ export function broadcastLeave(): void {
}
/**
* Start a 10-second heartbeat that broadcasts presence.
* Start a 5-second heartbeat that broadcasts presence.
* Returns a cleanup function to stop the heartbeat.
*
* No beforeunload leave navigating between rApps in the same space would
* otherwise flash the user offline to peers. Peers GC us after ~12s if we
* truly close the tab.
*
* @param getOpts - Called each heartbeat to get current presence state
*/
export function startPresenceHeartbeat(getOpts: () => PresenceOpts): () => void {
broadcastPresence(getOpts());
const timer = setInterval(() => broadcastPresence(getOpts()), 10_000);
// Clean up immediately on page unload
const onUnload = () => broadcastLeave();
window.addEventListener('beforeunload', onUnload);
return () => {
clearInterval(timer);
window.removeEventListener('beforeunload', onUnload);
};
const timer = setInterval(() => broadcastPresence(getOpts()), 5_000);
return () => clearInterval(timer);
}

View File

@ -27,6 +27,7 @@ const PEER_COLORS = [
interface PeerState {
peerId: string;
userId?: string; // stable identity across page nav / peerId churn
username: string;
color: string;
cursor: { x: number; y: number } | null;
@ -242,16 +243,25 @@ export class RStackCollabOverlay extends HTMLElement {
this.#connectToDoc();
}
// Listen for space-wide presence broadcasts (module context from all rApps)
// Listen for space-wide presence broadcasts (module context from all rApps).
// Key by userId when available so a peer navigating between rApps (new WS peerId
// each page) maps back to the same row instead of flashing offline.
this.#unsubPresence = runtime.onCustomMessage('presence', (msg: any) => {
if (!msg.username) return;
// Use peerId from server relay, or fall back to a hash of username
const pid = msg.peerId || `anon-${msg.username}`;
if (pid === this.#localPeerId) return; // ignore self
const existing = this.#peers.get(pid);
this.#peers.set(pid, {
const key = msg.userId || pid;
// Drop any stale entry keyed by the old peerId for this same userId
if (msg.userId) {
for (const [k, p] of this.#peers) {
if (k !== key && p.userId === msg.userId) this.#peers.delete(k);
}
}
const existing = this.#peers.get(key);
this.#peers.set(key, {
peerId: pid,
userId: msg.userId || existing?.userId,
username: msg.username || existing?.username || 'Anonymous',
color: msg.color || existing?.color || this.#colorForPeer(pid),
cursor: existing?.cursor ?? null,
@ -499,7 +509,7 @@ export class RStackCollabOverlay extends HTMLElement {
#gcPeers() {
const now = Date.now();
const staleThreshold = this.#externalPeers ? 30000 : 15000;
const staleThreshold = this.#externalPeers ? 30000 : 12000;
let changed = false;
for (const [id, peer] of this.#peers) {
if (now - peer.lastSeen > staleThreshold) {
@ -522,13 +532,27 @@ export class RStackCollabOverlay extends HTMLElement {
}
}
/** Deduplicate peers by username, keeping the most recently seen entry per user. */
/**
* Deduplicate peers for display. Keyed by username (lowercase) so a user
* appearing twice e.g. old awareness entry (peerId-keyed) alongside a new
* presence entry (userId-keyed) after navigating rApps collapses to one
* row. Prefers entries carrying module/context metadata so the panel shows
* which rApp the peer is in.
*/
#uniquePeers(): PeerState[] {
const byName = new Map<string, PeerState>();
for (const peer of this.#peers.values()) {
const key = peer.username.toLowerCase();
const existing = byName.get(key);
if (!existing || peer.lastSeen > existing.lastSeen) {
if (!existing) {
byName.set(key, peer);
continue;
}
const peerHasMeta = !!(peer.module || peer.context);
const existingHasMeta = !!(existing.module || existing.context);
if (peerHasMeta && !existingHasMeta) {
byName.set(key, peer);
} else if (peerHasMeta === existingHasMeta && peer.lastSeen > existing.lastSeen) {
byName.set(key, peer);
}
}