Presence avatar count differs between owner view and shared-link view; fixed on reload #109

Open
opened 2026-04-29 13:47:46 +00:00 by eslamnawara · 3 comments
Member

Summary

The presence indicator (active-user avatars in the top toolbar) shows a
different number of connected users depending on which entry point is
used to view the same board:

  • The owner view shows fewer avatars (e.g. 1).
  • The shared-link view shows more avatars (e.g. 4).
    for the same board at the same moment.

The two should agree because they are displaying the same set of active
sessions.

Steps to reproduce

  1. Open a board in tab A via the owner URL.
  2. Open the same board in tab B via the shared link.
  3. Optionally open additional viewers / let other users join.
  4. Compare the avatar count in the top toolbar between tab A and tab B.

Expected

Both views show the same set of active-user avatars at any given moment,
and the list updates live as users join or leave — without requiring a
reload.

Actual

The two views disagree on the number of avatars. Reloading either tab
brings it in sync with the actual session count.
image

## Summary The presence indicator (active-user avatars in the top toolbar) shows a different number of connected users depending on which entry point is used to view the same board: - The owner view shows fewer avatars (e.g. 1). - The shared-link view shows more avatars (e.g. 4). for the same board at the same moment. The two should agree because they are displaying the same set of active sessions. ## Steps to reproduce 1. Open a board in tab A via the owner URL. 2. Open the same board in tab B via the shared link. 3. Optionally open additional viewers / let other users join. 4. Compare the avatar count in the top toolbar between tab A and tab B. ## Expected Both views show the same set of active-user avatars at any given moment, and the list updates live as users join or leave — without requiring a reload. ## Actual The two views disagree on the number of avatars. Reloading either tab brings it in sync with the actual session count. ![image](/attachments/225f35e6-69d7-4427-90ca-4ea2d6a4d64a)
233 KiB
Member

Implementation Spec for Issue #109

Objective

Make the presence avatar list converge on every join and leave, without requiring a reload, regardless of which entry point a tab was opened from.

Root cause

Three gaps in sync.js:

  1. No state hydration on join. When a new tab joins, it sends {type: 'join', ...}. Existing tabs add the new tab to their _remoteUsers. But existing tabs do not reply with their own identity. So the new tab only learns about an existing tab when that existing tab happens to broadcast a cursor or edit.
  2. No leave on beforeunload. Closing a tab doesn't tell other tabs to prune the user.
  3. No server-side disconnect notification. A crashed/network-dropped tab leaves stale entries until reload.

The user sees the divergence in the screenshot: owner tab shows fewer avatars than the shared-link tab because each one independently accumulated only the users it happened to "discover" via secondary broadcasts.

Approach

Fix #1 and #2 client-side. Both are minimal changes to sync.js and produce convergent presence on every connect/disconnect through user action. #3 (server-side disconnect detection without explicit leave) is more invasive and out of scope — the client beforeunload covers normal close; abnormal disconnects converge on the next join handshake.

Files to Modify

  • crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js — extend the join handler to reply with own identity for unknown users; add a beforeunload leave broadcast.

Implementation Plan

Step 1: Client — handshake on join + leave on unload

File: crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js

1a. On receiving join, reply with own identity if the joiner is new.

In handleWsMessage, the existing join branch is:

} else if (msg.type === 'join') {
    if (msg.user) {
        _remoteUsers[msg.sender || msg.user.id] = msg.user;
        updatePresenceDisplay();
    }
    if (typeof WhiteboardFrames !== 'undefined' && WhiteboardFrames.announceCurrentSlide) {
        WhiteboardFrames.announceCurrentSlide();
    }
}

Replace with:

} else if (msg.type === 'join') {
    if (msg.user) {
        var sid = msg.sender || msg.user.id;
        var alreadyKnown = !!_remoteUsers[sid];
        _remoteUsers[sid] = msg.user;
        updatePresenceDisplay();
        // First time seeing this user — announce ourselves so they
        // discover us. The alreadyKnown guard prevents the reply from
        // bouncing back forever (the original sender adds us first,
        // so when our reply arrives they short-circuit on alreadyKnown).
        if (!alreadyKnown) {
            wsSend({ type: 'join', user: { id: localUserId, name: 'Anonymous', color: _localColor } });
        }
    }
    if (typeof WhiteboardFrames !== 'undefined' && WhiteboardFrames.announceCurrentSlide) {
        WhiteboardFrames.announceCurrentSlide();
    }
}

1b. Send a leave broadcast on page unload.

The current beforeunload handler only flushes pending updates:

window.addEventListener('beforeunload', function() {
    if (updateTimer) {
        clearTimeout(updateTimer);
        updateTimer = null;
    }
    flushUpdates();
});

Append a leave broadcast (best-effort — not all browsers reliably deliver after beforeunload, but most do for the synchronous WebSocket send):

window.addEventListener('beforeunload', function() {
    if (updateTimer) {
        clearTimeout(updateTimer);
        updateTimer = null;
    }
    flushUpdates();
    try { wsSend({ type: 'leave' }); } catch (e) {}
});

The receiver path for leave (line ~216) already prunes the sender via delete _remoteUsers[msg.sender] and calls updatePresenceDisplay(). The wsSend helper stamps sender: localUserId on outgoing messages, so the receiver knows who left.

Dependencies: none.

Acceptance Criteria

  • Open tab A (owner) and tab B (shared link) on the same board. Within a second of B's connect, both tabs show the same set of avatars.
  • Open a third tab C — A and B both see C, and C sees A and B. No reload needed.
  • Close tab B normally — A and C both prune B from their presence list within a moment.
  • Cursor / edit broadcasts continue to work as before.
  • No infinite reply loop on join (each pair handshakes once and stops).
  • cargo fmt, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace --lib clean.

Notes

  • Why alreadyKnown rather than a _reply: true flag: simpler, idempotent, doesn't require the sender to know about the protocol marker. The first join for an unknown user always triggers exactly one reply per existing tab; the reply is itself a join, but by the time it arrives the original sender has already added the replier (so alreadyKnown is true) and the chain stops.
  • Best-effort leave: beforeunload doesn't guarantee delivery (browser may close the WebSocket before the message flushes). Crashes / network drops still leave stale entries — a future fix could add server-side disconnect detection (server tracks each connection's localUserId and broadcasts a leave when the WS closes). Out of scope here.
  • _localColor is already defined at module scope (line ~928) and stable across the lifetime of the tab — safe to use in the handshake reply.
