fix: reconnect on ws failure

This commit is contained in:
Evan Carroll 2026-01-18 23:12:24 -06:00
parent 84cb4e5e78
commit 27b3658e1d
5 changed files with 430 additions and 3 deletions

View file

@ -75,4 +75,14 @@
.error-message { .error-message {
@apply p-4 bg-red-900/50 border border-red-500 rounded-lg text-red-200; @apply p-4 bg-red-900/50 border border-red-500 rounded-lg text-red-200;
} }
/* Reconnection overlay spinner animation */
.reconnect-spinner {
animation: reconnect-pulse 1.5s ease-in-out infinite;
}
}
@keyframes reconnect-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
} }

View file

@ -20,6 +20,7 @@ pub mod scene_viewer;
pub mod settings; pub mod settings;
pub mod settings_popup; pub mod settings_popup;
pub mod tabs; pub mod tabs;
pub mod reconnection_overlay;
pub mod ws_client; pub mod ws_client;
pub use avatar_canvas::*; pub use avatar_canvas::*;
@ -38,6 +39,7 @@ pub use layout::*;
pub use modals::*; pub use modals::*;
pub use notification_history::*; pub use notification_history::*;
pub use notifications::*; pub use notifications::*;
pub use reconnection_overlay::*;
pub use scene_viewer::*; pub use scene_viewer::*;
pub use settings::*; pub use settings::*;
pub use settings_popup::*; pub use settings_popup::*;

View file

