diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs index 76ceed9..24042be 100644 --- a/client_web/src/components/game_screen.rs +++ b/client_web/src/components/game_screen.rs @@ -1,3 +1,4 @@ +use std::cell::Cell; use std::collections::VecDeque; use futures::channel::mpsc::UnboundedSender; @@ -40,14 +41,19 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let pending = use_context::>>().expect("pending not found in context"); let cmd_tx_effect = cmd_tx.clone(); - // Tracks staged_moves length across Effect runs so we can detect additions. - Effect::new(move |prev_len: Option| { + // Non-reactive counter so we can detect when staged_moves grows without + // returning a value from the Effect (which causes a Leptos reactive loop + // when the Effect also writes to the same signal it reads). + let prev_staged_len = Cell::new(0usize); + + Effect::new(move |_| { let moves = staged_moves.get(); let n = moves.len(); // Play checker sound whenever a move is added (own moves, immediate feedback). - if prev_len.map_or(false, |p| n > p) { + if n > prev_staged_len.get() { crate::sound::play_checker_move(); } + prev_staged_len.set(n); if n == 2 { let to_cm = |&(from, to): &(u8, u8)| { CheckerMove::new(from as usize, to as usize).unwrap_or_default() @@ -60,8 +66,9 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { .ok(); staged_moves.set(vec![]); selected_origin.set(None); + // Reset the counter so the next turn starts clean. + prev_staged_len.set(0); } - n }); // ── Auto-roll effect ───────────────────────────────────────────────────── diff --git a/client_web/src/components/scoring.rs b/client_web/src/components/scoring.rs index f463969..4a19a81 100644 --- a/client_web/src/components/scoring.rs +++ b/client_web/src/components/scoring.rs @@ -5,6 +5,8 @@ use leptos::prelude::*; use trictrac_store::CheckerMove; #[cfg(target_arch = "wasm32")] use wasm_bindgen_futures::spawn_local; +#[cfg(target_arch = "wasm32")] +use std::sync::{Arc, atomic::{AtomicBool, Ordering}}; use crate::app::NetCommand; use crate::i18n::*; @@ -84,27 +86,42 @@ pub fn ScoringPanel( let hovered_ctx = use_context::>>(); - // On mount: show all this event's moves as board arrows immediately. - // After 3.4 s (0.45 s slide-in + 3 s display + guard), slide to peek - // and clear arrows. A cancellation flag prevents stale tasks from - // interfering when the component is replaced by a new scored event. + // On mount: show all this event's moves as board arrows immediately, + // then after 3.4 s slide to peek and clear the arrows. + // + // Two important constraints: + // 1. The initial hm.set() must be deferred (spawn_local, not sync in body) + // to avoid writing a reactive signal mid-render while Board reads it — + // that triggers Leptos's cycle guard → `unreachable` WASM panic. + // 2. The cancellation flag must be Rc>, NOT RwSignal. + // RwSignal is a NodeId into Leptos's arena; the arena slot is freed + // when ScoringPanel's owner drops (on every GameScreen remount). If the + // 3.4 s future outlives the component and calls is_alive.get_untracked() + // on a freed slot, that also panics with `unreachable`. Rc> + // is reference-counted outside the arena and stays valid for as long as + // the future holds onto it. + #[cfg(target_arch = "wasm32")] if let Some(hm) = hovered_ctx { - hm.set(all_moves.clone()); + let is_alive = Arc::new(AtomicBool::new(true)); + let is_alive_cleanup = is_alive.clone(); + // on_cleanup requires Send + Sync; Arc satisfies both. + on_cleanup(move || is_alive_cleanup.store(false, Ordering::Relaxed)); - #[cfg(target_arch = "wasm32")] - { - let is_alive = RwSignal::new(true); - on_cleanup(move || is_alive.set(false)); + spawn_local(async move { + // Show arrows (runs in the next microtask, after render settles). + hm.set(all_moves); - spawn_local(async move { - TimeoutFuture::new(3_400).await; - if !is_alive.get_untracked() { - return; - } - hm.set(vec![]); - peeked.set(true); - }); - } + TimeoutFuture::new(3_400).await; + // Guard: component may have been destroyed while we were waiting. + // is_alive was set to false by on_cleanup, which runs before Leptos + // frees the signal arena slots — so peeked is still valid iff this + // returns true. + if !is_alive.load(Ordering::Relaxed) { + return; + } + hm.set(vec![]); + peeked.set(true); + }); } let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect();