Skip to content

Fix/multi tab signalling coordination#550

Open
jhweir wants to merge 4 commits intodevfrom
fix/multi-tab-signalling-coordination
Open

Fix/multi tab signalling coordination#550
jhweir wants to merge 4 commits intodevfrom
fix/multi-tab-signalling-coordination

Conversation

@jhweir
Copy link
Contributor

@jhweir jhweir commented Mar 4, 2026

Fix: Multi-tab signalling coordination

Problem

When a user opens Flux in multiple browser tabs, each tab independently runs its own signalling heartbeat and broadcasts its own agent state. This causes two issues:

  1. Avatar flickering — each tab broadcasts a different currentRoute for the same DID, so remote peers see the agent's position oscillate between channels every heartbeat cycle.
  2. WebRTC connect/disconnect loop — tabs broadcast conflicting inCall / callRoute values, causing remote peers to repeatedly create and destroy peer connections for the same agent.

The root cause is that every tab creates its own Ad4mClient (separate WebSocket), registers its own signal handler, and runs its own heartbeat timer — with zero cross-tab coordination.

Solution

Introduces a focus-follows-leader tab coordination pattern using the BroadcastChannel API. Only one tab (the "leader") broadcasts heartbeats and state to the network. Leadership automatically follows user focus — whichever tab the user is looking at becomes the leader, with no manual interaction required.

New file: app/src/composables/useTabCoordinator.ts

Singleton composable that manages tab leader election:

  • Automatic focus-based leadership — on focus / visibilitychange, the active tab claims leadership and the previous leader yields silently.
  • Call pinning — a tab that is in a WebRTC call refuses to yield leadership, preventing mid-call disruptions.
  • Crash recovery — the leader sends heartbeats every 5s via BroadcastChannel. If no heartbeat arrives for 15s, visible follower tabs auto-claim.
  • Graceful resignbeforeunload broadcasts a resign message so remaining tabs can claim immediately.
  • Async claim with grace periodclaimLeadership() waits 300ms for potential call-pinned rejections before confirming, avoiding race conditions.

Modified: app/src/composables/useSignallingService.ts

  • All tabs still register onSignal handlers and run the agent staleness evaluator (UI stays accurate everywhere).
  • Only the leader tab runs the heartbeat timer and calls broadcastState().
  • onBecomeLeader / onLoseLeadership callbacks auto-toggle broadcasting when leadership changes.

Modified: app/src/stores/webrtcStore.ts

  • joinRoom() now force-claims leadership before joining. If the claim is rejected (another tab is in a call), it shows a toast: "You are already in a call in another tab" and requests the leader tab to surface via window.focus().
  • leaveRoom() resets the coordinator's inCall flag so other tabs can claim leadership again.
  • A watch(inCall) keeps the coordinator in sync with the store's call state.

How to test

  1. Open Flux in two browser tabs, both on the same community.
  2. Navigate to different channels in each tab — remote peers should only see your avatar in the channel of the focused tab.
  3. Join a call from Tab A, then switch to Tab B and try to join the same call — you should see a toast and Tab A should be surfaced.
  4. Leave the call in Tab A, then join from Tab B — should work normally.
  5. Close Tab A while it's the leader — Tab B should automatically take over signalling within seconds.

Files changed

File Change
app/src/composables/useTabCoordinator.ts New — tab coordinator composable
app/src/composables/useSignallingService.ts Guard broadcasts behind leader check
app/src/stores/webrtcStore.ts Guard call joining with leadership claim

Summary by CodeRabbit

  • New Features

    • Multi-tab leadership so only one tab broadcasts signaling; automatic leader election based on tab focus and in-call pinning.
    • Cross-tab synchronization keeps call state consistent and requests leader attention when needed (prevents duplicate broadcasts).
  • Bug Fixes

    • Prevents duplicate network signaling across tabs and improves robustness when joining or leaving calls.

jhweir added 2 commits March 4, 2026 16:45
Introduces useTabCoordinator composable using BroadcastChannel API for
focus-follows-leader tab coordination. Only the leader tab broadcasts
heartbeats and state, preventing duplicate signals that caused avatar
flickering and WebRTC connect/disconnect loops when multiple tabs were open.

