fix(client_web): fix slidie dice jans

This commit is contained in:
Henri Bourcereau 2026-04-11 19:12:54 +02:00
parent f2dc81d613
commit 703803e329
2 changed files with 46 additions and 22 deletions

View file

@ -1,3 +1,4 @@
use std::cell::Cell;
use std::collections::VecDeque; use std::collections::VecDeque;
use futures::channel::mpsc::UnboundedSender; use futures::channel::mpsc::UnboundedSender;
@ -40,14 +41,19 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
let pending = let pending =
use_context::<RwSignal<VecDeque<GameUiState>>>().expect("pending not found in context"); use_context::<RwSignal<VecDeque<GameUiState>>>().expect("pending not found in context");
let cmd_tx_effect = cmd_tx.clone(); let cmd_tx_effect = cmd_tx.clone();
// Tracks staged_moves length across Effect runs so we can detect additions. // Non-reactive counter so we can detect when staged_moves grows without
Effect::new(move |prev_len: Option<usize>| { // 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 moves = staged_moves.get();
let n = moves.len(); let n = moves.len();
// Play checker sound whenever a move is added (own moves, immediate feedback). // 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(); crate::sound::play_checker_move();
} }
prev_staged_len.set(n);
if n == 2 { if n == 2 {
let to_cm = |&(from, to): &(u8, u8)| { let to_cm = |&(from, to): &(u8, u8)| {
CheckerMove::new(from as usize, to as usize).unwrap_or_default() CheckerMove::new(from as usize, to as usize).unwrap_or_default()
@ -60,8 +66,9 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
.ok(); .ok();
staged_moves.set(vec![]); staged_moves.set(vec![]);
selected_origin.set(None); selected_origin.set(None);
// Reset the counter so the next turn starts clean.
prev_staged_len.set(0);
} }
n
}); });
// ── Auto-roll effect ───────────────────────────────────────────────────── // ── Auto-roll effect ─────────────────────────────────────────────────────

View file

@ -5,6 +5,8 @@ use leptos::prelude::*;
use trictrac_store::CheckerMove; use trictrac_store::CheckerMove;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
#[cfg(target_arch = "wasm32")]
use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
use crate::app::NetCommand; use crate::app::NetCommand;
use crate::i18n::*; use crate::i18n::*;
@ -84,28 +86,43 @@ pub fn ScoringPanel(
let hovered_ctx = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>(); let hovered_ctx = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
// On mount: show all this event's moves as board arrows immediately. // 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 // then after 3.4 s slide to peek and clear the arrows.
// and clear arrows. A cancellation flag prevents stale tasks from //
// interfering when the component is replaced by a new scored event. // Two important constraints:
if let Some(hm) = hovered_ctx { // 1. The initial hm.set() must be deferred (spawn_local, not sync in body)
hm.set(all_moves.clone()); // 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<Cell<bool>>, NOT RwSignal<bool>.
// 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<Cell<bool>>
// is reference-counted outside the arena and stays valid for as long as
// the future holds onto it.
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ if let Some(hm) = hovered_ctx {
let is_alive = RwSignal::new(true); let is_alive = Arc::new(AtomicBool::new(true));
on_cleanup(move || is_alive.set(false)); let is_alive_cleanup = is_alive.clone();
// on_cleanup requires Send + Sync; Arc<AtomicBool> satisfies both.
on_cleanup(move || is_alive_cleanup.store(false, Ordering::Relaxed));
spawn_local(async move { spawn_local(async move {
// Show arrows (runs in the next microtask, after render settles).
hm.set(all_moves);
TimeoutFuture::new(3_400).await; TimeoutFuture::new(3_400).await;
if !is_alive.get_untracked() { // 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; return;
} }
hm.set(vec![]); hm.set(vec![]);
peeked.set(true); peeked.set(true);
}); });
} }
}
let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect(); let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect();