diff --git a/examples/query/react/infinite-queries/package.json b/examples/query/react/infinite-queries/package.json index 933e07886d..e3c7934767 100644 --- a/examples/query/react/infinite-queries/package.json +++ b/examples/query/react/infinite-queries/package.json @@ -15,7 +15,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@reduxjs/toolkit": "https://pkg.csb.dev/reduxjs/redux-toolkit/commit/aa419c22/@reduxjs/toolkit/_pkg.tgz", + "@reduxjs/toolkit": "https://pkg.csb.dev/reduxjs/redux-toolkit/commit/c9cc8ca0/@reduxjs/toolkit/_pkg.tgz", "react": "^18.2.0", "react-dom": "^18.2.0", "react-intersection-observer": "^9.13.1", diff --git a/examples/query/react/infinite-queries/src/App.tsx b/examples/query/react/infinite-queries/src/App.tsx index af9d854e4d..442c3306d7 100644 --- a/examples/query/react/infinite-queries/src/App.tsx +++ b/examples/query/react/infinite-queries/src/App.tsx @@ -1,12 +1,16 @@ +import { BrowserRouter, Link, Route, Routes, useLocation } from "react-router" import "./App.css" -import { BrowserRouter, Routes, Route, Link } from "react-router" -import { PaginationExample } from "./features/pagination/PaginationExample" +import { Outlet } from "react-router" +import BidirectionalCursorInfScroll from "./features/bidirectional-cursor-infinite-scroll/BidirectionalCursorInfScroll" import { - InfiniteScrollExample, InfiniteScrollAbout, + InfiniteScrollExample, } from "./features/infinite-scroll/InfiniteScrollExample" +import LimitOffsetExample from "./features/limit-offset/LimitOffsetExample" import { InfiniteScrollMaxPagesExample } from "./features/max-pages/InfiniteScrollMaxExample" +import PaginationInfScrollExample from "./features/pagination-infinite-scroll/PaginationInfScrollExample" +import { PaginationExample } from "./features/pagination/PaginationExample" const Menu = () => { return ( @@ -14,13 +18,30 @@ const Menu = () => {

Examples

