Fix WS revalidate on tournament bracket & match pages (#3061)

This commit is contained in:
Kalle 2026-05-13 20:17:29 +03:00 committed by GitHub
parent eafd10f3a9
commit 20118fb7a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 79 additions and 3 deletions

View File

@ -277,7 +277,11 @@ function ChatProviderInner({
isSystemMessage,
);
if (isSystemMessage || messageArr[0].revalidateOnly) {
revalidate();
// The actor that triggered this revalidate is the current user — their
// own form submission already reran loaders, so skip the duplicate fetch.
const isOwnRevalidate =
messageArr[0].revalidateOnly && messageArr[0].authorUserId === userId;
if (!isOwnRevalidate) revalidate();
}
const sound = messageTypeToSound(messageArr[0].type);
@ -444,6 +448,14 @@ function ChatProviderInner({
ws.current?.send(JSON.stringify({ event: "UNSUBSCRIBE", chatCode }));
}, []);
const subscribeTopic = React.useCallback((topic: string) => {
ws.current?.send(JSON.stringify({ event: "SUBSCRIBE_TOPIC", topic }));
}, []);
const unsubscribeTopic = React.useCallback((topic: string) => {
ws.current?.send(JSON.stringify({ event: "UNSUBSCRIBE_TOPIC", topic }));
}, []);
const requestHistory = React.useCallback((chatCode: string) => {
ws.current?.send(JSON.stringify({ event: "CHAT_HISTORY", chatCode }));
}, []);
@ -508,6 +520,8 @@ function ChatProviderInner({
send,
subscribe,
unsubscribe,
subscribeTopic,
unsubscribeTopic,
requestHistory,
markAsRead,
unreadCounts,
@ -529,6 +543,8 @@ function ChatProviderInner({
send,
subscribe,
unsubscribe,
subscribeTopic,
unsubscribeTopic,
requestHistory,
markAsRead,
unreadCounts,

View File

@ -25,7 +25,7 @@ function logSkalpError(action: string) {
type PartialChatMessage = Pick<
ChatMessage,
"type" | "context" | "room" | "revalidateOnly"
"type" | "context" | "room" | "revalidateOnly" | "authorUserId"
>;
interface ChatSystemMessageService {
send: (msg: PartialChatMessage | PartialChatMessage[]) => undefined;
@ -59,6 +59,7 @@ export const send: ChatSystemMessageService["send"] = (partialMsg) => {
context: partialMsg.context,
type: partialMsg.type,
revalidateOnly: partialMsg.revalidateOnly,
authorUserId: partialMsg.authorUserId,
};
});

View File

@ -1,6 +1,7 @@
import * as React from "react";
import { useUser } from "../auth/core/user";
import type { ChatMessage } from "./chat-types";
import { useChatContext } from "./useChatContext";
// increasing this = scrolling happens even when scrolled more upwards
const THRESHOLD = 100;
@ -69,3 +70,25 @@ export function useChatAutoScroll(
scrollToBottom,
};
}
/**
* Subscribes the page to a Skalop topic over the shared chat WebSocket so that
* `revalidateOnly` broadcasts to the topic trigger a data loader revalidation.
* Topics are lightweight: no metadata, no participants, no history purely a
* fan-out channel. Pass `connected=false` to opt out (e.g. once a tournament
* has been finalized and no further updates are expected).
*/
export function useWebsocketRevalidation(topic: string, connected = true) {
const chat = useChatContext();
const subscribeTopic = chat?.subscribeTopic;
const unsubscribeTopic = chat?.unsubscribeTopic;
const readyState = chat?.readyState;
React.useEffect(() => {
if (!connected || readyState !== "CONNECTED") return;
if (!subscribeTopic || !unsubscribeTopic) return;
subscribeTopic(topic);
return () => unsubscribeTopic(topic);
}, [topic, connected, readyState, subscribeTopic, unsubscribeTopic]);
}

View File

@ -42,6 +42,8 @@ export interface ChatContextValue {
send: (chatCode: string, contents: string) => void;
subscribe: (chatCode: string) => void;
unsubscribe: (chatCode: string) => void;
subscribeTopic: (topic: string) => void;
unsubscribeTopic: (topic: string) => void;
requestHistory: (chatCode: string) => void;
markAsRead: (chatCode: string) => void;
unreadCounts: Record<string, number>;

View File

@ -23,6 +23,8 @@ export interface ChatMessage {
context?: SystemMessageContext;
/** If true, the purpose of this message is just to run the data loaders again meaning the logic related to showing a new chat message is skipped. Defaults to false. */
revalidateOnly?: boolean;
/** User id of the actor that triggered this message. Used to skip own-author revalidates so we don't double-fetch loaders right after a form submission. */
authorUserId?: number;
userId?: number;
timestamp: number;
room: string;

View File

@ -102,6 +102,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
ChatSystemMessage.send({
room: match.chatCode,
revalidateOnly: true,
authorUserId: user.id,
});
}
}
@ -154,6 +155,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
ChatSystemMessage.send({
room: match.chatCode,
revalidateOnly: true,
authorUserId: user.id,
});
}
@ -226,6 +228,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
ChatSystemMessage.send({
room: match.chatCode,
revalidateOnly: true,
authorUserId: user.id,
});
}
@ -280,6 +283,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
ChatSystemMessage.send({
room: match.chatCode,
revalidateOnly: true,
authorUserId: user.id,
});
}
@ -304,6 +308,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
ChatSystemMessage.send({
room: match.chatCode,
revalidateOnly: true,
authorUserId: user.id,
});
}
@ -384,6 +389,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
ChatSystemMessage.send({
room: match.chatCode,
revalidateOnly: true,
authorUserId: user.id,
});
}

View File

@ -57,6 +57,7 @@ export const action: ActionFunction = async ({ request }) => {
room: targetChatCode,
type: "LIKE_RECEIVED",
revalidateOnly: true,
authorUserId: user.id,
});
}
@ -78,6 +79,7 @@ export const action: ActionFunction = async ({ request }) => {
room: targetChatCode,
type: "LIKE_RECEIVED",
revalidateOnly: true,
authorUserId: user.id,
});
}
break;
@ -201,11 +203,13 @@ export const action: ActionFunction = async ({ request }) => {
room: ownGroup.chatCode,
type: "MATCH_STARTED",
revalidateOnly: true,
authorUserId: user.id,
},
{
room: theirGroup.chatCode,
type: "MATCH_STARTED",
revalidateOnly: true,
authorUserId: user.id,
},
]);
}

View File

@ -66,6 +66,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
ChatSystemMessage.send({
room: chatCodeToRevalidate,
revalidateOnly: true,
authorUserId: user.id,
});
}

View File

@ -41,6 +41,7 @@ export const action: ActionFunction = async ({ request }) => {
ChatSystemMessage.send({
room: chatCodeToRevalidate,
revalidateOnly: true,
authorUserId: user.id,
});
}
@ -78,6 +79,7 @@ export const action: ActionFunction = async ({ request }) => {
ChatSystemMessage.send({
room: chatCodeToRevalidate,
revalidateOnly: true,
authorUserId: user.id,
});
}

View File

@ -347,6 +347,7 @@ export const action: ActionFunction = async ({ params, request }) => {
room: tournamentWebsocketRoom(tournament.ctx.id),
type: "TOURNAMENT_UPDATED",
revalidateOnly: true,
authorUserId: user.id,
},
]);
}

View File

@ -24,6 +24,7 @@ import {
SendouTabs,
} from "~/components/elements/Tabs";
import { useUser } from "~/features/auth/core/user";
import { useWebsocketRevalidation } from "~/features/chat/chat-hooks";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import { useHydrated } from "~/hooks/useHydrated";
import { useSearchParamState } from "~/hooks/useSearchParamState";
@ -44,6 +45,7 @@ import * as AbDivisions from "../core/AbDivisions";
import type { Bracket as BracketType } from "../core/Bracket";
import * as PreparedMaps from "../core/PreparedMaps";
import type { Tournament } from "../core/Tournament";
import { tournamentWebsocketRoom } from "../tournament-bracket-utils";
export { action };
@ -81,6 +83,11 @@ export default function TournamentBracketsPage() {
[tournament, bracketIdx],
);
useWebsocketRevalidation(
tournamentWebsocketRoom(tournament.ctx.id),
!tournament.ctx.isFinalized,
);
React.useEffect(() => {
if (visibility !== "visible" || tournament.ctx.isFinalized) return;

View File

@ -813,18 +813,19 @@ export const action: ActionFunction = async ({ params, request }) => {
clearTournamentDataCache(tournamentId);
// TODO: we could optimize this in the future by including an `authorUserId` field and skip revalidation if the author is the same as the current user
if (emitMatchUpdate) {
ChatSystemMessage.send([
{
room: tournamentMatchWebsocketRoom(matchId),
type: "TOURNAMENT_MATCH_UPDATED",
revalidateOnly: true,
authorUserId: user.id,
},
...endedDroppedMatchIds.map((id) => ({
room: tournamentMatchWebsocketRoom(id),
type: "TOURNAMENT_MATCH_UPDATED" as const,
revalidateOnly: true as const,
authorUserId: user.id,
})),
]);
}
@ -834,6 +835,7 @@ export const action: ActionFunction = async ({ params, request }) => {
room: tournamentWebsocketRoom(tournament.ctx.id),
type: "TOURNAMENT_UPDATED",
revalidateOnly: true,
authorUserId: user.id,
},
]);
}

View File

@ -1,6 +1,7 @@
import { useLoaderData } from "react-router";
import { containerClassName } from "~/components/Main";
import { MatchPage } from "~/components/match-page/MatchPage";
import { useWebsocketRevalidation } from "~/features/chat/chat-hooks";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { action } from "../actions/to.$id.matches.$mid.server";
import { TournamentMatchBanner } from "../components/TournamentMatchBanner";
@ -8,6 +9,7 @@ import { TournamentMatchHeader } from "../components/TournamentMatchHeader";
import { TournamentMatchTabs } from "../components/TournamentMatchTabs";
import { loader } from "../loaders/to.$id.matches.$mid.server";
import { MatchPageProvider } from "../match-page-context";
import { tournamentMatchWebsocketRoom } from "../tournament-match-utils";
export { action, loader };
@ -18,6 +20,11 @@ export const handle: SendouRouteHandle = {
export default function TournamentMatchPage() {
const data = useLoaderData<typeof loader>();
useWebsocketRevalidation(
tournamentMatchWebsocketRoom(data.match.id),
!data.matchIsOver,
);
return (
<MatchPageProvider data={data}>
<MatchPage className={containerClassName("normal")}>

View File

@ -410,11 +410,13 @@ export const action: ActionFunction = async ({ request, params }) => {
room: tournamentMatchWebsocketRoom(matchId),
type: "TOURNAMENT_MATCH_UPDATED" as const,
revalidateOnly: true as const,
authorUserId: user.id,
})),
{
room: tournamentWebsocketRoom(tournament.ctx.id),
type: "TOURNAMENT_UPDATED" as const,
revalidateOnly: true as const,
authorUserId: user.id,
},
]);
}