## Implementation Spec for Issue #109 ### Objective Make the presence avatar list converge on every join and leave, without requiring a reload, regardless of which entry point a tab was opened from. ### Root cause Three gaps in `sync.js`: 1. **No state hydration on join.** When a new tab joins, it sends `{type: 'join', ...}`. Existing tabs add the new tab to their `_remoteUsers`. But existing tabs do *not* reply with their own identity. So the new tab only learns about an existing tab when that existing tab happens to broadcast a cursor or edit. 2. **No `leave` on `beforeunload`.** Closing a tab doesn't tell other tabs to prune the user. 3. **No server-side disconnect notification.** A crashed/network-dropped tab leaves stale entries until reload. The user sees the divergence in the screenshot: owner tab shows fewer avatars than the shared-link tab because each one independently accumulated only the users it happened to "discover" via secondary broadcasts. ### Approach Fix #1 and #2 client-side. Both are minimal changes to `sync.js` and produce convergent presence on every connect/disconnect through user action. #3 (server-side disconnect detection without explicit leave) is more invasive and out of scope — the client `beforeunload` covers normal close; abnormal disconnects converge on the next join handshake. ### Files to Modify - `crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js` — extend the `join` handler to reply with own identity for unknown users; add a `beforeunload` `leave` broadcast. ### Implementation Plan #### Step 1: Client — handshake on join + leave on unload File: `crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js` **1a. On receiving `join`, reply with own identity if the joiner is new.** In `handleWsMessage`, the existing `join` branch is: ```js } else if (msg.type === 'join') { if (msg.user) { _remoteUsers[msg.sender || msg.user.id] = msg.user; updatePresenceDisplay(); } if (typeof WhiteboardFrames !== 'undefined' && WhiteboardFrames.announceCurrentSlide) { WhiteboardFrames.announceCurrentSlide(); } } ``` Replace with: ```js } else if (msg.type === 'join') { if (msg.user) { var sid = msg.sender || msg.user.id; var alreadyKnown = !!_remoteUsers[sid]; _remoteUsers[sid] = msg.user; updatePresenceDisplay(); // First time seeing this user — announce ourselves so they // discover us. The alreadyKnown guard prevents the reply from // bouncing back forever (the original sender adds us first, // so when our reply arrives they short-circuit on alreadyKnown). if (!alreadyKnown) { wsSend({ type: 'join', user: { id: localUserId, name: 'Anonymous', color: _localColor } }); } } if (typeof WhiteboardFrames !== 'undefined' && WhiteboardFrames.announceCurrentSlide) { WhiteboardFrames.announceCurrentSlide(); } } ``` **1b. Send a `leave` broadcast on page unload.** The current `beforeunload` handler only flushes pending updates: ```js window.addEventListener('beforeunload', function() { if (updateTimer) { clearTimeout(updateTimer); updateTimer = null; } flushUpdates(); }); ``` Append a leave broadcast (best-effort — not all browsers reliably deliver after `beforeunload`, but most do for the synchronous WebSocket send): ```js window.addEventListener('beforeunload', function() { if (updateTimer) { clearTimeout(updateTimer); updateTimer = null; } flushUpdates(); try { wsSend({ type: 'leave' }); } catch (e) {} }); ``` The receiver path for `leave` (line ~216) already prunes the sender via `delete _remoteUsers[msg.sender]` and calls `updatePresenceDisplay()`. The `wsSend` helper stamps `sender: localUserId` on outgoing messages, so the receiver knows who left. Dependencies: none. ### Acceptance Criteria - [ ] Open tab A (owner) and tab B (shared link) on the same board. Within a second of B's connect, both tabs show the same set of avatars. - [ ] Open a third tab C — A and B both see C, and C sees A and B. No reload needed. - [ ] Close tab B normally — A and C both prune B from their presence list within a moment. - [ ] Cursor / edit broadcasts continue to work as before. - [ ] No infinite reply loop on join (each pair handshakes once and stops). - [ ] `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace --lib` clean. ### Notes - **Why `alreadyKnown` rather than a `_reply: true` flag**: simpler, idempotent, doesn't require the sender to know about the protocol marker. The first `join` for an unknown user always triggers exactly one reply per existing tab; the reply is itself a `join`, but by the time it arrives the original sender has already added the replier (so `alreadyKnown` is true) and the chain stops. - **Best-effort `leave`**: `beforeunload` doesn't guarantee delivery (browser may close the WebSocket before the message flushes). Crashes / network drops still leave stale entries — a future fix could add server-side disconnect detection (server tracks each connection's localUserId and broadcasts a `leave` when the WS closes). Out of scope here. - **`_localColor` is already defined** at module scope (line ~928) and stable across the lifetime of the tab — safe to use in the handshake reply.
Member

Test Results

  • cargo fmt --all -- --check — clean
  • cargo clippy --workspace --all-targets -- -D warnings — clean
  • cargo test --workspace --lib — 0 failed
  • node --check sync.js — clean
## Test Results - `cargo fmt --all -- --check` — clean - `cargo clippy --workspace --all-targets -- -D warnings` — clean - `cargo test --workspace --lib` — 0 failed - `node --check sync.js` — clean
Member

Implementation Summary

Two client-only changes in sync.js to make presence converge on every join and leave.

crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js

