Paginate the user outfits page
My main inspiration for doing this is actually our potentially-huge upcoming Vercel bill lol
From inspecting my Honeycomb dashboard, it looks like the main offender for backend CPU time usage is outfit images. And it looks like they come in big spikes, of lots of low usage and then suddenly 1,000 requests in one minute.
My suspicion is that this is from users with many saved outfits loading their outfit page, which previously would show all of them at once.
We do have `loading="lazy"` set, but not all browsers support that yet, and I've had trouble pinning down the exact behavior anyway!
Anyway, paginating makes for a better experience for those huge-list users anyway. We've been meaning to do it, so here we go!
My hope is that this drastically decreases backend CPU hours immediately 🤞 If not, we'll need to investigate in more detail where these outfit image requests are actually coming from!
Note that I added the pagination to the existing `outfits` GraphQL endpoint, rather than creating a new one. I felt comfortable doing this because it requires login anyway, so I'm confident that other clients aren't using it; and because, while this kind of thing often creates a risk of problems with frontend and backend code getting out of sync, I think someone running old frontend code will just see only their first 30 outfits (but no pagination toolbar), and get confused and refresh the page, at which point they'll see all of them. (And I actually _prefer_ that slightly confusing UX, to avoid getting more giant spikes of outfit image requests, lol :p)
This commit is contained in:
parent
33740af5ee
commit
299561d1e3
4 changed files with 118 additions and 49 deletions
|
@ -3,21 +3,18 @@ import { Box, Center, Flex, Wrap, WrapItem } from "@chakra-ui/react";
|
||||||
import { ClassNames } from "@emotion/react";
|
import { ClassNames } from "@emotion/react";
|
||||||
import gql from "graphql-tag";
|
import gql from "graphql-tag";
|
||||||
import { useQuery } from "@apollo/client";
|
import { useQuery } from "@apollo/client";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import { Heading1, MajorErrorMessage, useCommonStyles } from "./util";
|
import { Heading1, MajorErrorMessage, useCommonStyles } from "./util";
|
||||||
import HangerSpinner from "./components/HangerSpinner";
|
import HangerSpinner from "./components/HangerSpinner";
|
||||||
import OutfitThumbnail from "./components/OutfitThumbnail";
|
import OutfitThumbnail from "./components/OutfitThumbnail";
|
||||||
import useRequireLogin from "./components/useRequireLogin";
|
import useRequireLogin from "./components/useRequireLogin";
|
||||||
import WIPCallout from "./components/WIPCallout";
|
import PaginationToolbar from "./components/PaginationToolbar";
|
||||||
|
|
||||||
function UserOutfitsPage() {
|
function UserOutfitsPage() {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Flex justifyContent="space-between" marginBottom="4">
|
<Heading1 marginBottom="4">Your outfits</Heading1>
|
||||||
<Heading1>Your outfits</Heading1>
|
|
||||||
<WIPCallout details="This list doesn't work well with a lot of outfits yet. We'll paginate it soon! And starred outfits are coming, too!" />
|
|
||||||
</Flex>
|
|
||||||
<UserOutfitsPageContent />
|
<UserOutfitsPageContent />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
@ -26,12 +23,16 @@ function UserOutfitsPage() {
|
||||||
function UserOutfitsPageContent() {
|
function UserOutfitsPageContent() {
|
||||||
const { isLoading: userLoading } = useRequireLogin();
|
const { isLoading: userLoading } = useRequireLogin();
|
||||||
|
|
||||||
|
const { search } = useLocation();
|
||||||
|
const offset = parseInt(new URLSearchParams(search).get("offset")) || 0;
|
||||||
|
|
||||||
const { loading: queryLoading, error, data } = useQuery(
|
const { loading: queryLoading, error, data } = useQuery(
|
||||||
gql`
|
gql`
|
||||||
query UserOutfitsPageContent {
|
query UserOutfitsPageContent($offset: Int!) {
|
||||||
currentUser {
|
currentUser {
|
||||||
id
|
id
|
||||||
outfits {
|
numTotalOutfits
|
||||||
|
outfits(limit: 20, offset: $offset) {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
updatedAt
|
updatedAt
|
||||||
|
@ -56,32 +57,38 @@ function UserOutfitsPageContent() {
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
{
|
{
|
||||||
|
variables: { offset },
|
||||||
context: { sendAuth: true },
|
context: { sendAuth: true },
|
||||||
skip: userLoading,
|
skip: userLoading,
|
||||||
|
// This will give us the cached numTotalOutfits while we wait for the
|
||||||
|
// next page!
|
||||||
|
returnPartialData: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (userLoading || queryLoading) {
|
const isLoading = userLoading || queryLoading;
|
||||||
return (
|
|
||||||
<Center>
|
|
||||||
<HangerSpinner />
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <MajorErrorMessage error={error} variant="network" />;
|
return <MajorErrorMessage error={error} variant="network" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const outfits = data.currentUser.outfits;
|
const outfits = data?.currentUser?.outfits || [];
|
||||||
|
|
||||||
if (outfits.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
|
<Box>
|
||||||
|
<PaginationToolbar
|
||||||
|
isLoading={isLoading}
|
||||||
|
totalCount={data?.currentUser?.numTotalOutfits}
|
||||||
|
numPerPage={20}
|
||||||
|
/>
|
||||||
|
<Box height="6" />
|
||||||
|
{isLoading ? (
|
||||||
|
<Center>
|
||||||
|
<HangerSpinner />
|
||||||
|
</Center>
|
||||||
|
) : outfits.length === 0 ? (
|
||||||
<Box>You don't have any outfits yet. Maybe you can create some!</Box>
|
<Box>You don't have any outfits yet. Maybe you can create some!</Box>
|
||||||
);
|
) : (
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Wrap spacing="4" justify="space-around">
|
<Wrap spacing="4" justify="space-around">
|
||||||
{outfits.map((outfit) => (
|
{outfits.map((outfit) => (
|
||||||
<WrapItem key={outfit.id}>
|
<WrapItem key={outfit.id}>
|
||||||
|
@ -89,6 +96,14 @@ function UserOutfitsPageContent() {
|
||||||
</WrapItem>
|
</WrapItem>
|
||||||
))}
|
))}
|
||||||
</Wrap>
|
</Wrap>
|
||||||
|
)}
|
||||||
|
<Box height="6" />
|
||||||
|
<PaginationToolbar
|
||||||
|
isLoading={isLoading}
|
||||||
|
totalCount={data?.currentUser?.numTotalOutfits}
|
||||||
|
numPerPage={20}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,26 +2,29 @@ import React from "react";
|
||||||
import { Box, Button, Flex, Select } from "@chakra-ui/react";
|
import { Box, Button, Flex, Select } from "@chakra-ui/react";
|
||||||
import { Link, useHistory, useLocation } from "react-router-dom";
|
import { Link, useHistory, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
const PER_PAGE = 30;
|
function PaginationToolbar({
|
||||||
|
isLoading,
|
||||||
function PaginationToolbar({ isLoading, totalCount, ...props }) {
|
totalCount,
|
||||||
|
numPerPage = 30,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
const { search } = useLocation();
|
const { search } = useLocation();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const currentOffset =
|
const currentOffset =
|
||||||
parseInt(new URLSearchParams(search).get("offset")) || 0;
|
parseInt(new URLSearchParams(search).get("offset")) || 0;
|
||||||
|
|
||||||
const currentPageIndex = Math.floor(currentOffset / PER_PAGE);
|
const currentPageIndex = Math.floor(currentOffset / numPerPage);
|
||||||
const currentPageNumber = currentPageIndex + 1;
|
const currentPageNumber = currentPageIndex + 1;
|
||||||
const numTotalPages = totalCount ? Math.ceil(totalCount / PER_PAGE) : null;
|
const numTotalPages = totalCount ? Math.ceil(totalCount / numPerPage) : null;
|
||||||
|
|
||||||
const prevPageSearchParams = new URLSearchParams(search);
|
const prevPageSearchParams = new URLSearchParams(search);
|
||||||
const prevPageOffset = currentOffset - PER_PAGE;
|
const prevPageOffset = currentOffset - numPerPage;
|
||||||
prevPageSearchParams.set("offset", prevPageOffset);
|
prevPageSearchParams.set("offset", prevPageOffset);
|
||||||
const prevPageUrl = "?" + prevPageSearchParams.toString();
|
const prevPageUrl = "?" + prevPageSearchParams.toString();
|
||||||
|
|
||||||
const nextPageSearchParams = new URLSearchParams(search);
|
const nextPageSearchParams = new URLSearchParams(search);
|
||||||
const nextPageOffset = currentOffset + PER_PAGE;
|
const nextPageOffset = currentOffset + numPerPage;
|
||||||
nextPageSearchParams.set("offset", nextPageOffset);
|
nextPageSearchParams.set("offset", nextPageOffset);
|
||||||
const nextPageUrl = "?" + nextPageSearchParams.toString();
|
const nextPageUrl = "?" + nextPageSearchParams.toString();
|
||||||
|
|
||||||
|
@ -37,13 +40,13 @@ function PaginationToolbar({ isLoading, totalCount, ...props }) {
|
||||||
const goToPageNumber = React.useCallback(
|
const goToPageNumber = React.useCallback(
|
||||||
(newPageNumber) => {
|
(newPageNumber) => {
|
||||||
const newPageIndex = newPageNumber - 1;
|
const newPageIndex = newPageNumber - 1;
|
||||||
const newPageOffset = newPageIndex * PER_PAGE;
|
const newPageOffset = newPageIndex * numPerPage;
|
||||||
|
|
||||||
const newPageSearchParams = new URLSearchParams(search);
|
const newPageSearchParams = new URLSearchParams(search);
|
||||||
newPageSearchParams.set("offset", newPageOffset);
|
newPageSearchParams.set("offset", newPageOffset);
|
||||||
history.push({ search: newPageSearchParams.toString() });
|
history.push({ search: newPageSearchParams.toString() });
|
||||||
},
|
},
|
||||||
[search, history]
|
[search, history, numPerPage]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1291,13 +1291,19 @@ const buildUserClosetListsLoader = (db, loaders) =>
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildUserOutfitsLoader = (db, loaders) =>
|
const buildUserOutfitsLoader = (db, loaders) =>
|
||||||
new DataLoader(async (userIds) => {
|
new DataLoader(async (queries) => {
|
||||||
const qs = userIds.map((_) => "?").join(",");
|
// This isn't actually optimized as a batch query, we're just using a
|
||||||
|
// DataLoader API consistency with our other loaders!
|
||||||
|
return queries.map(async ({ userId, limit, offset }) => {
|
||||||
|
const actualLimit = Math.min(limit || 30, 30);
|
||||||
|
const actualOffset = offset || 0;
|
||||||
|
|
||||||
const [rows] = await db.execute(
|
const [rows] = await db.execute(
|
||||||
`SELECT * FROM outfits
|
`SELECT * FROM outfits
|
||||||
WHERE user_id IN (${qs})
|
WHERE user_id = ?
|
||||||
ORDER BY name`,
|
ORDER BY name
|
||||||
userIds
|
LIMIT ? OFFSET ?`,
|
||||||
|
[userId, actualLimit, actualOffset]
|
||||||
);
|
);
|
||||||
|
|
||||||
const entities = rows.map(normalizeRow);
|
const entities = rows.map(normalizeRow);
|
||||||
|
@ -1305,9 +1311,25 @@ const buildUserOutfitsLoader = (db, loaders) =>
|
||||||
loaders.outfitLoader.prime(entity.id, entity);
|
loaders.outfitLoader.prime(entity.id, entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
return userIds.map((userId) =>
|
return entities;
|
||||||
entities.filter((e) => e.userId === String(userId))
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildUserNumTotalOutfitsLoader = (db) =>
|
||||||
|
new DataLoader(async (userIds) => {
|
||||||
|
const qs = userIds.map((_) => "?").join(",");
|
||||||
|
const [rows] = await db.execute(
|
||||||
|
`SELECT user_id, COUNT(*) as num_total_outfits FROM outfits
|
||||||
|
WHERE user_id IN (${qs})
|
||||||
|
GROUP BY user_id`,
|
||||||
|
userIds
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const entities = rows.map(normalizeRow);
|
||||||
|
|
||||||
|
return userIds
|
||||||
|
.map((userId) => entities.find((e) => e.userId === String(userId)))
|
||||||
|
.map((e) => (e ? e.numTotalOutfits : 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildUserLastTradeActivityLoader = (db) =>
|
const buildUserLastTradeActivityLoader = (db) =>
|
||||||
|
@ -1486,6 +1508,7 @@ function buildLoaders(db) {
|
||||||
loaders.userByEmailLoader = buildUserByEmailLoader(db);
|
loaders.userByEmailLoader = buildUserByEmailLoader(db);
|
||||||
loaders.userClosetHangersLoader = buildUserClosetHangersLoader(db);
|
loaders.userClosetHangersLoader = buildUserClosetHangersLoader(db);
|
||||||
loaders.userClosetListsLoader = buildUserClosetListsLoader(db, loaders);
|
loaders.userClosetListsLoader = buildUserClosetListsLoader(db, loaders);
|
||||||
|
loaders.userNumTotalOutfitsLoader = buildUserNumTotalOutfitsLoader(db);
|
||||||
loaders.userOutfitsLoader = buildUserOutfitsLoader(db, loaders);
|
loaders.userOutfitsLoader = buildUserOutfitsLoader(db, loaders);
|
||||||
loaders.userLastTradeActivityLoader = buildUserLastTradeActivityLoader(db);
|
loaders.userLastTradeActivityLoader = buildUserLastTradeActivityLoader(db);
|
||||||
loaders.zoneLoader = buildZoneLoader(db);
|
loaders.zoneLoader = buildZoneLoader(db);
|
||||||
|
|
|
@ -28,9 +28,16 @@ const typeDefs = gql`
|
||||||
|
|
||||||
"""
|
"""
|
||||||
This user's outfits. Returns an empty list if the current user is not
|
This user's outfits. Returns an empty list if the current user is not
|
||||||
|
authorized to see them. This list is paginated, and will return at most 30
|
||||||
|
at once.
|
||||||
|
"""
|
||||||
|
outfits(limit: Int, offset: Int): [Outfit!]!
|
||||||
|
|
||||||
|
"""
|
||||||
|
The number of outfits this user has. Returns 0 if the current user is not
|
||||||
authorized to see them.
|
authorized to see them.
|
||||||
"""
|
"""
|
||||||
outfits: [Outfit!]!
|
numTotalOutfits: Int!
|
||||||
|
|
||||||
"This user's email address. Requires the correct supportSecret to view."
|
"This user's email address. Requires the correct supportSecret to view."
|
||||||
emailForSupportUsers(supportSecret: String!): String!
|
emailForSupportUsers(supportSecret: String!): String!
|
||||||
|
@ -241,15 +248,36 @@ const resolvers = {
|
||||||
return lastTradeActivity.toISOString();
|
return lastTradeActivity.toISOString();
|
||||||
},
|
},
|
||||||
|
|
||||||
outfits: async ({ id }, _, { currentUserId, userOutfitsLoader }) => {
|
outfits: async (
|
||||||
|
{ id },
|
||||||
|
{ limit = 30, offset = 0 },
|
||||||
|
{ currentUserId, userOutfitsLoader }
|
||||||
|
) => {
|
||||||
if (currentUserId !== id) {
|
if (currentUserId !== id) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const outfits = await userOutfitsLoader.load(id);
|
const outfits = await userOutfitsLoader.load({
|
||||||
|
userId: id,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
return outfits.map((outfit) => ({ id: outfit.id }));
|
return outfits.map((outfit) => ({ id: outfit.id }));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
numTotalOutfits: async (
|
||||||
|
{ id },
|
||||||
|
_,
|
||||||
|
{ currentUserId, userNumTotalOutfitsLoader }
|
||||||
|
) => {
|
||||||
|
if (currentUserId !== id) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const numTotalOutfits = await userNumTotalOutfitsLoader.load(id);
|
||||||
|
return numTotalOutfits;
|
||||||
|
},
|
||||||
|
|
||||||
emailForSupportUsers: async ({ id }, { supportSecret }, { db }) => {
|
emailForSupportUsers: async ({ id }, { supportSecret }, { db }) => {
|
||||||
assertSupportSecretOrThrow(supportSecret);
|
assertSupportSecretOrThrow(supportSecret);
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue