url deep-link: game id in #/game/<id>, multi-tab + bad-link fallback (v0.3.0)
CI / build + unit + e2e (pull_request) Successful in 1m52s

This commit is contained in:
2026-10-11 15:32:51 +00:00
parent 502fbd4874
commit f622cb676a
5 changed files with 210 additions and 17 deletions
+94 -15
View File
@@ -111,6 +111,28 @@ function clearActiveGameId(): void {
}
}
// ---------------------------------------------------------------------------
// Hash routing. The active game's id lives in the URL fragment: home is `#/`
// (a bare page with no hash is also home) and an active game is `#/game/<id>`.
// This manual hash sync — no react-router, no server-side route — fits the
// existing state-based router and is static/Traefik safe, because the fragment
// never reaches the server (every non-API GET just serves `index.html`). The
// localStorage active-game id above stays as the fallback for a bare `/` load;
// the URL hash takes precedence when present.
// ---------------------------------------------------------------------------
/** The game id encoded in `#/game/<id>`, or `null` when the hash is home/bare. */
function hashGameId(hash: string): string | null {
const m = hash.match(/^#\/game\/(.+)$/);
return m && m[1].length > 0 ? m[1] : null;
}
/** Write the home route (`#/`) into the URL hash unless it is already home/bare. */
function setHomeHash(): void {
const h = window.location.hash;
if (h !== '' && h !== '#/') window.location.hash = '#/';
}
export default function App() {
const [screen, setScreen] = useState<Screen>({ name: 'home' });
const [presets, setPresets] = useState<PresetMeta[]>([]);
@@ -123,6 +145,13 @@ export default function App() {
// list of saved games (newest first) shown on the home screen.
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
const [savedGames, setSavedGames] = useState<GameSummary[]>([]);
// Once the boot fetch resolves (success or fallback), allow the URL-sync effect
// to start writing the hash. Until then, a deep link in the URL must not be
// clobbered by an app-driven "home" write during the initial render.
const bootedRef = useRef(false);
// Latest game + handlers, read by the (mount-once) hashchange listener so it
// never acts on a stale closure. Written each render (same pattern as sse.ts).
const latestRef = useRef<{ game: GameState | null; goHome: () => void; loadGame: (id: string) => void } | null>(null);
// On the home screen: load the scenario manifest and the list of saved games
// (newest-updated first) so the user can resume a prior game.
@@ -142,28 +171,36 @@ export default function App() {
};
}, [screen.name]);
// On first mount, auto-resume the last active game (if any is stored): fetch
// its persisted state, remember it, and drop the user straight back into the
// game screen. The map is refetched by the effect below (keyed on `game`) and
// the `useSse(game?.id)` effect re-attaches the stream, resyncing from the
// server on connect. If the id is stale (404 / deleted), clear it and fall
// back to the home screen gracefully.
// On first mount, decide which game to show and load it. A deep link in the
// URL (`#/game/<id>`) takes precedence over the localStorage auto-resume; a
// bare page (no hash) falls back to the stored active game. The map is
// refetched by the effect below (keyed on `game`) and `useSse(game?.id)`
// re-attaches the stream, resyncing from the server on connect. A bad or
// missing id (404) must never crash: we clear the hash to `#/`, drop any
// stale stored id, and stay on the home screen. Once boot resolves (success
// or fallback) `bootedRef` flips so the URL-sync effect may start writing.
useEffect(() => {
const id = readActiveGameId();
if (!id) return;
const id = hashGameId(window.location.hash) ?? readActiveGameId();
if (!id) {
bootedRef.current = true; // nothing to load — activate URL syncing
return;
}
let alive = true;
(async () => {
try {
const { game } = await api.getGame(id);
if (!alive) return;
persistActiveGameId(id); // remember it so a future bare `/` load resumes it
setActiveGameId(id);
setGame(game);
setScreen({ name: 'game' });
} catch {
if (alive) {
clearActiveGameId();
setActiveGameId(null);
}
if (!alive) return;
clearActiveGameId();
setActiveGameId(null);
setHomeHash(); // bad/missing id → fall back to the home screen
} finally {
bootedRef.current = true; // boot resolved — activate URL syncing
}
})();
return () => {
@@ -171,6 +208,38 @@ export default function App() {
};
}, []);
// Keep the URL in sync with the current screen: a loaded game is
// `#/game/<id>`; everything else (home and the pick screens) is `#/`. This is
// the single writer of the hash for app-driven transitions (game created,
// resumed, or leaving for home). It only writes when the desired hash differs
// from the current one, so it never triggers a redundant `hashchange`.
useEffect(() => {
if (!bootedRef.current) return;
const desired = screen.name === 'game' && game ? `#/game/${game.id}` : '#/';
if (window.location.hash !== desired) window.location.hash = desired;
}, [screen.name, game?.id]);
// React to user-driven hash changes: manual edits and browser back/forward.
// Only re-act when the parsed id actually differs from the loaded game (or the
// hash went home while we're in a game), so the writes above never loop. Uses
// `latestRef` so the mount-once effect always sees the current game + handlers.
useEffect(() => {
const onHashChange = () => {
const cur = latestRef.current;
if (!cur) return;
const id = hashGameId(window.location.hash);
if (id) {
if (id === cur.game?.id) return; // already showing this game — no-op
void cur.loadGame(id);
} else if (cur.game) {
cur.goHome(); // hash is home but we're in a game — return home
}
// else: hash is home and we're already on home/pick — nothing to do
};
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, []);
// Keep the choropleth in sync with whatever state is shown (territory changes).
useEffect(() => {
if (!game) return;
@@ -282,20 +351,24 @@ export default function App() {
[game],
);
const resumeGame = useCallback(async (id: string) => {
// Load (fetch + show) a saved game by id. Shared by the home screen's resume
// affordances and by the hash router when the URL points at a different game.
// On failure (deleted / 404) it never throws: it clears the stale reference,
// clears the hash to `#/`, and stays on the home screen.
const loadGame = useCallback(async (id: string) => {
setLoading(true);
try {
const { game } = await api.getGame(id);
// Make this the active game again so a refresh keeps resuming it.
// Make this the active game so a bare `/` reload keeps resuming it.
persistActiveGameId(id);
setActiveGameId(id);
setGame(game);
setAdvisors([]);
setScreen({ name: 'game' });
} catch (e) {
// Deleted / not found: clear any stale reference and stay on home.
clearActiveGameId();
setActiveGameId(null);
setHomeHash();
setScreen({ name: 'home' });
toastError(e);
} finally {
@@ -303,6 +376,9 @@ export default function App() {
}
}, []);
// The home screen's "resume" affordances just load a saved game by id.
const resumeGame = useCallback((id: string) => void loadGame(id), [loadGame]);
const goHome = useCallback(() => {
setScreen({ name: 'home' });
setGame(null);
@@ -310,6 +386,9 @@ export default function App() {
setAdvisors([]);
}, []);
// Expose the latest game + handlers to the mount-once hashchange listener.
latestRef.current = { game, goHome, loadGame };
if (screen.name === 'game' && game) {
return (
<>