diff --git a/apps/web/package.json b/apps/web/package.json
index 7f55925..9bd16e2 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -69,6 +69,7 @@
"react-dom": "~19.1.1",
"react-hook-form": "~7.57.0",
"react-virtuoso": "~4.13.0",
+ "react-window": "^2.1.1",
"remark-gfm": "~4.0.1",
"remove-markdown": "~0.6.2",
"sonner": "~2.0.7",
diff --git a/apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-pages.tsx b/apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-pages.tsx
index b94795d..68a01de 100644
--- a/apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-pages.tsx
+++ b/apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-pages.tsx
@@ -1,9 +1,10 @@
"use client";
import type { Page } from "@acme/db/schema";
-import { useQuery } from "@tanstack/react-query";
-import { BookOpen, ChevronRight } from "lucide-react";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { BookOpen, ChevronRight, Loader2 } from "lucide-react";
import { useState } from "react";
+import { List } from "react-window";
import {
Collapsible,
CollapsibleContent,
@@ -19,21 +20,31 @@ import { AppSidebarPageItem } from "./app-sidebar-page-item";
import { CreatePageButton } from "./create-page-button";
type AppSidebarPagesProps = {
- initialPages: Page[];
+ infinitePagesQueryOptions: {
+ direction: "forward" | "backward";
+ limit: number;
+ };
defaultOpen?: boolean;
};
export const AppSidebarPages = ({
- initialPages,
+ infinitePagesQueryOptions,
defaultOpen = true,
}: AppSidebarPagesProps) => {
const trpc = useTRPC();
const { state, setOpen } = useSidebar();
+ const queryOptions = trpc.pages.getInfinite.infiniteQueryOptions(
+ infinitePagesQueryOptions,
+ );
+ const { data, fetchNextPage, isFetchingNextPage, hasNextPage } =
+ useInfiniteQuery({
+ ...queryOptions,
+ getNextPageParam: ({ nextCursor }) => {
+ return nextCursor;
+ },
+ });
- const { data: pages } = useQuery({
- ...trpc.pages.getByUser.queryOptions(),
- initialData: initialPages,
- });
+ const pages = data?.pages?.flatMap((page) => page.items) ?? [];
const [isOpen, setIsOpen] = useState(defaultOpen);
@@ -53,23 +64,65 @@ export const AppSidebarPages = ({
-
-
+
+ {isFetchingNextPage ? (
+
+ ) : (
+
+ )}
Pages
-
-
+
+
+
- {pages?.map((page) => (
-
- ))}
+ {
+ // Fetch next page when user scrolls near the end
+ if (
+ stopIndex >= pages.length - 5 &&
+ !isFetchingNextPage &&
+ hasNextPage
+ ) {
+ fetchNextPage();
+ }
+ }}
+ />
);
};
+
+const PageRow = ({
+ index,
+ style,
+ pages,
+}: {
+ index: number;
+ style: React.CSSProperties;
+} & { pages: Page[] }) => {
+ const page = pages?.[index];
+ if (!page) return null;
+ return (
+
+ );
+};
diff --git a/apps/web/src/app/(app)/@appSidebar/default.tsx b/apps/web/src/app/(app)/@appSidebar/default.tsx
index 3a560cb..d18f150 100644
--- a/apps/web/src/app/(app)/@appSidebar/default.tsx
+++ b/apps/web/src/app/(app)/@appSidebar/default.tsx
@@ -11,7 +11,7 @@ import {
} from "~/components/ui/sidebar";
import { Skeleton } from "~/components/ui/skeleton";
import { env } from "~/env";
-import { api } from "~/trpc/server";
+import { prefetch, trpc } from "~/trpc/server";
import { DynamicAppSidebarDevtools } from "./_components/app-sidebar-devtools.dynamic";
import { AppSidebarNavigation } from "./_components/app-sidebar-main";
import { AppSidebarPages } from "./_components/app-sidebar-pages";
@@ -19,9 +19,18 @@ import { AppSidebarPagesSkeleton } from "./_components/app-sidebar-pages-skeleto
import { AppSidebarUser } from "./_components/app-sidebar-user";
import { AppSidebarUserSkeleton } from "./_components/app-sidebar-user-skeleton";
+export const infinitePagesQueryOptions = {
+ direction: "forward" as const,
+ limit: 25,
+} as const;
+
async function SuspendedAppSidebarPages() {
- const pages = await api.pages.getByUser();
- return ;
+ prefetch(
+ trpc.pages.getInfinite.infiniteQueryOptions(infinitePagesQueryOptions),
+ );
+ return (
+
+ );
}
export default function AppSidebar() {
@@ -42,8 +51,8 @@ export default function AppSidebar() {
-
-
+
+
Navigation
}>
diff --git a/apps/web/src/app/(app)/pages/_components/page-title-textarea.tsx b/apps/web/src/app/(app)/pages/_components/page-title-textarea.tsx
index a8bbe41..04021ff 100644
--- a/apps/web/src/app/(app)/pages/_components/page-title-textarea.tsx
+++ b/apps/web/src/app/(app)/pages/_components/page-title-textarea.tsx
@@ -7,6 +7,7 @@ import { useDebouncedCallback } from "use-debounce";
import { FullHeightTextarea } from "~/components/ui/full-height-textarea";
import { cn } from "~/components/utils";
import { useTRPC } from "~/trpc/react";
+import { infinitePagesQueryOptions } from "../../@appSidebar/default";
const DEFAULT_PLACEHOLDER = "New page";
const DEFAULT_DEBOUNCE_TIME = 150;
@@ -52,19 +53,22 @@ export function PageTitleTextarea({
},
);
- // Update the pages.getAll query cache
+ // Update the pages.getInfinite query cache from sidebar
queryClient.setQueryData(
- trpc.pages.getByUser.queryOptions().queryKey,
+ trpc.pages.getInfinite.infiniteQueryOptions(infinitePagesQueryOptions)
+ .queryKey,
(old) => {
if (!old) return old;
- return old.map((p) =>
- p.id === page.id
- ? {
- ...p,
- title: newTitle,
- }
- : p,
- );
+ const pages = old.pages.map((p) => ({
+ ...p,
+ items: p.items.map((item) =>
+ item.id === page.id ? { ...item, title: newTitle } : item,
+ ),
+ }));
+ return {
+ ...old,
+ pages,
+ };
},
);
diff --git a/packages/api/src/api-router/pages.ts b/packages/api/src/api-router/pages.ts
index a22c8d4..56b11d4 100644
--- a/packages/api/src/api-router/pages.ts
+++ b/packages/api/src/api-router/pages.ts
@@ -1,5 +1,5 @@
import { blocknoteBlocks } from "@acme/blocknote/server";
-import { and, cosineDistance, desc, eq, gt, sql } from "@acme/db";
+import { and, cosineDistance, desc, eq, gt, lt, sql } from "@acme/db";
import {
Document,
DocumentEmbedding,
@@ -114,6 +114,54 @@ export const pagesRouter = {
});
}
}),
+ getInfinite: protectedProcedure
+ .input(
+ z.object({
+ cursor: z.iso.datetime().optional(),
+ direction: z.enum(["forward", "backward"]).default("forward"),
+ limit: z.number().min(1).max(50).default(10),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ try {
+ const { limit, cursor, direction } = input;
+
+ const pages = await ctx.db
+ .select()
+ .from(Page)
+ .where(
+ and(
+ eq(Page.user_id, ctx.session.user.id),
+ cursor
+ ? direction === "forward"
+ ? lt(Page.updated_at, cursor)
+ : gt(Page.updated_at, cursor)
+ : undefined,
+ ),
+ )
+ .orderBy(desc(Page.updated_at))
+ .limit(limit + 1);
+
+ let nextCursor: string | undefined;
+ if (pages.length > limit) {
+ const nextItem = pages.pop();
+ nextCursor = nextItem?.updated_at
+ ? new Date(nextItem.updated_at).toISOString()
+ : undefined;
+ }
+
+ return {
+ items: pages,
+ nextCursor,
+ };
+ } catch (error) {
+ console.error("Database error in pages.getInfinite:", error);
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to fetch pages",
+ });
+ }
+ }),
getRelevantPages: protectedProcedure
.input(
z.object({
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0179d10..ac89afb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -245,6 +245,9 @@ importers:
react-virtuoso:
specifier: ~4.13.0
version: 4.13.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ react-window:
+ specifier: ^2.1.1
+ version: 2.1.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
remark-gfm:
specifier: ~4.0.1
version: 4.0.1
@@ -5800,6 +5803,12 @@ packages:
react: '>=16 || >=17 || >= 18 || >= 19'
react-dom: '>=16 || >=17 || >= 18 || >=19'
+ react-window@2.1.1:
+ resolution: {integrity: sha512-Wx5yHri8G1nFxImnJRkEEKtRTnG3cWaqknUJyYvgisQtl1mw/d8LQmLXfuKxpn2dY8IwDn5mCIuxm2NVyIvgVQ==}
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+
react@19.1.1:
resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==}
engines: {node: '>=0.10.0'}
@@ -12823,6 +12832,11 @@ snapshots:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
+ react-window@2.1.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
+ dependencies:
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+
react@19.1.1: {}
readable-stream@3.6.2: