fix(lobby): clear stale MP creds on Play Solo to avoid blank-screen trap

If the user played multiplayer earlier in the tab session, room-code,
room-token, and player-color persisted in sessionStorage. Clicking Play
Solo then:

  1. navigate('/game')  — no code param
  2. GameRoute reads sessionStorage, finds stale creds → Case 1
     canonicalises the URL to /game/<stale-code>
  3. MultiplayerGameView mounts, opens a WS to a dead room, handshake
     fails silently → blank white screen with a live URL like
     /game/OSJBJY in the address bar.

Fix: handlePlaySolo explicitly wipes room-code, room-token, player-color,
layout-name, and modifier-profile-name before navigating. The solo path
then goes through GameRoute's Case 2 (no code, no creds) and mounts
GameView cleanly.

Regression test in solo-smoke.spec.ts seeds sessionStorage with stale
MP creds, clicks Play Solo, and asserts:
  - URL settles on /game (not /game/<stale>)
  - No 'mp-joining' placeholder
  - Board renders (e2 pawn visible)
  - All stale keys are wiped from sessionStorage
  - No console errors

Verified the test fails without the fix (Playwright hits the blank
screen / Joining placeholder) and passes with it.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 17:03:21 -06:00
commit 6e0479703d
No known key found for this signature in database
2 changed files with 77 additions and 0 deletions

View file

@ -209,4 +209,70 @@ test.describe('Solo-play smoke (T2 preview tests)', () => {
timeout: 3000,
});
});
// Regression guard for the "play-solo lands on blank screen" bug.
//
// Before the fix, a stale multiplayer session (room-code/room-token in
// sessionStorage from a previous room.create) would make Play Solo
// navigate to /game → GameRoute's Case 1 canonicalises to
// /game/<stale-code> → MultiplayerGameView mounts → WS handshake to a
// long-dead room silently fails → blank screen.
//
// The fix: handlePlaySolo() explicitly clears room-code, room-token,
// player-color, layout-name, and modifier-profile-name from
// sessionStorage before navigating, guaranteeing a fresh solo mount.
test('play-solo with stale MP creds in sessionStorage lands on /game, not /game/<stale>', async ({
page,
}) => {
// Seed sessionStorage to simulate a previous room.create or room.join
// whose creds were never cleared (e.g. user closed the tab after
// playing multiplayer, then reopened the lobby later).
await page.evaluate(() => {
sessionStorage.setItem('room-code', 'STALE1');
sessionStorage.setItem('room-token', 'stale-token-deadbeef');
sessionStorage.setItem('player-color', 'white');
sessionStorage.setItem('layout-name', 'Some Previous Layout');
sessionStorage.setItem('modifier-profile-name', 'Old Profile');
});
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(`CONSOLE: ${msg.text()}`);
});
await page.locator('[data-action="play-solo"]').click();
// URL must settle on /game WITHOUT a code suffix. Using a short
// timeout so a regression surfaces as a test failure rather than
// the suite hanging on the blank-screen state.
await page.waitForURL('**/game', { timeout: 3000 });
await expect(page).toHaveURL(/\/game$/);
// Board must render a real solo game (e2 pawn present, not the
// "Joining room …" placeholder).
await expect(page.locator('[data-testid="mp-joining"]')).toHaveCount(0);
await expect(page.locator('[data-square="e2"] [data-piece]')).toBeVisible({
timeout: 3000,
});
// All stale sessionStorage entries must be wiped so a page reload
// doesn't re-trigger the bug on the refreshed /game route.
const staleCreds = await page.evaluate(() => ({
code: sessionStorage.getItem('room-code'),
token: sessionStorage.getItem('room-token'),
color: sessionStorage.getItem('player-color'),
layout: sessionStorage.getItem('layout-name'),
profile: sessionStorage.getItem('modifier-profile-name'),
}));
expect(staleCreds).toEqual({
code: null,
token: null,
color: null,
layout: null,
profile: null,
});
if (errors.length > 0) throw new Error(errors.join('; '));
});
});

View file

@ -324,6 +324,17 @@ export function Lobby({ chessState }: LobbyProps = {}) {
};
const handlePlaySolo = () => {
// Clear any stale multiplayer creds from a previous session. Without
// this, a user who played multiplayer earlier (creds still in
// sessionStorage) would hit GameRoute's Case 1 — /game with creds
// present gets canonicalised to /game/<stale-code>, which then
// tries to reconnect to a long-dead room and renders a blank
// screen when the WS handshake silently fails.
sessionStorage.removeItem('room-code');
sessionStorage.removeItem('room-token');
sessionStorage.removeItem('player-color');
sessionStorage.removeItem('layout-name');
sessionStorage.removeItem('modifier-profile-name');
resetToFreshGame();
navigate('/game');
};