@ -0,0 +1,391 @@
//! Reconnection overlay component.
//!
//! Displays a full-screen overlay when WebSocket connection is lost,
//! with countdown timer and automatic retry logic.
use leptos::prelude::*;
use super::ws_client::WsState;
/// Reconnection attempt phase.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReconnectionPhase {
/// Initial phase: 5 attempts with 5-second countdown each.
Initial { attempt: u8 },
/// Extended phase: 10 attempts with 10-second countdown each.
Extended { attempt: u8 },
/// All attempts exhausted.
Failed,
}
impl ReconnectionPhase {
/// Get the countdown duration in seconds for the current phase.
pub fn countdown_duration(&self) -> u32 {
match self {
Self::Initial { .. } => 5,
Self::Extended { .. } => 10,
Self::Failed => 0,
}
}
/// Get the maximum attempts for the current phase.
pub fn max_attempts(&self) -> u8 {
match self {
Self::Initial { .. } => 5,
Self::Extended { .. } => 10,
Self::Failed => 0,
}
}
/// Advance to the next attempt or phase.
pub fn next(self) -> Self {
match self {
Self::Initial { attempt } if attempt < 5 => Self::Initial { attempt: attempt + 1 },
Self::Initial { .. } => Self::Extended { attempt: 1 },
Self::Extended { attempt } if attempt < 10 => Self::Extended { attempt: attempt + 1 },
Self::Extended { .. } | Self::Failed => Self::Failed,
}
}
/// Get the current attempt number.
pub fn attempt(&self) -> u8 {
match self {
Self::Initial { attempt } | Self::Extended { attempt } => *attempt,
Self::Failed => 0,
}
}
/// Check if this is the initial phase.
pub fn is_initial(&self) -> bool {
matches!(self, Self::Initial { .. })
}
}
/// Internal state for the reconnection overlay.
#[derive(Clone, Copy, Debug)]
enum OverlayState {
/// Hidden (connected).
Hidden,
/// Showing countdown.
Countdown {
phase: ReconnectionPhase,
remaining: u32,
},
/// Currently attempting to reconnect.
Reconnecting { phase: ReconnectionPhase },
/// All attempts failed.
Failed,
}
/// Reconnection overlay component.
///
/// Shows a full-screen overlay when WebSocket connection is lost,
/// with countdown timer and automatic retry logic.
#[component]
pub fn ReconnectionOverlay(
/// WebSocket connection state to monitor.
ws_state: Signal<WsState>,
/// Callback to trigger a reconnection attempt.
on_reconnect: Callback<()>,
) -> impl IntoView {
// Internal overlay state
let (overlay_state, set_overlay_state) = signal(OverlayState::Hidden);
// Timer handle stored for cleanup
#[cfg(feature = "hydrate")]
let timer_handle: std::rc::Rc<std::cell::RefCell<Option<gloo_timers::callback::Interval>>> =
std::rc::Rc::new(std::cell::RefCell::new(None));
// Watch for WebSocket state changes
#[cfg(feature = "hydrate")]
{
let timer_handle = timer_handle.clone();
Effect::new(move |_| {
let state = ws_state.get();
match state {
WsState::Connected => {
// Connection restored - hide overlay and stop timer
if let Some(timer) = timer_handle.borrow_mut().take() {
drop(timer);
}
set_overlay_state.set(OverlayState::Hidden);
}
WsState::Disconnected | WsState::Error => {
// Check current state - only start countdown if we're hidden
let current = overlay_state.get_untracked();
if matches!(current, OverlayState::Hidden) {
// Start initial countdown
let phase = ReconnectionPhase::Initial { attempt: 1 };
let duration = phase.countdown_duration();
set_overlay_state.set(OverlayState::Countdown {
phase,
remaining: duration,
});
// Start timer
start_countdown_timer(
timer_handle.clone(),
set_overlay_state,
on_reconnect.clone(),
);
} else if matches!(current, OverlayState::Reconnecting { .. }) {
// Reconnection attempt failed - advance to next attempt
if let OverlayState::Reconnecting { phase } = current {
let next_phase = phase.next();
if matches!(next_phase, ReconnectionPhase::Failed) {
set_overlay_state.set(OverlayState::Failed);
} else {
let duration = next_phase.countdown_duration();
set_overlay_state.set(OverlayState::Countdown {
phase: next_phase,
remaining: duration,
});
// Restart timer for next countdown
start_countdown_timer(
timer_handle.clone(),
set_overlay_state,
on_reconnect.clone(),
);
}
}
}
}
WsState::Connecting => {
// Currently attempting to connect - keep current state
// The reconnecting state should already be set
}
}
});
}
// Render based on state
move || {
let state = overlay_state.get();
match state {
OverlayState::Hidden => None,
OverlayState::Countdown { phase, remaining } => {
let (phase_text, attempt_text) = match phase {
ReconnectionPhase::Initial { attempt } => {
("Attempt", format!("{} of 5", attempt))
}
ReconnectionPhase::Extended { attempt } => {
("Extended attempt", format!("{} of 10", attempt))
}
ReconnectionPhase::Failed => ("", String::new()),
};
Some(
view! {
<div
class="fixed inset-0 z-[100] flex items-center justify-center"
role="alertdialog"
aria-modal="true"
aria-labelledby="reconnect-title"
>
// Darkened backdrop
<div
class="absolute inset-0 bg-black/80 backdrop-blur-md"
aria-hidden="true"
></div>
// Dialog box
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-md w-full mx-4 p-8 border border-gray-700 text-center">
// Countdown circle
<div class="w-20 h-20 mx-auto rounded-full bg-yellow-900/30 flex items-center justify-center mb-6">
<span class="text-4xl font-bold text-yellow-300">
{remaining}
</span>
</div>
<h2
id="reconnect-title"
class="text-xl font-semibold text-white mb-2"
>
"Lost connection..."
</h2>
<p class="text-gray-300 mb-4">
{format!("attempting to reconnect in {} seconds", remaining)}
</p>
<p class="text-sm text-gray-400">
{format!("{} {}", phase_text, attempt_text)}
</p>
</div>
</div>
}
.into_any(),
)
}
OverlayState::Reconnecting { phase } => {
let (phase_text, attempt_text) = match phase {
ReconnectionPhase::Initial { attempt } => {
("Attempt", format!("{} of 5", attempt))
}
ReconnectionPhase::Extended { attempt } => {
("Extended attempt", format!("{} of 10", attempt))
}
ReconnectionPhase::Failed => ("", String::new()),
};
Some(
view! {
<div
class="fixed inset-0 z-[100] flex items-center justify-center"
role="alertdialog"
aria-modal="true"
aria-labelledby="reconnect-title"
>
// Darkened backdrop
<div
class="absolute inset-0 bg-black/80 backdrop-blur-md"
aria-hidden="true"
></div>
// Dialog box
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-md w-full mx-4 p-8 border border-gray-700 text-center">
// Spinner
<div class="w-20 h-20 mx-auto rounded-full bg-blue-900/30 flex items-center justify-center mb-6 reconnect-spinner">
<svg
class="w-10 h-10 text-blue-400 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
<h2
id="reconnect-title"
class="text-xl font-semibold text-white mb-2"
>
"Reconnecting..."
</h2>
<p class="text-gray-300 mb-4">"Attempting to restore connection"</p>
<p class="text-sm text-gray-400">
{format!("{} {}", phase_text, attempt_text)}
</p>
</div>
</div>
}
.into_any(),
)
}
OverlayState::Failed => Some(
view! {
<div
class="fixed inset-0 z-[100] flex items-center justify-center"
role="alertdialog"
aria-modal="true"
aria-labelledby="reconnect-title"
>
// Darkened backdrop
<div
class="absolute inset-0 bg-black/80 backdrop-blur-md"
aria-hidden="true"
></div>
// Dialog box
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-md w-full mx-4 p-8 border border-gray-700 text-center">
// Error icon
<div class="w-20 h-20 mx-auto rounded-full bg-red-900/30 flex items-center justify-center mb-6">
<svg
class="w-10 h-10 text-red-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</div>
<h2
id="reconnect-title"
class="text-xl font-semibold text-white mb-2"
>
"Connection Failed"
</h2>
<p class="text-gray-300 mb-6">
"Unable to reconnect after multiple attempts. Please check your network connection and try again."
</p>
<button
type="button"
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-800 transition-colors duration-200"
on:click=move |_| {
#[cfg(feature = "hydrate")]
{
if let Some(window) = web_sys::window() {
let _ = window.location().reload();
}
}
}
>
"Refresh Page"
</button>
</div>
</div>
}
.into_any(),
),
}
}
}
/// Start the countdown timer.
#[cfg(feature = "hydrate")]
fn start_countdown_timer(
timer_handle: std::rc::Rc<std::cell::RefCell<Option<gloo_timers::callback::Interval>>>,
set_overlay_state: WriteSignal<OverlayState>,
on_reconnect: Callback<()>,
) {
use gloo_timers::callback::Interval;
// Stop any existing timer
if let Some(old_timer) = timer_handle.borrow_mut().take() {
drop(old_timer);
}
// Create new timer that ticks every second
let timer = Interval::new(1000, move || {
set_overlay_state.update(|state| {
if let OverlayState::Countdown { phase, remaining } = state {
if *remaining > 1 {
// Decrement countdown
*remaining -= 1;
} else {
// Countdown reached zero - trigger reconnection
*state = OverlayState::Reconnecting { phase: *phase };
on_reconnect.run(());
}
}
});
});
*timer_handle.borrow_mut() = Some(timer);
}

