diff --git a/src/components/Timer/Timer.jsx b/src/components/Timer/Timer.jsx
index a227f21aec..bd2a455e27 100644
--- a/src/components/Timer/Timer.jsx
+++ b/src/components/Timer/Timer.jsx
@@ -13,6 +13,7 @@ import {
FaStopCircle,
FaUndoAlt,
} from 'react-icons/fa';
+import jwtDecode from 'jwt-decode';
import { connect, useDispatch } from 'react-redux';
import { toast } from 'react-toastify';
import useWebSocket, { ReadyState } from 'react-use-websocket';
@@ -26,6 +27,23 @@ import css from './Timer.module.css';
import TimerPopout from './TimerPopout';
import TimerStatus from './TimerStatus';
+// Reconnect backoff: 1s, 2s, 4s, 8s, 16s, then every 30s thereafter, forever,
+// for as long as the stored token is usable. A real backend restart/deploy can
+// easily take longer than a short attempt ladder (this one used to give up
+// after ~61s and strand a visible, still-open tab on "Disconnected" with
+// nothing left to retry it - no visibilitychange/online event fires for a tab
+// that was never backgrounded or actually offline). Only a bad/expired token
+// should ever show Disconnected + the manual refresh button.
+const RECONNECT_BASE_DELAY_MS = 1000;
+const RECONNECT_MAX_DELAY_MS = 30000;
+// Caps the backoff exponent, not the number of attempts - purely to keep
+// 2 ** n from ever overflowing across a very long-running disconnection.
+const RECONNECT_BACKOFF_EXPONENT_CAP = 10;
+// After this many failed attempts in a row, assume we're genuinely offline (sleep,
+// wifi drop, etc.) and chime locally if a session was actually running when the
+// connection dropped - we may not be able to reach the server to confirm either way.
+const OFFLINE_CHIME_ATTEMPT_THRESHOLD = 3;
+
function Timer({ authUser, darkMode, isPopout }) {
const dispatch = useDispatch();
const realIsPopout = typeof isPopout === 'boolean' ? isPopout : !!globalThis.opener;
@@ -37,14 +55,138 @@ function Timer({ authUser, darkMode, isPopout }) {
* */
const [customReadyState, setCustomReadyState] = useState(ReadyState.CONNECTING);
const [wsKey, setWsKey] = useState(0);
+ // True only once the stored token is unusable - that's the one thing that
+ // should ever stop auto-reconnect and show "Disconnected" + the manual
+ // refresh button instead of "Reconnecting...". While the token stays valid,
+ // reconnect attempts never give up.
+ const [reconnectExhausted, setReconnectExhausted] = useState(false);
+ const reconnectAttemptRef = useRef(0);
+ const reconnectTimeoutRef = useRef(null);
+ const offlineChimeFiredRef = useRef(false);
+ // The heartbeat-miss path calls handleConnectionLost() directly (for immediate
+ // feedback) and then best-effort closes the socket, whose resulting onClose would
+ // call handleConnectionLost() again for the same outage. This guards against that
+ // double count; it's cleared whenever a new connection attempt actually begins
+ // (scheduleReconnect's timeout, onOpen, or a manual/immediate reconnect), so each
+ // *new* attempt's own failure still counts once.
+ const connectionLostHandledRef = useRef(false);
+ // started/paused aren't declared until later (they come from the message state),
+ // but WSoptions/handleConnectionLost must exist before the useWebSocket() call that
+ // produces them, so this ref (kept in sync below, once they exist) lets the
+ // close/error handlers check "was a session actually running when we dropped?"
+ // without a direct reference that would be used before it's declared.
+ const timerFlagsRef = useRef({ started: false, paused: false });
+
+ const clearScheduledReconnect = useCallback(() => {
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current);
+ reconnectTimeoutRef.current = null;
+ }
+ }, []);
+
+ // No point retrying with a token the server will reject anyway (expired, or just
+ // malformed/missing) - same expiry convention as utils/authInit.js.
+ const isStoredTokenUsable = useCallback(() => {
+ const token = localStorage.getItem(config.tokenKey);
+ if (!token) return false;
+ // prettier-ignore
+ try {
+ const decoded = jwtDecode(token);
+ const expirySec = new Date(decoded.expiryTimestamp).getTime() / 1000;
+ return expirySec > Date.now() / 1000;
+ } catch (_error) { // NOSONAR - a malformed/undecodable token is just as
+ // unusable as an expired one; treating it the same way (not usable)
+ // instead of letting the decode error bubble up and break the reconnect
+ // check is intentional, not a swallowed error.
+ return false;
+ }
+ }, []);
+
+ // Once exhausted, nothing was going to retry on its own again - without this, a
+ // tab left on "Disconnected" stays stuck until the user notices and clicks the
+ // manual refresh button. Re-checking the token here (rather than just retrying
+ // blindly) means a genuinely bad/expired token is the only thing that still
+ // leaves it stuck - everything else gets a fresh attempt.
+ const resetAndReconnectIfTokenUsable = useCallback(() => {
+ if (!isStoredTokenUsable()) return false;
+ clearScheduledReconnect();
+ reconnectAttemptRef.current = 0;
+ connectionLostHandledRef.current = false;
+ setReconnectExhausted(false);
+ setWsKey(k => k + 1);
+ return true;
+ }, [isStoredTokenUsable, clearScheduledReconnect]);
+
+ const scheduleReconnect = useCallback(() => {
+ clearScheduledReconnect();
+
+ if (!isStoredTokenUsable()) {
+ setReconnectExhausted(true);
+ return;
+ }
+
+ const exponent = Math.min(reconnectAttemptRef.current - 1, RECONNECT_BACKOFF_EXPONENT_CAP);
+ const delay = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** exponent, RECONNECT_MAX_DELAY_MS);
+ reconnectTimeoutRef.current = setTimeout(() => {
+ connectionLostHandledRef.current = false; // this next attempt's own failure should count
+ setWsKey(k => k + 1);
+ }, delay);
+ }, [clearScheduledReconnect, isStoredTokenUsable]);
+
+ // Shared handling for "the socket just went down" - called from onClose, onError,
+ // and the heartbeat-miss path below, so there's one place deciding whether to
+ // chime and/or keep retrying, instead of each path guessing independently.
+ const handleConnectionLost = useCallback(() => {
+ if (connectionLostHandledRef.current) return; // already processed this disconnect once
+ connectionLostHandledRef.current = true;
+
+ setRunning(false);
+ setCustomReadyState(ReadyState.CLOSED);
+
+ reconnectAttemptRef.current += 1;
+
+ const { started: wasStarted, paused: wasPaused } = timerFlagsRef.current;
+ if (
+ reconnectAttemptRef.current >= OFFLINE_CHIME_ATTEMPT_THRESHOLD &&
+ wasStarted &&
+ !wasPaused &&
+ !offlineChimeFiredRef.current
+ ) {
+ offlineChimeFiredRef.current = true;
+ // Reflect the pause locally (button/goal display) too, not just the modal -
+ // we can't tell the server while offline, so this is a local-only guess that
+ // a genuine reconnect resync (the sendGetTimer effect below, once
+ // customReadyState goes back to OPEN) always overwrites with the server's
+ // real paused state, whether or not the server itself ever learns we dropped.
+ setMessage(prev => ({ ...prev, paused: true }));
+ setInacModal(true);
+ }
+
+ scheduleReconnect();
+ }, [scheduleReconnect]);
+
const WSoptions = {
share: false,
protocols: localStorage.getItem(config.tokenKey),
- onOpen: () => setCustomReadyState(ReadyState.OPEN),
- onClose: () => setCustomReadyState(ReadyState.CLOSED),
+ onOpen: () => {
+ setCustomReadyState(ReadyState.OPEN);
+ clearScheduledReconnect();
+ reconnectAttemptRef.current = 0;
+ offlineChimeFiredRef.current = false;
+ connectionLostHandledRef.current = false;
+ setReconnectExhausted(false);
+ },
+ onClose: () => handleConnectionLost(),
+ // A real WebSocket always follows an error with a close event, which does the
+ // actual reconnect bookkeeping - this just gets the UI/countdown out of a
+ // "connected" state immediately instead of waiting for that close to land.
onError: () => {
+ setRunning(false);
setCustomReadyState(ReadyState.CLOSED);
},
+ // We drive all reconnection ourselves via wsKey (see scheduleReconnect) so the
+ // library never also retries on its own.
+ shouldReconnect: () => false,
};
/**
@@ -103,6 +245,10 @@ function Timer({ authUser, darkMode, isPopout }) {
const [message, setMessage] = useState(defaultMessage);
const { time, paused, started, goal, startAt } = message;
+ useEffect(() => {
+ timerFlagsRef.current = { started, paused };
+ }, [started, paused]);
+
const [running, setRunning] = useState(false);
const [confirmationResetModal, setConfirmationResetModal] = useState(false);
const [confirmSubmitModalOpen, setConfirmSubmitModalOpen] = useState(false);
@@ -278,12 +424,24 @@ function Timer({ authUser, darkMode, isPopout }) {
[timerState],
);
+ // Deliberately does NOT reset message/goal/remaining to defaultMessage - the
+ // automatic backoff-retry path never does either, and doing it here optimistically
+ // wiped the real accumulated session (e.g. a goal built up over hours) back to the
+ // 15-minute default before a fresh GET_TIMER confirmed anything. If the token is
+ // still bad, the retry just fails again and reconnectExhausted flips back on - the
+ // user is left looking at a wiped-out default on top of "Disconnected" instead of
+ // their last known real state. The real GET_TIMER response (sent once OPEN) is
+ // what should ever overwrite message - same as every other reconnect path.
const handleRefreshTimer = useCallback(() => {
+ clearScheduledReconnect();
+ reconnectAttemptRef.current = 0;
+ offlineChimeFiredRef.current = false;
+ connectionLostHandledRef.current = false;
+ setReconnectExhausted(false);
getWebSocket()?.close();
setCustomReadyState(ReadyState.CONNECTING);
- setMessage(defaultMessage);
setWsKey(k => k + 1);
- }, [getWebSocket]);
+ }, [getWebSocket, clearScheduledReconnect]);
// Initialize session ID on component mount
useEffect(() => {
@@ -623,18 +781,30 @@ function Timer({ authUser, darkMode, isPopout }) {
* at the same time.
*/
useEffect(() => {
+ // react-use-websocket resets lastJsonMessage to null/undefined every time it
+ // creates a fresh socket instance - which a reconnect attempt does on every
+ // retry (see scheduleReconnect's wsKey bump), not just on first mount. Without
+ // this guard, each retry re-ran this effect with no real message, fell through
+ // to `lastJsonMessage || defaultMessage` below, and wiped message.started/paused
+ // back to the (false/false) defaults - erasing the very "was a session running
+ // when we dropped" state handleConnectionLost's chime threshold depends on, so
+ // the chime could never fire no matter how long the outage lasted. There is
+ // nothing to apply on a null message - the component's initial state is already
+ // defaultMessage, so this effect only ever needs to react to a genuine message.
+ if (!lastJsonMessage) return;
+
// Exclude heartbeat message and timelog event messages
- if (lastJsonMessage && lastJsonMessage.heartbeat === 'pong') {
+ if (lastJsonMessage.heartbeat === 'pong') {
isWSOpenRef.current = 0;
return;
}
// Ignore TIMELOG_EVENT messages - they're for the timestamps tab, not the timer
- if (lastJsonMessage && lastJsonMessage.type === 'TIMELOG_EVENT') {
+ if (lastJsonMessage.type === 'TIMELOG_EVENT') {
return;
}
if (
- lastJsonMessage?.requestId &&
+ lastJsonMessage.requestId &&
pendingStopRef.current?.requestId === lastJsonMessage.requestId
) {
const { timeoutId, resolve } = pendingStopRef.current;
@@ -643,54 +813,78 @@ function Timer({ authUser, darkMode, isPopout }) {
resolve(lastJsonMessage.started === false);
}
// Handle explicit week close pause action messages
- if (lastJsonMessage && lastJsonMessage.action === 'WEEK_CLOSE_PAUSE') {
+ if (lastJsonMessage.action === 'WEEK_CLOSE_PAUSE') {
handleWeekEndPause();
return; // Exit early to prevent other modal logic
}
- const {
- paused: pausedLJM,
- forcedPause: forcedPauseLJM,
- started: startedLJM,
- chiming: chimingLJM,
- weekEndPause: weekEndPauseLJM,
- } = lastJsonMessage || defaultMessage;
-
- // console.log('DEBUG WS TIMER RECEIVED', {
- // time: lastJsonMessage?.time,
- // started: lastJsonMessage?.started,
- // paused: lastJsonMessage?.paused,
- // receivedAt: Date.now(),
- // });
-
- setMessage(lastJsonMessage || defaultMessage);
+ const { paused: pausedLJM, forcedPause: forcedPauseLJM, started: startedLJM } = lastJsonMessage;
+
+ setMessage(lastJsonMessage);
setRunning(startedLJM && !pausedLJM);
if (!startedLJM || pausedLJM) {
setRemaining(lastJsonMessage?.time ?? defaultMessage.time);
}
- // Show inactivity or time-over modals based on message state
+ // Show inactivity modal based on message state
setInacModal(forcedPauseLJM);
+ // Deliberately NOT depending on customReadyState/weekEndModal here: this effect
+ // should only re-derive message/running/remaining when a genuinely NEW message
+ // arrives. Reconnecting (customReadyState flipping CLOSED -> OPEN) with no fresh
+ // GET_TIMER reply yet must not re-apply a stale lastJsonMessage and resurrect
+ // running=true from before the disconnect - see the time-over-modal effect below
+ // for the one case that does want to react to customReadyState.
+ }, [lastJsonMessage]);
+
+ // The time-over ("Time Complete!") modal specifically does want to react to
+ // reconnects/week-end-pause changes (e.g. show it once back online if the
+ // session was already chiming), so this stays a separate effect rather than
+ // folding it into the one above.
+ useEffect(() => {
+ if (
+ lastJsonMessage &&
+ (lastJsonMessage.heartbeat === 'pong' ||
+ lastJsonMessage.type === 'TIMELOG_EVENT' ||
+ lastJsonMessage.action === 'WEEK_CLOSE_PAUSE')
+ ) {
+ return;
+ }
+ const { chiming: chimingLJM } = lastJsonMessage || defaultMessage;
setTimeIsOverModalIsOpen(chimingLJM && (customReadyState === ReadyState.OPEN || !weekEndModal));
}, [lastJsonMessage, customReadyState, weekEndModal]);
- // This useEffect is to make sure that the WS connection is maintained by sending a heartbeat every 60 seconds
+ // Keeps the WS connection alive AND is the fast, independent detector for "the
+ // server is gone but our socket never got a close/error event" - e.g. an abruptly
+ // killed backend process doesn't always deliver a prompt TCP close to an already-
+ // established connection (confirmed empirically: ~20-30s before onClose/onError
+ // fired in this environment, well past a short reviewer test window). A 60s
+ // heartbeat needing >3 misses took 4+ minutes to notice - far too slow for Jae's
+ // requirement that a dropped connection pause+chime promptly (verified against a
+ // real 25s/80s+ backend outage). HEARTBEAT_INTERVAL_MS/HEARTBEAT_TIMEOUT_MS/
+ // MISSES_BEFORE_LOST together land detection around 3 * 5s = 15s - fast enough for
+ // a short outage, but still a few consecutive misses (not one blip) before acting.
useEffect(() => {
+ const HEARTBEAT_INTERVAL_MS = 5000;
+ const HEARTBEAT_TIMEOUT_MS = 4000;
+ const MISSES_BEFORE_LOST = 3;
const interval = setInterval(() => {
if (running) {
isWSOpenRef.current += 1;
sendHeartbeat();
setTimeout(() => {
- // make sure to notify the user if the heartbeat is not responded for 3 times
- if (isWSOpenRef.current > 3) {
- setRunning(false);
- setInacModal(true);
- getWebSocket().close(); // try to close the WS connection, but it might not work when internet is cut off
- setCustomReadyState(ReadyState.CLOSED);
+ if (isWSOpenRef.current >= MISSES_BEFORE_LOST) {
+ // Don't decide locally that this is a genuine forced pause - a background/
+ // throttled tab can miss heartbeats for a long time and then "wake up" with
+ // a wildly stale assumption. Route through the same handling as a real
+ // close/error so reconnecting resyncs with the server's actual state
+ // (already logged elsewhere, or a real forced pause) instead of guessing.
+ handleConnectionLost();
+ getWebSocket()?.close(); // best-effort; may not fire a close event if truly offline
+ isWSOpenRef.current = 0;
}
- }, 10000); // close the WS if no response after 10 seconds
+ }, HEARTBEAT_TIMEOUT_MS);
}
- }, 60000);
+ }, HEARTBEAT_INTERVAL_MS);
return () => clearInterval(interval);
}, [running]);
@@ -792,6 +986,51 @@ function Timer({ authUser, darkMode, isPopout }) {
sendGetTimer();
}, [customReadyState, viewingUserId, wsKey]);
+ // Resync with the server as soon as a backgrounded tab becomes visible again, and
+ // either resync or jump straight to a reconnect attempt (instead of waiting out
+ // the rest of the backoff) as soon as the browser reports it's back online.
+ useEffect(() => {
+ const handleVisibilityChange = () => {
+ if (document.visibilityState !== 'visible') return;
+ if (customReadyState === ReadyState.OPEN) {
+ sendGetTimer();
+ } else if (reconnectExhausted) {
+ // Only a still-bad/expired token should leave this stuck - anything else
+ // deserves a fresh attempt now that the tab is actually being looked at.
+ resetAndReconnectIfTokenUsable();
+ }
+ };
+
+ const handleOnline = () => {
+ if (customReadyState === ReadyState.OPEN) {
+ sendGetTimer();
+ } else if (reconnectExhausted) {
+ resetAndReconnectIfTokenUsable();
+ } else {
+ // Already mid-backoff (not yet exhausted) - don't make the user wait out
+ // the rest of it now that the browser says we're back online.
+ clearScheduledReconnect();
+ setWsKey(k => k + 1);
+ }
+ };
+
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+ globalThis.addEventListener('online', handleOnline);
+ return () => {
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
+ globalThis.removeEventListener('online', handleOnline);
+ };
+ }, [
+ customReadyState,
+ reconnectExhausted,
+ sendGetTimer,
+ clearScheduledReconnect,
+ resetAndReconnectIfTokenUsable,
+ ]);
+
+ // Cancel any pending reconnect attempt on unmount.
+ useEffect(() => clearScheduledReconnect, [clearScheduledReconnect]);
+
// Enhanced cleanup when viewing user changes
useEffect(() => {
clearSubmittedTime();
@@ -1013,6 +1252,7 @@ function Timer({ authUser, darkMode, isPopout }) {
message={message}
toggleTimer={() => globalThis.close()}
handleRefreshTimer={handleRefreshTimer}
+ reconnectExhausted={reconnectExhausted}
/>
)}
@@ -1057,16 +1297,18 @@ function Timer({ authUser, darkMode, isPopout }) {
) : (
)}
@@ -1221,6 +1463,7 @@ function Timer({ authUser, darkMode, isPopout }) {
message={message}
toggleTimer={toggleTimer}
handleRefreshTimer={handleRefreshTimer}
+ reconnectExhausted={reconnectExhausted}
/>
)}
diff --git a/src/components/Timer/TimerStatus.jsx b/src/components/Timer/TimerStatus.jsx
index 0281ff3230..fdbc2cf140 100644
--- a/src/components/Timer/TimerStatus.jsx
+++ b/src/components/Timer/TimerStatus.jsx
@@ -3,20 +3,37 @@ import { BsXLg, BsArrowClockwise } from 'react-icons/bs';
import cs from 'classnames';
import css from './Countdown.module.css';
-export default function TimerStatus({ readyState, toggleTimer, handleRefreshTimer }) {
+const READY_STATE_LABELS = {
+ [ReadyState.CONNECTING]: 'Connecting to the timer service',
+ [ReadyState.OPEN]: 'Open',
+ [ReadyState.CLOSING]: 'Closing the connection',
+ [ReadyState.CLOSED]: 'The connection with the timer service is closed',
+ [ReadyState.UNINSTANTIATED]: 'Uninstantiated',
+};
+
+// While auto-reconnect is still retrying in the background, show "Reconnecting..."
+// regardless of the exact underlying readyState - only once attempts are
+// exhausted (or the stored token is unusable) do we ask the user to act.
+function getConnectionStatus(isReconnecting, reconnectExhausted, readyState) {
+ if (isReconnecting) return 'Reconnecting...';
+ if (reconnectExhausted) return 'Disconnected';
+ return READY_STATE_LABELS[readyState];
+}
+
+export default function TimerStatus({
+ readyState,
+ toggleTimer,
+ handleRefreshTimer,
+ reconnectExhausted,
+}) {
/*
This is the status of the connection with the timer service
We just use the readyState of the websocket connection to show the status
*/
- const connectionStatus = {
- [ReadyState.CONNECTING]: 'Connecting to the timer service',
- [ReadyState.OPEN]: 'Open',
- [ReadyState.CLOSING]: 'Closing the connection',
- [ReadyState.CLOSED]: 'The connection with the timer service is closed',
- [ReadyState.UNINSTANTIATED]: 'Uninstantiated',
- }[readyState];
+ const isReconnecting = readyState !== ReadyState.OPEN && !reconnectExhausted;
+ const connectionStatus = getConnectionStatus(isReconnecting, reconnectExhausted, readyState);
- const showRefreshButton = readyState === ReadyState.CLOSED;
+ const showRefreshButton = reconnectExhausted;
/*
Here is the component to show the timer status
@@ -33,6 +50,9 @@ export default function TimerStatus({ readyState, toggleTimer, handleRefreshTime
)}
diff --git a/src/components/Timer/__test__/Timer.test.jsx b/src/components/Timer/__test__/Timer.test.jsx
index 24f61c39d9..508fd8972b 100644
--- a/src/components/Timer/__test__/Timer.test.jsx
+++ b/src/components/Timer/__test__/Timer.test.jsx
@@ -1,32 +1,66 @@
// eslint-disable-next-line no-unused-vars
import '@testing-library/jest-dom';
import { act, fireEvent, render, screen } from '@testing-library/react';
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { Provider } from 'react-redux';
import configureMockStore from 'redux-mock-store';
+import config from '../../../config.json';
import Timer from '../Timer';
+// Minimal (unsigned, never verified - jwtDecode only reads the payload) JWT so
+// Timer's reconnect-auth-check (isStoredTokenUsable) sees a usable token, using
+// the same `expiryTimestamp` claim utils/authInit.js expects.
+const base64url = obj =>
+ btoa(JSON.stringify(obj))
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/, '');
+const makeFakeToken = expiryTimestamp =>
+ `${base64url({ alg: 'none', typ: 'JWT' })}.${base64url({ expiryTimestamp })}.sig`;
+
// Shared, hoisted so the vi.mock factory below (which Vitest hoists above this
// file's imports) can read/write it, and the test body can push fake server
// messages through it.
const mocks = vi.hoisted(() => ({
lastJsonMessage: null,
setLastJsonMessage: null,
+ // Latest options Timer.jsx passed to useWebSocket - lets tests fire onClose/
+ // onError/onOpen directly, the same way a real socket event would.
+ latestOptions: null,
+ // How many of the next (re)connection attempts - including the very first
+ // mount - should fail instead of succeeding. Decremented on each attempt.
+ failNextAttempts: 0,
}));
// Timer.jsx talks to the backend only through react-use-websocket. Mocking it
-// here lets us drive "server" messages directly and assert on Timer's local
-// (optimistic) state updates in isolation, without a real websocket/server.
+// here lets us drive "server" messages and connection lifecycle events (open/
+// close/error, reconnect attempts) directly, and assert on Timer's local state
+// in isolation, without a real websocket/server.
// Named useMockWebSocket (not "default") so eslint's react-hooks/rules-of-hooks
// recognizes the useState/useEffect calls inside as belonging to a hook.
function useMockWebSocket(_url, options) {
const [lastJsonMessage, setLastJsonMessage] = useState(mocks.lastJsonMessage);
mocks.setLastJsonMessage = setLastJsonMessage;
+ mocks.latestOptions = options;
+
+ // Timer.jsx forces a fresh connection attempt by bumping wsKey, which flows in
+ // here as queryParams.reconnect - mirrors the real library opening a new socket
+ // whenever the computed URL changes.
+ const reconnectToken = options.queryParams?.reconnect;
+ const hasHandledTokenRef = useRef();
useEffect(() => {
- options.onOpen?.();
+ if (hasHandledTokenRef.current === reconnectToken) return;
+ hasHandledTokenRef.current = reconnectToken;
+
+ if (mocks.failNextAttempts > 0) {
+ mocks.failNextAttempts -= 1;
+ options.onClose?.({ code: 1006, reason: 'mock failure' });
+ } else {
+ options.onOpen?.();
+ }
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ }, [reconnectToken]);
return {
sendMessage: vi.fn(),
@@ -91,6 +125,16 @@ afterAll(() => {
beforeEach(() => {
mocks.lastJsonMessage = null;
mocks.setLastJsonMessage = null;
+ mocks.latestOptions = null;
+ mocks.failNextAttempts = 0;
+ localStorage.setItem(
+ config.tokenKey,
+ makeFakeToken(new Date(Date.now() + 86400000).toISOString()),
+ );
+});
+
+afterEach(() => {
+ localStorage.clear();
});
describe('Timer handleStartButton', () => {
@@ -157,3 +201,355 @@ describe('Timer Log Time form after a reload', () => {
expect(formData).toHaveTextContent('0:1');
});
});
+
+// Pushes a GET_TIMER-shaped message for a timer that is actively running
+// (started, not paused) with the given goal, nothing elapsed yet.
+const pushRunningMessage = goal => {
+ act(() => {
+ mocks.setLastJsonMessage({
+ started: true,
+ paused: false,
+ chiming: false,
+ forcedPause: false,
+ weekEndPause: false,
+ time: goal,
+ goal,
+ initialGoal: goal,
+ startAt: Date.now(),
+ });
+ });
+};
+
+describe('Timer connection loss and recovery', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('stops the local countdown immediately on a dropped connection, so it cannot hit 0 and fire Time Complete on its own', () => {
+ renderTimer();
+ pushRunningMessage(2000); // 2-second goal
+
+ expect(screen.getByLabelText('Stop timer and log timer')).toBeInTheDocument();
+
+ // Simulate the connection dropping (sleep, wifi drop, server restart, etc.).
+ act(() => {
+ mocks.latestOptions.onClose({ code: 1006, reason: 'mock drop' });
+ });
+
+ // Immediately: UI should reflect "not connected", not the normal running
+ // countdown (checked before auto-reconnect's first attempt has a chance to
+ // fire and resync, so this specifically proves the *local* response to the
+ // drop, not just that a later resync happened to fix things up).
+ expect(screen.queryByLabelText('Stop timer and log timer')).not.toBeInTheDocument();
+
+ // If the countdown had kept running locally, 5 real seconds would be enough
+ // for a 2-second goal to hit 0 and pop "Time Complete!". It shouldn't, because
+ // running was forced false the moment the connection was lost.
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+
+ expect(screen.queryByText('Time Complete!')).not.toBeInTheDocument();
+ });
+
+ it('shows "Reconnecting..." (not "Disconnected") while retrying, and recovers on its own once reconnected, with no manual click', () => {
+ renderTimer();
+ pushRunningMessage(60000);
+
+ act(() => {
+ mocks.latestOptions.onClose({ code: 1006, reason: 'mock drop' });
+ });
+
+ expect(screen.getByText('Reconnecting...')).toBeInTheDocument();
+ expect(screen.queryByText('Disconnected')).not.toBeInTheDocument();
+ expect(screen.queryByLabelText('Reload timer')).not.toBeInTheDocument();
+
+ // First backoff step (1s) - mock succeeds (failNextAttempts defaults to 0).
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(screen.queryByText('Reconnecting...')).not.toBeInTheDocument();
+ expect(screen.getByLabelText('Stop timer and log timer')).toBeInTheDocument();
+ });
+
+ it('does not chime after reconnecting if the server says the timer was already stopped elsewhere', () => {
+ renderTimer();
+ pushRunningMessage(60000);
+
+ act(() => {
+ mocks.latestOptions.onClose({ code: 1006, reason: 'mock drop' });
+ });
+ act(() => {
+ vi.advanceTimersByTime(1000); // first retry succeeds
+ });
+
+ // The reconnect resync (sendGetTimer) comes back showing it was already
+ // stopped/logged in another tab while this one was down.
+ act(() => {
+ mocks.setLastJsonMessage({
+ started: false,
+ paused: false,
+ chiming: false,
+ forcedPause: false,
+ weekEndPause: false,
+ time: 60000,
+ goal: 60000,
+ initialGoal: 60000,
+ startAt: null,
+ });
+ });
+
+ expect(screen.queryByText('Timer Paused')).not.toBeInTheDocument();
+ });
+
+ it('chimes after reconnecting if the server confirms a genuine forced pause', () => {
+ renderTimer();
+ pushRunningMessage(60000);
+
+ act(() => {
+ mocks.latestOptions.onClose({ code: 1006, reason: 'mock drop' });
+ });
+ act(() => {
+ vi.advanceTimersByTime(1000); // first retry succeeds
+ });
+
+ // The server's own liveness check caught a real dead connection while this
+ // was the only tab, and already marked it forced-paused.
+ act(() => {
+ mocks.setLastJsonMessage({
+ started: true,
+ paused: true,
+ chiming: false,
+ forcedPause: true,
+ weekEndPause: false,
+ time: 45000,
+ goal: 60000,
+ initialGoal: 60000,
+ startAt: null,
+ });
+ });
+
+ expect(screen.getByText('Timer Paused')).toBeInTheDocument();
+ });
+
+ it('does not flash the 00:15:00 default goal/time right after reconnecting, before the fresh resync reply lands', () => {
+ renderTimer();
+ pushRunningMessage(30 * 60 * 1000); // 30-minute goal
+ expect(screen.getByText('Goal: 00:30:00')).toBeInTheDocument();
+
+ act(() => {
+ mocks.latestOptions.onClose({ code: 1006, reason: 'mock drop' });
+ });
+
+ // A real reconnect (the library opening a fresh socket on every retry, not
+ // just the first) resets the underlying lastJsonMessage to null until a new
+ // reply arrives - simulate that here, same as the real library would.
+ act(() => {
+ mocks.setLastJsonMessage(null);
+ });
+ act(() => {
+ vi.advanceTimersByTime(1000); // first backoff step - mock reconnect succeeds
+ });
+
+ // Reconnected (customReadyState is OPEN again, Countdown is back on screen),
+ // but no fresh GET_TIMER reply has arrived yet - exactly the moment the old
+ // code read the still-null lastJsonMessage as "nothing yet, apply
+ // defaultMessage" and flashed the goal/time display to the hardcoded
+ // 15-minute default for a moment on every reconnect.
+ expect(screen.queryByText('Goal: 00:15:00')).not.toBeInTheDocument();
+ expect(screen.getByText('Goal: 00:30:00')).toBeInTheDocument();
+ });
+
+ it('does not lose track of "a session was running" across repeated reconnect attempts, so the offline chime still fires after enough of them', () => {
+ // Every attempt in this window fails, so the chime threshold is reached
+ // through genuine repeated drops, not a lucky early reconnect.
+ mocks.failNextAttempts = 3;
+
+ renderTimer();
+ pushRunningMessage(60000);
+
+ // Attempt 1 (the initial drop).
+ act(() => {
+ mocks.latestOptions.onClose({ code: 1006, reason: 'mock drop' });
+ });
+ expect(screen.queryByText('Timer Paused')).not.toBeInTheDocument();
+
+ // Each retry's own fresh socket resets lastJsonMessage to null before it
+ // fails again - exactly the transient state that used to wipe
+ // started/paused back to the (false/false) defaults and permanently block
+ // the chime, no matter how many attempts followed or how long the outage
+ // lasted (confirmed against a real 25s/80s+ backend outage: the chime
+ // never fired at all before this fix).
+ act(() => {
+ mocks.setLastJsonMessage(null);
+ });
+ act(() => {
+ vi.advanceTimersByTime(1000); // 1st backoff step -> attempt 2 (fails)
+ });
+ expect(screen.queryByText('Timer Paused')).not.toBeInTheDocument();
+
+ act(() => {
+ mocks.setLastJsonMessage(null);
+ });
+ act(() => {
+ vi.advanceTimersByTime(2000); // 2nd backoff step -> attempt 3 (fails)
+ });
+
+ // Attempt 3 crosses the chime threshold - and only gets there because the
+ // null resets above did not erase "this session was running and not
+ // paused" in between.
+ expect(screen.getByText('Timer Paused')).toBeInTheDocument();
+ });
+
+ it('stops retrying and shows Disconnected + a manual refresh button once the stored token is unusable', () => {
+ localStorage.removeItem(config.tokenKey); // simulate an expired/cleared token
+
+ renderTimer();
+ pushRunningMessage(60000);
+
+ act(() => {
+ mocks.latestOptions.onClose({ code: 1006, reason: 'mock drop' });
+ });
+
+ // No retry gets scheduled at all - exhausted immediately.
+ expect(screen.getByText('Disconnected')).toBeInTheDocument();
+ expect(screen.getByLabelText('Reload timer')).toBeInTheDocument();
+ });
+
+ it('keeps retrying every 30s indefinitely (never shows Disconnected) as long as the token stays valid', () => {
+ // A real backend restart/deploy can easily take longer than a short fixed
+ // attempt ladder - this test runs well past where the old 6-attempt/~61s
+ // ladder used to give up, with every single attempt still failing
+ // (failNextAttempts stays ahead of however many retries fire).
+ mocks.failNextAttempts = 20;
+
+ renderTimer();
+ pushRunningMessage(60000);
+
+ act(() => {
+ mocks.latestOptions.onClose({ code: 1006, reason: 'mock drop' });
+ });
+
+ // ~6.5 minutes: backoff ramps 1s/2s/4s/8s/16s then every 30s after -
+ // roughly 17 failed attempts fit in this window, far beyond the old cap.
+ act(() => {
+ vi.advanceTimersByTime(400000);
+ });
+
+ expect(screen.getByText('Reconnecting...')).toBeInTheDocument();
+ expect(screen.queryByText('Disconnected')).not.toBeInTheDocument();
+ expect(screen.queryByLabelText('Reload timer')).not.toBeInTheDocument();
+ });
+
+ it('reconnects on an online event once exhausted, if the token has since become usable again', () => {
+ localStorage.removeItem(config.tokenKey); // simulate an expired/cleared token
+
+ renderTimer();
+ pushRunningMessage(60000);
+
+ act(() => {
+ mocks.latestOptions.onClose({ code: 1006, reason: 'mock drop' });
+ });
+
+ expect(screen.getByText('Disconnected')).toBeInTheDocument();
+
+ // Token becomes usable again (e.g. refreshed in another tab) before the
+ // browser reports we're back online - should no longer be stuck.
+ localStorage.setItem(
+ config.tokenKey,
+ makeFakeToken(new Date(Date.now() + 86400000).toISOString()),
+ );
+
+ act(() => {
+ window.dispatchEvent(new Event('online'));
+ });
+
+ expect(screen.queryByText('Disconnected')).not.toBeInTheDocument();
+ expect(screen.getByLabelText('Stop timer and log timer')).toBeInTheDocument();
+ });
+
+ it('eventually stops the countdown via the heartbeat-miss path when pongs stop arriving', () => {
+ renderTimer();
+ // Goal must outlast the heartbeat-miss window, otherwise the countdown would
+ // legitimately reach 0 on its own well before heartbeat-miss matters.
+ pushRunningMessage(3600000); // 1 hour
+
+ expect(screen.getByLabelText('Stop timer and log timer')).toBeInTheDocument();
+
+ // 3 heartbeat cycles (5s interval + 4s miss-check each) with no pong ever
+ // arriving - the same background-tab-throttling scenario that used to chime
+ // hours later (with the old 60s/10s/4-miss timing) based on a stale
+ // assumption, and which a reviewer found never chimed at all within a real
+ // 25s/80s+ backend outage because of the message-reset bug fixed alongside
+ // this. Stop just past the 3rd miss-check (3*5000 + 4000 = 19000ms) but
+ // before the first reconnect retry (+1000ms) fires, so this specifically
+ // catches the moment disconnection is detected.
+ act(() => {
+ vi.advanceTimersByTime(19100);
+ });
+
+ expect(screen.queryByText('Time Complete!')).not.toBeInTheDocument();
+ expect(screen.getByText('Reconnecting...')).toBeInTheDocument();
+ });
+
+ it('preserves the real accumulated goal through a manual reload instead of wiping it to the 15-minute default - covers "after 5 hours it says Disconnected, refresh resets ... and the time is lost"', () => {
+ localStorage.removeItem(config.tokenKey); // simulate an expired token
+ renderTimer();
+
+ const longGoal = 4.5 * 60 * 60 * 1000; // a long accumulated session/goal
+ pushRunningMessage(longGoal);
+ expect(screen.getByText('Goal: 04:30:00')).toBeInTheDocument();
+
+ // Connection drops with a bad token - exhausted immediately, shows Disconnected.
+ act(() => {
+ mocks.latestOptions.onClose({ code: 1006, reason: 'mock drop' });
+ });
+ expect(screen.getByText('Disconnected')).toBeInTheDocument();
+
+ // The user actually logs back in (token becomes usable again) before clicking
+ // the manual "Reload timer" button.
+ localStorage.setItem(
+ config.tokenKey,
+ makeFakeToken(new Date(Date.now() + 86400000).toISOString()),
+ );
+
+ fireEvent.click(screen.getByLabelText('Reload timer'));
+
+ // The mock socket auto-opens (failNextAttempts is 0), but no fresh GET_TIMER
+ // response has arrived yet - this is exactly the moment the old code wiped
+ // message to the 15-minute default (would show "Goal: 00:15:00" here). The
+ // real goal must still be showing; only a genuine GET_TIMER reply should ever
+ // change it.
+ expect(screen.queryByText('Disconnected')).not.toBeInTheDocument();
+ expect(screen.getByText('Goal: 04:30:00')).toBeInTheDocument();
+
+ // The real GET_TIMER resync now arrives (the server's own state was never at
+ // risk - only the client's display was) and the Log Time form reflects the
+ // true accumulated time, same as the already-fixed "0:0 after a reload" bug.
+ act(() => {
+ mocks.setLastJsonMessage({
+ started: true,
+ paused: true,
+ chiming: false,
+ forcedPause: true,
+ weekEndPause: false,
+ time: 30 * 60 * 1000, // 30 minutes left of the 4.5h goal
+ goal: longGoal,
+ initialGoal: longGoal,
+ startAt: null,
+ });
+ });
+
+ fireEvent.click(screen.getByLabelText('Stop timer and log timer'));
+ // 4 hours logged (>= 2) shows a "long session" confirmation modal first.
+ fireEvent.click(screen.getByRole('button', { name: /yes, submit/i }));
+
+ const formData = screen.getByTestId('time-entry-form-data');
+ expect(formData).toHaveTextContent('4:0'); // 4.5h goal - 30min left = 4h worked
+ });
+});