UserCard data via query.batch

This commit is contained in:
Kalle
2026-08-18 18:59:56 +03:00
parent 32a14fabb5
commit 459fe3fca0
12 changed files with 236 additions and 140 deletions

View File

@@ -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
<!-- UserCard.svelte -->
const data = $derived(await getUserCard({ userId, withFriendCode }));
```
Pages then render `<UserCard userId={...}>` 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`).

View File

@@ -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}
<UserCard userId={member.id}>
<span class="memberLink">
<NoteAvatar {sentiment} size="xs">
<Avatar user={member} size="xxs" />
</NoteAvatar>
<div class="memberNameStack">
<span>{member.username}</span>
{#if member.inGameName}
<span class="memberInGameName">{member.inGameName}</span>
{/if}
</div>
</span>
<UserCard userId={member.id} withFriendCode>
{#snippet children(card)}
<span class="memberLink">
<NoteAvatar sentiment={card?.privateNote?.sentiment} size="xs">
<Avatar user={member} size="xxs" />
</NoteAvatar>
<div class="memberNameStack">
<span>{member.username}</span>
{#if member.inGameName}
<span class="memberInGameName">{member.inGameName}</span>
{/if}
</div>
</span>
{/snippet}
</UserCard>
{/snippet}

View File

@@ -649,3 +649,71 @@ export async function findUserScrims(userId: number): Promise<SidebarScrim[]> {
};
});
}
// 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<number>;
}): Promise<Set<number>> {
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));
}

View File

@@ -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

View File

@@ -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(
<div class="stack md">
{#each sortedUsers as user (user.id)}
<UserCard userId={user.id} withMutualFriends>
<span class="stack horizontal sm items-center">
<NoteAvatar
sentiment={cards?.userCards()?.get(user.id)?.privateNote?.sentiment}
size="sm"
>
<Avatar size="xs" {user} />
</NoteAvatar>
<span>
{user.username}
{#if user.isOwner}
<div class="text-lighter text-xs">
{m.scrims_cancelRequestModal_requester()}
</div>
{/if}
{#snippet children(card)}
<span class="stack horizontal sm items-center">
<NoteAvatar sentiment={card?.privateNote?.sentiment} size="sm">
<Avatar size="xs" {user} />
</NoteAvatar>
<span>
{user.username}
{#if user.isOwner}
<div class="text-lighter text-xs">
{m.scrims_cancelRequestModal_requester()}
</div>
{/if}
</span>
</span>
</span>
{/snippet}
</UserCard>
{/each}
</div>

View File

@@ -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));
</script>
{#if teamAvatarUrl}
<Avatar size="xs" url={teamAvatarUrl} alt={teamName} />
{:else}
<UserCard userId={owner.id} withMutualFriends>
<NoteAvatar sentiment={cardData?.privateNote?.sentiment} size="sm">
<Avatar size="xs" user={owner} alt={owner.username} />
</NoteAvatar>
{#snippet children(card)}
<NoteAvatar sentiment={card?.privateNote?.sentiment} size="sm">
<Avatar size="xs" user={owner} alt={owner.username} />
</NoteAvatar>
{/snippet}
</UserCard>
{/if}

View File

@@ -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();
</script>
<Popover>
@@ -25,16 +22,14 @@ const cards = getUserCardContext();
<div class="stack md">
{#each users as user (user.id)}
<UserCard userId={user.id} withMutualFriends>
<span class="stack horizontal sm items-center">
<NoteAvatar
sentiment={cards?.userCards()?.get(user.id)?.privateNote
?.sentiment}
size="xs"
>
<Avatar size="xxs" {user} />
</NoteAvatar>
{user.username}
</span>
{#snippet children(card)}
<span class="stack horizontal sm items-center">
<NoteAvatar sentiment={card?.privateNote?.sentiment} size="xs">
<Avatar size="xxs" {user} />
</NoteAvatar>
{user.username}
</span>
{/snippet}
</UserCard>
{/each}
</div>

View File

@@ -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,

View File

@@ -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)

View File

@@ -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<UserCardStat["type"], number> = {
};
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"]) {
<!--
@component
Click-to-open trigger that shows a popover with the user's card. Card data is resolved by `userId`
from the {@link UserCardContext} (set by pages whose queries spread `{ userCards }` from
`UserCardRepository.findAllByUserIds`); pass `data` directly to bypass the lookup. When no card data
exists for the user, the `children` are rendered plain without a trigger.
Click-to-open trigger that shows a popover with the user's card. Card data is loaded by the card
itself through the batched `getUserCard` query.
Viewer-relative friendship data (`isFriend`) is lazy-loaded via the `getUserCardFriendship` remote
query the first time the card opens. Mutual friends are only fetched and shown when
@@ -303,7 +296,7 @@ both the extra query and the row.
{/snippet}
{#if !data}
{@render children()}
{@render children(data)}
{:else}
<Popover
{isOpen}
@@ -312,7 +305,7 @@ both the extra query and the row.
>
{#snippet trigger(triggerProps)}
<button type="button" class="trigger" {...triggerProps}>
{@render children()}
{@render children(data)}
</button>
{/snippet}
<div

View File

@@ -1,24 +0,0 @@
import { createContext } from "svelte";
import type { UserCardData } from "./user-card-types.ts";
export interface UserCardContext {
/** Card data by user id, as returned by `UserCardRepository.findAllByUserIds`. */
userCards: () => Map<number, UserCardData> | undefined;
}
const [getOrThrow, set] = createContext<UserCardContext>();
/** Provides `UserCard` data for the subtree; pages whose queries include `userCards` set this. */
export const setUserCardContext = set;
/**
* The nearest provided {@link UserCardContext}, or `undefined` when no ancestor set one —
* `UserCard` then renders its children plain without the popover.
*/
export function getUserCardContext(): UserCardContext | undefined {
try {
return getOrThrow();
} catch {
return undefined;
}
}

View File

@@ -1,9 +1,48 @@
import * as R from "remeda";
import * as v from "valibot";
import { getUser } from "#lib/features/auth/user.server.ts";
import * as FriendRepository from "#lib/features/friends/FriendRepository.server.ts";
import * as ScrimPostRepository from "#lib/features/scrims/ScrimPostRepository.server.ts";
import { id } from "#lib/utils/schemas.ts";
import { query } from "$app/server";
import type { UserCardFriendship } from "./user-card-types.ts";
import * as UserCardRepository from "./UserCardRepository.server.ts";
import type { UserCardData, UserCardFriendship } from "./user-card-types.ts";
/**
* Card data for one user, loaded by the `UserCard` component itself. Every card of a page calls
* this in the same macrotask, so they resolve as one request and one batch of database queries.
*
* `withFriendCode` is a request, not a grant: friend codes are private to the people a user is
* scheduled to play with, so the server resolves who the viewer may see one of (see
* {@link friendCodeVisibleUserIds}) rather than trusting the caller.
*/
export const getUserCard = query.batch(
v.object({ userId: id, withFriendCode: v.boolean() }),
async (args) => {
const userIds = R.unique(args.map((arg) => arg.userId));
const friendCodeRequestedIds = R.unique(
args.filter((arg) => arg.withFriendCode).map((arg) => arg.userId),
);
// xxx: add cache
const [{ userCards }, friendCodeVisibleIds] = await Promise.all([
UserCardRepository.findAllByUserIds({
userIds,
include: { friendCode: friendCodeRequestedIds.length > 0 },
}),
friendCodeVisibleUserIds(friendCodeRequestedIds),
]);
return ({ userId, withFriendCode }): UserCardData | undefined => {
const card = userCards.get(userId);
if (!card) return undefined;
if (withFriendCode && friendCodeVisibleIds.has(userId)) return card;
return { ...card, friendCode: null };
};
},
);
/**
* Viewer-relative friendship data for a single user, lazy-loaded by the `UserCard`
@@ -54,3 +93,22 @@ export const getUserCardFriendship = query(
};
},
);
/**
* Of `userIds`, the ones whose friend code the viewer has a reason to see right now: they share an
* accepted scrim. Staff see every requested code, and a logged out viewer none.
*/
async function friendCodeVisibleUserIds(
userIds: Array<number>,
): Promise<Set<number>> {
if (userIds.length === 0) return new Set();
const viewer = getUser();
if (!viewer) return new Set();
if (viewer.roles.includes("STAFF")) return new Set(userIds);
return ScrimPostRepository.findUserIdsSharingAcceptedScrim({
userId: viewer.id,
otherUserIds: userIds,
});
}