+ <>
+
-
- {virtualizer.items.map(({ index, start }) => {
- const msg = messages[index];
- const systemMessage = systemMessageText(msg);
+ {virtualizer.items.map(({ index, start }) => {
+ const msg = messages[index];
+ const systemMessage = systemMessageText(msg);
- return (
-
- {systemMessage ? (
-
- ) : (
-
- )}
-
- );
- })}
-
+ return (
+
+ {systemMessage ? (
+
+ ) : (
+
+ )}
+
+ );
+ })}
- {unseenMessagesInTheRoom ? (
-
- {t("common:chat.newMessages")}
-
- ) : null}
- {readOnly ? (
- // only observers ever see this, so it stays English
-
Read-only
- ) : disabled ? (
-
- {t("common:chat.expired")}
-
- ) : (
-
- )}
-
+ {unseenMessagesInTheRoom ? (
+
+ {t("common:chat.newMessages")}
+
+ ) : null}
+ >
);
}
+/** A plain form on purpose: the chat client POSTs the message itself, so sending never touches the router or revalidates the page's loaders. */
function Composer({ onSend }: { onSend: ChatProps["onSend"] }) {
- const { t } = useTranslation(["common"]);
- const { pathname } = useLocation();
+ const { t } = useTranslation(["common", "forms"]);
const readyState = useEventsReadyState();
- const [publicId, setPublicId] = React.useState(() => shortNanoid());
- const [hasSent, setHasSent] = React.useState(false);
-
- // a send's autofocus must not carry over to an unrelated page
- React.useEffect(() => {
- setHasSent(false);
- }, [pathname]);
+ const [contents, setContents] = React.useState("");
+ const inputRef = React.useRef
(null);
+ const [connectionStatusShown, setConnectionStatusShown] =
+ React.useState(false);
+ useDebounce(
+ () => setConnectionStatusShown(readyState !== "CONNECTED"),
+ CONNECTION_STATUS_GRACE_MS,
+ [readyState],
+ );
const sendingDisabled = readyState !== "CONNECTED";
+ const showConnectionStatus = sendingDisabled && connectionStatusShown;
+ const isEmpty = contents.trim().length === 0;
+
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault();
+ if (sendingDisabled || isEmpty) return;
+
+ const parsed = v.safeParse(sendChatMessageSchema, {
+ publicId: shortNanoid(),
+ contents,
+ });
+ if (!parsed.success) return;
+
+ onSend(parsed.output);
+ setContents("");
+ inputRef.current?.focus();
+ };
return (
- {
- onSend(values);
- setPublicId(shortNanoid());
- setHasSent(true);
- }}
- >
- {({ FormField }) => (
- <>
- {readyState !== "CONNECTED" ? (
-
- {t(
- readyState === "CONNECTING"
- ? "common:chat.connecting"
- : "common:chat.disconnected",
- )}
-
- ) : null}
-
- >
- )}
-
- );
-}
-
-function ComposerRow({
- FormField,
- sendingDisabled,
- hasSent,
-}: {
- FormField: FormRenderProps["FormField"];
- sendingDisabled: boolean;
- hasSent: boolean;
-}) {
- const { t } = useTranslation(["common"]);
- const contents = useFormValue("contents");
- const isEmpty = typeof contents !== "string" || contents.trim().length === 0;
-
- return (
-
-
- }
- testId="chat-submit-button"
- />
-
+
);
}
diff --git a/app/features/chat/routes/api.chat.rooms.ts b/app/features/chat/routes/api.chat.rooms.ts
index ac205f538..d9b32f664 100644
--- a/app/features/chat/routes/api.chat.rooms.ts
+++ b/app/features/chat/routes/api.chat.rooms.ts
@@ -1,23 +1,10 @@
import { requireUser } from "~/features/auth/core/user.server";
-import * as ChatRepository from "../ChatRepository.server";
-import * as ChatRoomResolver from "../ChatRoomResolver.server";
-import { roomListItem } from "../chat-room-list.server";
+import { resolveRoomList } from "../chat-room-list.server";
import type { ChatRoomListItem } from "../chat-types";
-/** The user's open chat rooms with unread counts; fetched after mount and refetched on `chatMessage` / `roomsChanged` events, never riding a page loader. */
+/** The user's open chat rooms with unread counts, refetched on `chatMessage` / `roomsChanged` events and reconnects; the root loader serves the first snapshot. */
export const loader = async (): Promise<{ rooms: ChatRoomListItem[] }> => {
const user = requireUser();
- const rooms = await ChatRoomResolver.findAllByUserId(user.id);
- const messageStats = await ChatRepository.findMessageStatsByRoomIds(
- user.id,
- rooms.map((room) => room.roomId),
- );
- const statsByRoomId = new Map(messageStats.map((row) => [row.roomId, row]));
-
- return {
- rooms: rooms.map((room) =>
- roomListItem(room, statsByRoomId.get(room.roomId), user),
- ),
- };
+ return { rooms: await resolveRoomList(user) };
};
diff --git a/app/features/events/events-hooks.browser.test.tsx b/app/features/events/events-hooks.browser.test.tsx
index a87535775..63c6106a6 100644
--- a/app/features/events/events-hooks.browser.test.tsx
+++ b/app/features/events/events-hooks.browser.test.tsx
@@ -155,13 +155,20 @@ const LATE_FIRST_CONNECT_MS = 2_000;
let catchUps = 0;
let triggerCatchUp: () => void = () => {};
-function CatchUpHarness({ enabled }: { enabled: boolean }) {
+function CatchUpHarness({
+ enabled,
+ heldSince,
+}: {
+ enabled: boolean;
+ heldSince?: number;
+}) {
useEventsConnection(true);
triggerCatchUp = useEventStreamCatchUp({
enabled,
onCatchUp: () => {
catchUps++;
},
+ heldSince,
});
return null;
@@ -245,6 +252,17 @@ describe("useEventStreamCatchUp", () => {
expect(catchUps).toBe(1);
});
+ test("catches up on a first connect landing long after the data held was current", async () => {
+ await render(
+ ,
+ );
+ await advanceTimers();
+ await helloArrives();
+ await advanceTimers(CATCH_UP_MAX_JITTER_MS);
+
+ expect(catchUps).toBe(1);
+ });
+
test("does not catch up on the first connect after being enabled", async () => {
const screen = await mountConnecting(false);
diff --git a/app/features/events/events-hooks.ts b/app/features/events/events-hooks.ts
index 2ab38d2a2..a103fc94b 100644
--- a/app/features/events/events-hooks.ts
+++ b/app/features/events/events-hooks.ts
@@ -69,9 +69,12 @@ export function useEventsTopic(topic: string, enabled = true) {
export function useEventStreamCatchUp({
enabled,
onCatchUp,
+ heldSince,
}: {
enabled: boolean;
onCatchUp: () => void;
+ /** When the data the caller holds was current (client clock), for data that predates listening: a first connect landing long after it catches up. Defaults to when listening started. */
+ heldSince?: number;
}) {
const connected = useEventsConnected();
const latestOnCatchUp = React.useRef(onCatchUp);
@@ -97,7 +100,7 @@ export function useEventStreamCatchUp({
[],
);
- useCatchUpOnConnect(enabled, connected, catchUp);
+ useCatchUpOnConnect(enabled, connected, catchUp, heldSince);
React.useEffect(() => {
if (!enabled) return;
@@ -123,6 +126,7 @@ function useCatchUpOnConnect(
enabled: boolean,
connected: boolean,
onConnect: () => void,
+ heldSince?: number,
) {
const hasConnectedRef = React.useRef(false);
const listeningSinceRef = React.useRef(null);
@@ -141,13 +145,14 @@ function useCatchUpOnConnect(
hasConnectedRef.current = true;
if (
isFirstConnect &&
- Date.now() - listeningSinceRef.current < LATE_FIRST_CONNECT_MS
+ Date.now() - (heldSince ?? listeningSinceRef.current) <
+ LATE_FIRST_CONNECT_MS
) {
return;
}
onConnect();
- }, [enabled, connected, onConnect]);
+ }, [enabled, connected, onConnect, heldSince]);
}
const returnListeners = new Set<() => void>();
diff --git a/app/features/scrims/loaders/scrims.$id.server.ts b/app/features/scrims/loaders/scrims.$id.server.ts
index 62aeb8fd9..4fb1f3996 100644
--- a/app/features/scrims/loaders/scrims.$id.server.ts
+++ b/app/features/scrims/loaders/scrims.$id.server.ts
@@ -1,5 +1,5 @@
import type { LoaderFunctionArgs } from "react-router";
-import type { RouteChatRoom } from "~/features/chat/chat-types";
+import * as RouteChatRooms from "~/features/chat/RouteChatRooms.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
@@ -54,10 +54,13 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
})),
post,
// staff observers chat alongside the participants
- chatRooms: (post.chatRoomId !== null &&
- (participantIds.includes(user.id) || user.roles.includes("STAFF"))
- ? [{ roomId: post.chatRoomId, autoOpen: true }]
- : []) satisfies RouteChatRoom[],
+ chatRooms: await RouteChatRooms.resolve(
+ user,
+ post.chatRoomId !== null &&
+ (participantIds.includes(user.id) || user.roles.includes("STAFF"))
+ ? [{ roomId: post.chatRoomId, autoOpen: true }]
+ : [],
+ ),
anyUserPrefersNoScreen,
mapByMap,
};
diff --git a/app/features/sendouq-match/loaders/q.match.$id.server.test.ts b/app/features/sendouq-match/loaders/q.match.$id.server.test.ts
index 0dfff2bdd..c96676663 100644
--- a/app/features/sendouq-match/loaders/q.match.$id.server.test.ts
+++ b/app/features/sendouq-match/loaders/q.match.$id.server.test.ts
@@ -39,12 +39,19 @@ describe("q match loader", () => {
const loadAs = (userId: number, matchId: number) =>
matchLoader({ user: userId, params: { id: String(matchId) } });
+ const surfacedRooms = (data: Awaited>) =>
+ data.chatRooms.map(({ room, autoOpen, label }) => ({
+ roomId: room.id,
+ autoOpen,
+ label,
+ }));
+
test("surfaces both group chats read-only to staff outside the match", async () => {
const match = await createMatch();
const data = await loadAs(staffId(), match.id);
- expect(data.chatRooms).toEqual([
+ expect(surfacedRooms(data)).toEqual([
{ roomId: match.chatRoomId, autoOpen: true },
{
roomId: await groupChatRoomId(match.alphaGroup.id),
@@ -57,6 +64,12 @@ describe("q match loader", () => {
label: "Group Bravo",
},
]);
+ // only the room opening on arrival brings its history along
+ expect(data.chatRooms.map((entry) => entry.messages !== null)).toEqual([
+ true,
+ false,
+ false,
+ ]);
});
test("gives a participant the match chat and their own group chat only", async () => {
@@ -64,7 +77,7 @@ describe("q match loader", () => {
const data = await loadAs(alphaUserIds()[0], match.id);
- expect(data.chatRooms).toEqual([
+ expect(surfacedRooms(data)).toEqual([
{ roomId: match.chatRoomId, autoOpen: true },
{
roomId: await groupChatRoomId(match.alphaGroup.id),
diff --git a/app/features/sendouq-match/loaders/q.match.$id.server.ts b/app/features/sendouq-match/loaders/q.match.$id.server.ts
index fa6eb11f4..4f740f094 100644
--- a/app/features/sendouq-match/loaders/q.match.$id.server.ts
+++ b/app/features/sendouq-match/loaders/q.match.$id.server.ts
@@ -1,6 +1,7 @@
import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
-import type { RouteChatRoom } from "~/features/chat/chat-types";
+import type { RouteChatRoomInput } from "~/features/chat/chat-types";
+import * as RouteChatRooms from "~/features/chat/RouteChatRooms.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import * as ScannerIngestRepository from "~/features/scanner-ingest/ScannerIngestRepository.server";
@@ -60,42 +61,44 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
reportedWeapons,
ingestedScoreboards,
isOffSeason: Seasons.current() === null,
- chatRooms: ((): RouteChatRoom[] => {
- if (!user) return [];
-
- if (isParticipant) {
- const ownGroup = matchUnmapped.groupAlpha.members.some(
- (member) => member.id === user.id,
- )
- ? match.groupAlpha
- : match.groupBravo;
-
- return [match.chatRoomId, ownGroup.chatRoomId]
- .filter((id): id is number => typeof id === "number")
- .map((roomId) => ({ roomId, autoOpen: true }));
- }
-
- if (!isStaff) return [];
-
- return [
- // staff observers chat alongside the participants in the match room
- { roomId: matchUnmapped.chatRoomId, autoOpen: true },
- // the group chats stay private team spaces: staff only ever reads them
- {
- roomId: matchUnmapped.groupAlpha.chatRoomId,
- autoOpen: false,
- label: "Group Alpha",
- },
- {
- roomId: matchUnmapped.groupBravo.chatRoomId,
- autoOpen: false,
- label: "Group Bravo",
- },
- ].filter(
- (room): room is RouteChatRoom => typeof room.roomId === "number",
- );
- })(),
+ chatRooms: await RouteChatRooms.resolve(user, routeChatRoomInputs()),
};
+
+ function routeChatRoomInputs(): RouteChatRoomInput[] {
+ if (!user) return [];
+
+ if (isParticipant) {
+ const ownGroup = matchUnmapped.groupAlpha.members.some(
+ (member) => member.id === user.id,
+ )
+ ? match.groupAlpha
+ : match.groupBravo;
+
+ return [match.chatRoomId, ownGroup.chatRoomId]
+ .filter((id): id is number => typeof id === "number")
+ .map((roomId) => ({ roomId, autoOpen: true }));
+ }
+
+ if (!isStaff) return [];
+
+ return [
+ // staff observers chat alongside the participants in the match room
+ { roomId: matchUnmapped.chatRoomId, autoOpen: true },
+ // the group chats stay private team spaces: staff only ever reads them
+ {
+ roomId: matchUnmapped.groupAlpha.chatRoomId,
+ autoOpen: false,
+ label: "Group Alpha",
+ },
+ {
+ roomId: matchUnmapped.groupBravo.chatRoomId,
+ autoOpen: false,
+ label: "Group Bravo",
+ },
+ ].filter(
+ (room): room is RouteChatRoomInput => typeof room.roomId === "number",
+ );
+ }
};
export type SendouQMatchLoaderData = SerializeFrom;
diff --git a/app/features/sendouq/loaders/q.looking.server.ts b/app/features/sendouq/loaders/q.looking.server.ts
index 021cae2a3..8b58caa59 100644
--- a/app/features/sendouq/loaders/q.looking.server.ts
+++ b/app/features/sendouq/loaders/q.looking.server.ts
@@ -1,7 +1,7 @@
import type { LoaderFunctionArgs } from "react-router";
import * as R from "remeda";
import { requireUser } from "~/features/auth/core/user.server";
-import type { RouteChatRoom } from "~/features/chat/chat-types";
+import * as RouteChatRooms from "~/features/chat/RouteChatRooms.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import { cachedStreams } from "~/features/sendouq-streams/core/streams.server";
@@ -70,10 +70,11 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
: [],
lastUpdated: Date.now(),
streamsCount: (await cachedStreams()).length,
- chatRooms: (ownGroup &&
- ownGroup.members.length > 1 &&
- ownGroup.chatRoomId !== null
- ? [{ roomId: ownGroup.chatRoomId, autoOpen: true }]
- : []) satisfies RouteChatRoom[],
+ chatRooms: await RouteChatRooms.resolve(
+ user,
+ ownGroup && ownGroup.members.length > 1 && ownGroup.chatRoomId !== null
+ ? [{ roomId: ownGroup.chatRoomId, autoOpen: true }]
+ : [],
+ ),
};
};
diff --git a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts
index d4dc92e68..02085867f 100644
--- a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts
+++ b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts
@@ -1,6 +1,6 @@
import cachified from "@epic-web/cachified";
import type { LoaderFunctionArgs } from "react-router";
-import type { RouteChatRoom } from "~/features/chat/chat-types";
+import * as RouteChatRooms from "~/features/chat/RouteChatRooms.server";
import * as ScannerIngestRepository from "~/features/scanner-ingest/ScannerIngestRepository.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
@@ -209,10 +209,13 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
endedEarly,
noScreen,
// observers (TO/streamer/site staff) chat alongside the participants
- chatRooms: (match.chatRoomId &&
- (isParticipant || isSiteStaff || tournament.isOrganizerOrStreamer(user))
- ? [{ roomId: match.chatRoomId, autoOpen: true }]
- : []) satisfies RouteChatRoom[],
+ chatRooms: await RouteChatRooms.resolve(
+ user,
+ match.chatRoomId &&
+ (isParticipant || isSiteStaff || tournament.isOrganizerOrStreamer(user))
+ ? [{ roomId: match.chatRoomId, autoOpen: true }]
+ : [],
+ ),
canJoin,
// the views can't derive these themselves, the layout ships no bracket match data
bracketContext: {
diff --git a/app/form/SendouForm.tsx b/app/form/SendouForm.tsx
index ec2fbc3e6..ca276a246 100644
--- a/app/form/SendouForm.tsx
+++ b/app/form/SendouForm.tsx
@@ -108,10 +108,6 @@ type BaseFormProps = {
) => boolean;
/** Called once after the action returns without field errors. */
onSuccess?: () => void;
- /** For forms that render their own submit control inside `children`. */
- hideSubmitButton?: boolean;
- /** When false, navigating away with unsaved edits is not blocked (e.g. a chat draft). */
- guardUnsavedChanges?: boolean;
};
/**
@@ -187,8 +183,6 @@ function SendouFormInner({
secondarySubmit,
hideSubmitButtonWhen,
onSuccess,
- hideSubmitButton = false,
- guardUnsavedChanges = true,
}: SendouFormProps) {
const { t } = useTranslation(["forms"]);
const fetcher = useFetcher<{ fieldErrors?: Record }>();
@@ -264,11 +258,7 @@ function SendouFormInner({
const hasUnsavedChangesRef = React.useRef<() => boolean>(() => false);
hasUnsavedChangesRef.current = () =>
- guardUnsavedChanges &&
- mode === "submit" &&
- !readOnly &&
- store.dirty &&
- fetcher.state === "idle";
+ mode === "submit" && !readOnly && store.dirty && fetcher.state === "idle";
useUnsavedChangesChecker(hasUnsavedChangesRef);
const previousFetcherStateRef = React.useRef(fetcher.state);
@@ -325,7 +315,7 @@ function SendouFormInner({
<>
{title ? {title}
: null}
{resolvedChildren}
- {mode !== "submit" || readOnly || hideSubmitButton ? null : (
+ {mode !== "submit" || readOnly ? null : (
boolean) | undefined
diff --git a/app/root.tsx b/app/root.tsx
index 71fa3633c..87400f69a 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -38,6 +38,7 @@ import { Layout, NPROGRESS_ANCHOR_ID } from "./components/layout";
import { getUser } from "./features/auth/core/user.server";
import { userMiddleware } from "./features/auth/core/user-middleware.server";
import { ChatProvider } from "./features/chat/ChatProvider";
+import { resolveRoomList } from "./features/chat/chat-room-list.server";
import { isMatchResultsScopedRevalidation } from "./features/chat/revalidation-scope";
import { GlobalStatusProvider } from "./features/global-status/GlobalStatusProvider";
import { getSidenavSession } from "./features/layout/core/sidenav-session.server";
@@ -146,6 +147,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
return data(
{
locale,
+ chatRoomList: user ? await resolveRoomList(user) : [],
i18nPreloadUrls: localePreloadUrls(locale),
theme: themeSession.getTheme(),
sidenavCollapsed: sidenavSession.getCollapsed(),
@@ -266,7 +268,10 @@ function Document({
-
+
diff --git a/changelog/2026-09-20-chat-loads-with-page.md b/changelog/2026-09-20-chat-loads-with-page.md
new file mode 100644
index 000000000..7f7191414
--- /dev/null
+++ b/changelog/2026-09-20-chat-loads-with-page.md
@@ -0,0 +1,4 @@
+---
+type: feature
+---
+Chat opens together with the page instead of loading in after it, so pages with a chat no longer shift around when it appears
diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md
index cda2acf39..9572b8d79 100644
--- a/docs/dev/architecture.md
+++ b/docs/dev/architecture.md
@@ -223,15 +223,17 @@ There is a single `ChatProvider` mounted near the root, glue over the framework-
Two things drive which rooms the client cares about:
-1) **The user's own rooms.** `GET /api/chat/rooms` returns every room the user participates in, resolved from the owning entity (SendouQ group/match, tournament match/team, scrim). These show up in the chat list regardless of which page the user is on.
-2) **Route-exposed `chatRooms`.** A loader can expose `chatRooms: RouteChatRoom[]` in its returned data. The provider reads this out of `useMatches()` and surfaces those rooms for the duration of the route being active — used for rooms the user is viewing but is not a participant of (e.g. a tournament match chat viewed by a TO). An `autoOpen` room is opened for the viewer, the rest are only listed in the chat sidebar (e.g. the private group chats of a SendouQ match, which staff may read but not post in).
+1) **The user's own rooms.** Every room the user participates in, resolved from the owning entity (SendouQ group/match, tournament match/team, scrim). The root loader serves the list with the page (`chatRoomList`) and `GET /api/chat/rooms` refetches it on events and reconnects. These show up in the chat list regardless of which page the user is on.
+2) **Route-exposed `chatRooms`.** A loader can expose `chatRooms: RouteChatRoom[]` in its returned data, built with `RouteChatRooms.resolve()` so the page arrives with each room as the sidebar lists it and, for rooms opening on arrival, its latest messages (no fetch after mount, no content shift). The provider reads this out of `useMatches()` and surfaces those rooms for the duration of the route being active — used for rooms the user is viewing but is not a participant of (e.g. a tournament match chat viewed by a TO). An `autoOpen` room is opened for the viewer, the rest are only listed in the chat sidebar (e.g. the private group chats of a SendouQ match, which staff may read but not post in). The loader decides who gets which rooms; the helper drops any the user may not view.
Example loader:
```ts
return {
// ...other loader data
- chatRooms: [{ roomId: match.chatRoomId, autoOpen: true }],
+ chatRooms: await RouteChatRooms.resolve(user, [
+ { roomId: match.chatRoomId, autoOpen: true },
+ ]),
};
```