- New useTabCoordinator composable with automatic leader election
- Guard signalling broadcasts behind leader check
- Guard call joining with leadership claim and call-pinned conflict toast
- Sync inCall state with coordinator for call pinning
Bug 1: call-pinned handler never reverted the optimistic leadership claim
because currentLeaderTabId was set to our own tabId in becomeLeader(),
making the !== check always false.

Bug 2: claimLeadership was synchronous but the call-pinned rejection
arrives asynchronously via BroadcastChannel. joinRoom checked the result
before the rejection had a chance to arrive.

Fixes:
- call-pinned handler now always reverts leadership (own messages
  are already filtered at the top of handleMessage)
- claimLeadership is now async with a 300ms grace period for rejections
- joinRoom awaits the claim result properly
@netlify
Copy link

netlify bot commented Mar 4, 2026

Deploy Preview for fluxsocial-dev ready!

Name Link
🔨 Latest commit 6169181
🔍 Latest deploy log https://app.netlify.com/projects/fluxsocial-dev/deploys/69a874c64fc08800087fc908
😎 Deploy Preview https://deploy-preview-550--fluxsocial-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link

netlify bot commented Mar 4, 2026

Deploy Preview for fluxdocs ready!

Name Link
🔨 Latest commit 6169181
🔍 Latest deploy log https://app.netlify.com/projects/fluxdocs/deploys/69a874c6d0d1bc0008be55b7
😎 Deploy Preview https://deploy-preview-550--fluxdocs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link

coderabbitai bot commented Mar 4, 2026

Warning

Rate limit exceeded

@jhweir has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 18 minutes and 11 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cdc7190c-c56b-41ca-88da-14515ce84e49

📥 Commits

Reviewing files that changed from the base of the PR and between efff8a1 and 6169181.

📒 Files selected for processing (2)
  • app/src/composables/useSignallingService.ts
  • app/src/composables/useTabCoordinator.ts
