Skip to content

Commit 17f631f

Browse files
authored
Merge pull request #136 from atomly/jorge/feat/sidebar-pages-pagination
feat: replace react-window with react-virtuoso in sidebar pages
2 parents 2ff3a56 + fea9b97 commit 17f631f

File tree

18 files changed

+226
-147
lines changed

18 files changed

+226
-147
lines changed

apps/web/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@
6969
"react-dom": "~19.1.1",
7070
"react-hook-form": "~7.57.0",
7171
"react-virtuoso": "~4.13.0",
72-
"react-window": "^2.1.1",
7372
"remark-gfm": "~4.0.1",
7473
"remove-markdown": "~0.6.2",
7574
"sonner": "~2.0.7",

apps/web/src/ai/tools/create-page.client.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"use client";
22

3-
import type { Page } from "@acme/db/schema";
43
import { useMutation, useQueryClient } from "@tanstack/react-query";
54
import { useRouter } from "next/navigation";
5+
import { infinitePagesQueryOptions } from "~/app/api/trpc/options/pages-query-options";
66
import { useTRPC } from "~/trpc/react";
77
import { useAppEventEmitter } from "../../components/events/app-event-context";
88
import { PageCreatedEvent } from "../../events/page-created-event";
@@ -35,11 +35,34 @@ export function useCreatePageTool() {
3535
});
3636
},
3737
onSuccess: (newPage) => {
38+
// Optimistically update the pages list
3839
queryClient.setQueryData(
39-
trpc.pages.getByUser.queryOptions().queryKey,
40-
(oldPages: Page[] | undefined) => {
41-
if (!oldPages) return [newPage];
42-
return [newPage, ...oldPages];
40+
trpc.pages.getPaginated.infiniteQueryOptions(
41+
infinitePagesQueryOptions,
42+
).queryKey,
43+
(old) => {
44+
if (!old)
45+
return {
46+
pageParams: [],
47+
pages: [
48+
{
49+
items: [newPage],
50+
nextCursor: undefined,
51+
},
52+
],
53+
};
54+
const [first, ...rest] = old.pages;
55+
return {
56+
...old,
57+
pages: [
58+
{
59+
...first,
60+
items: [newPage, ...(first?.items ?? [])],
61+
nextCursor: first?.nextCursor,
62+
},
63+
...rest,
64+
],
65+
};
4366
},
4467
);
4568

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"use client";
2+
3+
import {
4+
SidebarMenuSubButton,
5+
SidebarMenuSubItem,
6+
} from "~/components/ui/sidebar";
7+
import { Skeleton } from "~/components/ui/skeleton";
8+
9+
type AppSidebarPageItemSkeletonProps = {
10+
className?: string;
11+
};
12+
13+
export function AppSidebarPageItemSkeleton({
14+
className,
15+
}: AppSidebarPageItemSkeletonProps) {
16+
return (
17+
<SidebarMenuSubItem className={className}>
18+
<SidebarMenuSubButton asChild>
19+
<div
20+
className={
21+
"group/page-item flex items-center justify-between p-0! hover:bg-transparent"
22+
}
23+
>
24+
<Skeleton className="h-5 w-full rounded-sm" />
25+
</div>
26+
</SidebarMenuSubButton>
27+
</SidebarMenuSubItem>
28+
);
29+
}

apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-page-item.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,19 @@ import { DeletePageButton } from "./delete-page-button";
1212

1313
type AppSidebarPageItemProps = {
1414
page: Page;
15+
className?: string;
1516
};
1617

17-
export function AppSidebarPageItem(props: AppSidebarPageItemProps) {
18-
const { page } = props;
19-
18+
export function AppSidebarPageItem({
19+
page,
20+
className,
21+
}: AppSidebarPageItemProps) {
2022
const pathname = usePathname();
2123

2224
const isActive = pathname.includes(page?.id ?? "");
2325

2426
return (
25-
<SidebarMenuSubItem key={page?.id}>
27+
<SidebarMenuSubItem key={page?.id} className={className}>
2628
<SidebarMenuSubButton asChild>
2729
<div
2830
className={cn(
Lines changed: 38 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"use client";
22

3-
import type { Page } from "@acme/db/schema";
3+
import type { PaginatedPagesInput } from "@acme/api";
44
import { useInfiniteQuery } from "@tanstack/react-query";
55
import { BookOpen, ChevronRight, Loader2 } from "lucide-react";
66
import { useState } from "react";
7-
import { List } from "react-window";
7+
import { Virtuoso } from "react-virtuoso";
88
import {
99
Collapsible,
1010
CollapsibleContent,
@@ -17,32 +17,32 @@ import {
1717
} from "~/components/ui/sidebar";
1818
import { useTRPC } from "~/trpc/react";
1919
import { AppSidebarPageItem } from "./app-sidebar-page-item";
20+
import { AppSidebarPageItemSkeleton } from "./app-sidebar-page-item-skeleton";
2021
import { CreatePageButton } from "./create-page-button";
2122

2223
type AppSidebarPagesProps = {
23-
infinitePagesQueryOptions: {
24-
direction: "forward" | "backward";
25-
limit: number;
26-
};
24+
infinitePagesQueryOptions: PaginatedPagesInput;
2725
defaultOpen?: boolean;
2826
};
2927

28+
const APPROXIMATE_ITEM_HEIGHT = 28;
29+
const VIEWPORT_INCREASE_FACTOR = 5;
30+
3031
export const AppSidebarPages = ({
3132
infinitePagesQueryOptions,
3233
defaultOpen = true,
3334
}: AppSidebarPagesProps) => {
3435
const trpc = useTRPC();
3536
const { state, setOpen } = useSidebar();
36-
const queryOptions = trpc.pages.getInfinite.infiniteQueryOptions(
37+
const queryOptions = trpc.pages.getPaginated.infiniteQueryOptions(
3738
infinitePagesQueryOptions,
3839
);
39-
const { data, fetchNextPage, isFetchingNextPage, hasNextPage } =
40-
useInfiniteQuery({
41-
...queryOptions,
42-
getNextPageParam: ({ nextCursor }) => {
43-
return nextCursor;
44-
},
45-
});
40+
const { status, data, fetchNextPage, hasNextPage } = useInfiniteQuery({
41+
...queryOptions,
42+
getNextPageParam: ({ nextCursor }) => {
43+
return nextCursor;
44+
},
45+
});
4646

4747
const pages = data?.pages?.flatMap((page) => page.items) ?? [];
4848

@@ -72,7 +72,7 @@ export const AppSidebarPages = ({
7272
tooltip="Pages"
7373
onClick={handlePagesClick}
7474
>
75-
{isFetchingNextPage ? (
75+
{status === "pending" ? (
7676
<Loader2 className="size-3 animate-spin" />
7777
) : (
7878
<BookOpen />
@@ -82,47 +82,35 @@ export const AppSidebarPages = ({
8282
</SidebarMenuButton>
8383
</CollapsibleTrigger>
8484

85-
<CollapsibleContent className="flex min-h-0 flex-col">
86-
<SidebarMenuSub className="mr-0 flex-1 overflow-scroll pr-0">
87-
<CreatePageButton />
88-
<List
89-
rowComponent={PageRow}
90-
rowCount={pages.length}
91-
rowHeight={28}
92-
// @ts-expect-error - react-window types are incorrectly expecting index/style in rowProps
93-
rowProps={{
94-
pages,
95-
}}
96-
onRowsRendered={({ stopIndex }) => {
97-
// Fetch next page when user scrolls near the end
98-
if (
99-
stopIndex >= pages.length - 5 &&
100-
!isFetchingNextPage &&
101-
hasNextPage
102-
) {
85+
<CollapsibleContent className="flex h-full min-h-0 flex-col">
86+
<SidebarMenuSub className="mx-0 mr-0 flex-1 gap-0 overflow-scroll border-none px-0">
87+
<CreatePageButton className="ml-3.5 border-sidebar-border border-l ps-2.5 pb-2" />
88+
<Virtuoso
89+
className="h-full w-full"
90+
data={pages}
91+
increaseViewportBy={
92+
APPROXIMATE_ITEM_HEIGHT * VIEWPORT_INCREASE_FACTOR
93+
}
94+
itemContent={(_, page) => (
95+
<AppSidebarPageItem
96+
page={page}
97+
className="ml-3.5 border-sidebar-border border-l ps-2.5 pb-1"
98+
/>
99+
)}
100+
endReached={() => {
101+
if (status === "success" && hasNextPage) {
103102
fetchNextPage();
104103
}
105104
}}
105+
components={{
106+
Footer: () =>
107+
status === "pending" ? (
108+
<AppSidebarPageItemSkeleton className="ml-3.5 border-sidebar-border border-l ps-2.5" />
109+
) : null,
110+
}}
106111
/>
107112
</SidebarMenuSub>
108113
</CollapsibleContent>
109114
</Collapsible>
110115
);
111116
};
112-
113-
const PageRow = ({
114-
index,
115-
style,
116-
pages,
117-
}: {
118-
index: number;
119-
style: React.CSSProperties;
120-
} & { pages: Page[] }) => {
121-
const page = pages?.[index];
122-
if (!page) return null;
123-
return (
124-
<div style={style}>
125-
<AppSidebarPageItem page={page} />
126-
</div>
127-
);
128-
};

apps/web/src/app/(app)/@appSidebar/_components/create-page-button.tsx

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"use client";
22

3-
import type { Page } from "@acme/db/schema";
43
import { useMutation, useQueryClient } from "@tanstack/react-query";
54
import { Plus } from "lucide-react";
65
import { useRouter } from "next/navigation";
@@ -11,8 +10,13 @@ import {
1110
SidebarMenuSubItem,
1211
} from "~/components/ui/sidebar";
1312
import { useTRPC } from "~/trpc/react";
13+
import { infinitePagesQueryOptions } from "../../../api/trpc/options/pages-query-options";
1414

15-
export function CreatePageButton() {
15+
type CreatePageButtonProps = {
16+
className?: string;
17+
};
18+
19+
export function CreatePageButton({ className }: CreatePageButtonProps) {
1620
const router = useRouter();
1721
const trpc = useTRPC();
1822
const queryClient = useQueryClient();
@@ -35,10 +39,32 @@ export function CreatePageButton() {
3539
onSuccess: (newPage) => {
3640
// Optimistically update the pages list
3741
queryClient.setQueryData(
38-
trpc.pages.getByUser.queryOptions().queryKey,
39-
(oldPages: Page[] | undefined) => {
40-
if (!oldPages) return [newPage];
41-
return [newPage, ...oldPages];
42+
trpc.pages.getPaginated.infiniteQueryOptions(
43+
infinitePagesQueryOptions,
44+
).queryKey,
45+
(old) => {
46+
if (!old)
47+
return {
48+
pageParams: [],
49+
pages: [
50+
{
51+
items: [newPage],
52+
nextCursor: undefined,
53+
},
54+
],
55+
};
56+
const [first, ...rest] = old.pages;
57+
return {
58+
...old,
59+
pages: [
60+
{
61+
...first,
62+
items: [newPage, ...(first?.items ?? [])],
63+
nextCursor: first?.nextCursor,
64+
},
65+
...rest,
66+
],
67+
};
4268
},
4369
);
4470

@@ -53,7 +79,7 @@ export function CreatePageButton() {
5379
const showLoading = isPending || isCreating;
5480

5581
return (
56-
<SidebarMenuSubItem>
82+
<SidebarMenuSubItem className={className}>
5783
<SidebarMenuSubButton asChild>
5884
<div className="border-2 border-sidebar-border border-dashed">
5985
<Button

apps/web/src/app/(app)/@appSidebar/_components/delete-page-button.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
DialogTitle,
1616
} from "~/components/ui/dialog";
1717
import { useTRPC } from "~/trpc/react";
18+
import { infinitePagesQueryOptions } from "../../../api/trpc/options/pages-query-options";
1819

1920
interface DeletePageButtonProps {
2021
page: Page;
@@ -55,10 +56,18 @@ export function DeletePageButton({ page, className }: DeletePageButtonProps) {
5556

5657
// Optimistically update the pages list
5758
queryClient.setQueryData(
58-
trpc.pages.getByUser.queryOptions().queryKey,
59-
(oldPages: Page[] | undefined) => {
60-
if (!oldPages) return [];
61-
return oldPages.filter((p) => p.id !== page.id);
59+
trpc.pages.getPaginated.infiniteQueryOptions(
60+
infinitePagesQueryOptions,
61+
).queryKey,
62+
(old) => {
63+
if (!old) return old;
64+
return {
65+
...old,
66+
pages: old.pages.map((p) => ({
67+
...p,
68+
items: p.items.filter((p) => p.id !== page.id),
69+
})),
70+
};
6271
},
6372
);
6473

apps/web/src/app/(app)/@appSidebar/default.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,17 @@ import {
1212
import { Skeleton } from "~/components/ui/skeleton";
1313
import { env } from "~/env";
1414
import { prefetch, trpc } from "~/trpc/server";
15+
import { infinitePagesQueryOptions } from "../../api/trpc/options/pages-query-options";
1516
import { DynamicAppSidebarDevtools } from "./_components/app-sidebar-devtools.dynamic";
1617
import { AppSidebarNavigation } from "./_components/app-sidebar-main";
1718
import { AppSidebarPages } from "./_components/app-sidebar-pages";
1819
import { AppSidebarPagesSkeleton } from "./_components/app-sidebar-pages-skeleton";
1920
import { AppSidebarUser } from "./_components/app-sidebar-user";
2021
import { AppSidebarUserSkeleton } from "./_components/app-sidebar-user-skeleton";
2122

22-
export const infinitePagesQueryOptions = {
23-
direction: "forward" as const,
24-
limit: 25,
25-
} as const;
26-
2723
async function SuspendedAppSidebarPages() {
2824
prefetch(
29-
trpc.pages.getInfinite.infiniteQueryOptions(infinitePagesQueryOptions),
25+
trpc.pages.getPaginated.infiniteQueryOptions(infinitePagesQueryOptions),
3026
);
3127
return (
3228
<AppSidebarPages infinitePagesQueryOptions={infinitePagesQueryOptions} />

0 commit comments

Comments
 (0)