Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/calm-pages-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"kitcn": patch
---

## Patches

- Fix cRPC infinite queries backed by native Convex pagination loading pages
before `fetchNextPage()` is called.
462 changes: 462 additions & 0 deletions docs/plans/304-native-convex-pagination-in-crpc-infinite-queries.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion fixtures/next-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"convex": "1.38.0",
"hono": "4.12.9",
"kitcn": "workspace:*",
"lucide-react": "^1.25.0",
"lucide-react": "^1.26.0",
"next": "16.2.6",
"next-themes": "^0.4.6",
"react": "19.2.4",
Expand Down
2 changes: 1 addition & 1 deletion fixtures/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"convex": "1.38.0",
"hono": "4.12.9",
"kitcn": "workspace:*",
"lucide-react": "^1.25.0",
"lucide-react": "^1.26.0",
"next": "16.2.6",
"next-themes": "^0.4.6",
"react": "19.2.4",
Expand Down
2 changes: 1 addition & 1 deletion fixtures/start-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"convex": "1.38.0",
"hono": "4.12.9",
"kitcn": "workspace:*",
"lucide-react": "^1.25.0",
"lucide-react": "^1.26.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"shadcn": "latest",
Expand Down
2 changes: 1 addition & 1 deletion fixtures/start/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"convex": "1.38.0",
"hono": "4.12.9",
"kitcn": "workspace:*",
"lucide-react": "^1.25.0",
"lucide-react": "^1.26.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"shadcn": "latest",
Expand Down
2 changes: 1 addition & 1 deletion fixtures/vite-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"convex": "1.38.0",
"hono": "4.12.9",
"kitcn": "workspace:*",
"lucide-react": "^1.25.0",
"lucide-react": "^1.26.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"shadcn": "latest",
Expand Down
2 changes: 1 addition & 1 deletion fixtures/vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"convex": "1.38.0",
"hono": "4.12.9",
"kitcn": "workspace:*",
"lucide-react": "^1.25.0",
"lucide-react": "^1.26.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"shadcn": "latest",
Expand Down
14 changes: 14 additions & 0 deletions packages/kitcn/src/internal/pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { PaginationResult } from 'convex/server';

type SplitPaginationResult<T> = PaginationResult<T> & {
splitCursor: string;
};

export const shouldSplitPaginationPage = <T>(
page: PaginationResult<T>,
initialNumItems?: number
): page is SplitPaginationResult<T> =>
Boolean(page.splitCursor) &&
(page.pageStatus === 'SplitRecommended' ||
page.pageStatus === 'SplitRequired' ||
(initialNumItems !== undefined && page.page.length > initialNumItems * 2));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve default page size for reactive splits

When callers rely on the .paginated({ limit }) server default and call infiniteQueryOptions(args) without an override, initialNumItems is undefined here, so the new size-based split branch is disabled for plain-splitCursor pages. In that common/default-limit path, a live page can grow past twice its intended page size without being split, leaving an ever-growing first subscription; carry the generated/default page size into this predicate instead of only using the optional client override.

Useful? React with πŸ‘Β / πŸ‘Ž.

81 changes: 81 additions & 0 deletions packages/kitcn/src/react/use-infinite-query.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,87 @@ describe('useInfiniteQuery', () => {
expect((firstCall.queries[0] as any).enabled).toBe(true);
});

test('does not split a native Convex page solely because it has a split cursor', () => {
const queryClient = new QueryClient();
const wrapper = makeWrapper(queryClient);
const firstPage = {
page: [{ _id: 'post-1' }, { _id: 'post-2' }, { _id: 'post-3' }],
isDone: false,
continueCursor: 'cursor-3',
splitCursor: 'cursor-2',
};
const secondPage = {
page: [{ _id: 'post-4' }, { _id: 'post-5' }, { _id: 'post-6' }],
isDone: true,
continueCursor: 'cursor-6',
};

useQueriesSpy.mockImplementation((arg: UseQueriesArg) => {
useQueriesCalls.push(arg);
return (arg as any).combine(
[firstPage, secondPage]
.slice(0, arg.queries.length)
.map((data, index) => ({
data,
dataUpdatedAt: index + 1,
isError: false,
isFetching: false,
isLoading: false,
isPlaceholderData: false,
}))
);
});

const options = createOptions({ limit: 3 });
const { result } = renderHook(() => useInfiniteQuery(options), { wrapper });

expect(result.current.data).toEqual(firstPage.page);
expect(result.current.hasNextPage).toBe(true);
expect(useQueriesCalls.at(-1)?.queries).toHaveLength(1);

act(() => {
result.current.fetchNextPage();
});

expect(result.current.data).toEqual([
...firstPage.page,
...secondPage.page,
]);
expect(result.current.hasNextPage).toBe(false);
expect(useQueriesCalls.at(-1)?.queries).toHaveLength(2);
});

test('splits a page when Convex recommends it', () => {
const queryClient = new QueryClient();
const wrapper = makeWrapper(queryClient);
const firstPage = {
page: [{ _id: 'post-1' }, { _id: 'post-2' }, { _id: 'post-3' }],
isDone: false,
continueCursor: 'cursor-3',
splitCursor: 'cursor-2',
pageStatus: 'SplitRecommended',
};

useQueriesSpy.mockImplementation((arg: UseQueriesArg) => {
useQueriesCalls.push(arg);
return (arg as any).combine([
{
data: firstPage,
dataUpdatedAt: 1,
isError: false,
isFetching: false,
isLoading: false,
isPlaceholderData: false,
},
]);
});

const options = createOptions({ limit: 3 });
renderHook(() => useInfiniteQuery(options), { wrapper });

expect(useQueriesCalls.at(-1)?.queries).toHaveLength(2);
});

test('fetchNextPage adds a new page query with continueCursor and limit', () => {
const queryClient = new QueryClient();
const wrapper = makeWrapper(queryClient);
Expand Down
25 changes: 12 additions & 13 deletions packages/kitcn/src/react/use-infinite-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { CRPCClientError, isCRPCClientError } from '../crpc/error';
import { convexQuery } from '../crpc/query-options';
import { type ExtractPaginatedItem, FUNC_REF_SYMBOL } from '../crpc/types';
import { shouldSplitPaginationPage } from '../internal/pagination';
import type { DistributiveOmit } from '../internal/types';
import { useAuthValue, useSafeConvexAuth } from './auth-store';
import { useMeta } from './context';
Expand Down Expand Up @@ -134,13 +135,6 @@ type PageState = {
endCursor?: string | null; // For page splitting - the cursor where this page ends
};

// Page splitting: when a page gets too large, Convex may return splitCursor
// - SplitRecommended: page is large, should split on next render
// - SplitRequired: page MUST be split (too large to return)
type PageResultWithSplit<T> = PaginationResult<T> & {
splitCursor?: string | null;
};

/** Build a unique key for recovery attempt detection */
const buildRecoveryKey = (
pageKeys: number[],
Expand Down Expand Up @@ -507,7 +501,7 @@ const useInfiniteQueryInternal = <Query extends PaginatedQueryReference>(
const allItems: PaginatedQueryItem<Query>[] = [];
const pages: PaginatedQueryItem<Query>[][] = [];
const seenIds = new Set<string>();
let lastPage: PageResultWithSplit<PaginatedQueryItem<Query>> | undefined;
let lastPage: PaginationResult<PaginatedQueryItem<Query>> | undefined;
let paginationStatus: PaginationStatus = 'LoadingFirstPage';

for (let i = 0; i < results.length; i++) {
Expand All @@ -516,7 +510,7 @@ const useInfiniteQueryInternal = <Query extends PaginatedQueryReference>(
paginationStatus = i === 0 ? 'LoadingFirstPage' : 'LoadingMore';
break;
}
const page = pageQuery.data as PageResultWithSplit<
const page = pageQuery.data as PaginationResult<
PaginatedQueryItem<Query>
>;
lastPage = page;
Expand Down Expand Up @@ -577,25 +571,29 @@ const useInfiniteQueryInternal = <Query extends PaginatedQueryReference>(
state,
});

// Handle page splitting - when a page returns splitCursor, we need to split it
// Split when Convex requests it or a reactive page outgrows its target size.
useEffect(() => {
for (let i = 0; i < combined._rawResults.length; i++) {
const pageQuery = combined._rawResults[i];
if (pageQuery.data) {
const page = pageQuery.data as PageResultWithSplit<
const page = pageQuery.data as PaginationResult<
PaginatedQueryItem<Query>
>;
const pageKey = state.pageKeys[i];
const pageState = state.queries[pageKey];

// Check if this page needs splitting and we haven't already split it
if (page.splitCursor && pageState && !pageState.endCursor) {
if (
shouldSplitPaginationPage(page, limit) &&
pageState &&
!pageState.endCursor
) {
setState((prev) => {
const currentPageState = prev.queries[pageKey];
if (!currentPageState || currentPageState.endCursor) return prev;

const newKey = prev.nextPageKey;
const splitCursor = page.splitCursor!; // Checked above: page.splitCursor is truthy
const splitCursor = page.splitCursor;
const splitPageArgs = {
...argsObject,
cursor: splitCursor,
Expand Down Expand Up @@ -635,6 +633,7 @@ const useInfiniteQueryInternal = <Query extends PaginatedQueryReference>(
state.pageKeys,
state.queries,
argsObject,
limit,
setState,
]);

Expand Down
26 changes: 12 additions & 14 deletions packages/kitcn/src/solid/use-infinite-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { createEffect, createMemo, createSignal, on } from 'solid-js';
import { CRPCClientError, isCRPCClientError } from '../crpc/error';
import { convexQuery } from '../crpc/query-options';
import { type ExtractPaginatedItem, FUNC_REF_SYMBOL } from '../crpc/types';
import { shouldSplitPaginationPage } from '../internal/pagination';
import type { DistributiveOmit } from '../internal/types';
import { useMeta } from './auth';
import { useAuthValue, useSafeConvexAuth } from './auth-store';
Expand Down Expand Up @@ -139,13 +140,6 @@ type PageState = {
endCursor?: string | null; // For page splitting - the cursor where this page ends
};

// Page splitting: when a page gets too large, Convex may return splitCursor
// - SplitRecommended: page is large, should split on next render
// - SplitRequired: page MUST be split (too large to return)
type PageResultWithSplit<T> = PaginationResult<T> & {
splitCursor?: string | null;
};

/** Build a unique key for recovery attempt detection */
const buildRecoveryKey = (
pageKeys: number[],
Expand Down Expand Up @@ -500,7 +494,7 @@ const useInfiniteQueryInternal = <Query extends PaginatedQueryReference>(
const allItems: PaginatedQueryItem<Query>[] = [];
const pages: PaginatedQueryItem<Query>[][] = [];
const seenIds = new Set<string>();
let lastPage: PageResultWithSplit<PaginatedQueryItem<Query>> | undefined;
let lastPage: PaginationResult<PaginatedQueryItem<Query>> | undefined;
let paginationStatus: PaginationStatus = 'LoadingFirstPage';

for (let i = 0; i < results.length; i++) {
Expand All @@ -509,7 +503,7 @@ const useInfiniteQueryInternal = <Query extends PaginatedQueryReference>(
paginationStatus = i === 0 ? 'LoadingFirstPage' : 'LoadingMore';
break;
}
const page = pageQuery.data as PageResultWithSplit<
const page = pageQuery.data as PaginationResult<
PaginatedQueryItem<Query>
>;
lastPage = page;
Expand Down Expand Up @@ -558,7 +552,7 @@ const useInfiniteQueryInternal = <Query extends PaginatedQueryReference>(
})) as {
data: PaginatedQueryItem<Query>[];
dataUpdatedAt: number;
lastPage: PageResultWithSplit<PaginatedQueryItem<Query>> | undefined;
lastPage: PaginationResult<PaginatedQueryItem<Query>> | undefined;
pages: PaginatedQueryItem<Query>[][];
status: PaginationStatus;
error: Error | null;
Expand All @@ -581,7 +575,7 @@ const useInfiniteQueryInternal = <Query extends PaginatedQueryReference>(
state,
});

// Handle page splitting - when a page returns splitCursor, we need to split it
// Split when Convex requests it or a reactive page outgrows its target size.
createEffect(
on(
[
Expand All @@ -594,21 +588,25 @@ const useInfiniteQueryInternal = <Query extends PaginatedQueryReference>(
for (let i = 0; i < combined._rawResults.length; i++) {
const pageQuery = combined._rawResults[i];
if (pageQuery.data) {
const page = pageQuery.data as PageResultWithSplit<
const page = pageQuery.data as PaginationResult<
PaginatedQueryItem<Query>
>;
const pageKey = state().pageKeys[i];
const pageState = state().queries[pageKey];

// Check if this page needs splitting and we haven't already split it
if (page.splitCursor && pageState && !pageState.endCursor) {
if (
shouldSplitPaginationPage(page, limit) &&
pageState &&
!pageState.endCursor
) {
setState((prev) => {
const currentPageState = prev.queries[pageKey];
if (!currentPageState || currentPageState.endCursor)
return prev;

const newKey = prev.nextPageKey;
const splitCursor = page.splitCursor!;
const splitCursor = page.splitCursor;
const splitPageArgs = {
...argsObject(),
cursor: splitCursor,
Expand Down
35 changes: 35 additions & 0 deletions packages/kitcn/src/solid/use-infinite-query.vitest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,41 @@ describe('useInfiniteQuery', () => {
expect((firstCall.queries[0] as any).enabled).toBe(true);
});

test('does not split a native Convex page solely because it has a split cursor', () => {
const queryClient = new QueryClient();
const wrapper = makeWrapper(queryClient);
const firstPage = {
page: [{ _id: 'post-1' }, { _id: 'post-2' }, { _id: 'post-3' }],
isDone: false,
continueCursor: 'cursor-3',
splitCursor: 'cursor-2',
};
let capturedAccessor: (() => UseQueriesArg) | null = null;

mockUseQueries.mockImplementation((accessor: any) => {
capturedAccessor = accessor;
const arg = typeof accessor === 'function' ? accessor() : accessor;
useQueriesCalls.push(arg);
return arg.combine([
{
data: firstPage,
dataUpdatedAt: 1,
isError: false,
isFetching: false,
isLoading: false,
isPlaceholderData: false,
},
]);
});

const options = createOptions({ limit: 3 });
const { result } = renderHook(() => useInfiniteQuery(options), { wrapper });

expect(result.data).toEqual(firstPage.page);
expect(result.hasNextPage).toBe(true);
expect(capturedAccessor!().queries).toHaveLength(1);
});

test('fetchNextPage adds a new page query with continueCursor and limit', () => {
const queryClient = new QueryClient();
const wrapper = makeWrapper(queryClient);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';

import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
Expand Down
Loading