View file

@ -80,6 +80,7 @@ pub struct WsError {
pub fn use_channel_websocket( pub fn use_channel_websocket(
realm_slug: Signal<String>, realm_slug: Signal<String>,
channel_id: Signal<Option<uuid::Uuid>>, channel_id: Signal<Option<uuid::Uuid>>,
reconnect_trigger: Signal<u32>,
on_members_update: Callback<Vec<ChannelMemberWithAvatar>>, on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
on_chat_message: Callback<ChatMessage>, on_chat_message: Callback<ChatMessage>,
on_loose_props_sync: Callback<Vec<LooseProp>>, on_loose_props_sync: Callback<Vec<LooseProp>>,
@ -120,6 +121,8 @@ pub fn use_channel_websocket(
Effect::new(move |_| { Effect::new(move |_| {
let slug = realm_slug.get(); let slug = realm_slug.get();
let ch_id = channel_id.get(); let ch_id = channel_id.get();
// Track reconnect_trigger to force reconnection when it changes
let _trigger = reconnect_trigger.get();
// Cleanup previous connection // Cleanup previous connection
if let Some(old_ws) = ws_ref_clone.borrow_mut().take() { if let Some(old_ws) = ws_ref_clone.borrow_mut().take() {
@ -458,6 +461,7 @@ fn handle_server_message(
pub fn use_channel_websocket( pub fn use_channel_websocket(
_realm_slug: Signal<String>, _realm_slug: Signal<String>,
_channel_id: Signal<Option<uuid::Uuid>>, _channel_id: Signal<Option<uuid::Uuid>>,
_reconnect_trigger: Signal<u32>,
_on_members_update: Callback<Vec<ChannelMemberWithAvatar>>, _on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
_on_chat_message: Callback<ChatMessage>, _on_chat_message: Callback<ChatMessage>,
_on_loose_props_sync: Callback<Vec<LooseProp>>, _on_loose_props_sync: Callback<Vec<LooseProp>>,

View file

@ -14,8 +14,8 @@ use uuid::Uuid;
use crate::components::{ use crate::components::{
ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings, ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings,
FadingMember, InventoryPopup, KeybindingsPopup, MessageLog, NotificationHistoryModal, FadingMember, InventoryPopup, KeybindingsPopup, MessageLog, NotificationHistoryModal,
NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, SettingsPopup, NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, ReconnectionOverlay,
ViewerSettings, SettingsPopup, ViewerSettings,
}; };
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use crate::components::{ use crate::components::{
@ -118,6 +118,9 @@ pub fn RealmPage() -> impl IntoView {
// Error notification state (for whisper failures, etc.) // Error notification state (for whisper failures, etc.)
let (error_message, set_error_message) = signal(Option::<String>::None); let (error_message, set_error_message) = signal(Option::<String>::None);
// Reconnection trigger - increment to force WebSocket reconnection
let (reconnect_trigger, set_reconnect_trigger) = signal(0u32);
let realm_data = LocalResource::new(move || { let realm_data = LocalResource::new(move || {
let slug = slug.get(); let slug = slug.get();
async move { async move {
@ -330,9 +333,10 @@ pub fn RealmPage() -> impl IntoView {
}); });
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
let (_ws_state, ws_sender) = use_channel_websocket( let (ws_state, ws_sender) = use_channel_websocket(
slug, slug,
Signal::derive(move || channel_id.get()), Signal::derive(move || channel_id.get()),
Signal::derive(move || reconnect_trigger.get()),
on_members_update, on_members_update,
on_chat_message, on_chat_message,
on_loose_props_sync, on_loose_props_sync,
@ -954,6 +958,22 @@ pub fn RealmPage() -> impl IntoView {
/> />
} }
} }
// Reconnection overlay - shown when WebSocket disconnects
{
#[cfg(feature = "hydrate")]
let ws_state_for_overlay = ws_state;
#[cfg(not(feature = "hydrate"))]
let ws_state_for_overlay = Signal::derive(|| crate::components::ws_client::WsState::Disconnected);
view! {
<ReconnectionOverlay
ws_state=ws_state_for_overlay
on_reconnect=Callback::new(move |_: ()| {
set_reconnect_trigger.update(|t| *t += 1);
})
/>
}
}
} }
.into_any() .into_any()
} }