diff --git a/.gitignore b/.gitignore index a77f46a2..2e42cf58 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules /packages/*/dist /packages/*/coverage +tsconfig.vitest-temp.json \ No newline at end of file diff --git a/README.md b/README.md index 0174f92c..7589d550 100644 --- a/README.md +++ b/README.md @@ -260,19 +260,17 @@ Any additional `options` you pass to `useMutation` will be merged with the optio ### `createConnectQueryKey` -```ts -function createConnectQueryKey( - params: KeyParams, -): ConnectQueryKey; -``` - -This function is used under the hood of `useQuery` and other hooks to compute a [`queryKey`](https://tanstack.com/query/v4/docs/react/guides/query-keys) for TanStack Query. You can use it to create (partial) keys yourself to filter queries. +This function is used under the hood of `useQuery` and other hooks to compute a [`queryKey`](https://tanstack.com/query/v4/docs/react/guides/query-keys) for TanStack Query. You can use it to create keys yourself to filter queries. `useQuery` creates a query key with the following parameters: 1. The qualified name of the RPC. 2. The transport being used. 3. The request message. +4. The cardinality of the RPC (either "finite" or "infinite"). +5. Adds a DataTag which brands the key with the associated data type of the response. + +The DataTag type allows @tanstack/react-query functions to properly infer the type of the data returned by the query. This is useful for things like `QueryClient.setQueryData` and `QueryClient.getQueryData`. To create the same key manually, you simply provide the same parameters: @@ -355,7 +353,7 @@ function callUnaryMethod( This API allows you to directly call the method using the provided transport. Use this if you need to manually call a method outside of the context of a React component, or need to call it where you can't use hooks. -### `createProtobufSafeUpdater` +### `createProtobufSafeUpdater` (deprecated) Creates a typesafe updater that can be used to update data in a query cache. Used in combination with a queryClient. @@ -387,6 +385,8 @@ queryClient.setQueryData( ``` +** Note: This API is deprecated and will be removed in a future version. `ConnectQueryKey` now contains type information to make it safer to use `setQueryData` directly. ** + ### `createQueryOptions` ```ts @@ -599,21 +599,15 @@ Connect-Query does require React, but the core (`createConnectQueryKey` and `cal ### How do I do Prefetching? -When you might not have access to React context, you can use the `create` series of functions and provide a transport directly. For example: +When you might not have access to React context, you can use `createQueryOptions` and provide a transport directly. For example: ```ts import { say } from "./gen/eliza-ElizaService_connectquery"; function prefetch() { - return queryClient.prefetchQuery({ - queryKey: createConnectQueryKey({ - schema: say, - transport: myTransport, - input: { sentence: "Hello" }, - cardinality: "finite", - }), - queryFn: () => callUnaryMethod(myTransport, say, { sentence: "Hello" }), - }); + return queryClient.prefetchQuery( + createQueryOptions(say, { sentence: "Hello" }, { transport: myTransport }), + ); } ``` diff --git a/package-lock.json b/package-lock.json index 80906232..85218402 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9536,7 +9536,7 @@ "peerDependencies": { "@bufbuild/protobuf": "2.x", "@connectrpc/connect": "^2.0.1", - "@tanstack/react-query": "5.x", + "@tanstack/react-query": ">=5.62.0", "react": "^18 || ^19", "react-dom": "^18 || ^19" } @@ -9560,7 +9560,7 @@ "peerDependencies": { "@bufbuild/protobuf": "2.x", "@connectrpc/connect": "^2.0.1", - "@tanstack/query-core": "5.x" + "@tanstack/query-core": ">=5.62.0" } }, "packages/examples/react/basic": { diff --git a/packages/connect-query-core/package.json b/packages/connect-query-core/package.json index f9e53d73..e9f7c80a 100644 --- a/packages/connect-query-core/package.json +++ b/packages/connect-query-core/package.json @@ -44,7 +44,7 @@ "peerDependencies": { "@bufbuild/protobuf": "2.x", "@connectrpc/connect": "^2.0.1", - "@tanstack/query-core": "5.x" + "@tanstack/query-core": ">=5.62.0" }, "files": [ "dist/**" diff --git a/packages/connect-query-core/src/connect-query-key.test.ts b/packages/connect-query-core/src/connect-query-key.test.ts index 19379650..857e9a06 100644 --- a/packages/connect-query-core/src/connect-query-key.test.ts +++ b/packages/connect-query-core/src/connect-query-key.test.ts @@ -14,14 +14,20 @@ import { create } from "@bufbuild/protobuf"; import type { Transport } from "@connectrpc/connect"; -import { ElizaService, SayRequestSchema } from "test-utils/gen/eliza_pb.js"; +import { + ElizaService, + SayRequestSchema, + SayResponseSchema, + type SayResponse, +} from "test-utils/gen/eliza_pb.js"; import { ListRequestSchema, ListService } from "test-utils/gen/list_pb.js"; -import { describe, expect, it } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { createConnectQueryKey } from "./connect-query-key.js"; import { skipToken } from "./index.js"; import { createMessageKey } from "./message-key.js"; import { createTransportKey } from "./transport-key.js"; +import { type InfiniteData, QueryClient } from "@tanstack/query-core"; describe("createConnectQueryKey", () => { const fakeTransport: Transport = { @@ -125,12 +131,96 @@ describe("createConnectQueryKey", () => { it("cannot except invalid input", () => { createConnectQueryKey({ + // @ts-expect-error(2322) cannot create a key with invalid input schema: ElizaService.method.say, input: { - // @ts-expect-error(2322) cannot create a key with invalid input sentence: 1, }, cardinality: undefined, }); }); + + it("contains type hints to indicate the output type", () => { + const sampleQueryClient = new QueryClient(); + const key = createConnectQueryKey({ + schema: ElizaService.method.say, + input: create(SayRequestSchema, { sentence: "hi" }), + cardinality: "finite", + }); + const data = sampleQueryClient.getQueryData(key); + + expectTypeOf(data).toEqualTypeOf(); + }); + + it("supports typesafe data updaters", () => { + const sampleQueryClient = new QueryClient(); + const key = createConnectQueryKey({ + schema: ElizaService.method.say, + input: create(SayRequestSchema, { sentence: "hi" }), + cardinality: "finite", + }); + // @ts-expect-error(2345) this is a test to check if the type is correct + sampleQueryClient.setQueryData(key, { sentence: 1 }); + // @ts-expect-error(2345) $typename is required + sampleQueryClient.setQueryData(key, { + sentence: "a proper value but missing $typename", + }); + sampleQueryClient.setQueryData( + key, + create(SayResponseSchema, { sentence: "a proper value" }), + ); + + sampleQueryClient.setQueryData(key, (prev) => { + expectTypeOf(prev).toEqualTypeOf(); + return create(SayResponseSchema, { + sentence: "a proper value", + }); + }); + }); + + describe("infinite queries", () => { + it("contains type hints to indicate the output type", () => { + const sampleQueryClient = new QueryClient(); + const key = createConnectQueryKey({ + schema: ElizaService.method.say, + input: create(SayRequestSchema, { sentence: "hi" }), + cardinality: "infinite", + }); + const data = sampleQueryClient.getQueryData(key); + + expectTypeOf(data).toEqualTypeOf | undefined>(); + }); + + it("supports typesafe data updaters", () => { + const sampleQueryClient = new QueryClient(); + const key = createConnectQueryKey({ + schema: ElizaService.method.say, + input: create(SayRequestSchema, { sentence: "hi" }), + cardinality: "infinite", + }); + sampleQueryClient.setQueryData(key, { + pages: [ + // @ts-expect-error(2345) make sure the shape is as expected + { sentence: 1 }, + ], + }); + sampleQueryClient.setQueryData(key, { + // @ts-expect-error(2345) $typename is required + pages: [{ sentence: "a proper value but missing $typename" }], + }); + sampleQueryClient.setQueryData(key, { + pageParams: [0], + pages: [create(SayResponseSchema, { sentence: "a proper value" })], + }); + sampleQueryClient.setQueryData(key, (prev) => { + expectTypeOf(prev).toEqualTypeOf< + InfiniteData | undefined + >(); + return { + pageParams: [0], + pages: [create(SayResponseSchema, { sentence: "a proper value" })], + }; + }); + }); + }); }); diff --git a/packages/connect-query-core/src/connect-query-key.ts b/packages/connect-query-core/src/connect-query-key.ts index c7e3d58f..9f7623f3 100644 --- a/packages/connect-query-core/src/connect-query-key.ts +++ b/packages/connect-query-core/src/connect-query-key.ts @@ -18,13 +18,60 @@ import type { DescMethodUnary, DescService, MessageInitShape, + MessageShape, } from "@bufbuild/protobuf"; -import type { Transport } from "@connectrpc/connect"; -import type { SkipToken } from "@tanstack/query-core"; +import type { ConnectError, Transport } from "@connectrpc/connect"; +import type { DataTag, InfiniteData, SkipToken } from "@tanstack/query-core"; import { createMessageKey } from "./message-key.js"; import { createTransportKey } from "./transport-key.js"; +type SharedConnectQueryOptions = { + /** + * A key for a Transport reference, created with createTransportKey(). + */ + transport?: string; + /** + * The name of the service, e.g. connectrpc.eliza.v1.ElizaService + */ + serviceName: string; + /** + * The name of the method, e.g. Say. + */ + methodName?: string; + /** + * A key for the request message, created with createMessageKey(), + * or "skipped". + */ + input?: Record | "skipped"; +}; + +type InfiniteConnectQueryKey = + DataTag< + [ + "connect-query", + SharedConnectQueryOptions & { + /** This data represents a infinite, paged result */ + cardinality: "infinite"; + }, + ], + InfiniteData>, + ConnectError + >; + +type FiniteConnectQueryKey = + DataTag< + [ + "connect-query", + SharedConnectQueryOptions & { + /** This data represents a finite result */ + cardinality: "finite"; + }, + ], + MessageShape, + ConnectError + >; + /** * TanStack Query manages query caching for you based on query keys. `QueryKey`s in TanStack Query are arrays with arbitrary JSON-serializable data - typically handwritten for each endpoint. * @@ -44,35 +91,15 @@ import { createTransportKey } from "./transport-key.js"; * } * ] */ -export type ConnectQueryKey = [ - /** - * To distinguish Connect query keys from other query keys, they always start with the string "connect-query". - */ - "connect-query", - { - /** - * A key for a Transport reference, created with createTransportKey(). - */ - transport?: string; - /** - * The name of the service, e.g. connectrpc.eliza.v1.ElizaService - */ - serviceName: string; - /** - * The name of the method, e.g. Say. - */ - methodName?: string; - /** - * A key for the request message, created with createMessageKey(), - * or "skipped". - */ - input?: Record | "skipped"; - /** - * Whether this is an infinite query, or a regular one. - */ - cardinality?: "infinite" | "finite" | undefined; - }, -]; +export type ConnectQueryKey = + | InfiniteConnectQueryKey + | FiniteConnectQueryKey + | [ + "connect-query", + SharedConnectQueryOptions & { + cardinality: undefined; + }, + ]; type KeyParamsForMethod = { /** @@ -152,14 +179,48 @@ type KeyParamsForService = { * * @see ConnectQueryKey for information on the components of Connect-Query's keys. */ +export function createConnectQueryKey< + I extends DescMessage, + O extends DescMessage, +>( + params: KeyParamsForMethod> & { + cardinality: "finite"; + }, +): FiniteConnectQueryKey; +export function createConnectQueryKey< + I extends DescMessage, + O extends DescMessage, +>( + params: KeyParamsForMethod> & { + cardinality: "infinite"; + }, +): InfiniteConnectQueryKey; +export function createConnectQueryKey< + I extends DescMessage, + O extends DescMessage, +>( + params: KeyParamsForMethod> & { + cardinality: undefined; + }, +): ConnectQueryKey; +export function createConnectQueryKey< + O extends DescMessage, + Desc extends DescService, +>(params: KeyParamsForService): ConnectQueryKey; export function createConnectQueryKey< I extends DescMessage, O extends DescMessage, Desc extends DescService, >( params: KeyParamsForMethod> | KeyParamsForService, -): ConnectQueryKey { - const props: ConnectQueryKey[1] = +): ConnectQueryKey { + const props: { + serviceName: string; + methodName?: string; + transport?: string; + cardinality?: "finite" | "infinite"; + input?: "skipped" | Record; + } = params.schema.kind == "rpc" ? { serviceName: params.schema.parent.typeName, @@ -185,5 +246,5 @@ export function createConnectQueryKey< ); } } - return ["connect-query", props]; + return ["connect-query", props] as ConnectQueryKey; } diff --git a/packages/connect-query-core/src/create-infinite-query-options.ts b/packages/connect-query-core/src/create-infinite-query-options.ts index f4aa2d5f..176ef108 100644 --- a/packages/connect-query-core/src/create-infinite-query-options.ts +++ b/packages/connect-query-core/src/create-infinite-query-options.ts @@ -67,7 +67,7 @@ function createUnaryInfiniteQueryFn< }, ): QueryFunction< MessageShape, - ConnectQueryKey, + ConnectQueryKey, MessageInitShape[ParamKey] > { return async (context) => { @@ -104,10 +104,10 @@ export function createInfiniteQueryOptions< O, ParamKey >["getNextPageParam"]; - queryKey: ConnectQueryKey; + queryKey: ConnectQueryKey; queryFn: QueryFunction< MessageShape, - ConnectQueryKey, + ConnectQueryKey, MessageInitShape[ParamKey] >; structuralSharing: (oldData: unknown, newData: unknown) => unknown; @@ -131,7 +131,7 @@ export function createInfiniteQueryOptions< O, ParamKey >["getNextPageParam"]; - queryKey: ConnectQueryKey; + queryKey: ConnectQueryKey; queryFn: SkipToken; structuralSharing: (oldData: unknown, newData: unknown) => unknown; initialPageParam: MessageInitShape[ParamKey]; @@ -156,11 +156,11 @@ export function createInfiniteQueryOptions< O, ParamKey >["getNextPageParam"]; - queryKey: ConnectQueryKey; + queryKey: ConnectQueryKey; queryFn: | QueryFunction< MessageShape, - ConnectQueryKey, + ConnectQueryKey, MessageInitShape[ParamKey] > | SkipToken; @@ -187,11 +187,11 @@ export function createInfiniteQueryOptions< O, ParamKey >["getNextPageParam"]; - queryKey: ConnectQueryKey; + queryKey: ConnectQueryKey; queryFn: | QueryFunction< MessageShape, - ConnectQueryKey, + ConnectQueryKey, MessageInitShape[ParamKey] > | SkipToken; diff --git a/packages/connect-query-core/src/create-query-options.ts b/packages/connect-query-core/src/create-query-options.ts index 3d7069b8..6b8b0e7e 100644 --- a/packages/connect-query-core/src/create-query-options.ts +++ b/packages/connect-query-core/src/create-query-options.ts @@ -32,7 +32,7 @@ function createUnaryQueryFn( transport: Transport, schema: DescMethodUnary, input: MessageInitShape | undefined, -): QueryFunction, ConnectQueryKey> { +): QueryFunction, ConnectQueryKey> { return async (context) => { return callUnaryMethod(transport, schema, input, { signal: context.signal, @@ -55,8 +55,8 @@ export function createQueryOptions< transport: Transport; }, ): { - queryKey: ConnectQueryKey; - queryFn: QueryFunction, ConnectQueryKey>; + queryKey: ConnectQueryKey; + queryFn: QueryFunction, ConnectQueryKey>; structuralSharing: (oldData: unknown, newData: unknown) => unknown; }; export function createQueryOptions< @@ -71,7 +71,7 @@ export function createQueryOptions< transport: Transport; }, ): { - queryKey: ConnectQueryKey; + queryKey: ConnectQueryKey; queryFn: SkipToken; structuralSharing: (oldData: unknown, newData: unknown) => unknown; }; @@ -87,8 +87,8 @@ export function createQueryOptions< transport: Transport; }, ): { - queryKey: ConnectQueryKey; - queryFn: QueryFunction, ConnectQueryKey> | SkipToken; + queryKey: ConnectQueryKey; + queryFn: QueryFunction, ConnectQueryKey> | SkipToken; structuralSharing: (oldData: unknown, newData: unknown) => unknown; }; export function createQueryOptions< @@ -103,8 +103,8 @@ export function createQueryOptions< transport: Transport; }, ): { - queryKey: ConnectQueryKey; - queryFn: QueryFunction, ConnectQueryKey> | SkipToken; + queryKey: ConnectQueryKey; + queryFn: QueryFunction, ConnectQueryKey> | SkipToken; structuralSharing: (oldData: unknown, newData: unknown) => unknown; } { const queryKey = createConnectQueryKey({ diff --git a/packages/connect-query-core/src/utils.ts b/packages/connect-query-core/src/utils.ts index b51b6a89..ffd7d7f5 100644 --- a/packages/connect-query-core/src/utils.ts +++ b/packages/connect-query-core/src/utils.ts @@ -60,6 +60,8 @@ export type ConnectUpdater = /** * This helper makes sure that the type for the original response message is returned. + * + * @deprecated the ConnectQueryKey type now links to the return data type so `setQueryData` can be called safely without this helper. */ export const createProtobufSafeUpdater = ( diff --git a/packages/connect-query/package.json b/packages/connect-query/package.json index f610582e..6433705f 100644 --- a/packages/connect-query/package.json +++ b/packages/connect-query/package.json @@ -52,7 +52,7 @@ "peerDependencies": { "@bufbuild/protobuf": "2.x", "@connectrpc/connect": "^2.0.1", - "@tanstack/react-query": "5.x", + "@tanstack/react-query": ">=5.62.0", "react": "^18 || ^19", "react-dom": "^18 || ^19" }, diff --git a/packages/connect-query/src/use-infinite-query.ts b/packages/connect-query/src/use-infinite-query.ts index f3d71ce8..afa84fb3 100644 --- a/packages/connect-query/src/use-infinite-query.ts +++ b/packages/connect-query/src/use-infinite-query.ts @@ -52,7 +52,7 @@ export type UseInfiniteQueryOptions< ConnectError, InfiniteData>, MessageShape, - ConnectQueryKey, + ConnectQueryKey, MessageInitShape[ParamKey] >, "getNextPageParam" | "initialPageParam" | "queryFn" | "queryKey" @@ -106,7 +106,7 @@ export type UseSuspenseInfiniteQueryOptions< ConnectError, InfiniteData>, MessageShape, - ConnectQueryKey, + ConnectQueryKey, MessageInitShape[ParamKey] >, "getNextPageParam" | "initialPageParam" | "queryFn" | "queryKey" diff --git a/packages/connect-query/src/use-query.ts b/packages/connect-query/src/use-query.ts index 6db782dc..3675af22 100644 --- a/packages/connect-query/src/use-query.ts +++ b/packages/connect-query/src/use-query.ts @@ -48,7 +48,7 @@ export type UseQueryOptions< MessageShape, ConnectError, SelectOutData, - ConnectQueryKey + ConnectQueryKey >, "queryFn" | "queryKey" > & { @@ -89,7 +89,7 @@ export type UseSuspenseQueryOptions< MessageShape, ConnectError, SelectOutData, - ConnectQueryKey + ConnectQueryKey >, "queryFn" | "queryKey" > & {