Handshake on join. The handleWsMessage branch for type: 'join' now replies with our own identity when we see an unknown joiner, so the joiner discovers us without waiting for an unrelated cursor / edit broadcast. An alreadyKnown check stops the reply chain after one round-trip:

} else if (msg.type === 'join') {
    if (msg.user) {
        var sid = msg.sender || msg.user.id;
        var alreadyKnown = !!_remoteUsers[sid];
        _remoteUsers[sid] = msg.user;
        updatePresenceDisplay();
        if (!alreadyKnown) {
            wsSend({ type: 'join', user: { id: localUserId, name: 'Anonymous', color: _localColor } });
        }
    }
    ...
}

Leave on beforeunload. Best-effort wsSend({ type: 'leave' }) appended to the existing unload handler. The receiver path for leave already prunes the sender from _remoteUsers and refreshes the presence display.

Files Changed

  • crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js+18 / -3

Test Results

  • cargo fmt --all -- --check — clean
  • cargo clippy --workspace --all-targets -- -D warnings — clean
  • cargo test --workspace --lib — 0 failed
  • node --check sync.js — clean

Manual smoke

  1. Open tab A (owner) and tab B (shared link) on the same board. Within a moment of B's connect, both show the same set of avatars.
  2. Open tab C — A, B, C all see each other. No reload.
  3. Close tab B — A and C prune B's avatar.
  4. Cursor / edit broadcasts continue working.
  5. No infinite reply loop on join (the alreadyKnown guard caps each pair at one handshake).

Notes

  • Best-effort leave: beforeunload doesn't guarantee delivery if the browser closes the WebSocket too early. Crashes / network drops still leave stale entries until the next join handshake from the affected pair, at which point the unknown-user reply path re-syncs everyone.
  • Server-side leave on disconnect would catch crashes too — would require the server to track each connection's localUserId. Out of scope here; the client-only fix resolves the reported symptom (join-time divergence).
## Implementation Summary Two client-only changes in `sync.js` to make presence converge on every join and leave. ### `crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js` **Handshake on join.** The `handleWsMessage` branch for `type: 'join'` now replies with our own identity when we see an unknown joiner, so the joiner discovers us without waiting for an unrelated cursor / edit broadcast. An `alreadyKnown` check stops the reply chain after one round-trip: ```js } else if (msg.type === 'join') { if (msg.user) { var sid = msg.sender || msg.user.id; var alreadyKnown = !!_remoteUsers[sid]; _remoteUsers[sid] = msg.user; updatePresenceDisplay(); if (!alreadyKnown) { wsSend({ type: 'join', user: { id: localUserId, name: 'Anonymous', color: _localColor } }); } } ... } ``` **Leave on `beforeunload`.** Best-effort `wsSend({ type: 'leave' })` appended to the existing unload handler. The receiver path for `leave` already prunes the sender from `_remoteUsers` and refreshes the presence display. ### Files Changed - `crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js` — `+18 / -3` ### Test Results - `cargo fmt --all -- --check` — clean - `cargo clippy --workspace --all-targets -- -D warnings` — clean - `cargo test --workspace --lib` — 0 failed - `node --check sync.js` — clean ### Manual smoke 1. Open tab A (owner) and tab B (shared link) on the same board. Within a moment of B's connect, both show the same set of avatars. 2. Open tab C — A, B, C all see each other. No reload. 3. Close tab B — A and C prune B's avatar. 4. Cursor / edit broadcasts continue working. 5. No infinite reply loop on join (the `alreadyKnown` guard caps each pair at one handshake). ### Notes - **Best-effort `leave`**: `beforeunload` doesn't guarantee delivery if the browser closes the WebSocket too early. Crashes / network drops still leave stale entries until the next join handshake from the affected pair, at which point the unknown-user reply path re-syncs everyone. - **Server-side leave on disconnect** would catch crashes too — would require the server to track each connection's `localUserId`. Out of scope here; the client-only fix resolves the reported symptom (join-time divergence).
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_whiteboard#109
No description provided.