diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5ccca78e..2de13227 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,7 +7,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - node: ['12.x', '14.x'] + node: ['12.x'] os: [ubuntu-latest] #, windows-latest, macOS-latest steps: @@ -21,6 +21,8 @@ jobs: - name: Install deps and build (with cache) uses: bahmutov/npm-install@v1 + with: + install-command: yarn --frozen-lockfile --ignore-scripts - name: Lint run: yarn lint @@ -28,5 +30,62 @@ jobs: - name: Test run: yarn test --ci --coverage --maxWorkers=2 - - name: Build - run: yarn build + - name: Pack (including Prepare) + run: npm pack + + - uses: actions/upload-artifact@v2 + with: + name: package + path: rtk-incubator-rtk-query*.tgz + + test: + name: Test Types with TypeScript ${{ matrix.ts }} + + needs: [build] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ['12.x'] + ts: ['3.9', '4.0', '4.1', '4.2', 'next'] + steps: + - name: Checkout repo + uses: actions/checkout@v2 + + - name: Use node ${{ matrix.node }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node }} + + - name: Install deps and build (with cache) + uses: bahmutov/npm-install@v1 + with: + install-command: yarn --frozen-lockfile --ignore-scripts + + - name: Install TypeScript ${{ matrix.ts }} + run: npm install typescript@${{ matrix.ts }} --ignore-scripts + + - uses: actions/download-artifact@v2 + with: + name: package + + - name: Unpack build artifact to dist + run: tar -xzvf rtk-incubator-rtk-query-*.tgz --strip-components=1 package/dist + + - name: Remap @redux/toolkit from src to dist + run: | + sed -i -re 's|(@rtk-incubator/rtk-query.*)\./src|\1./|' ./test/tsconfig.json + + - name: '@ts-ignore @ts-expect-error messages in pre-3.9 in the tests' + if: ${{ matrix.ts < 3.9 }} + run: | + sed -i 's/@ts-expect-error/@ts-ignore/' test/*.ts* + + - name: "@ts-ignore stuff that didn't exist pre-4.1 in the tests" + if: ${{ matrix.ts < 4.1 }} + run: sed -i -e 's/@pre41-ts-ignore/@ts-ignore/' -e '/pre41-remove-start/,/pre41-remove-end/d' test/*.ts* + + - name: Test types + run: | + ./node_modules/.bin/tsc --version + ./node_modules/.bin/tsc --skipLibCheck -p test diff --git a/.prettierrc b/.prettierrc index 168d9d2a..553727a1 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,15 @@ { - "endOfLine": "auto" + "endOfLine": "auto", + "printWidth": 120, + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "overrides": [ + { + "files": "*.ts", + "options": { + "parser": "babel-ts" + } + } + ] } diff --git a/docs/api/ApiProvider.md b/docs/api/ApiProvider.md index 230830b2..b9086370 100644 --- a/docs/api/ApiProvider.md +++ b/docs/api/ApiProvider.md @@ -7,20 +7,9 @@ hide_title: true # `ApiProvider` -Can be used as a `Provider` if you **do not already have a Redux store**. +[summary](docblock://react-hooks/ApiProvider.tsx?token=ApiProvider) -```ts title="Basic usage - wrap your App with ApiProvider" -import * as React from 'react'; -import { ApiProvider } from '@rtk-incubator/rtk-query'; - -function App() { - return ( - - - - ); -} -``` +[examples](docblock://react-hooks/ApiProvider.tsx?token=ApiProvider) :::danger Using this together with an existing redux store will cause them to conflict with each other. If you are already using Redux, please use follow the instructions as shown in the [Getting Started guide](../introduction/getting-started). diff --git a/docs/api/createApi.md b/docs/api/createApi.md index 8c944162..089b5019 100644 --- a/docs/api/createApi.md +++ b/docs/api/createApi.md @@ -27,14 +27,16 @@ The main point where you will define a service to use in your application. ### `baseQuery` +[summary](docblock://createApi.ts?token=CreateApiOptions.baseQuery) + ```ts title="Simulating axios-like interceptors with a custom base query" const baseQuery = fetchBaseQuery({ baseUrl: '/' }); -const baseQueryWithReauth: BaseQueryFn< - string | FetchArgs, - unknown, - FetchBaseQueryError -> = async (args, api, extraOptions) => { +const baseQueryWithReauth: BaseQueryFn = async ( + args, + api, + extraOptions +) => { let result = await baseQuery(args, api, extraOptions); if (result.error && result.error.status === '401') { // try to get a new token @@ -49,16 +51,16 @@ const baseQueryWithReauth: BaseQueryFn< } } return result; -} +}; ``` ### `entityTypes` -Specifying entity types is optional, but you should define them so that they can be used for caching and invalidation. When defining an entity type, you will be able to add them with `provides` and [invalidate](../concepts/mutations#advanced-mutations-with-revalidation) them with `invalidates` when configuring [endpoints](#endpoints). +[summary](docblock://createApi.ts?token=CreateApiOptions.entityTypes) ### `reducerPath` -The `reducerPath` is a _unique_ key that your service will be mounted to in your store. If you call `createApi` more than once in your application, you will need to provide a unique value each time. Defaults to `api`. +[summary](docblock://createApi.ts?token=CreateApiOptions.reducerPath) ```js title="apis.js" import { createApi, fetchBaseQuery } from '@rtk-incubator/rtk-query'; @@ -82,30 +84,31 @@ const apiTwo = createApi({ ### `serializeQueryArgs` -Accepts a custom function if you have a need to change the creation of cache keys for any reason. Defaults to: +[summary](docblock://createApi.ts?token=CreateApiOptions.reducerPath) +Defaults to: ```ts no-compile export const defaultSerializeQueryArgs: SerializeQueryArgs = ({ endpoint, queryArgs }) => { - // Sort the object keys before stringifying, to prevent useQuery({ a: 1, b: 2 }) having a different cache key than useQuery({ b: 2, a: 1 }) + // Sort the object keys before stringifying, to prevent useQuery({ a: 1, b: 2 }) having a different cache key than useQuery({ b: 2, a: 1 }) return `${endpoint}(${JSON.stringify(queryArgs, Object.keys(queryArgs || {}).sort())})`; }; ``` ### `endpoints` -Endpoints are just a set of operations that you want to perform against your server. You define them as an object using the builder syntax. There are two basic endpoint types: [`query`](../concepts/queries) and [`mutation`](../concepts/mutations). +[summary](docblock://createApi.ts?token=CreateApiOptions.endpoints) #### Anatomy of an endpoint - `query` _(required)_ - - `query` is the only required property, and can be either a `string` or an `object` that is passed to your `baseQuery`. If you are using [fetchBaseQuery](./fetchBaseQuery), this can be a `string` or an object of properties in `FetchArgs`. If you use your own custom `baseQuery`, you can customize this behavior to your liking + - [summary](docblock://endpointDefinitions.ts?token=EndpointDefinitionWithQuery.query) - `transformResponse` _(optional)_ - - A function to manipulate the data returned by a query or mutation + - [summary](docblock://endpointDefinitions.ts?token=EndpointDefinitionWithQuery.transformResponse) - ```js title="Unpack a deeply nested collection" transformResponse: (response) => response.some.nested.collection; ``` - ```js title="Normalize the response data" + - ```js title="Normalize the response data" transformResponse: (response) => response.reduce((acc, curr) => { acc[curr.id] = curr; @@ -114,15 +117,12 @@ Endpoints are just a set of operations that you want to perform against your ser ``` - `provides` _(optional)_ - - Used by `queries` to provide entities to the cache - - Expects an array of entity type strings, or an array of objects of entity types with ids. - 1. `['Post']` - equivalent to 2 - 2. `[{ type: 'Post' }]` - equivalent to 1 - 3. `[{ type: 'Post', id: 1 }]` + + [summary](docblock://endpointDefinitions.ts?token=QueryExtraOptions.provides) + - `invalidates` _(optional)_ - - Used by `mutations` for [cache invalidation](../concepts/mutations#advanced-mutations-with-revalidation) purposes. - - Expects the same shapes as `provides` + [summary](docblock://endpointDefinitions.ts?token=MutationExtraOptions.invalidates) - `onStart`, `onError` and `onSuccess` _(optional)_ - Available to both [queries](../concepts/queries) and [mutations](../concepts/mutations) - Can be used in `mutations` for [optimistic updates](../concepts/optimistic-updates). @@ -290,6 +290,7 @@ export const { internalActions, util, injectEndpoints, + enhanceEndpoints, usePrefetch, ...generatedHooks } = api; diff --git a/docs/api/fetchBaseQuery.md b/docs/api/fetchBaseQuery.md index b70d6952..97ec40ce 100644 --- a/docs/api/fetchBaseQuery.md +++ b/docs/api/fetchBaseQuery.md @@ -52,13 +52,12 @@ export const pokemonApi = createApi({ query: (name: string) => `pokemon/${name}`, // Will make a request like https://pokeapi.co/api/v2/bulbasaur }), updatePokemon: builder.mutation({ - query: ({ name, patch }) => ({ - url: `pokemon/${name}`, - method: 'PATCH', // When performing a mutation, you typically use a method of PATCH/PUT/POST/DELETE for REST endpoints - body: patch, // fetchBaseQuery automatically adds `content-type: application/json` to the Headers and calls `JSON.stringify(patch)` - }) - }, - }) + query: ({ name, patch }) => ({ + url: `pokemon/${name}`, + method: 'PATCH', // When performing a mutation, you typically use a method of PATCH/PUT/POST/DELETE for REST endpoints + body: patch, // fetchBaseQuery automatically adds `content-type: application/json` to the Headers and calls `JSON.stringify(patch)` + }), + }), }), }); ``` diff --git a/docs/api/setupListeners.md b/docs/api/setupListeners.md index fc201b43..298dd587 100644 --- a/docs/api/setupListeners.md +++ b/docs/api/setupListeners.md @@ -63,8 +63,8 @@ export function setupListeners( } ``` -If you notice, `onFocus`, `onFocusLost`, `onOffline`, `onOnline` are all actions that are provided to the callback. Additionally, these action are made available to `api.internalActions` and are able to be used by dispatching them like this: +If you notice, `onFocus`, `onFocusLost`, `onOffline`, `onOnline` are all actions that are provided to the callback. Additionally, these actions are made available to `api.internalActions` and are able to be used by dispatching them like this: ```ts title="Manual onFocus event" -dispatch(api.internalActions.onFocus())` +dispatch(api.internalActions.onFocus()); ``` diff --git a/docs/concepts/error-handling.md b/docs/concepts/error-handling.md index 521a6e01..08960d10 100644 --- a/docs/concepts/error-handling.md +++ b/docs/concepts/error-handling.md @@ -116,31 +116,9 @@ RTK Query exports a utility called `retry` that you can wrap the `baseQuery` in The default behavior would retry at these intervals: -1. 600ms + random time -2. 1200ms + random time -3. 2400ms + random time -4. 4800ms + random time -5. 9600ms + random time - -```ts title="Retry every request 5 times by default" -// maxRetries: 5 is the default, and can be omitted. Shown for documentation purposes. -const staggeredBaseQuery = retry(fetchBaseQuery({ baseUrl: '/' }), { maxRetries: 5 }); - -export const api = createApi({ - baseQuery: staggeredBaseQuery, - endpoints: (build) => ({ - getPosts: build.query({ - query: () => ({ url: 'posts' }), - }), - getPost: build.query({ - query: (id: string) => ({ url: `posts/${id}` }), - extraOptions: { maxRetries: 8 }, // You can override the retry behavior on each endpoint - }), - }), -}); +[remarks](docblock://retry.ts?token=defaultBackoff) -export const { useGetPostsQuery, useGetPostQuery } = api; -``` +[examples](docblock://retry.ts?token=retry) In the event that you didn't want to retry on a specific endpoint, you can just set `maxRetries: 0`. diff --git a/docs/concepts/mutations.md b/docs/concepts/mutations.md index 6fe9f4fe..ee44f698 100644 --- a/docs/concepts/mutations.md +++ b/docs/concepts/mutations.md @@ -39,26 +39,36 @@ Notice the `onStart`, `onSuccess`, `onError` methods? Be sure to check out how t ### Type interfaces ```ts title="Mutation endpoint definition" -export interface MutationDefinition< +export type MutationDefinition< QueryArg, - BaseQuery extends (arg: any, ...args: any[]) => any, + BaseQuery extends BaseQueryFn, EntityTypes extends string, ResultType, ReducerPath extends string = string, Context = Record -> extends BaseEndpointDefinition { +> = BaseEndpointDefinition & { type: DefinitionType.mutation; invalidates?: ResultDescription; provides?: never; onStart?(arg: QueryArg, mutationApi: MutationApi): void; - onError?(arg: QueryArg, mutationApi: MutationApi, error: unknown): void; - onSuccess?(arg: QueryArg, mutationApi: MutationApi, result: ResultType): void; -} + onError?( + arg: QueryArg, + mutationApi: MutationApi, + error: unknown, + meta: BaseQueryMeta + ): void; + onSuccess?( + arg: QueryArg, + mutationApi: MutationApi, + result: ResultType, + meta: BaseQueryMeta | undefined + ): void; +}; ``` ```ts title="MutationApi" export interface MutationApi { - dispatch: ThunkDispatch, unknown, AnyAction>; + dispatch: ThunkDispatch; getState(): RootState; extra: unknown; requestId: string; diff --git a/docs/concepts/prefetching.md b/docs/concepts/prefetching.md index c60ea258..1ec67ca4 100644 --- a/docs/concepts/prefetching.md +++ b/docs/concepts/prefetching.md @@ -39,13 +39,8 @@ usePrefetch>( You can specify these prefetch options when declaring the hook or at the call site. The call site will take priority over the defaults. -1. `ifOlderThan` - (default: `false` | `number`) - _number is value in seconds_ - - - If specified, it will only run the query if the difference between `new Date()` and the last `fulfilledTimeStamp` is greater than the given value - -2. `force` - - - If `force: true`, it will ignore the `ifOlderThan` value if it is set and the query will be run even if it exists in the cache. +1. [summary](docblock://core/module.ts?token=PrefetchOptions) +2. [overloadSummary](docblock://core/module.ts?token=PrefetchOptions) #### What to expect when you call the `callback` diff --git a/docs/concepts/queries.md b/docs/concepts/queries.md index 2dfd9fc9..8659b2f0 100644 --- a/docs/concepts/queries.md +++ b/docs/concepts/queries.md @@ -115,7 +115,7 @@ The way that this component is setup would have some nice traits: ### Selecting data from a query result -Sometimes you may have a parent component that is subscribed to a query, and then in a child component you want to pick an item from that query. In most cases you don't want to perform an additional request for a `getItemById`-type query when you know that you already have the result. `selectFromResult` allows you to get a specific segment from a query result in a performant manner. When using this feature, the component will not rerender unless the underlying data of the selected item has changed. If the selected item is one elemnt in a larger collection, it will disregard changes to elements in the same collection. +Sometimes you may have a parent component that is subscribed to a query, and then in a child component you want to pick an item from that query. In most cases you don't want to perform an additional request for a `getItemById`-type query when you know that you already have the result. `selectFromResult` allows you to get a specific segment from a query result in a performant manner. When using this feature, the component will not rerender unless the underlying data of the selected item has changed. If the selected item is one element in a larger collection, it will disregard changes to elements in the same collection. ```ts title="Using selectFromResult to extract a single result" function PostsList() { diff --git a/package.json b/package.json index 0be5cde0..071a46f3 100644 --- a/package.json +++ b/package.json @@ -47,20 +47,6 @@ "pre-commit": "tsdx lint" } }, - "prettier": { - "printWidth": 120, - "semi": true, - "singleQuote": true, - "trailingComma": "es5", - "overrides": [ - { - "files": "*.ts", - "options": { - "parser": "babel-ts" - } - } - ] - }, "size-limit": [ { "name": "ESM full", @@ -110,8 +96,7 @@ ], "dependencies": { "@babel/runtime": "^7.12.5", - "@types/jest": "^26.0.22", - "immer": ">=8.0.0" + "immer": "^8.0.3" }, "peerDependencies": { "@reduxjs/toolkit": "^1.5.0", @@ -145,7 +130,8 @@ "@testing-library/react": "^11.2.6", "@testing-library/react-hooks": "^3.4.2", "@testing-library/user-event": "^12.2.2", - "@types/node-fetch": "^2.5.9", + "@types/jest": "^26.0.21", + "@types/react-dom": "^17.0.2", "@types/react-redux": "^7.1.9", "@typescript-eslint/eslint-plugin": "^4.19.0", "@typescript-eslint/parser": "^4.19.0", @@ -179,6 +165,6 @@ "immer": "^8.0.0", "ts-jest": "^26.4.4", "jest": "^26.6.3", - "@types/jest": "^26.0.15" + "@types/jest": "26.0.15" } } diff --git a/src/apiTypes.ts b/src/apiTypes.ts index d5c97155..67d1933c 100644 --- a/src/apiTypes.ts +++ b/src/apiTypes.ts @@ -46,10 +46,16 @@ export type Api< Enhancers extends ModuleName = CoreModule > = Id< Id[Enhancers]>> & { + /** + * A function to inject the endpoints into the original API, but also give you that same API with correct types for these endpoints back. Useful with code-splitting. + */ injectEndpoints(_: { endpoints: (build: EndpointBuilder) => NewDefinitions; overrideExisting?: boolean; }): Api; + /** + *A function to enhance a generated API with additional information. Useful with code-generation. + */ enhanceEndpoints(_: { addEntityTypes?: readonly NewEntityTypes[]; endpoints?: ReplaceEntityTypes> extends infer NewDefinitions diff --git a/src/core/apiState.ts b/src/core/apiState.ts index 6e6f5eb8..d313b22d 100644 --- a/src/core/apiState.ts +++ b/src/core/apiState.ts @@ -20,6 +20,9 @@ export type RefetchConfigOptions = { refetchOnFocus: boolean; }; +/** + * Strings describing the query state at any given time. + */ export enum QueryStatus { uninitialized = 'uninitialized', pending = 'pending', diff --git a/src/core/module.ts b/src/core/module.ts index 0bea4cd9..ead245ae 100644 --- a/src/core/module.ts +++ b/src/core/module.ts @@ -26,11 +26,19 @@ import { InternalSerializeQueryArgs } from '../defaultSerializeQueryArgs'; import { SliceActions } from './buildSlice'; import { BaseQueryFn } from '../baseQueryTypes'; +/** + * `ifOlderThan` - (default: `false` | `number`) - _number is value in seconds_ + * - If specified, it will only run the query if the difference between `new Date()` and the last `fulfilledTimeStamp` is greater than the given value + * + * @overloadSummary + * `force` + * - If `force: true`, it will ignore the `ifOlderThan` value if it is set and the query will be run even if it exists in the cache. + */ export type PrefetchOptions = - | { force?: boolean } | { ifOlderThan?: false | number; - }; + } + | { force?: boolean }; export const coreModuleName = Symbol(); export type CoreModule = typeof coreModuleName; @@ -44,26 +52,84 @@ declare module '../apiTypes' { EntityTypes extends string > { [coreModuleName]: { + /** + * This api's reducer should be mounted at `store[api.reducerPath]`. + * + * @example + * ```ts + * configureStore({ + * reducer: { + * [api.reducerPath]: api.reducer, + * }, + * middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(api.middleware), + * }) + * ``` + */ reducerPath: ReducerPath; + /** + * Internal actions not part of the public API. Note: These are subject to change at any given time. + */ internalActions: InternalActions; + /** + * A standard redux reducer that enables core functionality. Make sure it's included in your store. + * + * @example + * ```ts + * configureStore({ + * reducer: { + * [api.reducerPath]: api.reducer, + * }, + * middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(api.middleware), + * }) + * ``` + */ reducer: Reducer, AnyAction>; + /** + * This is a standard redux middleware and is responsible for things like polling, garbage collection and a handful of other things. Make sure it's included in your store. + * + * @example + * ```ts + * configureStore({ + * reducer: { + * [api.reducerPath]: api.reducer, + * }, + * middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(api.middleware), + * }) + * ``` + */ middleware: Middleware<{}, RootState, ThunkDispatch>; + /** + * TODO + */ util: { + /** + * TODO + */ prefetchThunk>( endpointName: EndpointName, arg: QueryArgFrom, options: PrefetchOptions ): ThunkAction; + /** + * TODO + */ updateQueryResult: UpdateQueryResultThunk>; + /** + * TODO + */ patchQueryResult: PatchQueryResultThunk>; + /** + * TODO + */ resetApiState: SliceActions['resetApiState']; + /** + * TODO + */ invalidateEntities: ActionCreatorWithPayload>, string>; }; - // If you actually care about the return value, use useQuery - usePrefetch>( - endpointName: EndpointName, - options?: PrefetchOptions - ): (arg: QueryArgFrom, options?: PrefetchOptions) => void; + /** + * Endpoints based on the input endpoints provided to `createApi`, containing `select` and `action matchers`. + */ endpoints: { [K in keyof Definitions]: Definitions[K] extends QueryDefinition ? Id> @@ -107,6 +173,14 @@ export type ListenerActions = { export type InternalActions = SliceActions & ListenerActions; +/** + * Creates a module containing the basic redux logic for use with `buildCreateApi`. + * + * @example + * ```ts + * const createBaseApi = buildCreateApi(coreModule()); + * ``` + */ export const coreModule = (): Module => ({ name: coreModuleName, init( diff --git a/src/core/setupListeners.ts b/src/core/setupListeners.ts index 42114fee..91ac81eb 100644 --- a/src/core/setupListeners.ts +++ b/src/core/setupListeners.ts @@ -6,6 +6,23 @@ export const onOnline = createAction('__rtkq/online'); export const onOffline = createAction('__rtkq/offline'); let initialized = false; + +/** + * A utility used to enable `refetchOnMount` and `refetchOnReconnect` behaviors. + * It requires the dispatch method from your store. + * Calling `setupListeners(store.dispatch)` will configure listeners with the recommended defaults, + * but you have the option of providing a callback for more granular control. + * + * @example + * ```ts + * setupListeners(store.dispatch) + * ``` + * + * @param dispatch - The dispatch method from your store + * @param customHandler - An optional callback for more granular control over listener behavior + * @returns Return value of the handler. + * The default handler returns an `unsubscribe` method that can be called to remove the listeners. + */ export function setupListeners( dispatch: ThunkDispatch, customHandler?: ( diff --git a/src/createApi.ts b/src/createApi.ts index 64f364ad..24b8c535 100644 --- a/src/createApi.ts +++ b/src/createApi.ts @@ -9,26 +9,86 @@ export interface CreateApiOptions< ReducerPath extends string = 'api', EntityTypes extends string = never > { + /** + * The base query used by each endpoint if no `queryFn` option is specified. RTK Query exports a utility called [fetchBaseQuery](./fetchBaseQuery) as a lightweight wrapper around `fetch` for common use-cases. + */ baseQuery: BaseQuery; + /** + * An array of string entity type names. Specifying entity types is optional, but you should define them so that they can be used for caching and invalidation. When defining an entity type, you will be able to [provide](../concepts/mutations#provides) them with `provides` and [invalidate](../concepts/mutations#advanced-mutations-with-revalidation) them with `invalidates` when configuring [endpoints](#endpoints). + */ entityTypes?: readonly EntityTypes[]; + /** + * The `reducerPath` is a _unique_ key that your service will be mounted to in your store. If you call `createApi` more than once in your application, you will need to provide a unique value each time. Defaults to `api`. + */ reducerPath?: ReducerPath; + /** + * Accepts a custom function if you have a need to change the creation of cache keys for any reason. + */ serializeQueryArgs?: SerializeQueryArgs>; + /** + * Endpoints are just a set of operations that you want to perform against your server. You define them as an object using the builder syntax. There are two basic endpoint types: [`query`](../concepts/queries) and [`mutation`](../concepts/mutations). + */ endpoints(build: EndpointBuilder): Definitions; + /** + * Defaults to 60 (this value is in seconds). This is how long RTK Query will keep your data cached for after the last component unsubscribes. For example, if you query an endpoint, then unmount the component, then mount another component that makes the same request within the given time frame, the most recent value will be served from the cache. + */ keepUnusedDataFor?: number; + /** + * Defaults to `false`. This setting allows you to control whether RTK Query will only serve a cached result, or if it should `refetch` when set to `true` or if an adequate amount of time has passed since the last successful query result. + * - `false` - Will not cause a query to be performed _unless_ it does not exist yet. + * - `true` - Will always refetch when a new subscriber to a query is added. Behaves the same as calling the `refetch` callback or passing `forceRefetch: true` in the action creator. + * - `number` - **Value is in seconds**. If a number is provided and there is an existing query in the cache, it will compare the current time vs the last fulfilled timestamp, and only refetch if enough time has elapsed. + * + * If you specify this option alongside `skip: true`, this **will not be evaluated** until `skip` is false. + */ refetchOnMountOrArgChange?: boolean | number; + /** + * Defaults to `false`. This setting allows you to control whether RTK Query will try to refetch all subscribed queries after the application window regains focus. + * + * If you specify this option alongside `skip: true`, this **will not be evaluated** until `skip` is false. + */ refetchOnFocus?: boolean; + /** + * Defaults to `false`. This setting allows you to control whether RTK Query will try to refetch all subscribed queries after regaining a network connection. + * + * If you specify this option alongside `skip: true`, this **will not be evaluated** until `skip` is false. + */ refetchOnReconnect?: boolean; } -export type CreateApi = < - BaseQuery extends BaseQueryFn, - Definitions extends EndpointDefinitions, - ReducerPath extends string = 'api', - EntityTypes extends string = never ->( - options: CreateApiOptions -) => Api; +export type CreateApi = { + /** + * Creates a service to use in your application. Contains only the basic redux logic (the core module). + * + * @link https://rtk-query-docs.netlify.app/api/createApi + */ + < + BaseQuery extends BaseQueryFn, + Definitions extends EndpointDefinitions, + ReducerPath extends string = 'api', + EntityTypes extends string = never + >( + options: CreateApiOptions + ): Api; +}; +/** + * Builds a `createApi` method based on the provided `modules`. + * + * @link https://rtk-query-docs.netlify.app/concepts/customizing-create-api + * + * @example + * ```ts + * const MyContext = React.createContext(null as any); + * const customCreateApi = buildCreateApi( + * coreModule(), + * reactHooksModule({ useDispatch: createDispatchHook(MyContext) }) + * ); + * ``` + * + * @param modules - A variable number of modules that customize how the `createApi` method handles endpoints + * @returns A `createApi` method using the provided `modules`. + */ export function buildCreateApi, ...Module[]]>( ...modules: Modules ): CreateApi { diff --git a/src/defaultSerializeQueryArgs.ts b/src/defaultSerializeQueryArgs.ts index e8684480..5143cef8 100644 --- a/src/defaultSerializeQueryArgs.ts +++ b/src/defaultSerializeQueryArgs.ts @@ -2,7 +2,7 @@ import { QueryCacheKey } from './core/apiState'; import { EndpointDefinition } from './endpointDefinitions'; export const defaultSerializeQueryArgs: SerializeQueryArgs = ({ endpointName, queryArgs }) => { - // Sort the object keys before stringifying, to prevent useQuery({ a: 1, b: 2 }) having a different cache key than useQuery({ b: 2, a: 1 }) + // Sort the object keys before stringifying, to prevent useQuery({ a: 1, b: 2 }) having a different cache key than useQuery({ b: 2, a: 1 }) return `${endpointName}(${JSON.stringify(queryArgs, Object.keys(queryArgs || {}).sort())})`; }; diff --git a/src/endpointDefinitions.ts b/src/endpointDefinitions.ts index 91beea30..2b1cfaa2 100644 --- a/src/endpointDefinitions.ts +++ b/src/endpointDefinitions.ts @@ -16,27 +16,37 @@ import { NEVER } from './fakeBaseQuery'; const resultType = Symbol(); const baseQuery = Symbol(); +interface EndpointDefinitionWithQuery { + /** + * `query` is the only required property, and can be a function that returns either a `string` or an `object` which is passed to your `baseQuery`. If you are using [fetchBaseQuery](./fetchBaseQuery), this can return either a `string` or an `object` of properties in `FetchArgs`. If you use your own custom `baseQuery`, you can customize this behavior to your liking + */ + query(arg: QueryArg): BaseQueryArg; + queryFn?: never; + /** + * A function to manipulate the data returned by a query or mutation + */ + transformResponse?( + baseQueryReturnValue: BaseQueryResult, + meta: BaseQueryMeta + ): ResultType | Promise; +} + +interface EndpointDefinitionWithQueryFn { + queryFn( + arg: QueryArg, + api: BaseQueryApi, + extraOptions: BaseQueryExtraOptions, + baseQuery: (arg: Parameters[0]) => ReturnType + ): MaybePromise>>; + query?: never; + transformResponse?: never; +} + export type BaseEndpointDefinition = ( | ([CastAny, {}>] extends [NEVER] ? never - : { - query(arg: QueryArg): BaseQueryArg; - queryFn?: never; - transformResponse?( - baseQueryReturnValue: BaseQueryResult, - meta: BaseQueryMeta - ): ResultType | Promise; - }) - | { - queryFn( - arg: QueryArg, - api: BaseQueryApi, - extraOptions: BaseQueryExtraOptions, - baseQuery: (arg: Parameters[0]) => ReturnType - ): MaybePromise>>; - query?: never; - transformResponse?: never; - } + : EndpointDefinitionWithQuery) + | EndpointDefinitionWithQueryFn ) & { /* phantom type */ [resultType]?: ResultType; @@ -73,15 +83,22 @@ export interface QueryApi { context: Context; } -export type QueryDefinition< - QueryArg, - BaseQuery extends BaseQueryFn, +interface QueryExtraOptions< EntityTypes extends string, ResultType, + QueryArg, + BaseQuery extends BaseQueryFn, ReducerPath extends string = string, Context = Record -> = BaseEndpointDefinition & { +> { type: DefinitionType.query; + /** + * Used by `queries` to provide entities to the cache + * Expects an array of entity type strings, or an array of objects of entity types with ids. + * 1. `['Post']` - equivalent to `b` + * 2. `[{ type: 'Post' }]` - equivalent to `a` + * 3. `[{ type: 'Post', id: 1 }]` + */ provides?: ResultDescription>; invalidates?: never; onStart?(arg: QueryArg, queryApi: QueryApi): void; @@ -97,7 +114,17 @@ export type QueryDefinition< result: ResultType, meta: BaseQueryMeta | undefined ): void; -}; +} + +export type QueryDefinition< + QueryArg, + BaseQuery extends BaseQueryFn, + EntityTypes extends string, + ResultType, + ReducerPath extends string = string, + Context = Record +> = BaseEndpointDefinition & + QueryExtraOptions; export interface MutationApi { dispatch: ThunkDispatch; @@ -107,15 +134,19 @@ export interface MutationApi { context: Context; } -export type MutationDefinition< - QueryArg, - BaseQuery extends BaseQueryFn, +interface MutationExtraOptions< EntityTypes extends string, ResultType, + QueryArg, + BaseQuery extends BaseQueryFn, ReducerPath extends string = string, Context = Record -> = BaseEndpointDefinition & { +> { type: DefinitionType.mutation; + /** + * Used by `mutations` for [cache invalidation](../concepts/mutations#advanced-mutations-with-revalidation) purposes. + * Expects the same shapes as `provides` + */ invalidates?: ResultDescription>; provides?: never; onStart?(arg: QueryArg, mutationApi: MutationApi): void; @@ -131,7 +162,17 @@ export type MutationDefinition< result: ResultType, meta: BaseQueryMeta | undefined ): void; -}; +} + +export type MutationDefinition< + QueryArg, + BaseQuery extends BaseQueryFn, + EntityTypes extends string, + ResultType, + ReducerPath extends string = string, + Context = Record +> = BaseEndpointDefinition & + MutationExtraOptions; export type EndpointDefinition< QueryArg, diff --git a/src/fetchBaseQuery.ts b/src/fetchBaseQuery.ts index c03fa404..00ea6320 100644 --- a/src/fetchBaseQuery.ts +++ b/src/fetchBaseQuery.ts @@ -64,6 +64,21 @@ export type FetchBaseQueryMeta = { request: Request; response: Response }; /** * This is a very small wrapper around fetch that aims to simplify requests. * + * @example + * ```ts + * const baseQuery = fetchBaseQuery({ + * baseUrl: 'https://api.your-really-great-app.com/v1/', + * prepareHeaders: (headers, { getState }) => { + * const token = (getState() as RootState).auth.token; + * // If we have a token set in state, let's assume that we should be passing it. + * if (token) { + * headers.set('authorization', `Bearer ${token}`); + * } + * return headers; + * }, + * }) + * ``` + * * @param {string} baseUrl * The base URL for an API service. * Typically in the format of http://example.com/ diff --git a/src/react-hooks/ApiProvider.tsx b/src/react-hooks/ApiProvider.tsx index 0c5c70fe..9586de5c 100644 --- a/src/react-hooks/ApiProvider.tsx +++ b/src/react-hooks/ApiProvider.tsx @@ -5,7 +5,23 @@ import { setupListeners } from '../core/setupListeners'; import { Api } from '../apiTypes'; /** - * Can be used as a Provider if you **do not have a Redux store**. + * Can be used as a `Provider` if you **do not already have a Redux store**. + * + * @example + * ```ts title="Basic usage - wrap your App with ApiProvider" + * import * as React from 'react'; + * import { ApiProvider } from '@rtk-incubator/rtk-query'; + * + * function App() { + * return ( + * + * + * + * ); + * } + * ``` + * + * @remarks * Using this together with an existing redux store, both will * conflict with each other - please use the traditional redux setup * in that case. diff --git a/src/react-hooks/buildHooks.ts b/src/react-hooks/buildHooks.ts index a6d384b6..5555cc1b 100644 --- a/src/react-hooks/buildHooks.ts +++ b/src/react-hooks/buildHooks.ts @@ -128,7 +128,6 @@ type UseQueryStateDefaultResult> = status: QueryStatus; }; -// USE MUTATION START export type MutationStateSelector> = ( state: MutationResultSelectorResult, defaultMutationStateSelector: DefaultMutationStateSelector @@ -175,8 +174,6 @@ type UseMutationStateDefaultResult >; -// USE MUTATION END - export type MutationHook> = >( options?: UseMutationStateOptions ) => [ @@ -232,6 +229,14 @@ type GenericPrefetchThunk = ( options: PrefetchOptions ) => ThunkAction; +/** + * + * @param opts.api - An API with defined endpoints to create hooks for + * @param opts.moduleOptions.batch - The version of the `batchedUpdates` function to be used + * @param opts.moduleOptions.useDispatch - The version of the `useDispatch` hook to be used + * @param opts.moduleOptions.useSelector - The version of the `useSelector` hook to be used + * @returns An object containing functions to generate hooks based on an endpoint + */ export function buildHooks({ api, moduleOptions: { batch, useDispatch, useSelector }, diff --git a/src/react-hooks/module.ts b/src/react-hooks/module.ts index 3512b3cc..054abebf 100644 --- a/src/react-hooks/module.ts +++ b/src/react-hooks/module.ts @@ -5,6 +5,7 @@ import { MutationDefinition, isQueryDefinition, isMutationDefinition, + QueryArgFrom, } from '../endpointDefinitions'; import { TS41Hooks } from '../ts41Types'; import { Api, Module } from '../apiTypes'; @@ -18,6 +19,8 @@ import { useStore as rrUseStore, batch as rrBatch, } from 'react-redux'; +import { QueryKeys } from '../core/apiState'; +import { PrefetchOptions } from '../core/module'; export const reactHooksModuleName = Symbol(); export type ReactHooksModule = typeof reactHooksModuleName; @@ -33,6 +36,9 @@ declare module '../apiTypes' { EntityTypes extends string > { [reactHooksModuleName]: { + /** + * Endpoints based on the input endpoints provided to `createApi`, containing `select`, `hooks` and `action matchers`. + */ endpoints: { [K in keyof Definitions]: Definitions[K] extends QueryDefinition ? QueryHooks @@ -40,6 +46,13 @@ declare module '../apiTypes' { ? MutationHooks : never; }; + /** + * A hook that accepts a string endpoint name, and provides a callback that when called, pre-fetches the data for that endpoint. + */ + usePrefetch>( + endpointName: EndpointName, + options?: PrefetchOptions + ): (arg: QueryArgFrom, options?: PrefetchOptions) => void; } & TS41Hooks; } } @@ -47,12 +60,38 @@ declare module '../apiTypes' { type RR = typeof import('react-redux'); export interface ReactHooksModuleOptions { + /** + * The version of the `batchedUpdates` function to be used + */ batch?: RR['batch']; + /** + * The version of the `useDispatch` hook to be used + */ useDispatch?: RR['useDispatch']; + /** + * The version of the `useSelector` hook to be used + */ useSelector?: RR['useSelector']; + /** + * Currently unused - for potential future use + */ useStore?: RR['useStore']; } +/** + * Creates a module that generates react hooks from endpoints, for use with `buildCreateApi`. + * + * @example + * ```ts + * const MyContext = React.createContext(null as any); + * const customCreateApi = buildCreateApi( + * coreModule(), + * reactHooksModule({ useDispatch: createDispatchHook(MyContext) }) + * ); + * ``` + * + * @returns A module for use with `buildCreateApi` + */ export const reactHooksModule = ({ batch = rrBatch, useDispatch = rrUseDispatch, @@ -61,16 +100,16 @@ export const reactHooksModule = ({ }: ReactHooksModuleOptions = {}): Module => ({ name: reactHooksModuleName, init(api, options, context) { + const anyApi = (api as any) as Api, string, string, ReactHooksModule>; const { buildQueryHooks, buildMutationHook, usePrefetch } = buildHooks({ api, moduleOptions: { batch, useDispatch, useSelector, useStore }, }); - safeAssign(api, { usePrefetch }); + safeAssign(anyApi, { usePrefetch }); safeAssign(context, { batch }); return { injectEndpoint(endpointName, definition) { - const anyApi = (api as any) as Api, string, string, ReactHooksModule>; if (isQueryDefinition(definition)) { const { useQuery, useLazyQuery, useQueryState, useQuerySubscription } = buildQueryHooks(endpointName); safeAssign(anyApi.endpoints[endpointName], { diff --git a/src/retry.ts b/src/retry.ts index 5a422519..4ed5a8d0 100644 --- a/src/retry.ts +++ b/src/retry.ts @@ -1,16 +1,22 @@ import { BaseQueryEnhancer } from './baseQueryTypes'; import { HandledError } from './HandledError'; +/** + * Exponential backoff based on the attempt number. + * + * @remarks + * 1. 600ms * random(0.4, 1.4) + * 2. 1200ms * random(0.4, 1.4) + * 3. 2400ms * random(0.4, 1.4) + * 4. 4800ms * random(0.4, 1.4) + * 5. 9600ms * random(0.4, 1.4) + * + * @param attempt - Current attempt + * @param maxRetries - Maximum number of retries + */ async function defaultBackoff(attempt: number = 0, maxRetries: number = 5) { const attempts = Math.min(attempt, maxRetries); - /** - * Exponential backoff that would give a baseline like: - * 1 - 600ms + rand - * 2 - 1200ms + rand - * 3 - 2400ms + rand - * 4 - 4800ms + rand - * 5 - 9600ms + rand - */ + const timeout = ~~((Math.random() + 0.4) * (300 << attempts)); // Force a positive int in the case we make this an option await new Promise((resolve) => setTimeout((res) => resolve(res), timeout)); } @@ -20,6 +26,9 @@ interface StaggerOptions { * How many times the query will be retried (default: 5) */ maxRetries?: number; + /** + * Function used to determine delay between retries + */ backoff?: (attempt: number, maxRetries: number) => Promise; } @@ -57,4 +66,29 @@ const retryWithBackoff: BaseQueryEnhancer ({ + * getPosts: build.query({ + * query: () => ({ url: 'posts' }), + * }), + * getPost: build.query({ + * query: (id: string) => ({ url: `posts/${id}` }), + * extraOptions: { maxRetries: 8 }, // You can override the retry behavior on each endpoint + * }), + * }), + * }); + * + * export const { useGetPostsQuery, useGetPostQuery } = api; + * ``` + */ export const retry = Object.assign(retryWithBackoff, { fail }); diff --git a/test/buildHooks.test.tsx b/test/buildHooks.test.tsx index 8ce81619..45b2f16f 100644 --- a/test/buildHooks.test.tsx +++ b/test/buildHooks.test.tsx @@ -3,7 +3,7 @@ import { createApi, fetchBaseQuery, QueryStatus } from '@rtk-incubator/rtk-query import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { rest } from 'msw'; -import { actionsReducer, matchSequence, setupApiStore, useRenderCounter, waitMs } from './helpers'; +import { actionsReducer, expectExactType, matchSequence, setupApiStore, useRenderCounter, waitMs } from './helpers'; import { server } from './mocks/server'; import { AnyAction } from 'redux'; import { SubscriptionOptions } from '@internal/core/apiState'; @@ -1120,10 +1120,17 @@ describe('hooks with createApi defaults set', () => { const storeRef = setupApiStore(api); + // @pre41-ts-ignore + expectExactType(api.useGetPostsQuery)(api.endpoints.getPosts.useQuery); + // @pre41-ts-ignore + expectExactType(api.useUpdatePostMutation)(api.endpoints.updatePost.useMutation); + // @pre41-ts-ignore + expectExactType(api.useAddPostMutation)(api.endpoints.addPost.useMutation); + test('useQueryState serves a deeply memoized value and does not rerender unnecessarily', async () => { function Posts() { - const { data: posts } = api.useGetPostsQuery(); - const [addPost] = api.useAddPostMutation(); + const { data: posts } = api.endpoints.getPosts.useQuery(); + const [addPost] = api.endpoints.addPost.useMutation(); return (