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:
Joey Yakimowich-Payne 2026-04-18 16:26:35 -06:00
commit a7ec140cef
No known key found for this signature in database

View file

@ -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 { Lobby } from '../ui/Lobby'
import { GameView, MultiplayerGameView } from '../ui/GameView'
@ -71,10 +71,20 @@ function GameRoute({
const { code: urlCode } = useParams<{ code?: string }>()
const navigate = useNavigate()
// `joining` gates rendering while we fire the auto-join request for
// a shared-link arrival. Null = no join in flight. During this window
// the page shows a minimal "Joining…" placeholder.
const [joining, setJoining] = useState<string | null>(null)
// Synchronous ref guarding against React StrictMode's dev-mode
// double-effect. State updates from the first effect invocation
// don't propagate to the second (both run before React commits), so
// 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 code = sessionStorage.getItem('room-code')
@ -94,39 +104,31 @@ function GameRoute({
}, [urlCode, mpCreds, navigate])
// Case 4: /game/:code with no matching creds → auto-join the room
// and stash the returned creds. Run once per URL code.
//
// 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.
// and stash the returned creds. Guarded against StrictMode double-
// invocation by `joinedCodeRef`.
useEffect(() => {
if (urlCode === undefined) return
if (mpCreds !== null) return
const normalised = urlCode.toUpperCase()
if (joining === normalised) return
setJoining(normalised)
if (joinedCodeRef.current === normalised) return
joinedCodeRef.current = normalised
oneShotRoomRequest('room.join', { code: normalised })
.then(({ code, token, color }) => {
sessionStorage.setItem('room-code', code)
sessionStorage.setItem('room-token', token)
sessionStorage.setItem('player-color', color)
setMpCreds({ code, token })
setJoining(null)
})
.catch((err: unknown) => {
const msg =
err instanceof Error ? err.message : 'Could not join room'
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 })
})
}, [urlCode, mpCreds, joining, navigate])
}, [urlCode, mpCreds, navigate])
if (urlCode !== undefined && mpCreds === null) {
return (