fix(chess): use ref-based guard for shared-link auto-join to prevent StrictMode double-join
React 18 StrictMode runs effects twice on mount in dev. The previous state-based guard (joining) couldn't prevent the second invocation from firing room.join because both passes see the deferred state as null. The server accepts the first join as black, rejects the second with ROOM_FULL, and the user sees an incorrect error toast. Switching to useRef gives synchronous visibility — the second pass sees joinedCodeRef.current already set and bails. Ref is reset on .catch so a legitimate retry from the lobby isn't permanently blocked.
This commit is contained in:
parent
cc30545ced
commit
a7ec140cef
1 changed files with 23 additions and 21 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Routes, Route, useNavigate, useLocation, useParams } from 'react-router-dom'
|
import { Routes, Route, useNavigate, useLocation, useParams } from 'react-router-dom'
|
||||||
import { Lobby } from '../ui/Lobby'
|
import { Lobby } from '../ui/Lobby'
|
||||||
import { GameView, MultiplayerGameView } from '../ui/GameView'
|
import { GameView, MultiplayerGameView } from '../ui/GameView'
|
||||||
|
|
@ -71,10 +71,20 @@ function GameRoute({
|
||||||
const { code: urlCode } = useParams<{ code?: string }>()
|
const { code: urlCode } = useParams<{ code?: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
// `joining` gates rendering while we fire the auto-join request for
|
// Synchronous ref guarding against React StrictMode's dev-mode
|
||||||
// a shared-link arrival. Null = no join in flight. During this window
|
// double-effect. State updates from the first effect invocation
|
||||||
// the page shows a minimal "Joining…" placeholder.
|
// don't propagate to the second (both run before React commits), so
|
||||||
const [joining, setJoining] = useState<string | null>(null)
|
// a state guard would be insufficient — both passes would see null,
|
||||||
|
// both fire room.join, and the second gets ROOM_FULL from the
|
||||||
|
// server because the first already occupied the second slot.
|
||||||
|
//
|
||||||
|
// Refs update synchronously: setting `joinedCodeRef.current` in one
|
||||||
|
// effect is visible in the StrictMode second invocation immediately.
|
||||||
|
//
|
||||||
|
// The "Joining…" placeholder is driven off the absence of mpCreds
|
||||||
|
// plus the presence of urlCode — no separate `joining` state
|
||||||
|
// needed.
|
||||||
|
const joinedCodeRef = useRef<string | null>(null)
|
||||||
|
|
||||||
const [mpCreds, setMpCreds] = useState(() => {
|
const [mpCreds, setMpCreds] = useState(() => {
|
||||||
const code = sessionStorage.getItem('room-code')
|
const code = sessionStorage.getItem('room-code')
|
||||||
|
|
@ -94,39 +104,31 @@ function GameRoute({
|
||||||
}, [urlCode, mpCreds, navigate])
|
}, [urlCode, mpCreds, navigate])
|
||||||
|
|
||||||
// Case 4: /game/:code with no matching creds → auto-join the room
|
// Case 4: /game/:code with no matching creds → auto-join the room
|
||||||
// and stash the returned creds. Run once per URL code.
|
// and stash the returned creds. Guarded against StrictMode double-
|
||||||
//
|
// invocation by `joinedCodeRef`.
|
||||||
// We deliberately INCLUDE `mpCreds`/`joining` in the dep array so the
|
|
||||||
// effect is always consistent with the exhaustive-deps lint rule, and
|
|
||||||
// use the top-of-effect guards to prevent re-entry:
|
|
||||||
// - returns early if URL has no code
|
|
||||||
// - returns early once mpCreds is populated (we're done)
|
|
||||||
// - returns early if a join for this exact code is already in flight
|
|
||||||
//
|
|
||||||
// After a successful join, `setMpCreds({...})` triggers a re-render,
|
|
||||||
// the effect re-runs, and the `mpCreds !== null` guard short-circuits
|
|
||||||
// it cleanly without loops.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (urlCode === undefined) return
|
if (urlCode === undefined) return
|
||||||
if (mpCreds !== null) return
|
if (mpCreds !== null) return
|
||||||
const normalised = urlCode.toUpperCase()
|
const normalised = urlCode.toUpperCase()
|
||||||
if (joining === normalised) return
|
if (joinedCodeRef.current === normalised) return
|
||||||
setJoining(normalised)
|
joinedCodeRef.current = normalised
|
||||||
oneShotRoomRequest('room.join', { code: normalised })
|
oneShotRoomRequest('room.join', { code: normalised })
|
||||||
.then(({ code, token, color }) => {
|
.then(({ code, token, color }) => {
|
||||||
sessionStorage.setItem('room-code', code)
|
sessionStorage.setItem('room-code', code)
|
||||||
sessionStorage.setItem('room-token', token)
|
sessionStorage.setItem('room-token', token)
|
||||||
sessionStorage.setItem('player-color', color)
|
sessionStorage.setItem('player-color', color)
|
||||||
setMpCreds({ code, token })
|
setMpCreds({ code, token })
|
||||||
setJoining(null)
|
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
const msg =
|
const msg =
|
||||||
err instanceof Error ? err.message : 'Could not join room'
|
err instanceof Error ? err.message : 'Could not join room'
|
||||||
toast.error(`Join failed: ${msg}`)
|
toast.error(`Join failed: ${msg}`)
|
||||||
|
// Reset the ref so a retry from the lobby isn't permanently
|
||||||
|
// blocked by a stale "already attempted this code" flag.
|
||||||
|
joinedCodeRef.current = null
|
||||||
navigate('/', { replace: true })
|
navigate('/', { replace: true })
|
||||||
})
|
})
|
||||||
}, [urlCode, mpCreds, joining, navigate])
|
}, [urlCode, mpCreds, navigate])
|
||||||
|
|
||||||
if (urlCode !== undefined && mpCreds === null) {
|
if (urlCode !== undefined && mpCreds === null) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue