Handle confirmation on popstate event if player is active in a game (#2777)

Please merge it into v29, since in that version the back navigation out
of a game is currently **broken** after switching from hash-based to
path-based routing via #2740

## Description:

Protect against players accidentally leaving an active game by pressing
the browser back button. Uses the same confirmation dialog as the game
exit button.

Partially handles issue #1877 (protects against back button, not closing
tab or editing the URL directly).

<img width="861" height="373" alt="image"
src="https://github.com/user-attachments/assets/167cc137-6df3-44a7-a594-91ffd904857d"
/>

Partial credit to PR #2141

## 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:

deshack_82603
This commit is contained in:
Mattia Migliorini
2026-01-17 02:09:08 +01:00
committed by GitHub
parent 2fcca8ee26
commit b1d63533d5
2 changed files with 78 additions and 19 deletions
+36 -3
View File
@@ -69,7 +69,7 @@ export function joinLobby(
lobbyConfig: LobbyConfig,
onPrestart: () => void,
onJoin: () => void,
): () => void {
): (force?: boolean) => boolean {
console.log(
`joining lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}`,
);
@@ -79,6 +79,8 @@ export function joinLobby(
const transport = new Transport(lobbyConfig, eventBus);
let currentGameRunner: ClientGameRunner | null = null;
let hasJoined = false;
const onconnect = () => {
@@ -122,9 +124,15 @@ export function joinLobby(
terrainLoad,
terrainMapFileLoader,
)
.then((r) => r.start())
.then((r) => {
currentGameRunner = r;
r.start();
})
.catch((e) => {
console.error("error creating client game", e);
currentGameRunner = null;
const startingModal = document.querySelector(
"game-starting-modal",
) as HTMLElement;
@@ -165,9 +173,19 @@ export function joinLobby(
}
};
transport.connect(onconnect, onmessage);
return () => {
return (force: boolean = false) => {
if (!force && currentGameRunner?.shouldPreventWindowClose()) {
console.log("Player is active, prevent leaving game");
return false;
}
console.log("leaving game");
currentGameRunner = null;
transport.leaveGame();
return true;
};
}
@@ -256,6 +274,21 @@ export class ClientGameRunner {
this.lastMessageTime = Date.now();
}
/**
* Determines whether window closing should be prevented.
*
* Used to show a confirmation dialog when the user attempts to close
* the window or navigate away during an active game session.
*
* @returns {boolean} `true` if the window close should be prevented
* (when the player is alive in the game), `false` otherwise
* (when the player is not alive or doesn't exist)
*/
public shouldPreventWindowClose(): boolean {
// Show confirmation dialog if player is alive in the game
return !!this.myPlayer?.isAlive();
}
private async saveGame(update: WinUpdate) {
if (this.myPlayer === null) {
return;
+42 -16
View File
@@ -208,9 +208,11 @@ export interface JoinLobbyEvent {
}
class Client {
private gameStop: (() => void) | null = null;
private gameStop: ((force?: boolean) => boolean) | null = null;
private eventBus: EventBus = new EventBus();
private currentUrl: string | null = null;
private usernameInput: UsernameInput | null = null;
private flagInput: FlagInput | null = null;
@@ -277,7 +279,7 @@ class Client {
window.addEventListener("beforeunload", async () => {
console.log("Browser is closing");
if (this.gameStop !== null) {
this.gameStop();
this.gameStop(true);
await crazyGamesSDK.gameplayStop();
}
});
@@ -583,15 +585,7 @@ class Client {
// Attempt to join lobby
this.handleUrl();
let preventHashUpdate = false;
const onHashUpdate = () => {
// Prevent double-handling when both popstate and hashchange fire
if (preventHashUpdate) {
preventHashUpdate = false;
return;
}
// Reset the UI to its initial state
this.joinModal?.close();
if (this.gameStop !== null) {
@@ -602,11 +596,39 @@ class Client {
this.handleUrl();
};
const onPopState = () => {
if (this.currentUrl !== null && this.gameStop !== null) {
console.info("Game is active");
if (!this.gameStop()) {
console.info("Player is active, ask before leaving game");
const isConfirmed = confirm(
translateText("help_modal.exit_confirmation"),
);
if (!isConfirmed) {
// Rollback navigator history
history.pushState(null, "", this.currentUrl);
return;
}
}
console.info("Player is not active, leave the game immediately");
crazyGamesSDK.gameplayStop().then(() => {
// redirect to the home page
window.location.href = "/";
});
} else {
console.info("Game not active, handle hash update");
onHashUpdate();
}
};
// Handle browser navigation & manual hash edits
window.addEventListener("popstate", () => {
preventHashUpdate = true;
onHashUpdate();
});
window.addEventListener("popstate", onPopState);
window.addEventListener("hashchange", onHashUpdate);
window.addEventListener("join-changed", onHashUpdate);
@@ -738,7 +760,7 @@ class Client {
console.log(`joining lobby ${lobby.gameID}`);
if (this.gameStop !== null) {
console.log("joining lobby, stopping existing game");
this.gameStop();
this.gameStop(true);
document.body.classList.remove("in-game");
}
const config = await getServerConfigFromClient();
@@ -844,6 +866,9 @@ class Client {
"",
`/${config.workerPath(lobby.gameID)}/game/${lobby.gameID}?live`,
);
// Store current URL for popstate confirmation
this.currentUrl = window.location.href;
},
);
}
@@ -865,8 +890,9 @@ class Client {
return;
}
console.log("leaving lobby, cancelling game");
this.gameStop();
this.gameStop(true);
this.gameStop = null;
this.currentUrl = null;
document.body.classList.remove("in-game");