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
CI / build + unit + e2e (pull_request) Successful in 1m52s
This commit is contained in:
+94
-15
@@ -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 (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user