From 1d2da8f9a8d7ea0de1e4a35f9ef106afdcbb84dd Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 13 Aug 2026 15:58:13 -0400 Subject: [PATCH] fix(web): dispatch #lobby/ to the join screen or the invite splash The actual fix: router.ts now recognizes "lobby" as a route (a direct "#lobby/" hash, or a stash lobby-invite.ts left before an OAuth redirect) and renders lobbyScreen's join path for a signed-in visitor, lobbyInviteScreen for a signed-out one - bypassing the generic isAllowed() bounce-to-Home every other signed-in-only route gets, since this one wants a splash instead. A stash-restored address is replaceState'd back into the bar before rendering. shownLobbyId is new alongside the existing `shown`: "lobby" alone cannot tell two different lobbies apart, so onHashChange's own-address comparison would otherwise treat browser Back/Forward between two different "#lobby/" entries as no change at all. markEntered (wired to nav.markShown) is what the previous commits' fresh-open and join paths were already calling; this is where it is finally read. This closes the loop the router-extension and lobbyScreen-split commits set up: clicking MATCHES from inside a lobby now actually changes the screen, because `shown` no longer gets stuck wherever start() last left it. --- web/src/main.ts | 3 +- web/src/router.ts | 119 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 110 insertions(+), 12 deletions(-) diff --git a/web/src/main.ts b/web/src/main.ts index e70d14e..6798cf5 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -11,7 +11,7 @@ import { ApiError } from "./api"; import { collectNavLinks } from "./chrome"; import { render } from "./dom"; import { nav } from "./nav"; -import { onHashChange, start } from "./router"; +import { markEntered, onHashChange, start } from "./router"; import { challengeScreen } from "./screens/challenge"; import { errorScreen } from "./screens/error"; import { matchesScreen } from "./screens/matches"; @@ -19,6 +19,7 @@ import { matchesScreen } from "./screens/matches"; // The screens a screen can send the player to that it cannot import. See nav.ts. nav.start = start; nav.matches = matchesScreen; +nav.markShown = markEntered; // Re-entering without a fresh Mode choice (waiting.ts's Back button) gets // the least restrictive pool, Scenario, rather than silently narrowing to // Duel's. diff --git a/web/src/router.ts b/web/src/router.ts index b733ab2..5c971bb 100644 --- a/web/src/router.ts +++ b/web/src/router.ts @@ -3,17 +3,24 @@ * * Still no framework, but there is now a router, because there are now several * destinations rather than one screen with three states. It is a hash, a - * switch and a hashchange listener — about thirty lines, which is still less - * than the thing it routes. Sub-screens of Play (challenge, waiting) are not - * addresses; they are states the player is walked through and would be wrong - * to bookmark. The pages under About are the other way round — `#about/faq` is - * an address, because a question is a thing people link to. Neither is the - * blog: those are pages the build wrote, and this file never runs on them. + * switch and a hashchange listener — still smaller than the thing it routes. + * Sub-screens of Play (challenge, waiting) are not addresses; they are states + * the player is walked through and would be wrong to bookmark. The pages + * under About are the other way round — `#about/faq` is an address, because a + * question is a thing people link to. Neither is the blog: those are pages + * the build wrote, and this file never runs on them. * - * Three destinations are behind sign-in. The masthead hides two of their links - * and shows Play to everyone, but neither the hiding nor the showing is the - * door: this file is what actually decides, and it decides against a session it - * has read rather than against anything the page was rendered with. + * Three destinations are behind sign-in. The masthead hides two of their + * links and shows Play to everyone, but neither the hiding nor the showing is + * the door: this file is what actually decides, and it decides against a + * session it has read rather than against anything the page was rendered + * with. + * + * One address is not in the masthead at all: "#lobby/" is a live, + * ephemeral, per-visit route rather than a fixed destination, matched by + * shape (destinations.ts's lobbyIdFromHash) instead of by a fixed id — see + * the "lobby" branch below, which is the one place a route can carry a + * parameter the rest of this file has to thread through. */ import { capturePageview } from "./analytics"; @@ -24,14 +31,21 @@ import { type AboutPageId, isAllowed, isKnownAddress, + lobbyIdFromHash, movedRouteForHash, routeForHash, } from "./destinations"; import { el, render } from "./dom"; +import { + hasPendingLobbyInvite, + takePendingLobbyInvite, +} from "./lobby-invite-stash"; import { aboutScreen } from "./screens/about"; import { errorScreen } from "./screens/error"; import { hangarScreen } from "./screens/hangar"; import { homeScreen } from "./screens/home"; +import { lobbyScreen } from "./screens/lobby"; +import { lobbyInviteScreen } from "./screens/lobby-invite"; import { matchesScreen } from "./screens/matches"; import { notFoundScreen } from "./screens/not-found"; import { operationsScreen } from "./screens/operations"; @@ -45,12 +59,26 @@ export function routeFromLocation(): Route { const route = routeForHash(window.location.hash); if (route) return route as Route; + // A second kind of recognized address: "#lobby/", matched by shape in + // destinations.ts rather than being one more entry in the fixed list + // routeForHash reads off HASH_ROUTES. + if (lobbyIdFromHash(window.location.hash)) return "lobby"; + // Not a fallback and not optional: signing in from the camo editor navigates // to the player's own provider, and the OAuth callback returns to the site // root with no hash at all. This check is the only thing that puts the // player back in the editor with their work restored. if (hasPendingCamo()) return "camo"; + // The same kind of return, for a visitor who was signed out when they + // opened a shared lobby link: lobby-invite.ts's sign-in form stashes the + // id here before the redirect, the hash having no better way to survive + // the round trip than camo's editor state does. This only peeks — see + // start()'s "lobby" branch for where the stash is actually read and + // consumed, the same peek/consume split hasPendingCamo() and camoEditor() + // use. + if (hasPendingLobbyInvite()) return "lobby"; + // An address with no route of its own. Home is what is put up for the bare // root; for anything else the guard below has the last word, and start() is // careful not to treat this answer as a destination before then. @@ -109,6 +137,24 @@ function takeErrorFromUrl(): string | undefined { // work again from a dead end. let shown: Route | null = null; +// "lobby" alone is not enough to tell two different lobbies apart, and a +// player can reach a second one without this ever leaving "lobby" — a share +// link followed while another lobby's tab is still open, or the browser's +// own Back/Forward between two "#lobby/" history entries. Only set when +// `shown === "lobby"`; read alongside it, never on its own. +let shownLobbyId: string | null = null; + +/** + * Tells the router a screen it did not put up itself through start() is now + * on screen. Wired to nav.markShown in main.ts — see that field's own doc + * for why matches.ts needs it for the fresh-lobby entry. + */ +export function markEntered(route: Route, lobbyId: string | null = null): void { + shown = route; + shownLobbyId = route === "lobby" ? lobbyId : null; + markCurrent(route); +} + export async function start(): Promise { window.scrollTo(0, 0); @@ -132,6 +178,7 @@ export async function start(): Promise { // then did nothing, because the hash listener compared "home" against // "home" and saw no screen change. shown = null; + shownLobbyId = null; markCurrent(null); // Path and hash only. The query is where the OAuth callback puts its codes, // and none of that belongs on screen. @@ -141,6 +188,11 @@ export async function start(): Promise { const route = routeFromLocation(); shown = route; + // The real address answers this immediately; a stash-restored lobby + // (hash still empty at this point) is patched in once the "lobby" branch + // below actually resolves which id the stash named. + shownLobbyId = + route === "lobby" ? lobbyIdFromHash(window.location.hash) : null; markCurrent(route); // About and the pages under it: one screen, which reads the page out of the @@ -188,6 +240,42 @@ export async function start(): Promise { } } + if (route === "lobby") { + const fromHash = lobbyIdFromHash(window.location.hash); + // Always taken, whether or not the hash already answered the + // question: a direct "#lobby/" visit then still clears a stash + // left by an earlier, abandoned sign-in, the same way entering the + // camo editor unconditionally clears its own resume flag. That is + // what stops a stale invite from hijacking an unrelated later + // sign-in — the next lobby address visited for any reason clears it. + const stashed = takePendingLobbyInvite(); + const lobbyId = fromHash ?? stashed; + + // routeFromLocation() only ever answers "lobby" when one of the two + // sources above would resolve — this check is here so TypeScript does + // not have to take that on faith, not because it is expected to trip. + if (lobbyId) { + if (!fromHash) { + // Restored from the stash: the address bar still says "/" until + // this puts the real one back. Replaced, not pushed — this is + // the same visit resuming, not a new one, the same reasoning + // lobby.ts's own fresh-open path uses for its replaceState. + window.history.replaceState( + {}, + "", + `${window.location.pathname}${window.location.search}#lobby/${encodeURIComponent(lobbyId)}`, + ); + shownLobbyId = lobbyId; + } + render( + ...(session + ? lobbyScreen(session, { kind: "join", matchId: lobbyId }) + : lobbyInviteScreen(lobbyId, error)), + ); + return; + } + } + // Signed out at a signed-in address. Home is what a stranger asking for // Play gets — with the sign-in form on it, which is the answer to the // question they were really asking — and the address is rewritten to say @@ -253,7 +341,16 @@ export async function start(): Promise { * that is not Home. */ export function onHashChange(): void { - if (routeFromLocation() !== shown || !isKnownRoute()) { + const route = routeFromLocation(); + // "lobby" alone does not say which lobby: comparing the route string is + // enough for every fixed destination, but two different "#lobby/" + // hashes both answer "lobby" and must still count as a screen change — + // see shownLobbyId's own comment for when this actually happens. + const sameScreen = + route === shown && + (route !== "lobby" || + lobbyIdFromHash(window.location.hash) === shownLobbyId); + if (!sameScreen || !isKnownRoute()) { // The only place a screen change is known to have happened. Analytics // cannot work this out for itself: this router navigates by hashchange, // which PostHog's automatic pageview does not watch, and the two -- 2.51.2