📝 Walkthrough
📝 Walkthrough
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.63% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: implementing multi-tab signalling coordination to prevent conflicts across browser tabs.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/multi-tab-signalling-coordination

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/composables/useTabCoordinator.ts`:
- Around line 69-72: The heartbeat message sent from startLeaderHeartbeat omits
the inCall flag, so followers see heartbeats without msg.inCall and clear
otherTabInCall; update startLeaderHeartbeat to include the current inCall state
when calling post({ type: 'heartbeat' }) (e.g., post({ type: 'heartbeat', inCall
})) so receivers can check msg.inCall and preserve call-pinning; ensure the same
change is applied wherever heartbeats are emitted so otherTabInCall and reclaim
logic (lines reading msg.inCall) behave correctly.
- Around line 61-63: onBecomeLeader and onLoseLeadership are append-only arrays
that leak closures in the singleton coordinator; replace the raw Arrays with
Set<() => void> (or keep arrays but provide subscribe functions that return an
unsubscribe function) and update the subscription API in useTabCoordinator to
add callbacks via a subscribeBecomeLeader/subscribeLoseLeadership method that
returns a function to remove that callback, and ensure the leadership emitters
(where onBecomeLeader/onLoseLeadership are iterated) iterate the Set (or guarded
array) so removed callbacks are not called; also apply the same change to the
other occurrence using the same symbols to prevent accumulation of stale
handlers.
- Line 103: The forEach callbacks on onBecomeLeader and onLoseLeadership use
expression-bodied arrow functions which implicitly return a value and trigger
Biome's useIterableCallbackReturn error; change both callbacks to
statement-bodied arrow functions (e.g., onBecomeLeader.forEach(cb => { cb(); })
and onLoseLeadership.forEach(cb => { cb(); })) so they do not return a value
from the callback and the lint rule is satisfied, updating the callback
implementations in the useTabCoordinator composable where these symbols are
defined.

In `@app/src/stores/webrtcStore.ts`:
- Around line 77-78: The singleton tabCoordinator created via
useTabCoordinator() is never cleaned up, causing BroadcastChannel listeners and
heartbeat timers to leak when the store is recreated; update the store to call
tabCoordinator.destroy() on disposal by importing/using the scope cleanup hook
(onScopeDispose) and registering a callback that calls tabCoordinator.destroy();
also apply the same pattern to the similar useSignallingService() instance so
its destroy() is invoked on scope disposal.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3ccbc615-5580-4940-9450-d14d0cb402e7

📥 Commits

Reviewing files that changed from the base of the PR and between 51c0841 and c4e3919.

📒 Files selected for processing (3)
  • app/src/composables/useSignallingService.ts
  • app/src/composables/useTabCoordinator.ts
  • app/src/stores/webrtcStore.ts

Copy link

@marvin-bot-coasys marvin-bot-coasys left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Approved

The implementation is solid, the bug fixes are correct, and the architecture is sound. The focus-follows-leader pattern is the right approach for preventing multi-tab signalling conflicts.

Key Strengths

  • Clean singleton pattern with proper BroadcastChannel coordination
  • Well-documented focus-follows-leader semantics
  • Proper call pinning (active call blocks leadership transfer)
  • Correct async handling in claimLeadership (300ms grace period for rejections)
  • Guards all broadcasts behind leader check

Bug Fixes Verified

Bug #1 (call-pinned): ✅ Correctly simplified to always revert optimistic claim
Bug #2 (async race): ✅ Properly awaits claim result before proceeding

Code Quality

  • Proper lifecycle management (init/destroy)
  • Good defensive coding (try/catch on window.focus)
  • Clean integration into signalling service and WebRTC store

Optional Follow-ups (non-blocking)

  • Add manual test checklist (open 2+ tabs, switch focus, join call conflicts, etc.)
  • Consider random jitter (0-500ms) on visibility claim to reduce simultaneous claim collisions

No blocking issues. Ready to merge. 🎉

- Replace append-only arrays with Sets for leadership callbacks
- Subscribe methods now return unsubscribe functions
- stopSignalling calls unsubscribe to clean up closures
- Heartbeat now includes inCall flag so followers preserve call-pinning
- Use statement-bodied forEach callbacks to satisfy lint rules
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/composables/useSignallingService.ts`:
- Around line 304-309: The leadership hooks are being unsubscribed permanently
by calling the stored unsubBecomeLeader/unsubLoseLeadership in stopSignalling,
but startSignalling never re-subscribes; update the logic so that
tabCoordinator.onBecomeLeader and tabCoordinator.onLoseLeadership subscriptions
are created inside startSignalling (or a helper subscribeLeadership function)
and their unsubscribe functions (unsubBecomeLeader, unsubLoseLeadership) are
stored on a higher-scope variable, and then stopSignalling only calls those
stored unsub functions without clearing the subscription creation path; ensure
startSignalling checks whether subscriptions already exist to avoid
double-subscribe and reassigns unsubBecomeLeader/unsubLoseLeadership when
re-subscribing so subsequent restarts react to leader changes.

In `@app/src/composables/useTabCoordinator.ts`:
- Around line 83-86: leaderTimeoutTimer's timeout path can deadlock when
otherTabInCall is true because calling claimLeadership() hits the rejection
path; change the timeout handler to either clear or bypass otherTabInCall when
we are visible so a new leader can be elected — specifically, in the
leaderTimeoutTimer callback (using leaderTimeoutTimer, claimLeadership,
otherTabInCall) when document.visibilityState === 'visible' set otherTabInCall =
false or call claimLeadership with the forced option (e.g.,
claimLeadership(true) / claimLeadership({force: true}) depending on the function
signature) so the immediate rejection on lines ~125-130 is avoided and a new
leader is elected.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2de6e307-c1bb-43fa-8c7d-cf2e24b61236

📥 Commits

Reviewing files that changed from the base of the PR and between c4e3919 and efff8a1.

📒 Files selected for processing (2)
  • app/src/composables/useSignallingService.ts
  • app/src/composables/useTabCoordinator.ts

…therTabInCall on leader timeout

- useSignallingService: move onBecomeLeader/onLoseLeadership subscriptions
  into a subscribeLeadership() helper called from startSignalling so they
  are re-established after a stop/start cycle; stopSignalling nulls out
  the unsub handles instead of permanently destroying them.

- useTabCoordinator: clear otherTabInCall before claimLeadership() in the
  leader timeout handler so a dead leader's stale in-call flag doesn't
  deadlock new leader election.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants