From 459fe3fca006f37d3e47e44002775d6913ae3c05 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:59:56 +0300 Subject: [PATCH] UserCard data via query.batch --- MIGRATION.md | 54 ++++++++++++++- .../match-page/MatchRosterTab.svelte | 32 ++++----- .../scrims/ScrimPostRepository.server.ts | 68 +++++++++++++++++++ .../scrims/components/ScrimPage.svelte | 8 --- .../components/ScrimRequestMembersList.svelte | 32 ++++----- .../scrims/components/ScrimTeamAvatar.svelte | 12 ++-- .../components/ScrimTeamMembersPopover.svelte | 21 +++--- .../scrims/components/ScrimsPage.svelte | 7 -- .../src/lib/features/scrims/scrims.remote.ts | 17 ----- .../user-page/components/UserCard.svelte | 41 +++++------ .../features/user-page/user-card-context.ts | 24 ------- .../features/user-page/user-card.remote.ts | 60 +++++++++++++++- 12 files changed, 236 insertions(+), 140 deletions(-) delete mode 100644 apps/web/src/lib/features/user-page/user-card-context.ts diff --git a/MIGRATION.md b/MIGRATION.md index 4f26ce7c2..8d924112c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -67,6 +67,54 @@ Auth: `requireUser()` / `getUser()` / `actorId()` from `#lib/features/auth/user.server.ts` read `event.locals.user`, resolved once per request in `hooks.server.ts`. No AsyncLocalStorage. +## Component-owned data (`query.batch`) + +From `UserCard`, for a widget rendered many times per page whose data the React +loader gathered for it. + +**Before** (React Router): every loader that renders user cards collected the +ids on the page, called `UserCardRepository.findAllByUserIds`, spread +`{ userCards }` into its data, and a context handed the map down so each card +could look itself up by id. + +**After** (SvelteKit): the component asks for its own row and Kit collapses the +calls. `query.batch` collects every call made in the same macrotask (the whole +page, SSR included) into one request and one server invocation, whose callback +returns a per-argument lookup: + +```ts +// user-card.remote.ts +export const getUserCard = query.batch( + v.object({ userId: id, withFriendCode: v.boolean() }), + async (args) => { + const { userCards } = await UserCardRepository.findAllByUserIds({ + userIds: R.unique(args.map((arg) => arg.userId)), + }); + + return ({ userId }) => userCards.get(userId); + }, +); +``` + +```svelte + +const data = $derived(await getUserCard({ userId, withFriendCode })); +``` + +Pages then render `` and load nothing for it; the widget +hands its resolved row to its `children` snippet +(`Snippet<[UserCardData | undefined]>`) for callers that need a piece of it +outside the popover. Prefer this over threading data (or a context) through +pages when the same widget appears across many unrelated routes. + +Loader-supplied authorization does not survive the move: an argument like +`withFriendCode` is a *request* the client can forge, so whatever the route +guard used to prove (participant, staff) the batch callback must re-derive for +the viewer — here `ScrimPostRepository.findUserIdsSharingAcceptedScrim`. + +`query.batch` throws when prerendering; a prerendered route cannot render a +component that uses one. + ## Write path (action → command), the golden pattern From the `/scrims` slice (`scrims.remote.ts`): @@ -303,9 +351,9 @@ Where parity matters, out-specify it with a tripled class remote-function CSRF (which ignores `csrf.trustedOrigins`) sees the right per-port origin. - Context in async components must be set *before* the first `await` - (`set_context_after_init` is fatal in prod). Pattern: `setUserCardContext({ - userCards: () => data.userCards })` above `const data = $derived(await …)` — - the getter only runs after the await resolves. + (`set_context_after_init` is fatal in prod). Pattern: `setSomeContext({ value: + () => data.value })` above `const data = $derived(await …)` — the getter only + runs after the await resolves. - Mutating `$state` synchronously inside a `$derived`-triggered fetch is a `state_unsafe_mutation` — the e2e fetch-counter patch defers its counter writes a microtask (`HydrationTestIndicator.svelte`). diff --git a/apps/web/src/lib/components/match-page/MatchRosterTab.svelte b/apps/web/src/lib/components/match-page/MatchRosterTab.svelte index 2b89b79a0..94d42080f 100644 --- a/apps/web/src/lib/components/match-page/MatchRosterTab.svelte +++ b/apps/web/src/lib/components/match-page/MatchRosterTab.svelte @@ -23,7 +23,6 @@ import invariant from "@sendou/utils/invariant"; import Avatar from "#lib/components/Avatar.svelte"; import NoteAvatar from "#lib/components/NoteAvatar.svelte"; import UserCard from "#lib/features/user-page/components/UserCard.svelte"; -import { getUserCardContext } from "#lib/features/user-page/user-card-context.ts"; import { TAB_KEYS } from "./match-page-constants.ts"; // xxx: the active-roster editing flow (canEditSubbedOut/defaultIsEditing/ @@ -37,8 +36,6 @@ interface Props { let { teams }: Props = $props(); -const userCardContext = getUserCardContext(); - function defaultNameOf(team: RosterTabTeam) { invariant(team.defaultName, "team or defaultName must be provided"); return team.defaultName; @@ -112,21 +109,20 @@ function defaultNameOf(team: RosterTabTeam) { {/snippet} {#snippet rosterMemberLink(member: RosterTabMember)} - {@const sentiment = userCardContext - ?.userCards() - ?.get(member.id)?.privateNote?.sentiment} - - - - - -
- {member.username} - {#if member.inGameName} - {member.inGameName} - {/if} -
-
+ + {#snippet children(card)} + + + + +
+ {member.username} + {#if member.inGameName} + {member.inGameName} + {/if} +
+
+ {/snippet}
{/snippet} diff --git a/apps/web/src/lib/features/scrims/ScrimPostRepository.server.ts b/apps/web/src/lib/features/scrims/ScrimPostRepository.server.ts index 09cdf5639..f73e8fc81 100644 --- a/apps/web/src/lib/features/scrims/ScrimPostRepository.server.ts +++ b/apps/web/src/lib/features/scrims/ScrimPostRepository.server.ts @@ -649,3 +649,71 @@ export async function findUserScrims(userId: number): Promise { }; }); } + +// xxx: perf test+add to be bench script +/** + * Of `otherUserIds`, the ones who participate in an accepted (and not canceled) scrim that `userId` + * also participates in — i.e. the people they are actually scheduled to play with. Used to check + * whether a viewer has a reason to be shown someone's friend code. + */ +export async function findUserIdsSharingAcceptedScrim({ + userId, + otherUserIds, +}: { + userId: number; + otherUserIds: Array; +}): Promise> { + if (otherUserIds.length === 0) return new Set(); + + const postIdsOfUser = db + .selectFrom("ScrimPostUser") + .select("ScrimPostUser.scrimPostId") + .where("ScrimPostUser.userId", "=", userId) + .union( + db + .selectFrom("ScrimPostRequest") + .innerJoin( + "ScrimPostRequestUser", + "ScrimPostRequestUser.scrimPostRequestId", + "ScrimPostRequest.id", + ) + .select("ScrimPostRequest.scrimPostId") + .where("ScrimPostRequest.isAccepted", "=", 1) + .where("ScrimPostRequestUser.userId", "=", userId), + ); + + const rows = await db + .selectFrom("ScrimPost") + .innerJoin("ScrimPostUser", "ScrimPostUser.scrimPostId", "ScrimPost.id") + .innerJoin("ScrimPostRequest", (join) => + join + .onRef("ScrimPostRequest.scrimPostId", "=", "ScrimPost.id") + .on("ScrimPostRequest.isAccepted", "=", 1), + ) + .select("ScrimPostUser.userId") + .where("ScrimPost.canceledAt", "is", null) + .where("ScrimPost.id", "in", postIdsOfUser) + .where("ScrimPostUser.userId", "in", otherUserIds) + .union((eb) => + eb + .selectFrom("ScrimPost") + .innerJoin( + "ScrimPostRequest", + "ScrimPostRequest.scrimPostId", + "ScrimPost.id", + ) + .innerJoin( + "ScrimPostRequestUser", + "ScrimPostRequestUser.scrimPostRequestId", + "ScrimPostRequest.id", + ) + .select("ScrimPostRequestUser.userId") + .where("ScrimPost.canceledAt", "is", null) + .where("ScrimPostRequest.isAccepted", "=", 1) + .where("ScrimPost.id", "in", postIdsOfUser) + .where("ScrimPostRequestUser.userId", "in", otherUserIds), + ) + .execute(); + + return new Set(rows.map((row) => row.userId)); +} diff --git a/apps/web/src/lib/features/scrims/components/ScrimPage.svelte b/apps/web/src/lib/features/scrims/components/ScrimPage.svelte index f540b5f12..9021b7286 100644 --- a/apps/web/src/lib/features/scrims/components/ScrimPage.svelte +++ b/apps/web/src/lib/features/scrims/components/ScrimPage.svelte @@ -3,7 +3,6 @@ import { untrack } from "svelte"; import Main from "#lib/components/Main.svelte"; import MatchPage from "#lib/components/match-page/MatchPage.svelte"; import { chatUi } from "#lib/features/chat/chat-state.svelte.ts"; -import { setUserCardContext } from "#lib/features/user-page/user-card-context.ts"; import { getScrim } from "../scrims.remote.ts"; import ScrimMatchBanner from "./ScrimMatchBanner.svelte"; import ScrimMatchHeader from "./ScrimMatchHeader.svelte"; @@ -15,13 +14,6 @@ interface Props { let { scrimPostId }: Props = $props(); -// xxx: ??? -// context must be provided before the first await of an async component; the -// getter closes over `data` which resolves before anything renders -setUserCardContext({ - userCards: () => data.userCards, -}); - const data = $derived(await getScrim({ scrimPostId })); // xxx: need a better, generic solution diff --git a/apps/web/src/lib/features/scrims/components/ScrimRequestMembersList.svelte b/apps/web/src/lib/features/scrims/components/ScrimRequestMembersList.svelte index 56fc1c579..8ddb7b6bb 100644 --- a/apps/web/src/lib/features/scrims/components/ScrimRequestMembersList.svelte +++ b/apps/web/src/lib/features/scrims/components/ScrimRequestMembersList.svelte @@ -2,7 +2,6 @@ import Avatar from "#lib/components/Avatar.svelte"; import NoteAvatar from "#lib/components/NoteAvatar.svelte"; import UserCard from "#lib/features/user-page/components/UserCard.svelte"; -import { getUserCardContext } from "#lib/features/user-page/user-card-context.ts"; import { m } from "#lib/paraglide/messages.js"; import type { ScrimPostUser } from "../scrims-types.ts"; @@ -12,8 +11,6 @@ interface Props { let { users }: Props = $props(); -const cards = getUserCardContext(); - const sortedUsers = $derived( [...users].sort((a, b) => Number(b.isOwner) - Number(a.isOwner)), ); @@ -22,22 +19,21 @@ const sortedUsers = $derived(
{#each sortedUsers as user (user.id)} - - - - - - {user.username} - {#if user.isOwner} -
- {m.scrims_cancelRequestModal_requester()} -
- {/if} + {#snippet children(card)} + + + + + + {user.username} + {#if user.isOwner} +
+ {m.scrims_cancelRequestModal_requester()} +
+ {/if} +
-
+ {/snippet}
{/each}
diff --git a/apps/web/src/lib/features/scrims/components/ScrimTeamAvatar.svelte b/apps/web/src/lib/features/scrims/components/ScrimTeamAvatar.svelte index 22dc95dcb..fa47f8677 100644 --- a/apps/web/src/lib/features/scrims/components/ScrimTeamAvatar.svelte +++ b/apps/web/src/lib/features/scrims/components/ScrimTeamAvatar.svelte @@ -2,7 +2,6 @@ import Avatar from "#lib/components/Avatar.svelte"; import NoteAvatar from "#lib/components/NoteAvatar.svelte"; import UserCard from "#lib/features/user-page/components/UserCard.svelte"; -import { getUserCardContext } from "#lib/features/user-page/user-card-context.ts"; import type { ScrimPostUser } from "../scrims-types.ts"; interface Props { @@ -12,17 +11,16 @@ interface Props { } let { teamAvatarUrl, teamName, owner }: Props = $props(); - -const cards = getUserCardContext(); -const cardData = $derived(cards?.userCards()?.get(owner.id)); {#if teamAvatarUrl} {:else} - - - + {#snippet children(card)} + + + + {/snippet} {/if} diff --git a/apps/web/src/lib/features/scrims/components/ScrimTeamMembersPopover.svelte b/apps/web/src/lib/features/scrims/components/ScrimTeamMembersPopover.svelte index f587d50e1..b2a7526ec 100644 --- a/apps/web/src/lib/features/scrims/components/ScrimTeamMembersPopover.svelte +++ b/apps/web/src/lib/features/scrims/components/ScrimTeamMembersPopover.svelte @@ -4,7 +4,6 @@ import { Button, Popover } from "@sendou/components"; import Avatar from "#lib/components/Avatar.svelte"; import NoteAvatar from "#lib/components/NoteAvatar.svelte"; import UserCard from "#lib/features/user-page/components/UserCard.svelte"; -import { getUserCardContext } from "#lib/features/user-page/user-card-context.ts"; import type { ScrimPostUser } from "../scrims-types.ts"; interface Props { @@ -12,8 +11,6 @@ interface Props { } let { users }: Props = $props(); - -const cards = getUserCardContext(); @@ -25,16 +22,14 @@ const cards = getUserCardContext();
{#each users as user (user.id)} - - - - - {user.username} - + {#snippet children(card)} + + + + + {user.username} + + {/snippet} {/each}
diff --git a/apps/web/src/lib/features/scrims/components/ScrimsPage.svelte b/apps/web/src/lib/features/scrims/components/ScrimsPage.svelte index f33dc071f..60e5b63e3 100644 --- a/apps/web/src/lib/features/scrims/components/ScrimsPage.svelte +++ b/apps/web/src/lib/features/scrims/components/ScrimsPage.svelte @@ -6,7 +6,6 @@ import * as R from "remeda"; import LocaleTime from "#lib/components/LocaleTime.svelte"; import Main from "#lib/components/Main.svelte"; import { loggedInUser } from "#lib/features/auth/user-state.ts"; -import { setUserCardContext } from "#lib/features/user-page/user-card-context.ts"; import { searchParamsState } from "#lib/modules/search-params/search-params-state.svelte.ts"; import { m } from "#lib/paraglide/messages.js"; import { databaseTimestampToDate } from "#lib/utils/dates.ts"; @@ -23,12 +22,6 @@ const user = $derived(loggedInUser()); const params = searchParamsState(scrimsSearchParams); -// context must be provided before the first await of an async component; the -// getter closes over `data` which resolves before anything renders -setUserCardContext({ - userCards: () => data.userCards, -}); - const data = $derived( await getScrimPosts({ weekdayTimes: params.current.weekdayTimes, diff --git a/apps/web/src/lib/features/scrims/scrims.remote.ts b/apps/web/src/lib/features/scrims/scrims.remote.ts index 5f80f4126..1973611f8 100644 --- a/apps/web/src/lib/features/scrims/scrims.remote.ts +++ b/apps/web/src/lib/features/scrims/scrims.remote.ts @@ -19,7 +19,6 @@ import { resolveNotifications } from "#lib/features/notifications/core/resolve.s import * as SQGroupRepository from "#lib/features/sendouq/SQGroupRepository.server.ts"; import * as TeamRepository from "#lib/features/team/TeamRepository.server.ts"; import { getMemberRoleType } from "#lib/features/team/team-utils.ts"; -import * as UserCardRepository from "#lib/features/user-page/UserCardRepository.server.ts"; import * as UserRepository from "#lib/features/user-page/UserRepository.server.ts"; import { requirePermission } from "#lib/modules/permissions/guards.server.ts"; import * as Events from "#lib/server/events.ts"; @@ -114,19 +113,7 @@ export const getScrimPosts = query( }), })); - const cardUserIds = R.unique( - posts.flatMap((post) => [ - ...post.users.map((postUser) => postUser.id), - ...post.requests.flatMap((request) => - request.users.map((requestUser) => requestUser.id), - ), - ]), - ); - return { - ...(await UserCardRepository.findAllByUserIds({ - userIds: cardUserIds, - })), posts: dividePosts(posts, user?.id), teams: user ? await TeamRepository.findAllByMemberUserId(user.id) : [], filters, @@ -212,10 +199,6 @@ async function scrimSnapshot(scrimPostId: number) { }); return { - ...(await UserCardRepository.findAllByUserIds({ - userIds: participantIds, - include: { friendCode: true }, - })), post, chatRoomId: user.roles.includes("STAFF") || participantIds.includes(user.id) diff --git a/apps/web/src/lib/features/user-page/components/UserCard.svelte b/apps/web/src/lib/features/user-page/components/UserCard.svelte index c7493df3a..82eb530da 100644 --- a/apps/web/src/lib/features/user-page/components/UserCard.svelte +++ b/apps/web/src/lib/features/user-page/components/UserCard.svelte @@ -23,13 +23,15 @@ import Placement from "#lib/components/Placement.svelte"; import TierImage from "#lib/components/TierImage.svelte"; import type { XRankPlacementRegion } from "#lib/db/tables-json.ts"; import { loggedInUser } from "#lib/features/auth/user-state.ts"; -import { getUserCardContext } from "#lib/features/user-page/user-card-context.ts"; import type { UserCardData, UserCardFriendship, UserCardStat, } from "#lib/features/user-page/user-card-types.ts"; -import { getUserCardFriendship } from "#lib/features/user-page/user-card.remote.ts"; +import { + getUserCard, + getUserCardFriendship, +} from "#lib/features/user-page/user-card.remote.ts"; import { m } from "#lib/paraglide/messages.js"; import { brandImageUrl, @@ -54,30 +56,24 @@ const STAT_ORDER: Record = { }; interface Props { - userId?: number; - data?: UserCardData; + userId: number; + /** Ask for the user's friend code; the server only sends it to viewers entitled to it. */ + withFriendCode?: boolean; /** Fetch and show the mutual friends row. Off by default. */ withMutualFriends?: boolean; - children: Snippet; + children: Snippet<[UserCardData | undefined]>; } let { userId, - data: dataProp, + withFriendCode = false, withMutualFriends = false, children, }: Props = $props(); -const context = getUserCardContext(); +const data = $derived(await getUserCard({ userId, withFriendCode })); -const data = $derived( - dataProp ?? - (typeof userId === "number" - ? context?.userCards()?.get(userId) - : undefined), -); - -const isOwnCard = $derived(loggedInUser()?.id === data?.id); +const isOwnCard = $derived(loggedInUser()?.id === userId); const returnTo = $derived(`${page.url.pathname}${page.url.search}`); const stats = $derived( @@ -101,11 +97,10 @@ function handleOpenChange(nextIsOpen: boolean) { if (!nextIsOpen) return; if (friendshipRequested) return; if (isOwnCard) return; - const targetUserId = data?.id; - if (typeof targetUserId !== "number") return; + // xxx: do this properly friendshipRequested = true; - getUserCardFriendship({ userId: targetUserId, withMutualFriends }) + getUserCardFriendship({ userId, withMutualFriends }) .then((result) => { friendship = result; }) @@ -167,10 +162,8 @@ function customThemeStyle(customTheme: UserCardData["customTheme"]) {