v1 review pass: fixed some minor bugs

This commit is contained in:
khannurien
2026-03-16 11:08:39 +00:00
parent e88fed4e98
commit 867e64cb5b
37 changed files with 1228 additions and 400 deletions

View File

@@ -1,7 +1,15 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { WSContext, type WSContextValue } from "./WSContext.ts";
import {
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { type VoteEvent, WSContext, type WSContextValue } from "./WSContext.ts";
import { WS_URL } from "../config/api.ts";
import type { Dump, OnlineUser } from "../model.ts";
import type { Dump, OnlineUser, RawDump } from "../model.ts";
import { deserializeDump } from "../model.ts";
interface WSProviderProps {
children: ReactNode;
@@ -17,16 +25,21 @@ export function WSProvider({ children, token }: WSProviderProps) {
const [myVotes, setMyVotes] = useState<Set<string>>(new Set());
const [recentDumps, setRecentDumps] = useState<Dump[]>([]);
const [deletedDumpIds, setDeletedDumpIds] = useState<Set<string>>(new Set());
const [lastVoteEvent, setLastVoteEvent] = useState<VoteEvent | null>(null);
// Refs to avoid stale closures in event handlers
const voteCountsRef = useRef(voteCounts);
const myVotesRef = useRef(myVotes);
voteCountsRef.current = voteCounts;
myVotesRef.current = myVotes;
useLayoutEffect(() => {
voteCountsRef.current = voteCounts;
myVotesRef.current = myVotes;
});
const socketRef = useRef<WebSocket | null>(null);
// Tracks pending optimistic votes: dumpId → revert timeout ID
const pendingRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const pendingRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(),
);
useEffect(() => {
let closed = false;
@@ -36,7 +49,9 @@ export function WSProvider({ children, token }: WSProviderProps) {
function connect() {
if (closed) return;
const url = `${WS_URL}/ws${token ? `?token=${encodeURIComponent(token)}` : ""}`;
const url = `${WS_URL}/ws${
token ? `?token=${encodeURIComponent(token)}` : ""
}`;
const ws = new WebSocket(url);
socketRef.current = ws;
@@ -67,13 +82,21 @@ export function WSProvider({ children, token }: WSProviderProps) {
break;
case "votes_update": {
const { dumpId, voteCount } = msg as { dumpId: string; voteCount: number };
const { dumpId, voteCount, voterId, action } = msg as {
dumpId: string;
voteCount: number;
voterId: string;
action: "cast" | "remove";
};
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
if (voterId && action) {
setLastVoteEvent({ dumpId, voterId, action });
}
break;
}
case "dump_created": {
const dump = msg.dump as Dump;
const dump = deserializeDump(msg.dump as RawDump);
setRecentDumps((prev) => [dump, ...prev]);
break;
}
@@ -131,14 +154,14 @@ export function WSProvider({ children, token }: WSProviderProps) {
connect();
const pending = pendingRef.current;
return () => {
closed = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
socketRef.current?.close();
socketRef.current = null;
// Clear all pending revert timeouts
for (const t of pendingRef.current.values()) clearTimeout(t);
pendingRef.current.clear();
for (const t of pending.values()) clearTimeout(t);
pending.clear();
};
}, [token]);
@@ -148,13 +171,21 @@ export function WSProvider({ children, token }: WSProviderProps) {
const prevVoted = myVotesRef.current.has(dumpId);
if (prevVoted) return; // already voted
setMyVotes((prev) => { const n = new Set(prev); n.add(dumpId); return n; });
setMyVotes((prev) => {
const n = new Set(prev);
n.add(dumpId);
return n;
});
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount + 1 }));
// Schedule revert if no ack
const timeout = setTimeout(() => {
pendingRef.current.delete(dumpId);
setMyVotes((prev) => { const n = new Set(prev); n.delete(dumpId); return n; });
setMyVotes((prev) => {
const n = new Set(prev);
n.delete(dumpId);
return n;
});
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
}, ACK_TIMEOUT);
pendingRef.current.set(dumpId, timeout);
@@ -168,13 +199,24 @@ export function WSProvider({ children, token }: WSProviderProps) {
const prevVoted = myVotesRef.current.has(dumpId);
if (!prevVoted) return; // not voted
setMyVotes((prev) => { const n = new Set(prev); n.delete(dumpId); return n; });
setVoteCounts((prev) => ({ ...prev, [dumpId]: Math.max(0, prevCount - 1) }));
setMyVotes((prev) => {
const n = new Set(prev);
n.delete(dumpId);
return n;
});
setVoteCounts((prev) => ({
...prev,
[dumpId]: Math.max(0, prevCount - 1),
}));
// Schedule revert if no ack
const timeout = setTimeout(() => {
pendingRef.current.delete(dumpId);
setMyVotes((prev) => { const n = new Set(prev); n.add(dumpId); return n; });
setMyVotes((prev) => {
const n = new Set(prev);
n.add(dumpId);
return n;
});
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
}, ACK_TIMEOUT);
pendingRef.current.set(dumpId, timeout);
@@ -188,6 +230,7 @@ export function WSProvider({ children, token }: WSProviderProps) {
myVotes,
recentDumps,
deletedDumpIds,
lastVoteEvent,
castVote,
removeVote,
};