Problem
When a timer is actively running and the user accidentally closes the tab, refreshes the page, or navigates away, the timer session is silently lost. The server-side timer is abandoned without being completed, and the user loses any XP they would have earned.
Scope
Two separate navigation paths need to be covered:
- Browser-level close/refresh — the user closes the tab, presses F5, or navigates to an external URL. Handled via the
beforeunload DOM event.
- SPA navigation — the user clicks a link inside the app (e.g. nav drawer). Handled via React Router v7's
useBlocker hook.
Implementation
Where to add it
The timer's status field (from useActivityTimer) is already exposed through GameContext. The best place to add the guard is either:
- A new
useTimerNavigationGuard hook (cleanest), consumed once in GameContext or App.jsx, or
- Directly inside
useActivityTimer.js alongside the existing cleanup useEffect.
1 — Tab close / refresh (beforeunload)
Add a useEffect that registers a beforeunload listener when status === 'active' and removes it otherwise:
useEffect(() => {
if (status !== 'active') return;
const handler = (e) => {
e.preventDefault();
e.returnValue = ''; // required for Chrome
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [status]);
Note: modern browsers show a generic confirmation dialog — custom message strings are ignored.
2 — SPA navigation (useBlocker)
React Router v7 exposes useBlocker. A blocker triggers a confirmation before any in-app route change:
import { useBlocker } from 'react-router-dom';
const blocker = useBlocker(status === 'active');
// blocker.state === 'blocked' → show a modal asking the user to confirm
// blocker.proceed() → allow navigation (optionally stop timer first)
// blocker.reset() → cancel navigation
Show a simple confirmation modal (can reuse existing modal components) with two options:
- Stop timer and leave — calls
stop() then blocker.proceed()
- Stay — calls
blocker.reset()
Notes
status values are 'empty', 'active', 'waiting', 'completed' (see useActivityTimer.js).
- The
beforeunload guard should be placed where status is accessible — either inside useActivityTimer or a wrapper hook that calls useGame().
- If the user proceeds through the blocker without stopping the timer, consider whether to auto-stop server-side (via
/activity_timers/complete/) or leave the timer running for recovery on next login.
Problem
When a timer is actively running and the user accidentally closes the tab, refreshes the page, or navigates away, the timer session is silently lost. The server-side timer is abandoned without being completed, and the user loses any XP they would have earned.
Scope
Two separate navigation paths need to be covered:
beforeunloadDOM event.useBlockerhook.Implementation
Where to add it
The timer's
statusfield (fromuseActivityTimer) is already exposed throughGameContext. The best place to add the guard is either:useTimerNavigationGuardhook (cleanest), consumed once inGameContextorApp.jsx, oruseActivityTimer.jsalongside the existing cleanupuseEffect.1 — Tab close / refresh (
beforeunload)Add a
useEffectthat registers abeforeunloadlistener whenstatus === 'active'and removes it otherwise:Note: modern browsers show a generic confirmation dialog — custom message strings are ignored.
2 — SPA navigation (
useBlocker)React Router v7 exposes
useBlocker. A blocker triggers a confirmation before any in-app route change:Show a simple confirmation modal (can reuse existing modal components) with two options:
stop()thenblocker.proceed()blocker.reset()Notes
statusvalues are'empty','active','waiting','completed'(seeuseActivityTimer.js).beforeunloadguard should be placed wherestatusis accessible — either insideuseActivityTimeror a wrapper hook that callsuseGame()./activity_timers/complete/) or leave the timer running for recovery on next login.