From 6c2e0d1528b83f8e9ef014543fc35de619fabacc Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 30 Jan 2026 15:27:34 -0800 Subject: [PATCH] Fix matchmaking double join bug (#3065) ## Description: There were several issues with the matchmaking modal: 1. It was defined twice (once before login, and once after login), so players would sometimes join the matchmaking queue twice. 2. When clicking away from the modal (not clicking the back button), the "onClose" callback was not triggered. So if a person closed & reopened the modal, they would join twice' 3. Cache the userMe response so it can be called multiple times ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/client/Api.ts | 60 +++++++++++++++++------------- src/client/Matchmaking.ts | 50 +++++++++++++------------ src/client/components/BaseModal.ts | 10 +++++ 3 files changed, 70 insertions(+), 50 deletions(-) diff --git a/src/client/Api.ts b/src/client/Api.ts index 9456f88db..2c85544c5 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -47,34 +47,42 @@ export async function fetchPlayerById( return false; } } -export async function getUserMe(): Promise { - try { - const userAuthResult = await userAuth(); - if (!userAuthResult) return false; - const { jwt } = userAuthResult; - // Get the user object - const response = await fetch(getApiBase() + "/users/@me", { - headers: { - authorization: `Bearer ${jwt}`, - }, - }); - if (response.status === 401) { - await logOut(); - return false; - } - if (response.status !== 200) return false; - const body = await response.json(); - const result = UserMeResponseSchema.safeParse(body); - if (!result.success) { - const error = z.prettifyError(result.error); - console.error("Invalid response", error); - return false; - } - return result.data; - } catch (e) { - return false; +let __userMe: Promise | null = null; +export async function getUserMe(): Promise { + if (__userMe !== null) { + return __userMe; } + __userMe = (async () => { + try { + const userAuthResult = await userAuth(); + if (!userAuthResult) return false; + const { jwt } = userAuthResult; + + // Get the user object + const response = await fetch(getApiBase() + "/users/@me", { + headers: { + authorization: `Bearer ${jwt}`, + }, + }); + if (response.status === 401) { + await logOut(); + return false; + } + if (response.status !== 200) return false; + const body = await response.json(); + const result = UserMeResponseSchema.safeParse(body); + if (!result.success) { + const error = z.prettifyError(result.error); + console.error("Invalid response", error); + return false; + } + return result.data; + } catch (e) { + return false; + } + })(); + return __userMe; } export async function createCheckoutSession( diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts index e495ef131..c29931896 100644 --- a/src/client/Matchmaking.ts +++ b/src/client/Matchmaking.ts @@ -15,24 +15,15 @@ import { translateText } from "./Utils"; @customElement("matchmaking-modal") export class MatchmakingModal extends BaseModal { private gameCheckInterval: ReturnType | null = null; + private connectTimeout: ReturnType | null = null; @state() private connected = false; @state() private socket: WebSocket | null = null; @state() private gameID: string | null = null; - private elo = "unknown"; + private elo: number | "unknown" = "unknown"; constructor() { super(); this.id = "page-matchmaking"; - document.addEventListener("userMeResponse", (event: Event) => { - const customEvent = event as CustomEvent; - if (customEvent.detail) { - const userMeResponse = customEvent.detail as UserMeResponse; - this.elo = - userMeResponse.player?.leaderboard?.oneVone?.elo?.toString() ?? - "unknown"; - this.requestUpdate(); - } - }); } createRenderRoot() { @@ -125,18 +116,24 @@ export class MatchmakingModal extends BaseModal { ); this.socket.onopen = async () => { console.log("Connected to matchmaking server"); - setTimeout(() => { + this.connectTimeout = setTimeout(async () => { + if (this.socket?.readyState !== WebSocket.OPEN) { + console.warn("[Matchmaking] socket not ready"); + return; + } // Set a delay so the user can see the "connecting" message, // otherwise the "searching" message will be shown immediately. + // Also wait so people who back out immediately aren't added + // to the matchmaking queue. + this.socket.send( + JSON.stringify({ + type: "join", + jwt: await getPlayToken(), + }), + ); this.connected = true; this.requestUpdate(); - }, 1000); - this.socket?.send( - JSON.stringify({ - type: "join", - jwt: await getPlayToken(), - }), - ); + }, 2000); }; this.socket.onmessage = (event) => { console.log(event.data); @@ -145,6 +142,7 @@ export class MatchmakingModal extends BaseModal { this.socket?.close(); console.log(`matchmaking: got game ID: ${data.gameId}`); this.gameID = data.gameId; + this.gameCheckInterval = setInterval(() => this.checkGame(), 1000); } }; this.socket.onerror = (event: ErrorEvent) => { @@ -157,7 +155,6 @@ export class MatchmakingModal extends BaseModal { protected async onOpen(): Promise { const userMe = await getUserMe(); - // Early return if modal was closed during async operation if (!this.isModalOpen) { return; @@ -180,15 +177,21 @@ export class MatchmakingModal extends BaseModal { this.close(); return; } + + this.elo = userMe.player.leaderboard?.oneVone?.elo ?? "unknown"; + this.connected = false; this.gameID = null; this.connect(); - this.gameCheckInterval = setInterval(() => this.checkGame(), 1000); } protected onClose(): void { this.connected = false; this.socket?.close(); + if (this.connectTimeout) { + clearTimeout(this.connectTimeout); + this.connectTimeout = null; + } if (this.gameCheckInterval) { clearInterval(this.gameCheckInterval); this.gameCheckInterval = null; @@ -263,7 +266,7 @@ export class MatchmakingButton extends LitElement { } render() { - const button = this.isLoggedIn + return this.isLoggedIn ? html` + ` : html` `; - - return html` ${button} `; } private handleLoggedInClick() { diff --git a/src/client/components/BaseModal.ts b/src/client/components/BaseModal.ts index db44e5485..0f7d7b2e4 100644 --- a/src/client/components/BaseModal.ts +++ b/src/client/components/BaseModal.ts @@ -25,6 +25,16 @@ export abstract class BaseModal extends LitElement { return this; } + protected firstUpdated(): void { + if (this.modalEl) { + this.modalEl.onClose = () => { + if (this.isModalOpen) { + this.close(); + } + }; + } + } + disconnectedCallback() { this.unregisterEscapeHandler(); super.disconnectedCallback();