fix(frontend): repair session teardown and effect lifecycles
Closes WP-10 of the audit: M10-M16, M18, M19, M20 and the client half of H11. Steps 5 and 7 of the package were already done in WP-1. An expired token left the app half signed in: the interceptor only wrote to the console, so the nav still showed the user's name while every request failed. A 401 now clears the token and navigates to sign-in - a full navigation rather than a router push, so in-memory state and open sockets go with the page, and the API client does not have to reach into the stores and join an import cycle with them. A 401 from /auth/login is excluded: there, it is an answer. Signing out disconnected the notifications socket and nothing else. Chat, versus and game night stayed open holding a token that had just been removed, and the friends and game stores kept their contents, so signing in as someone else on the same tab showed the previous person's friend list. Teardown now lives in one place, because the failure mode was precisely that adding a socket did not mean remembering to close it. All four connect() guards tested `.connected` rather than existence, so a socket that was mid-reconnect was replaced without being closed and the orphan went on handling every event a second time. disconnect() likewise cleared only the socket, leaving matchId, result and score behind - the next match opened showing the previous match's completion modal. Effects depended on the whole `user` object, which loadUser() rebuilds on every call, so they re-ran when nothing had changed. In AsyncVersusGame that effect's cleanup calls resetGame(), so reloading a profile mid-match reset the board. They depend on the id now. The same effect also reset the store while its fetch was in flight, so init() then wrote the match back into what had just been cleared; it aborts before resetting. Page reads take an AbortSignal and their effects abort on unmount. Several already had a `cancelled` flag, which stops the state write but lets the request run to completion; the flag stays to guard the `finally` blocks. Undo was fire-and-forget: rapid clicks put overlapping POSTs in flight against a server that removes one link per call, and a failure was logged while the local chain had already been shortened. Undos are serialised and the server's chain - which it was already returning and the client was discarding - is now authoritative. The runtime API setting was substituted into a JS string literal by envsubst, which does no escaping, so a value containing a quote ended the literal and the remainder executed on every page load. It is escaped at render time (verified under busybox with the production values and with an injection payload, which comes out inert), and a malformed value is rejected on read. Note this deliberately does not assert same origin, as the plan suggested: the frontend and API are served from different subdomains in production, so that assertion would break the deployment. The Endless difficulty selector is removed rather than wired. Difficulty is derived from cast analysis on the server and Endless builds its pairs in the browser, so there was nothing to wire it to - it silently did nothing while telling the player their choice mattered. Verified in the running app: an invalid token clears and redirects to /login?expired=1 without looping, while a wrong password shows "Invalid email or password" on the form and stays put; sign-out clears the token, returns to sign-in and leaves no trace of the previous user; Endless renders without the control and keeps its real filters. No console errors beyond the 401s those checks deliberately provoked.
This commit is contained in:
1 parent
e747bc2f3b
commit
92d7dfdef3
41 files changed
+565
-129
No files matched your search
@@ -9,6 +9,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.3.0] - 2026-09-10
|
||||
|
||||
Session handling and effect correctness.
|
||||
|
||||
### Fixed
|
||||
|
||||
- An expired or revoked session no longer leaves the app half signed in. The
|
||||
response interceptor only wrote to the console, so the nav still showed your
|
||||
name while every request quietly failed. A 401 now clears the token and
|
||||
returns you to sign-in. Signing in with the wrong password is unaffected -
|
||||
that is an answer, not an expired session.
|
||||
- Signing out ends the whole session. It disconnected the notifications socket
|
||||
and nothing else, leaving the chat, versus and game night sockets open with a
|
||||
token that had just been removed, and the friends and game stores populated -
|
||||
so signing in as someone else on the same tab showed the previous person's
|
||||
friend list and pending-request badge.
|
||||
- Leaving a match no longer carries its result into the next one. Disconnecting
|
||||
cleared only the socket, so the match id, result and score survived and the
|
||||
next match opened showing the previous match's completion modal.
|
||||
- Reconnecting no longer duplicates everything. All four sockets tested whether
|
||||
the existing connection was *connected* rather than whether it existed, so one
|
||||
that was mid-reconnect got replaced without being closed; the abandoned socket
|
||||
kept its own handlers and delivered every event a second time.
|
||||
- Game night spectators are detected again. The check decoded the session token
|
||||
with `atob`, which rejects the base64url encoding tokens actually use, so it
|
||||
threw inside a socket handler and the spectator flag was never set.
|
||||
- Reloading your profile no longer discards a game in progress. Effects depended
|
||||
on the whole user object, which is rebuilt on every profile load, so they
|
||||
re-ran when nothing had changed - and in an async match the re-run reset the
|
||||
board mid-game.
|
||||
- Undo is applied in order and no longer drifts from the server. Rapid undos
|
||||
fired overlapping requests at a server that removes one link per call, and a
|
||||
failure was only logged while the local chain had already been shortened.
|
||||
- Leaving a page cancels its in-flight requests instead of letting them land
|
||||
afterwards.
|
||||
- The friends page no longer removes other components' socket listeners when it
|
||||
unmounts.
|
||||
- Copying a result no longer leaves a timer running after the page closes.
|
||||
- Share links point at the deployment that produced them, rather than always at
|
||||
production.
|
||||
|
||||
### Security
|
||||
|
||||
- The runtime API setting is escaped when written into the page. It was
|
||||
substituted raw, so a value containing a quote ended the string and the rest
|
||||
became executable JavaScript on every page load - and the page hands its auth
|
||||
token to whatever that value resolves to. A malformed value is now also
|
||||
rejected on read, falling back to the default.
|
||||
|
||||
### Removed
|
||||
|
||||
- The difficulty selector in Endless mode. It was never applied to anything:
|
||||
difficulty is derived from cast analysis on the server, and Endless builds its
|
||||
pairs in the browser, so the control silently did nothing. The genre and
|
||||
decade filters are unaffected.
|
||||
|
||||
## [1.2.3] - 2026-09-10
|
||||
|
||||
### Fixed
|
||||
|
||||
+4
-3
@@ -23,11 +23,12 @@ RUN npm run build
|
||||
# --- Production stage ---
|
||||
FROM nginx:alpine AS production
|
||||
# Pull current security fixes for OS packages inherited from the base image
|
||||
# (e.g. nghttp2-libs CVE-2026-27135), then install envsubst for runtime config.
|
||||
RUN apk upgrade --no-cache && apk add --no-cache gettext
|
||||
# (e.g. nghttp2-libs CVE-2026-27135). envsubst is no longer installed:
|
||||
# 40-render-config.sh writes config.js itself so it can escape the value, which
|
||||
# envsubst cannot do.
|
||||
RUN apk upgrade --no-cache
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY docker/config.js.template /etc/nginx/templates/config.js.template
|
||||
COPY docker/40-render-config.sh /docker-entrypoint.d/40-render-config.sh
|
||||
RUN chmod +x /docker-entrypoint.d/40-render-config.sh
|
||||
EXPOSE 80
|
||||
|
||||
Executable → Regular
+18
-2
@@ -4,6 +4,22 @@
|
||||
# so nginx itself is launched by the upstream entrypoint after this script.
|
||||
set -e
|
||||
: "${API_URL:=/api/v1}"
|
||||
export API_URL
|
||||
envsubst < /etc/nginx/templates/config.js.template > /usr/share/nginx/html/config.js
|
||||
|
||||
# The value is interpolated into a JavaScript string literal, so it is escaped
|
||||
# rather than substituted raw. This previously used envsubst, which does no
|
||||
# escaping at all: an API_URL containing a double quote closed the literal and
|
||||
# everything after it became executable JavaScript on every page load. Operator
|
||||
# supplied, so not a likely attack - but it is one typo away from a baffling
|
||||
# breakage, and the page then hands its auth token to whatever origin the
|
||||
# value resolves to.
|
||||
#
|
||||
# One substitution prefixes every backslash and double quote with a backslash;
|
||||
# doing both in a single pass avoids the ordering trap where escaping quotes
|
||||
# first would then double-escape the backslashes just added. Newlines are
|
||||
# dropped afterwards since neither can appear in a JS string literal.
|
||||
escaped=$(printf '%s' "$API_URL" | sed 's/[\\"]/\\&/g' | tr -d '\n\r')
|
||||
|
||||
printf 'window.__APP_CONFIG__ = { apiUrl: "%s" };\n' "$escaped" \
|
||||
> /usr/share/nginx/html/config.js
|
||||
|
||||
echo "[config] API_URL=${API_URL} -> /config.js"
|
||||
@@ -1 +0,0 @@
|
||||
window.__APP_CONFIG__ = { apiUrl: "${API_URL}" };
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend-temp",
|
||||
"private": true,
|
||||
"version": "1.2.3",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Kevin Riehl",
|
||||
|
||||
@@ -18,7 +18,11 @@ export async function getAllAchievements(): Promise<Achievement[]> {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getUserAchievements(): Promise<Achievement[]> {
|
||||
const { data } = await apiClient.get<Achievement[]>('/achievements/me');
|
||||
export async function getUserAchievements(
|
||||
signal?: AbortSignal,
|
||||
): Promise<Achievement[]> {
|
||||
const { data } = await apiClient.get<Achievement[]>('/achievements/me', {
|
||||
signal,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
+8
-2
@@ -36,17 +36,23 @@ export interface RecalculateResult {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function getPlatformStats(): Promise<PlatformStats> {
|
||||
const { data } = await apiClient.get<PlatformStats>('/admin/stats');
|
||||
export async function getPlatformStats(
|
||||
signal?: AbortSignal,
|
||||
): Promise<PlatformStats> {
|
||||
const { data } = await apiClient.get<PlatformStats>('/admin/stats', {
|
||||
signal,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function listUsers(
|
||||
page = 1,
|
||||
limit = 50,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AdminUsersResponse> {
|
||||
const { data } = await apiClient.get<AdminUsersResponse>('/admin/users', {
|
||||
params: { page, limit },
|
||||
signal,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -19,9 +19,46 @@ apiClient.interceptors.request.use((config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
/**
|
||||
* Requests where a 401 is an answer rather than an expired session.
|
||||
*
|
||||
* Signing in with the wrong password must surface the error on the form, not
|
||||
* bounce the page.
|
||||
*/
|
||||
const AUTH_ENDPOINTS = ['/auth/login', '/auth/register'];
|
||||
|
||||
function isSessionExpiry(url: string | undefined, status: number): boolean {
|
||||
if (status !== 401) return false;
|
||||
if (!storage.getAuthToken()) return false; // never had a session to lose
|
||||
return !AUTH_ENDPOINTS.some((path) => (url ?? '').includes(path));
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const status: number | undefined = error.response?.status;
|
||||
|
||||
/**
|
||||
* An expired or revoked token left the app in a half-signed-in state: the
|
||||
* user object stayed in memory, the nav still showed their name, and every
|
||||
* request failed silently because this interceptor only logged to the
|
||||
* console. Tokens last a day, or thirty with "keep me signed in", so this
|
||||
* was reached in ordinary use.
|
||||
*
|
||||
* A full navigation rather than a router push: it drops all in-memory
|
||||
* state and every open socket along with the page, which is exactly what
|
||||
* should happen to a session that no longer exists - and it avoids this
|
||||
* module having to reach into the stores, which would make the API client
|
||||
* part of an import cycle with them.
|
||||
*/
|
||||
if (status && isSessionExpiry(error.config?.url, status)) {
|
||||
storage.removeAuthToken();
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.assign('/login?expired=1');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const message =
|
||||
error.response?.data?.message || error.message || 'An error occurred';
|
||||
console.error('[API Error]', message);
|
||||
|
||||
@@ -28,9 +28,12 @@ export async function getTodaysChallenge(): Promise<DailyChallenge> {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getTodaysChallenges(): Promise<DailyChallenge[]> {
|
||||
export async function getTodaysChallenges(
|
||||
signal?: AbortSignal,
|
||||
): Promise<DailyChallenge[]> {
|
||||
const { data } = await apiClient.get<DailyChallenge[]>(
|
||||
'/daily-challenges/today',
|
||||
{ signal },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -73,8 +73,12 @@ export async function createRoom(body: {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getOpenRooms(): Promise<GameNightRoom[]> {
|
||||
const { data } = await apiClient.get<GameNightRoom[]>('/game-night/open');
|
||||
export async function getOpenRooms(
|
||||
signal?: AbortSignal,
|
||||
): Promise<GameNightRoom[]> {
|
||||
const { data } = await apiClient.get<GameNightRoom[]>('/game-night/open', {
|
||||
signal,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -107,9 +111,11 @@ export async function getRoom(roomId: string): Promise<GameNightRoom> {
|
||||
|
||||
export async function getRoomResults(
|
||||
roomId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<GameNightResults> {
|
||||
const { data } = await apiClient.get<GameNightResults>(
|
||||
`/game-night/${roomId}/results`,
|
||||
{ signal },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
+16
-5
@@ -85,10 +85,11 @@ export interface GameHistoryResponse {
|
||||
export async function getGameHistory(
|
||||
page = 1,
|
||||
limit = 20,
|
||||
signal?: AbortSignal,
|
||||
): Promise<GameHistoryResponse> {
|
||||
const { data } = await apiClient.get<GameHistoryResponse>(
|
||||
'/games/me/history',
|
||||
{ params: { page, limit } },
|
||||
{ params: { page, limit }, signal },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@@ -122,11 +123,21 @@ export async function validateLink(
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function undoLink(gameId: string): Promise<void> {
|
||||
await apiClient.post(`/games/${gameId}/undo`);
|
||||
export async function undoLink(
|
||||
gameId: string,
|
||||
): Promise<{ chain: ChainLink[] }> {
|
||||
const { data } = await apiClient.post<{ chain: ChainLink[] }>(
|
||||
`/games/${gameId}/undo`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getGameSession(id: string): Promise<GameHistoryEntry> {
|
||||
const { data } = await apiClient.get<GameHistoryEntry>(`/games/${id}`);
|
||||
export async function getGameSession(
|
||||
id: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<GameHistoryEntry> {
|
||||
const { data } = await apiClient.get<GameHistoryEntry>(`/games/${id}`, {
|
||||
signal,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
+14
-3
@@ -44,16 +44,22 @@ export async function getLeaderboard(
|
||||
page = 1,
|
||||
limit = 20,
|
||||
difficulty?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LeaderboardResponse> {
|
||||
const { data } = await apiClient.get<LeaderboardResponse>('/leaderboards', {
|
||||
signal,
|
||||
params: { period, page, limit, ...(difficulty && { difficulty }) },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getUserStats(mode: 'all' | 'daily' = 'daily'): Promise<UserStats> {
|
||||
export async function getUserStats(
|
||||
mode: 'all' | 'daily' = 'daily',
|
||||
signal?: AbortSignal,
|
||||
): Promise<UserStats> {
|
||||
const { data } = await apiClient.get<UserStats>('/leaderboards/me', {
|
||||
params: { mode },
|
||||
signal,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -64,8 +70,13 @@ export interface DailyCompletionStatus {
|
||||
hard: boolean;
|
||||
}
|
||||
|
||||
export async function checkDailyCompletion(): Promise<DailyCompletionStatus> {
|
||||
const { data } = await apiClient.get<DailyCompletionStatus>('/leaderboards/me/today');
|
||||
export async function checkDailyCompletion(
|
||||
signal?: AbortSignal,
|
||||
): Promise<DailyCompletionStatus> {
|
||||
const { data } = await apiClient.get<DailyCompletionStatus>(
|
||||
'/leaderboards/me/today',
|
||||
{ signal },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
+23
-6
@@ -44,11 +44,15 @@ export async function createMatch(
|
||||
export async function getWaitingMatches(
|
||||
excludeUser?: string,
|
||||
mode?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<VersusMatch[]> {
|
||||
const params: Record<string, string> = {};
|
||||
if (excludeUser) params.excludeUser = excludeUser;
|
||||
if (mode) params.mode = mode;
|
||||
const { data } = await apiClient.get<VersusMatch[]>('/versus/waiting', { params });
|
||||
const { data } = await apiClient.get<VersusMatch[]>('/versus/waiting', {
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -63,13 +67,22 @@ export async function cancelMatch(matchId: string): Promise<void> {
|
||||
await apiClient.post(`/versus/${matchId}/cancel`);
|
||||
}
|
||||
|
||||
export async function getMyWaitingMatches(): Promise<VersusMatch[]> {
|
||||
const { data } = await apiClient.get<VersusMatch[]>('/versus/me/waiting');
|
||||
export async function getMyWaitingMatches(
|
||||
signal?: AbortSignal,
|
||||
): Promise<VersusMatch[]> {
|
||||
const { data } = await apiClient.get<VersusMatch[]>('/versus/me/waiting', {
|
||||
signal,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getMatch(matchId: string): Promise<VersusMatch> {
|
||||
const { data } = await apiClient.get<VersusMatch>(`/versus/${matchId}`);
|
||||
export async function getMatch(
|
||||
matchId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<VersusMatch> {
|
||||
const { data } = await apiClient.get<VersusMatch>(`/versus/${matchId}`, {
|
||||
signal,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -150,9 +163,13 @@ export interface AsyncLeaderboardResponse {
|
||||
leaderboard: LeaderboardEntry[];
|
||||
}
|
||||
|
||||
export async function getAsyncLeaderboard(matchId: string): Promise<AsyncLeaderboardResponse> {
|
||||
export async function getAsyncLeaderboard(
|
||||
matchId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AsyncLeaderboardResponse> {
|
||||
const { data } = await apiClient.get<AsyncLeaderboardResponse>(
|
||||
`/versus/${matchId}/leaderboard`,
|
||||
{ signal },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { ScoreBreakdown, ChainLink } from '@/types';
|
||||
import { getLinkCount } from '@/types';
|
||||
@@ -20,7 +20,10 @@ interface ShareableResultProps {
|
||||
mode?: GameMode;
|
||||
}
|
||||
|
||||
const BASE_URL = 'https://movieloop.tehriehldeal.com';
|
||||
// Whatever origin this build is actually being served from. Hardcoding the
|
||||
// production host meant a staging or local build handed out production links.
|
||||
const BASE_URL =
|
||||
typeof window === 'undefined' ? '' : window.location.origin;
|
||||
|
||||
function getModeLabel(mode?: GameMode): string {
|
||||
if (!mode) return '';
|
||||
@@ -87,13 +90,24 @@ function generateShareText(props: ShareableResultProps): string {
|
||||
|
||||
export default function ShareableResult(props: ShareableResultProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyResetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// The reset timer outlived the component: copy, navigate away within two
|
||||
// seconds, and setCopied fired on an unmounted component.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (copyResetTimer.current) clearTimeout(copyResetTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = generateShareText(props);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
if (copyResetTimer.current) clearTimeout(copyResetTimer.current);
|
||||
copyResetTimer.current = setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard API not available
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import axios from 'axios';
|
||||
|
||||
/**
|
||||
* True when a rejection is a cancelled request rather than a failure.
|
||||
*
|
||||
* Page effects abort their in-flight reads on unmount, and the resulting
|
||||
* rejection is not an error worth showing anyone - without this check it would
|
||||
* surface as "Failed to load" a moment after the user navigated away, and would
|
||||
* be logged as if something had gone wrong.
|
||||
*/
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
if (axios.isCancel(error)) return true;
|
||||
return error instanceof DOMException && error.name === 'AbortError';
|
||||
}
|
||||
+38
-1
@@ -12,4 +12,41 @@ const fallback: AppConfig = {
|
||||
apiUrl: (import.meta.env.VITE_API_URL as string | undefined) ?? '/api/v1',
|
||||
};
|
||||
|
||||
export const config: AppConfig = window.__APP_CONFIG__ ?? fallback;
|
||||
/**
|
||||
* An API base must be either a same-origin path or an absolute http(s) URL.
|
||||
*
|
||||
* Note this deliberately does *not* require same origin: the production
|
||||
* frontend and API are served from different subdomains, so that assertion
|
||||
* would break the deployment. What it rules out is a malformed or hostile
|
||||
* value being used as a destination - the socket layer sends the auth token to
|
||||
* whatever this resolves to, so a value like `javascript:` or a stray fragment
|
||||
* of injected script must not reach it.
|
||||
*/
|
||||
function isUsableApiUrl(value: unknown): value is string {
|
||||
if (typeof value !== 'string' || value.length === 0) return false;
|
||||
if (value.startsWith('/')) return true;
|
||||
try {
|
||||
const { protocol } = new URL(value);
|
||||
return protocol === 'https:' || protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConfig(): AppConfig {
|
||||
const injected = window.__APP_CONFIG__;
|
||||
if (injected && isUsableApiUrl(injected.apiUrl)) {
|
||||
return { apiUrl: injected.apiUrl };
|
||||
}
|
||||
if (injected) {
|
||||
// Rendered by the container at start-up, so a bad value here is a
|
||||
// deployment mistake worth seeing rather than silently routing around.
|
||||
console.error(
|
||||
'[config] Ignoring unusable injected apiUrl; falling back to',
|
||||
fallback.apiUrl,
|
||||
);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export const config: AppConfig = resolveConfig();
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useChatStore } from '@/stores/chat-store';
|
||||
import { useFriendsStore } from '@/stores/friends-store';
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { useGameNightStore } from '@/stores/game-night-store';
|
||||
import { useNotificationStore } from '@/stores/notification-store';
|
||||
import { useVersusStore } from '@/stores/versus-store';
|
||||
|
||||
/**
|
||||
* Tears down everything belonging to the signed-in session.
|
||||
*
|
||||
* Logging out used to disconnect the notifications socket and nothing else, so
|
||||
* the chat, versus and game-night sockets stayed open holding a token that had
|
||||
* just been removed, and the friends and game stores kept their contents. Sign
|
||||
* in as somebody else on the same tab and you were shown the previous user's
|
||||
* friend list and pending-request badge.
|
||||
*
|
||||
* Every store is reset here rather than at the call sites so there is one place
|
||||
* to add to when a new store appears - the failure mode of the old approach was
|
||||
* precisely that adding a socket did not mean remembering to close it.
|
||||
*/
|
||||
export function tearDownSession(): void {
|
||||
useNotificationStore.getState().disconnect();
|
||||
useChatStore.getState().disconnect();
|
||||
useVersusStore.getState().disconnect();
|
||||
useGameNightStore.getState().disconnect();
|
||||
|
||||
useChatStore.getState().clearChat();
|
||||
useGameStore.getState().resetGame();
|
||||
useFriendsStore.getState().reset();
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import PageLayout from '@/components/layout/PageLayout';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { getUserAchievements, type Achievement } from '@/api/achievements';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isAbortError } from '@/lib/abort';
|
||||
import {
|
||||
Trophy,
|
||||
Zap,
|
||||
@@ -50,6 +51,10 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
|
||||
export default function Achievements() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
// Depend on the id, not the object: loadUser() rebuilds `user` on every
|
||||
// call, so an object dependency re-runs effects that nothing has changed
|
||||
// for - and cleanups that reset state then throw away live work.
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const authLoading = useAuthStore((s) => s.isLoading);
|
||||
const navigate = useNavigate();
|
||||
const [achievements, setAchievements] = useState<Achievement[]>([]);
|
||||
@@ -57,15 +62,21 @@ export default function Achievements() {
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user) {
|
||||
if (!userId) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
getUserAchievements()
|
||||
const controller = new AbortController();
|
||||
getUserAchievements(controller.signal)
|
||||
.then(setAchievements)
|
||||
.catch(() => setAchievements([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [user, authLoading, navigate]);
|
||||
.catch((err: unknown) => {
|
||||
if (!isAbortError(err)) setAchievements([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [userId, authLoading, navigate]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ import { ArrowLeft, Search, UserPlus, Clock, Check, Loader2 } from 'lucide-react
|
||||
export default function AddFriend() {
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
// Depend on the id, not the object: loadUser() rebuilds `user` on every
|
||||
// call, so an object dependency re-runs effects that nothing has changed
|
||||
// for - and cleanups that reset state then throw away live work.
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const authLoading = useAuthStore((s) => s.isLoading);
|
||||
const { searchResults, searchUsers, sendRequest, clearSearch } = useFriendsStore();
|
||||
const [query, setQuery] = useState('');
|
||||
@@ -19,9 +23,9 @@ export default function AddFriend() {
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user) navigate('/login');
|
||||
if (!userId) navigate('/login');
|
||||
return () => clearSearch();
|
||||
}, [user, authLoading, navigate, clearSearch]);
|
||||
}, [userId, authLoading, navigate, clearSearch]);
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
|
||||
+21
-7
@@ -7,6 +7,7 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { isAbortError } from '@/lib/abort';
|
||||
import {
|
||||
getPlatformStats,
|
||||
listUsers,
|
||||
@@ -33,17 +34,22 @@ import {
|
||||
|
||||
export default function Admin() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
// Depend on the id, not the object: loadUser() rebuilds `user` on every
|
||||
// call, so an object dependency re-runs effects that nothing has changed
|
||||
// for - and cleanups that reset state then throw away live work.
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const isAdmin = useAuthStore((s) => s.user?.isAdmin ?? false);
|
||||
const authLoading = useAuthStore((s) => s.isLoading);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user) {
|
||||
if (!userId) {
|
||||
navigate('/login');
|
||||
} else if (!user.isAdmin) {
|
||||
} else if (!isAdmin) {
|
||||
navigate('/');
|
||||
}
|
||||
}, [user, authLoading, navigate]);
|
||||
}, [userId, isAdmin, authLoading, navigate]);
|
||||
|
||||
if (!user?.isAdmin) return null;
|
||||
|
||||
@@ -76,10 +82,16 @@ function StatsSection() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getPlatformStats()
|
||||
const controller = new AbortController();
|
||||
getPlatformStats(controller.signal)
|
||||
.then(setStats)
|
||||
.catch(() => setStats(null))
|
||||
.finally(() => setLoading(false));
|
||||
.catch((err: unknown) => {
|
||||
if (!isAbortError(err)) setStats(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -407,10 +419,11 @@ function UsersSection() {
|
||||
const loading = loadedPage !== page;
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await listUsers(page, 20);
|
||||
const res = await listUsers(page, 20, controller.signal);
|
||||
if (cancelled) return;
|
||||
setUsers(res.data);
|
||||
setTotalPages(res.totalPages);
|
||||
@@ -422,6 +435,7 @@ function UsersSection() {
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [page]);
|
||||
|
||||
|
||||
@@ -35,10 +35,11 @@ export default function AsyncMatchLeaderboard() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!matchId) return;
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await getAsyncLeaderboard(matchId);
|
||||
const res = await getAsyncLeaderboard(matchId, controller.signal);
|
||||
if (!cancelled) setData(res);
|
||||
} catch (err: unknown) {
|
||||
const message = (err as { response?: { data?: { message?: string } } })
|
||||
@@ -50,6 +51,7 @@ export default function AsyncMatchLeaderboard() {
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [matchId]);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getAsyncLeaderboard,
|
||||
} from '@/api/versus';
|
||||
import { getLinkCount, rawToLinkCount } from '@/types';
|
||||
import { isAbortError } from '@/lib/abort';
|
||||
|
||||
type AsyncMode = 'creator' | 'challenger';
|
||||
|
||||
@@ -29,6 +30,10 @@ export default function AsyncVersusGame() {
|
||||
const { matchId } = useParams<{ matchId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
// Depend on the id, not the object: loadUser() rebuilds `user` on every
|
||||
// call, so an object dependency re-runs effects that nothing has changed
|
||||
// for - and cleanups that reset state then throw away live work.
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const authLoading = useAuthStore((s) => s.isLoading);
|
||||
|
||||
const status = useGameStore((s) => s.status);
|
||||
@@ -58,21 +63,23 @@ export default function AsyncVersusGame() {
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!matchId || !user) {
|
||||
if (!matchId || !userId) {
|
||||
navigate('/versus');
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const match = await getMatch(matchId!);
|
||||
const match = await getMatch(matchId!, controller.signal);
|
||||
|
||||
if (!match.isAsync) {
|
||||
navigate('/versus');
|
||||
return;
|
||||
}
|
||||
|
||||
if (match.player1.id === user!.id) {
|
||||
if (match.player1.id === userId) {
|
||||
// Creator mode
|
||||
if (match.status !== 'waiting') {
|
||||
// Already submitted — go to leaderboard
|
||||
@@ -111,20 +118,26 @@ export default function AsyncVersusGame() {
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (isAbortError(err)) return;
|
||||
const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
setError(message ?? 'Failed to load match');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
resetGame();
|
||||
init();
|
||||
void init();
|
||||
|
||||
return () => {
|
||||
// Abort before resetting. The cleanup used to reset the store while the
|
||||
// fetch was still in flight, so init() then resolved and wrote the match
|
||||
// straight back into the store that had just been cleared - leaving a
|
||||
// stale match set up behind whatever the player navigated to.
|
||||
controller.abort();
|
||||
resetGame();
|
||||
};
|
||||
}, [matchId, user, authLoading, navigate, resetGame, startGameFromVersus]);
|
||||
}, [matchId, userId, authLoading, navigate, resetGame, startGameFromVersus]);
|
||||
|
||||
// Handle game completion
|
||||
useEffect(() => {
|
||||
@@ -156,7 +169,7 @@ export default function AsyncVersusGame() {
|
||||
if (creatorEntry?.score) {
|
||||
setCreatorScore(creatorEntry.score as { totalScore: number });
|
||||
}
|
||||
const myEntry = lb.leaderboard.find((e) => e.playerId === user?.id);
|
||||
const myEntry = lb.leaderboard.find((e) => e.playerId === userId);
|
||||
if (myEntry) setMyRank(myEntry.rank);
|
||||
} catch {
|
||||
// Non-critical
|
||||
@@ -176,7 +189,7 @@ export default function AsyncVersusGame() {
|
||||
mode,
|
||||
showCompletion,
|
||||
sessionId,
|
||||
user?.id,
|
||||
userId,
|
||||
error,
|
||||
]);
|
||||
|
||||
|
||||
+8
-4
@@ -14,6 +14,10 @@ export default function Chat() {
|
||||
const { friendId } = useParams<{ friendId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
// Depend on the id, not the object: loadUser() rebuilds `user` on every
|
||||
// call, so an object dependency re-runs effects that nothing has changed
|
||||
// for - and cleanups that reset state then throw away live work.
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const authLoading = useAuthStore((s) => s.isLoading);
|
||||
const {
|
||||
messages,
|
||||
@@ -57,7 +61,7 @@ export default function Chat() {
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user) {
|
||||
if (!userId) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
@@ -71,15 +75,15 @@ export default function Chat() {
|
||||
clearChat();
|
||||
disconnect();
|
||||
};
|
||||
}, [user, authLoading, friendId, navigate, connect, disconnect, openConversation, clearChat]);
|
||||
}, [userId, authLoading, friendId, navigate, connect, disconnect, openConversation, clearChat]);
|
||||
|
||||
// Kept separate from the socket lifecycle above: folding friends.length into
|
||||
// that effect's deps would tear down and rebuild the socket on every change
|
||||
// to the friend list.
|
||||
useEffect(() => {
|
||||
if (authLoading || !user) return;
|
||||
if (authLoading || !userId) return;
|
||||
if (friends.length === 0) fetchFriends();
|
||||
}, [authLoading, user, friends.length, fetchFriends]);
|
||||
}, [authLoading, userId, friends.length, fetchFriends]);
|
||||
|
||||
// Scroll to bottom on new messages if user hasn't scrolled up
|
||||
useEffect(() => {
|
||||
|
||||
@@ -25,7 +25,9 @@ export default function DailyChallenge() {
|
||||
});
|
||||
const startGame = useGameStore((s) => s.startGame);
|
||||
const isValidating = useGameStore((s) => s.isValidating);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
// Identity is all this page needs. Selecting the object would re-render and
|
||||
// re-run the effect every time loadUser() rebuilds it.
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -43,7 +45,7 @@ export default function DailyChallenge() {
|
||||
setCompleted((prev) => ({ ...prev, ...localCompleted }));
|
||||
|
||||
// Check server for logged-in users (more reliable)
|
||||
if (user) {
|
||||
if (userId) {
|
||||
checkDailyCompletion()
|
||||
.then((status: DailyCompletionStatus) => {
|
||||
setCompleted((prev) => ({
|
||||
@@ -59,7 +61,7 @@ export default function DailyChallenge() {
|
||||
})
|
||||
.catch(() => setError('Failed to load today\'s challenges.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [user]);
|
||||
}, [userId]);
|
||||
|
||||
const handlePlay = async (challenge: DailyChallengeType) => {
|
||||
await startGame(
|
||||
|
||||
@@ -6,17 +6,12 @@ import PageLayout from '@/components/layout/PageLayout';
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { getFilteredPair, GENRES, DECADES } from '@/lib/movie-pairs';
|
||||
import { discoverMovies } from '@/api/movies';
|
||||
import { getDifficultyButtonClass } from '@/components/ui/difficulty-styles';
|
||||
import { Loader2, Shuffle, Filter } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Difficulty = 'easy' | 'medium' | 'hard';
|
||||
|
||||
export default function Endless() {
|
||||
const startGame = useGameStore((s) => s.startGame);
|
||||
const isValidating = useGameStore((s) => s.isValidating);
|
||||
const navigate = useNavigate();
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>('medium');
|
||||
const [genre, setGenre] = useState<string | null>(null);
|
||||
const [decade, setDecade] = useState<string | null>(null);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
@@ -56,23 +51,6 @@ export default function Endless() {
|
||||
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase text-muted-foreground">
|
||||
Difficulty
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
{(['easy', 'medium', 'hard'] as Difficulty[]).map((d) => (
|
||||
<Button
|
||||
key={d}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('flex-1', getDifficultyButtonClass(d, difficulty === d))}
|
||||
onClick={() => setDifficulty(d)}
|
||||
>
|
||||
{d.charAt(0).toUpperCase() + d.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
|
||||
+22
-9
@@ -13,6 +13,10 @@ import { Users, UserPlus, Swords, MessageCircle, X, Check, Loader2 } from 'lucid
|
||||
export default function Friends() {
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
// Depend on the id, not the object: loadUser() rebuilds `user` on every
|
||||
// call, so an object dependency re-runs effects that nothing has changed
|
||||
// for - and cleanups that reset state then throw away live work.
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const authLoading = useAuthStore((s) => s.isLoading);
|
||||
const {
|
||||
friends,
|
||||
@@ -35,13 +39,13 @@ export default function Friends() {
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user) {
|
||||
if (!userId) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
fetchFriends();
|
||||
fetchPendingRequests();
|
||||
}, [user, authLoading, navigate, fetchFriends, fetchPendingRequests]);
|
||||
}, [userId, authLoading, navigate, fetchFriends, fetchPendingRequests]);
|
||||
|
||||
// Request online status for friends
|
||||
useEffect(() => {
|
||||
@@ -55,15 +59,24 @@ export default function Friends() {
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
const { setFriendOnline, setFriendOffline } = useFriendsStore.getState();
|
||||
socket.on('friend-online', (data: { userId: string }) => setFriendOnline(data.userId));
|
||||
socket.on('friend-offline', (data: { userId: string }) => setFriendOffline(data.userId));
|
||||
socket.on('friends-online', (data: { onlineIds: string[] }) => {
|
||||
|
||||
// Named handlers so the cleanup can remove exactly these. `socket.off(event)`
|
||||
// with no handler removes *every* listener for that event, including ones
|
||||
// registered by other components or by the store itself - so unmounting this
|
||||
// page silently tore down presence handling for the whole app.
|
||||
const onFriendOnline = (data: { userId: string }) => setFriendOnline(data.userId);
|
||||
const onFriendOffline = (data: { userId: string }) => setFriendOffline(data.userId);
|
||||
const onFriendsOnline = (data: { onlineIds: string[] }) => {
|
||||
useFriendsStore.getState().setOnlineFriends(data.onlineIds);
|
||||
});
|
||||
};
|
||||
|
||||
socket.on('friend-online', onFriendOnline);
|
||||
socket.on('friend-offline', onFriendOffline);
|
||||
socket.on('friends-online', onFriendsOnline);
|
||||
return () => {
|
||||
socket.off('friend-online');
|
||||
socket.off('friend-offline');
|
||||
socket.off('friends-online');
|
||||
socket.off('friend-online', onFriendOnline);
|
||||
socket.off('friend-offline', onFriendOffline);
|
||||
socket.off('friends-online', onFriendsOnline);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
|
||||
@@ -54,10 +54,11 @@ export default function GameNight() {
|
||||
const refreshRooms = useCallback(() => setRefreshToken((t) => t + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const rooms = await getOpenRooms();
|
||||
const rooms = await getOpenRooms(controller.signal);
|
||||
if (!cancelled) setOpenRooms(rooms);
|
||||
} catch {
|
||||
// Leave the list as-is; the empty state below covers it.
|
||||
@@ -67,6 +68,7 @@ export default function GameNight() {
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [refreshToken]);
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ export default function GameNightLobby() {
|
||||
const { roomId } = useParams<{ roomId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
// Depend on the id, not the object: loadUser() rebuilds `user` on every
|
||||
// call, so an object dependency re-runs effects that nothing has changed
|
||||
// for - and cleanups that reset state then throw away live work.
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const authLoading = useAuthStore((s) => s.isLoading);
|
||||
const startGameFromVersus = useGameStore((s) => s.startGameFromVersus);
|
||||
|
||||
@@ -61,12 +65,12 @@ export default function GameNightLobby() {
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user || !roomId) {
|
||||
if (!userId || !roomId) {
|
||||
navigate('/game-night');
|
||||
return;
|
||||
}
|
||||
connect();
|
||||
}, [user, roomId, authLoading, connect, navigate]);
|
||||
}, [userId, roomId, authLoading, connect, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connected && roomId) {
|
||||
|
||||
@@ -26,10 +26,11 @@ export default function GameNightResults() {
|
||||
|
||||
useEffect(() => {
|
||||
if (results || !roomId) return;
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await getRoomResults(roomId);
|
||||
const res = await getRoomResults(roomId, controller.signal);
|
||||
if (!cancelled) setResults(res);
|
||||
} catch {
|
||||
// Leave results null — the empty state below covers it.
|
||||
@@ -39,6 +40,7 @@ export default function GameNightResults() {
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [roomId, results]);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { getGameSession, type GameHistoryEntry } from '@/api/games';
|
||||
import { ArrowLeft, Clock, Lightbulb, Calendar } from 'lucide-react';
|
||||
import { releaseYear } from '@/lib/tmdb';
|
||||
import { isAbortError } from '@/lib/abort';
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
@@ -22,10 +23,16 @@ export default function GameReview() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
getGameSession(id)
|
||||
const controller = new AbortController();
|
||||
getGameSession(id, controller.signal)
|
||||
.then(setGame)
|
||||
.catch(() => setError('Game not found'))
|
||||
.finally(() => setLoading(false));
|
||||
.catch((err: unknown) => {
|
||||
if (!isAbortError(err)) setError('Game not found');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -54,11 +54,12 @@ export default function Leaderboard() {
|
||||
const loading = settledKey !== requestKey;
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const diffParam = difficulty === 'all' ? undefined : difficulty;
|
||||
const result = await getLeaderboard(period, page, 20, diffParam);
|
||||
const result = await getLeaderboard(period, page, 20, diffParam, controller.signal);
|
||||
if (!cancelled) setData(result);
|
||||
} catch {
|
||||
if (!cancelled) setData(null);
|
||||
@@ -68,6 +69,7 @@ export default function Leaderboard() {
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [period, page, difficulty, requestKey]);
|
||||
|
||||
|
||||
@@ -56,10 +56,11 @@ export default function Profile() {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const h = await getGameHistory(1, 10);
|
||||
const h = await getGameHistory(1, 10, controller.signal);
|
||||
if (!cancelled && h) setGameHistory(h.data);
|
||||
} catch {
|
||||
// Leave history empty; the empty state below covers it.
|
||||
@@ -69,16 +70,18 @@ export default function Profile() {
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [userId, authLoading, navigate]);
|
||||
|
||||
// Stats re-fetch on first load and on every mode switch.
|
||||
useEffect(() => {
|
||||
if (authLoading || !userId) return;
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const s = await getUserStats(statsMode);
|
||||
const s = await getUserStats(statsMode, controller.signal);
|
||||
if (!cancelled) setStats(s);
|
||||
} catch {
|
||||
if (!cancelled) setStats(null);
|
||||
@@ -88,6 +91,7 @@ export default function Profile() {
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [userId, authLoading, statsMode]);
|
||||
|
||||
|
||||
@@ -103,12 +103,17 @@ export default function Versus() {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const [open, mine] = await Promise.all([
|
||||
getWaitingMatches(userId, filterMode === 'all' ? undefined : filterMode),
|
||||
getMyWaitingMatches(),
|
||||
getWaitingMatches(
|
||||
userId,
|
||||
filterMode === 'all' ? undefined : filterMode,
|
||||
controller.signal,
|
||||
),
|
||||
getMyWaitingMatches(controller.signal),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setMatches(open);
|
||||
@@ -123,6 +128,7 @@ export default function Versus() {
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [userId, authLoading, navigate, filterMode, requestKey]);
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ export default function VersusLobby() {
|
||||
const { matchId } = useParams<{ matchId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
// Depend on the id, not the object: loadUser() rebuilds `user` on every
|
||||
// call, so an object dependency re-runs effects that nothing has changed
|
||||
// for - and cleanups that reset state then throw away live work.
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const authLoading = useAuthStore((s) => s.isLoading);
|
||||
const startGameFromVersus = useGameStore((s) => s.startGameFromVersus);
|
||||
|
||||
@@ -52,13 +56,13 @@ export default function VersusLobby() {
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user || !matchId) {
|
||||
if (!userId || !matchId) {
|
||||
navigate('/versus');
|
||||
return;
|
||||
}
|
||||
|
||||
connect();
|
||||
}, [user, matchId, authLoading, connect, navigate]);
|
||||
}, [userId, matchId, authLoading, connect, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connected && matchId) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import * as authApi from '@/api/auth';
|
||||
import { useNotificationStore } from '@/stores/notification-store';
|
||||
import { tearDownSession } from '@/lib/session-teardown';
|
||||
import { registerServiceWorker } from '@/lib/push';
|
||||
import { storage } from '@/lib/storage';
|
||||
import { getErrorMessage } from '@/lib/error';
|
||||
@@ -73,7 +74,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
|
||||
logout: () => {
|
||||
storage.removeAuthToken();
|
||||
useNotificationStore.getState().disconnect();
|
||||
tearDownSession();
|
||||
set({ user: null, error: null });
|
||||
},
|
||||
|
||||
|
||||
@@ -38,7 +38,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
|
||||
connect: () => {
|
||||
const existing = get().socket;
|
||||
if (existing?.connected) return;
|
||||
// See notification-store: replace, never abandon, an existing socket.
|
||||
if (existing) {
|
||||
if (existing.connected) return;
|
||||
existing.removeAllListeners();
|
||||
existing.disconnect();
|
||||
}
|
||||
|
||||
const socket = createSocket('/chat');
|
||||
if (!socket) return;
|
||||
|
||||
@@ -24,16 +24,21 @@ interface FriendsState {
|
||||
setFriendOffline: (userId: string) => void;
|
||||
setOnlineFriends: (ids: string[]) => void;
|
||||
clearSearch: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useFriendsStore = create<FriendsState>((set, get) => ({
|
||||
friends: [],
|
||||
pendingRequests: [],
|
||||
sentRequests: [],
|
||||
searchResults: [],
|
||||
onlineFriendIds: new Set(),
|
||||
const initialState = {
|
||||
friends: [] as FriendsState['friends'],
|
||||
pendingRequests: [] as FriendsState['pendingRequests'],
|
||||
sentRequests: [] as FriendsState['sentRequests'],
|
||||
searchResults: [] as FriendsState['searchResults'],
|
||||
onlineFriendIds: new Set<string>(),
|
||||
pendingCount: 0,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
export const useFriendsStore = create<FriendsState>((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
fetchFriends: async () => {
|
||||
set({ loading: true });
|
||||
@@ -135,4 +140,8 @@ export const useFriendsStore = create<FriendsState>((set, get) => ({
|
||||
clearSearch: () => {
|
||||
set({ searchResults: [] });
|
||||
},
|
||||
|
||||
// Sign-out clears this: friends are per-user, and a stale list shown to the
|
||||
// next person to sign in on the same tab is a disclosure, not just a glitch.
|
||||
reset: () => set({ ...initialState, onlineFriendIds: new Set<string>() }),
|
||||
}));
|
||||
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
||||
import type { Socket } from 'socket.io-client';
|
||||
import { createSocket } from '@/lib/create-socket';
|
||||
import { storage } from '@/lib/storage';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import type { GameNightPlayer, GameNightRoom, GameNightResults } from '@/api/game-night';
|
||||
import type { ScoreBreakdown } from '@/types';
|
||||
|
||||
@@ -58,10 +59,15 @@ export const useGameNightStore = create<GameNightState>((set, get) => ({
|
||||
|
||||
connect: () => {
|
||||
const existing = get().socket;
|
||||
if (existing?.connected) return;
|
||||
// See notification-store: replace, never abandon, an existing socket.
|
||||
if (existing) {
|
||||
if (existing.connected) return;
|
||||
existing.removeAllListeners();
|
||||
existing.disconnect();
|
||||
}
|
||||
|
||||
const token = storage.getAuthToken();
|
||||
if (!token) return;
|
||||
// Still gated on having a token; the id itself comes from the auth store.
|
||||
if (!storage.getAuthToken()) return;
|
||||
|
||||
const socket = createSocket('/game-night');
|
||||
if (!socket) return;
|
||||
@@ -75,7 +81,10 @@ export const useGameNightStore = create<GameNightState>((set, get) => ({
|
||||
});
|
||||
|
||||
socket.on('room-state', (data: { room: GameNightRoom }) => {
|
||||
const userId = JSON.parse(atob(token.split('.')[1])).sub;
|
||||
// atob() rejects base64url, which is what a JWT payload is - any `-`
|
||||
// or `_` threw inside this handler, so isSpectator was never set. The id
|
||||
// is already known without decoding anything.
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
const myPlayer = data.room.players.find(
|
||||
(p: GameNightPlayer) => p.userId === userId,
|
||||
);
|
||||
@@ -189,9 +198,14 @@ export const useGameNightStore = create<GameNightState>((set, get) => ({
|
||||
disconnect: () => {
|
||||
const { socket } = get();
|
||||
if (socket) {
|
||||
socket.removeAllListeners();
|
||||
socket.disconnect();
|
||||
set({ socket: null, connected: false });
|
||||
}
|
||||
// Reset the whole slice, not just the socket. Clearing only socket and
|
||||
// connected left roomId, room, countdown and results behind, so
|
||||
// navigating away mid-match and opening a new one showed the *previous*
|
||||
// match's completion modal over the new game.
|
||||
set({ ...initialState });
|
||||
},
|
||||
|
||||
joinRoom: (roomId: string) => {
|
||||
|
||||
@@ -84,6 +84,41 @@ interface SessionArgs {
|
||||
// kicked off for an older generation must not write back into the store — that
|
||||
// is how a slow session-creation call used to land its id in the *next* game.
|
||||
let gameGeneration = 0;
|
||||
|
||||
/**
|
||||
* Serialises backend undos. Each waits for the previous one so the server, which
|
||||
* pops a single link per call, never receives overlapping requests.
|
||||
*/
|
||||
let undoQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Adopts the server's chain when it disagrees with the client's.
|
||||
*
|
||||
* The stored chain omits the opening movie A - the server validates the first
|
||||
* actor against session.movieAId rather than holding it as a link - so the two
|
||||
* are expected to differ by exactly that one leading entry. Only a difference
|
||||
* beyond that is a real divergence worth correcting.
|
||||
*/
|
||||
function reconcileChain(serverChain: ChainLink[]): void {
|
||||
const state = useGameStore.getState();
|
||||
if (state.status !== 'playing') return;
|
||||
|
||||
const expected = state.chain.length - 1;
|
||||
if (serverChain.length === expected) return;
|
||||
|
||||
const movieA = state.chain[0];
|
||||
if (!movieA) return;
|
||||
|
||||
const merged = [movieA, ...serverChain];
|
||||
const lastLink = merged[merged.length - 1];
|
||||
useGameStore.setState({
|
||||
chain: merged,
|
||||
currentSearchMode: lastLink.type === 'movie' ? 'actor' : 'movie',
|
||||
passedMovieB: merged.some(
|
||||
(link) => link.type === 'movie' && link.id === state.movieB?.id,
|
||||
),
|
||||
});
|
||||
}
|
||||
let sessionCreation: Promise<string | null> = Promise.resolve(null);
|
||||
|
||||
async function createSession(
|
||||
@@ -366,15 +401,32 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
validationError: null,
|
||||
});
|
||||
|
||||
// Fire-and-forget backend undo to keep DB chain in sync
|
||||
// The server owns the chain, so its undo has to happen too - and in order.
|
||||
//
|
||||
// This was fire-and-forget: rapid clicks put N unordered POSTs in flight
|
||||
// against a server that pops one link per call, and a failure was only
|
||||
// logged while the client chain had already been shortened, leaving the two
|
||||
// silently disagreeing until the next validate-link rejected something the
|
||||
// player could see was legal.
|
||||
//
|
||||
// Each undo now waits for the previous one, and the server's chain is
|
||||
// authoritative on the way back.
|
||||
const { sessionId } = get();
|
||||
if (sessionId) {
|
||||
import('@/api/games').then(({ undoLink }) => {
|
||||
undoLink(sessionId).catch((err) => {
|
||||
console.error('[GameStore] Failed to undo link on backend:', err);
|
||||
if (!sessionId) return;
|
||||
|
||||
undoQueue = undoQueue
|
||||
.then(async () => {
|
||||
const { undoLink } = await import('@/api/games');
|
||||
const { chain: serverChain } = await undoLink(sessionId);
|
||||
reconcileChain(serverChain);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('[GameStore] Failed to undo link on backend:', err);
|
||||
set({
|
||||
validationError:
|
||||
'Could not undo that link. Your chain may be out of sync - reload to continue.',
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setValidating: (isValidating: boolean) => set({ isValidating }),
|
||||
|
||||
@@ -26,7 +26,18 @@ export const useNotificationStore = create<NotificationState>((set, get) => ({
|
||||
|
||||
connect: () => {
|
||||
const existing = get().socket;
|
||||
if (existing?.connected) return;
|
||||
// Test existence, not `.connected`. A socket that exists but is
|
||||
// disconnected - reconnecting after a blip, or mid-backoff - used to fall
|
||||
// through and be replaced without anyone closing the old one. The orphan
|
||||
// kept its own reconnect loop and its own handler bindings, so every event
|
||||
// was handled twice: duplicated notifications, duplicated chat messages,
|
||||
// duplicated progress writes. Both lobbies call connect() from an effect
|
||||
// whose deps include `navigate`, so this happened in normal use.
|
||||
if (existing) {
|
||||
if (existing.connected) return;
|
||||
existing.removeAllListeners();
|
||||
existing.disconnect();
|
||||
}
|
||||
|
||||
const socket = createSocket('/notifications');
|
||||
if (!socket) return;
|
||||
|
||||
@@ -63,7 +63,12 @@ export const useVersusStore = create<VersusState>((set, get) => ({
|
||||
|
||||
connect: () => {
|
||||
const existing = get().socket;
|
||||
if (existing?.connected) return;
|
||||
// See notification-store: replace, never abandon, an existing socket.
|
||||
if (existing) {
|
||||
if (existing.connected) return;
|
||||
existing.removeAllListeners();
|
||||
existing.disconnect();
|
||||
}
|
||||
|
||||
const socket = createSocket('/versus');
|
||||
if (!socket) return;
|
||||
@@ -161,9 +166,14 @@ export const useVersusStore = create<VersusState>((set, get) => ({
|
||||
disconnect: () => {
|
||||
const { socket } = get();
|
||||
if (socket) {
|
||||
socket.removeAllListeners();
|
||||
socket.disconnect();
|
||||
set({ socket: null, connected: false });
|
||||
}
|
||||
// Reset the whole slice, not just the socket. Clearing only socket and
|
||||
// connected left matchId, lobbyState, matchResult and myScore behind, so
|
||||
// navigating away mid-match and opening a new one showed the *previous*
|
||||
// match's completion modal over the new game.
|
||||
set({ ...initialState });
|
||||
},
|
||||
|
||||
joinLobby: (matchId: string) => {
|
||||
|
||||
Reference in new issue
Block a user