@@ -32,18 +53,38 @@ const App = () => {

RTKQ Infinite Query Example Showcase

+ } /> - } /> - } /> - } - /> } - /> + path="/examples" + element={ +
+ Back to Menu + +
+ } + > + } /> + } /> + } + /> + } + /> + } + /> + } /> + } + /> +
diff --git a/examples/query/react/infinite-queries/src/app/useIntersectionCallback.ts b/examples/query/react/infinite-queries/src/app/useIntersectionCallback.ts new file mode 100644 index 0000000000..63834f0cef --- /dev/null +++ b/examples/query/react/infinite-queries/src/app/useIntersectionCallback.ts @@ -0,0 +1,22 @@ +import { useCallback, useRef } from "react" + +export function useIntersectionCallback(onIntersectCallback: () => void) { + const intersectionObserverRef = useRef(null) + + return useCallback( + (node: HTMLDivElement | null) => { + if (intersectionObserverRef.current) { + intersectionObserverRef.current.disconnect() + } + + intersectionObserverRef.current = new IntersectionObserver(entries => { + if (entries[0].isIntersecting) { + onIntersectCallback() + } + }) + + if (node) intersectionObserverRef.current.observe(node) + }, + [onIntersectCallback], + ) +} diff --git a/examples/query/react/infinite-queries/src/features/bidirectional-cursor-infinite-scroll/BidirectionalCursorInfScroll.tsx b/examples/query/react/infinite-queries/src/features/bidirectional-cursor-infinite-scroll/BidirectionalCursorInfScroll.tsx new file mode 100644 index 0000000000..5011057290 --- /dev/null +++ b/examples/query/react/infinite-queries/src/features/bidirectional-cursor-infinite-scroll/BidirectionalCursorInfScroll.tsx @@ -0,0 +1,136 @@ +import React, { useCallback, useEffect, useRef, useState } from "react" +import { Link, useLocation } from "react-router" +import { useIntersectionCallback } from "../../app/useIntersectionCallback" +import { apiWithInfiniteScroll } from "./infiniteScrollApi" + +const limit = 10 + +function BidirectionalCursorInfScroll({ startingProject = { id: 25 } }) { + const { + hasPreviousPage, + hasNextPage, + data, + error, + isFetching, + isLoading, + isError, + fetchNextPage, + fetchPreviousPage, + isFetchingNextPage, + isFetchingPreviousPage, + } = + apiWithInfiniteScroll.endpoints.getProjectsBidirectionalCursor.useInfiniteQuery( + limit, + { + initialPageParam: { + around: startingProject.id, + limit, + }, + }, + ) + + const beforeRef = useIntersectionCallback(fetchPreviousPage) + const afterRef = useIntersectionCallback(fetchNextPage) + + const location = useLocation() + + const startingProjectRef = useRef(null) + const [hasCentered, setHasCentered] = useState(false) + + useEffect(() => { + if (hasCentered) return + const startingElement = startingProjectRef.current + if (startingElement) { + startingElement.scrollIntoView({ + behavior: "auto", + block: "center", + }) + setHasCentered(true) + } + }, [data?.pages, hasCentered]) + + return ( +
+

Bidirectional Cursor-Based Infinite Scroll

+ {isLoading ? ( +

Loading...

+ ) : isError ? ( + Error: {error.message} + ) : null} + <> +
+ +
+
+
+ {data?.pages.map(page => ( + + {page.projects.map((project, index, arr) => { + return ( +
+
{`Project ${project.id} (created at: ${project.createdAt})`}
+
{`Server Time: ${page.serverTime}`}
+
+ ) + })} +
+ ))} +
+
+
+ +
+
+ {isFetching && !isFetchingPreviousPage && !isFetchingNextPage + ? "Background Updating..." + : null} +
+ + +
+ + Go to another page + +
+ ) +} + +export default BidirectionalCursorInfScroll diff --git a/examples/query/react/infinite-queries/src/features/bidirectional-cursor-infinite-scroll/infiniteScrollApi.ts b/examples/query/react/infinite-queries/src/features/bidirectional-cursor-infinite-scroll/infiniteScrollApi.ts new file mode 100644 index 0000000000..2a51d0fe36 --- /dev/null +++ b/examples/query/react/infinite-queries/src/features/bidirectional-cursor-infinite-scroll/infiniteScrollApi.ts @@ -0,0 +1,82 @@ +import { baseApi } from "../baseApi" + +type Project = { + id: number + createdAt: string +} + +type ProjectsCursorPaginated = { + projects: Project[] + serverTime: string + pageInfo: { + startCursor: number + endCursor: number + hasNextPage: boolean + hasPreviousPage: boolean + } +} + +interface ProjectsInitialPageParam { + before?: number + around?: number + after?: number + limit: number +} +type QueryParamLimit = number + +export const apiWithInfiniteScroll = baseApi.injectEndpoints({ + endpoints: builder => ({ + getProjectsBidirectionalCursor: builder.infiniteQuery< + ProjectsCursorPaginated, + QueryParamLimit, + ProjectsInitialPageParam + >({ + query: ({ before, after, around, limit }) => { + const params = new URLSearchParams() + params.append("limit", String(limit)) + if (after != null) { + params.append("after", String(after)) + } else if (before != null) { + params.append("before", String(before)) + } else if (around != null) { + params.append("around", String(around)) + } + + return { + url: `https://example.com/api/projectsBidirectionalCursor?${params.toString()}`, + } + }, + infiniteQueryOptions: { + initialPageParam: { limit: 10 }, + getPreviousPageParam: ( + firstPage, + allPages, + firstPageParam, + allPageParams, + ) => { + if (!firstPage.pageInfo.hasPreviousPage) { + return undefined + } + return { + before: firstPage.pageInfo.startCursor, + limit: firstPageParam.limit, + } + }, + getNextPageParam: ( + lastPage, + allPages, + lastPageParam, + allPageParams, + ) => { + if (!lastPage.pageInfo.hasNextPage) { + return undefined + } + return { + after: lastPage.pageInfo.endCursor, + limit: lastPageParam.limit, + } + }, + }, + }), + }), +}) diff --git a/examples/query/react/infinite-queries/src/features/limit-offset/LimitOffsetExample.tsx b/examples/query/react/infinite-queries/src/features/limit-offset/LimitOffsetExample.tsx new file mode 100644 index 0000000000..9610879fb2 --- /dev/null +++ b/examples/query/react/infinite-queries/src/features/limit-offset/LimitOffsetExample.tsx @@ -0,0 +1,129 @@ +import { createSelector } from "@reduxjs/toolkit" +import { + BaseQueryFn, + TypedUseQueryStateResult, +} from "@reduxjs/toolkit/query/react" +import { Link, useLocation } from "react-router" +import { useIntersectionCallback } from "../../app/useIntersectionCallback" +import { apiWithInfiniteScroll, ProjectsResponse } from "./infiniteScrollApi" + +type ProjectsInfiniteQueryResult = TypedUseQueryStateResult< + { pages: ProjectsResponse[] }, + unknown, + BaseQueryFn +> + +const selectCombinedProjects = createSelector( + (res: ProjectsInfiniteQueryResult) => { + return res.data + }, + data => data?.pages?.map(item => item?.projects)?.flat(), +) + +function LimitOffsetExample() { + const { + combinedData, + hasPreviousPage, + hasNextPage, + // data, + error, + isFetching, + isLoading, + isError, + fetchNextPage, + fetchPreviousPage, + isFetchingNextPage, + isFetchingPreviousPage, + } = apiWithInfiniteScroll.endpoints.projectsLimitOffset.useInfiniteQuery( + undefined, + { + selectFromResult: result => { + return { + ...result, + combinedData: selectCombinedProjects(result), + } + }, + }, + ) + + const intersectionCallbackRef = useIntersectionCallback(fetchNextPage) + const location = useLocation() + + return ( +
+

Limit and Offset Infinite Scroll

+ {isLoading ? ( +

Loading...

+ ) : isError ? ( + Error: {error.message} + ) : null} + + <> +
+ +
+
+ {combinedData?.map((project, index, arr) => { + return ( +
+
+
{`Project ${project.id} (created at: ${project.createdAt})`}
+
+
+ ) + })} + +
+
+
+ +
+
+ {isFetching && !isFetchingPreviousPage && !isFetchingNextPage + ? "Background Updating..." + : null} +
+ + +
+ + Go to another page + +
+ ) +} + +export default LimitOffsetExample diff --git a/examples/query/react/infinite-queries/src/features/limit-offset/infiniteScrollApi.ts b/examples/query/react/infinite-queries/src/features/limit-offset/infiniteScrollApi.ts new file mode 100644 index 0000000000..27bd2998a7 --- /dev/null +++ b/examples/query/react/infinite-queries/src/features/limit-offset/infiniteScrollApi.ts @@ -0,0 +1,72 @@ +import { baseApi } from "../baseApi" + +type Project = { + id: number + createdAt: string +} + +export type ProjectsResponse = { + projects: Project[] + numFound: number + serverTime: string +} + +interface ProjectsInitialPageParam { + offset: number + limit: number +} + +export const apiWithInfiniteScroll = baseApi.injectEndpoints({ + endpoints: builder => ({ + projectsLimitOffset: builder.infiniteQuery< + ProjectsResponse, + void, + ProjectsInitialPageParam + >({ + infiniteQueryOptions: { + initialPageParam: { + offset: 0, + limit: 20, + }, + getNextPageParam: ( + lastPage, + allPages, + lastPageParam, + allPageParams, + ) => { + const nextOffset = lastPageParam.offset + lastPageParam.limit + const remainingItems = lastPage?.numFound - nextOffset + + if (remainingItems <= 0) { + return undefined + } + + return { + ...lastPageParam, + offset: nextOffset, + } + }, + getPreviousPageParam: ( + firstPage, + allPages, + firstPageParam, + allPageParams, + ) => { + const prevOffset = firstPageParam.offset - firstPageParam.limit + if (prevOffset < 0) return undefined + + return { + ...firstPageParam, + offset: firstPageParam.offset - firstPageParam.limit, + } + }, + }, + query: ({ offset, limit }) => { + return { + url: `https://example.com/api/projectsLimitOffset?offset=${offset}&limit=${limit}`, + method: "GET", + } + }, + }), + }), +}) diff --git a/examples/query/react/infinite-queries/src/features/pagination-infinite-scroll/PaginationInfScrollExample.tsx b/examples/query/react/infinite-queries/src/features/pagination-infinite-scroll/PaginationInfScrollExample.tsx new file mode 100644 index 0000000000..c461b0a09c --- /dev/null +++ b/examples/query/react/infinite-queries/src/features/pagination-infinite-scroll/PaginationInfScrollExample.tsx @@ -0,0 +1,129 @@ +import { createSelector } from "@reduxjs/toolkit" +import { + BaseQueryFn, + TypedUseQueryStateResult, +} from "@reduxjs/toolkit/query/react" +import { Link, useLocation } from "react-router" +import { useIntersectionCallback } from "../../app/useIntersectionCallback" +import { apiWithInfiniteScroll, ProjectsResponse } from "./infiniteScrollApi" + +type ProjectsInfiniteQueryResult = TypedUseQueryStateResult< + { pages: ProjectsResponse[] }, + unknown, + BaseQueryFn +> + +const selectCombinedProjects = createSelector( + (res: ProjectsInfiniteQueryResult) => { + return res.data + }, + data => data?.pages?.map(item => item?.projects)?.flat(), +) + +function PaginationInfScrollExample() { + const { + combinedData, + hasPreviousPage, + hasNextPage, + // data, + error, + isFetching, + isLoading, + isError, + fetchNextPage, + fetchPreviousPage, + isFetchingNextPage, + isFetchingPreviousPage, + } = apiWithInfiniteScroll.endpoints.projectsPaginated.useInfiniteQuery( + undefined, + { + selectFromResult: result => { + return { + ...result, + combinedData: selectCombinedProjects(result), + } + }, + }, + ) + + const intersectionCallbackRef = useIntersectionCallback(fetchNextPage) + const location = useLocation() + + return ( +
+

Pagination Infinite Scroll

+ {isLoading ? ( +

Loading...

+ ) : isError ? ( + Error: {error.message} + ) : null} + + <> +
+ +
+
+ {combinedData?.map((project, index, arr) => { + return ( +
+
+
{`Project ${project.id} (created at: ${project.createdAt})`}
+
+
+ ) + })} + +
+
+
+ +
+
+ {isFetching && !isFetchingPreviousPage && !isFetchingNextPage + ? "Background Updating..." + : null} +
+ + +
+ + Go to another page + +
+ ) +} + +export default PaginationInfScrollExample diff --git a/examples/query/react/infinite-queries/src/features/pagination-infinite-scroll/infiniteScrollApi.ts b/examples/query/react/infinite-queries/src/features/pagination-infinite-scroll/infiniteScrollApi.ts new file mode 100644 index 0000000000..c519dce5bb --- /dev/null +++ b/examples/query/react/infinite-queries/src/features/pagination-infinite-scroll/infiniteScrollApi.ts @@ -0,0 +1,72 @@ +import { baseApi } from "../baseApi" + +type Project = { + id: number + createdAt: string +} + +export type ProjectsResponse = { + projects: Project[] + serverTime: string + totalPages: number +} + +interface ProjectsInitialPageParam { + page: number + size: number +} + +export const apiWithInfiniteScroll = baseApi.injectEndpoints({ + endpoints: builder => ({ + projectsPaginated: builder.infiniteQuery< + ProjectsResponse, + void, + ProjectsInitialPageParam + >({ + infiniteQueryOptions: { + initialPageParam: { + page: 0, + size: 20, + }, + getNextPageParam: ( + lastPage, + allPages, + lastPageParam, + allPageParams, + ) => { + const nextPage = lastPageParam.page + 1 + const remainingPages = lastPage?.totalPages - nextPage + + if (remainingPages <= 0) { + return undefined + } + + return { + ...lastPageParam, + page: nextPage, + } + }, + getPreviousPageParam: ( + firstPage, + allPages, + firstPageParam, + allPageParams, + ) => { + const prevPage = firstPageParam.page - 1 + if (prevPage < 0) return undefined + + return { + ...firstPageParam, + page: prevPage, + } + }, + }, + query: ({ page, size }) => { + return { + url: `https://example.com/api/projectsPaginated?page=${page}&size=${size}`, + method: "GET", + } + }, + }), + }), +}) diff --git a/examples/query/react/infinite-queries/src/mocks/handlers.ts b/examples/query/react/infinite-queries/src/mocks/handlers.ts index 75edcac3a6..f6d747268b 100644 --- a/examples/query/react/infinite-queries/src/mocks/handlers.ts +++ b/examples/query/react/infinite-queries/src/mocks/handlers.ts @@ -4,6 +4,13 @@ function delay(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) } +const projects = Array.from({ length: 50 }, (_, i) => { + return { + id: i, + createdAt: Date.now() + i * 1000, + } +}) + export const handlers = [ http.get("https://example.com/api/projects", async ({ request, params }) => { const url = new URL(request.url) @@ -69,4 +76,139 @@ export const handlers = [ }) }, ), + http.get( + "https://example.com/api/projectsBidirectionalCursor", + async ({ request }) => { + const url = new URL(request.url) + const limit = parseInt(url.searchParams.get("limit") ?? "5", 10) + const aroundCursor = parseInt(url.searchParams.get("around") ?? "", 10) + const afterCursor = parseInt(url.searchParams.get("after") ?? "", 10) + const beforeCursor = parseInt(url.searchParams.get("before") ?? "", 10) + + const validateCursor = (cursor: number, cursorType: string): number => { + const cursorIndex = projects.findIndex(project => project.id === cursor) + if (cursorIndex === -1) { + throw new Error(`Invalid \`${cursorType}\` cursor.`) + } + return cursorIndex + } + + let resultProjects = [] + try { + if (!isNaN(afterCursor)) { + const afterCursorIndex = validateCursor(afterCursor, "after") + const afterIndex = afterCursorIndex + 1 + resultProjects = projects.slice(afterIndex, afterIndex + limit) + } else if (!isNaN(beforeCursor)) { + const beforeCursorIndex = validateCursor(beforeCursor, "before") + const startIndex = Math.max(0, beforeCursorIndex - limit) + resultProjects = projects.slice(startIndex, beforeCursorIndex) + } else if (!isNaN(aroundCursor)) { + const aroundCursorIndex = validateCursor(aroundCursor, "around") + const ceiledLimit = Math.ceil(limit / 2) + + const beforeIndex = Math.max(0, aroundCursorIndex - ceiledLimit) + const afterIndex = Math.min( + projects.length - 1, + aroundCursorIndex + ceiledLimit, + ) + const beforeProjects = projects.slice(beforeIndex, aroundCursorIndex) + const afterProjects = projects.slice( + aroundCursorIndex + 1, + afterIndex + 1, + ) + + resultProjects = [ + ...beforeProjects, + projects[aroundCursorIndex], + ...afterProjects, + ] + } else { + resultProjects = projects.slice(0, limit) + } + + const startCursor = resultProjects[0]?.id + const endCursor = resultProjects[resultProjects.length - 1]?.id + + const hasNextPage = endCursor != null && endCursor < projects.length - 1 + const hasPreviousPage = startCursor !== 0 + + await delay(1000) + return HttpResponse.json({ + projects: resultProjects, + serverTime: Date.now(), + pageInfo: { + startCursor, + endCursor, + hasNextPage, + hasPreviousPage, + }, + }) + } catch (error) { + if (error instanceof Error) { + return HttpResponse.json({ message: error.message }, { status: 400 }) + } + } + }, + ), + http.get( + "https://example.com/api/projectsLimitOffset", + async ({ request }) => { + const url = new URL(request.url) + const limit = parseInt(url.searchParams.get("limit") ?? "5", 10) + let offset = parseInt(url.searchParams.get("offset") ?? "0", 10) + + if (isNaN(offset) || offset < 0) { + offset = 0 + } + if (isNaN(limit) || limit <= 0) { + return HttpResponse.json( + { + message: + "Invalid 'limit' parameter. It must be a positive integer.", + }, + { status: 400 }, + ) + } + + const result = projects.slice(offset, offset + limit) + + await delay(1000) + return HttpResponse.json({ + projects: result, + serverTime: Date.now(), + numFound: projects.length, + }) + }, + ), + http.get("https://example.com/api/projectsPaginated", async ({ request }) => { + const url = new URL(request.url) + const size = parseInt(url.searchParams.get("size") ?? "5", 10) + let page = parseInt(url.searchParams.get("page") ?? "0", 10) + + if (isNaN(page) || page < 0) { + page = 0 + } + if (isNaN(size) || size <= 0) { + return HttpResponse.json( + { message: "Invalid 'size' parameter. It must be a positive integer." }, + { status: 400 }, + ) + } + + const startIndex = page * size + const endIndex = startIndex + size + const result = projects.slice(startIndex, endIndex) + + await delay(1000) + return HttpResponse.json({ + projects: result, + serverTime: Date.now(), + totalPages: Math.ceil(projects.length / size), // totalPages is a parameter required for this example, but an API could include additional fields that can be used in certain scenarios, such as determining getNextPageParam or getPreviousPageParam. + // totalElements: projects.length, + // numberOfElements: result.length, + // isLast: endIndex >= projects.length, + // isFirst: page === 0, + }) + }), ] diff --git a/yarn.lock b/yarn.lock index 61f07ffb9f..7154042430 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7739,23 +7739,23 @@ __metadata: languageName: unknown linkType: soft -"@reduxjs/toolkit@https://pkg.csb.dev/reduxjs/redux-toolkit/commit/aa419c22/@reduxjs/toolkit/_pkg.tgz": - version: 2.4.0 - resolution: "@reduxjs/toolkit@https://pkg.csb.dev/reduxjs/redux-toolkit/commit/aa419c22/@reduxjs/toolkit/_pkg.tgz" +"@reduxjs/toolkit@https://pkg.csb.dev/reduxjs/redux-toolkit/commit/c9cc8ca0/@reduxjs/toolkit/_pkg.tgz": + version: 2.5.0 + resolution: "@reduxjs/toolkit@https://pkg.csb.dev/reduxjs/redux-toolkit/commit/c9cc8ca0/@reduxjs/toolkit/_pkg.tgz" dependencies: immer: "npm:^10.0.3" redux: "npm:^5.0.1" redux-thunk: "npm:^3.1.0" reselect: "npm:^5.1.0" peerDependencies: - react: ^16.9.0 || ^17.0.0 || ^18 + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 peerDependenciesMeta: react: optional: true react-redux: optional: true - checksum: 10/83dd571381b1e16bb75731c7dedc51e90934039d5b73ac6d4115d8e2408bbf8ca679d2030aaa59b0aa67cd85e5747a967b2005604ce670134189c0e74abb98df + checksum: 10/89bff8594de9815bdfc909e2e3942cb3983b8f32b3335b49ab042288609c990d26ba9b3ca1f65439d8d409bb642b938354079517d0c26db976155430cd7df659 languageName: node linkType: hard @@ -32439,7 +32439,7 @@ __metadata: version: 0.0.0-use.local resolution: "vite-template-redux@workspace:examples/query/react/infinite-queries" dependencies: - "@reduxjs/toolkit": "https://pkg.csb.dev/reduxjs/redux-toolkit/commit/aa419c22/@reduxjs/toolkit/_pkg.tgz" + "@reduxjs/toolkit": "https://pkg.csb.dev/reduxjs/redux-toolkit/commit/c9cc8ca0/@reduxjs/toolkit/_pkg.tgz" "@testing-library/dom": "npm:^9.3.4" "@testing-library/jest-dom": "npm:^6.2.0" "@testing-library/react": "npm:^14.1.2"