Skip to content

Block/confirm tab close when timer active #341

@gaidheal1

Description

@gaidheal1

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:

  1. Browser-level close/refresh — the user closes the tab, presses F5, or navigates to an external URL. Handled via the beforeunload DOM event.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: ui/uxLayout, visual design, and interface components not specific to another area
    No fields configured for Feature.

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions