diff --git a/README.md b/README.md index 0174f92c..5a5de71d 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,7 @@ Use this helper to get the default transport that's currently attached to the Re > [!TIP] > > All hooks accept a `transport` in the options. You can use the Transport from the context, or create one dynamically. If you create a Transport dynamically, make sure to memoize it, because it is taken into consideration when building query keys. +> All hooks also accept `contextValues`, which can be used to pass additional context on each call to the transport and any interceptors. ### `useQuery` @@ -204,7 +205,11 @@ function useQuery< >( schema: DescMethodUnary, input?: SkipToken | MessageInitShape, - { transport, ...queryOptions }: UseQueryOptions = {}, + { + transport, + contextValues, + ...queryOptions + }: UseQueryOptions = {}, ): UseQueryResult; ``` @@ -232,6 +237,7 @@ function useInfiniteQuery< transport, pageParamKey, getNextPageParam, + contextValues, ...queryOptions }: UseInfiniteQueryOptions, ): UseInfiniteQueryResult>, ConnectError>; @@ -250,7 +256,11 @@ Identical to useInfiniteQuery but mapping to the `useSuspenseInfiniteQuery` hook ```ts function useMutation( schema: DescMethodUnary, - { transport, ...queryOptions }: UseMutationOptions = {}, + { + transport, + contextValues, + ...queryOptions + }: UseMutationOptions = {}, ): UseMutationResult, ConnectError, PartialMessage>; ``` @@ -349,6 +359,7 @@ function callUnaryMethod( input: MessageInitShape | undefined, options?: { signal?: AbortSignal; + contextValues?: SerializableContextValues; }, ): Promise; ``` @@ -395,8 +406,10 @@ function createQueryOptions( input: SkipToken | PartialMessage | undefined, { transport, + contextValues, }: { transport: Transport; + contextValues?: SerializableContextValues; }, ): { queryKey: ConnectQueryKey; @@ -508,6 +521,10 @@ type ConnectQueryKey = [ * Whether this is an infinite query, or a regular one. */ cardinality?: "infinite" | "finite"; + /** + * The stringified version of contextValues, if present. + */ + contextValues?: string; }, ]; ``` diff --git a/packages/connect-query-core/src/call-unary-method.ts b/packages/connect-query-core/src/call-unary-method.ts index 4624a680..42f6349b 100644 --- a/packages/connect-query-core/src/call-unary-method.ts +++ b/packages/connect-query-core/src/call-unary-method.ts @@ -20,6 +20,7 @@ import type { } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf"; import type { Transport } from "@connectrpc/connect"; +import type { SerializableContextValues } from "./serializable-context-values.js"; /** * Call a unary method given its signature and input. @@ -34,6 +35,7 @@ export async function callUnaryMethod< input: MessageInitShape | undefined, options?: { signal?: AbortSignal; + contextValues?: SerializableContextValues; }, ): Promise> { const result = await transport.unary( @@ -42,7 +44,7 @@ export async function callUnaryMethod< undefined, undefined, input ?? create(schema.input), - undefined, + options?.contextValues, ); return result.message; } 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..bbb22e62 100644 --- a/packages/connect-query-core/src/connect-query-key.test.ts +++ b/packages/connect-query-core/src/connect-query-key.test.ts @@ -13,13 +13,13 @@ // limitations under the License. import { create } from "@bufbuild/protobuf"; -import type { Transport } from "@connectrpc/connect"; +import { createContextValues, type Transport } from "@connectrpc/connect"; import { ElizaService, SayRequestSchema } from "test-utils/gen/eliza_pb.js"; import { ListRequestSchema, ListService } from "test-utils/gen/list_pb.js"; import { describe, expect, it } from "vitest"; import { createConnectQueryKey } from "./connect-query-key.js"; -import { skipToken } from "./index.js"; +import { skipToken, type SerializableContextValues } from "./index.js"; import { createMessageKey } from "./message-key.js"; import { createTransportKey } from "./transport-key.js"; @@ -133,4 +133,21 @@ describe("createConnectQueryKey", () => { cardinality: undefined, }); }); + + it("allows to set contextValues", () => { + const baseContextValues = createContextValues(); + const fakeContextValues: SerializableContextValues = { + ...baseContextValues, + toString() { + return "serialized"; + }, + }; + const key = createConnectQueryKey({ + schema: ElizaService.method.say, + input: skipToken, + cardinality: "finite", + contextValues: fakeContextValues, + }); + expect(key[1].contextValues).toEqual("serialized"); + }); }); diff --git a/packages/connect-query-core/src/connect-query-key.ts b/packages/connect-query-core/src/connect-query-key.ts index c7e3d58f..01f32fec 100644 --- a/packages/connect-query-core/src/connect-query-key.ts +++ b/packages/connect-query-core/src/connect-query-key.ts @@ -24,6 +24,7 @@ import type { SkipToken } from "@tanstack/query-core"; import { createMessageKey } from "./message-key.js"; import { createTransportKey } from "./transport-key.js"; +import type { SerializableContextValues } from "./serializable-context-values.js"; /** * 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. @@ -71,6 +72,10 @@ export type ConnectQueryKey = [ * Whether this is an infinite query, or a regular one. */ cardinality?: "infinite" | "finite" | undefined; + /** + * The stringified version of contextValues, if present. + */ + contextValues?: string; }, ]; @@ -98,6 +103,10 @@ type KeyParamsForMethod = { * If omit the field with this name from the key for infinite queries. */ pageParamKey?: keyof MessageInitShape; + /** + * Any contextValues that need to be passed to the query. + */ + contextValues?: SerializableContextValues; }; type KeyParamsForService = { @@ -168,6 +177,10 @@ export function createConnectQueryKey< : { serviceName: params.schema.typeName, }; + + if ("contextValues" in params && params.contextValues !== undefined) { + props.contextValues = params.contextValues.toString(); + } if (params.transport !== undefined) { props.transport = createTransportKey(params.transport); } 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..7e4832f7 100644 --- a/packages/connect-query-core/src/create-infinite-query-options.ts +++ b/packages/connect-query-core/src/create-infinite-query-options.ts @@ -33,6 +33,7 @@ import { } from "./connect-query-key.js"; import { createStructuralSharing } from "./structural-sharing.js"; import { assert } from "./utils.js"; +import type { SerializableContextValues } from "./serializable-context-values.js"; /** * Options specific to connect-query @@ -49,6 +50,7 @@ export interface ConnectInfiniteQueryOptions< MessageInitShape[ParamKey], MessageShape >; + contextValues?: SerializableContextValues; } // eslint-disable-next-line @typescript-eslint/max-params -- we have 4 required arguments @@ -62,8 +64,10 @@ function createUnaryInfiniteQueryFn< input: MessageInitShape, { pageParamKey, + contextValues, }: { pageParamKey: ParamKey; + contextValues?: SerializableContextValues; }, ): QueryFunction< MessageShape, @@ -79,6 +83,7 @@ function createUnaryInfiniteQueryFn< }; return callUnaryMethod(transport, schema, inputCombinedWithPageParam, { signal: context.signal, + contextValues: contextValues, }); }; } @@ -97,6 +102,7 @@ export function createInfiniteQueryOptions< transport, getNextPageParam, pageParamKey, + contextValues, }: ConnectInfiniteQueryOptions & { transport: Transport }, ): { getNextPageParam: ConnectInfiniteQueryOptions< @@ -149,6 +155,7 @@ export function createInfiniteQueryOptions< transport, getNextPageParam, pageParamKey, + contextValues, }: ConnectInfiniteQueryOptions & { transport: Transport }, ): { getNextPageParam: ConnectInfiniteQueryOptions< @@ -180,6 +187,7 @@ export function createInfiniteQueryOptions< transport, getNextPageParam, pageParamKey, + contextValues, }: ConnectInfiniteQueryOptions & { transport: Transport }, ): { getNextPageParam: ConnectInfiniteQueryOptions< @@ -203,6 +211,7 @@ export function createInfiniteQueryOptions< schema, transport, input, + contextValues, }); const structuralSharing = createStructuralSharing(schema.output); const queryFn = @@ -210,6 +219,7 @@ export function createInfiniteQueryOptions< ? skipToken : createUnaryInfiniteQueryFn(transport, schema, input, { pageParamKey, + contextValues, }); return { getNextPageParam, diff --git a/packages/connect-query-core/src/create-query-options.ts b/packages/connect-query-core/src/create-query-options.ts index 3d7069b8..33c64a04 100644 --- a/packages/connect-query-core/src/create-query-options.ts +++ b/packages/connect-query-core/src/create-query-options.ts @@ -27,15 +27,18 @@ import { callUnaryMethod } from "./call-unary-method.js"; import type { ConnectQueryKey } from "./connect-query-key.js"; import { createConnectQueryKey } from "./connect-query-key.js"; import { createStructuralSharing } from "./structural-sharing.js"; +import type { SerializableContextValues } from "./serializable-context-values.js"; function createUnaryQueryFn( transport: Transport, schema: DescMethodUnary, input: MessageInitShape | undefined, + contextValues?: SerializableContextValues, ): QueryFunction, ConnectQueryKey> { return async (context) => { return callUnaryMethod(transport, schema, input, { signal: context.signal, + contextValues, }); }; } @@ -51,8 +54,10 @@ export function createQueryOptions< input: MessageInitShape | undefined, { transport, + contextValues, }: { transport: Transport; + contextValues?: SerializableContextValues; }, ): { queryKey: ConnectQueryKey; @@ -67,8 +72,10 @@ export function createQueryOptions< input: SkipToken, { transport, + contextValues, }: { transport: Transport; + contextValues?: SerializableContextValues; }, ): { queryKey: ConnectQueryKey; @@ -83,8 +90,10 @@ export function createQueryOptions< input: SkipToken | MessageInitShape | undefined, { transport, + contextValues, }: { transport: Transport; + contextValues?: SerializableContextValues; }, ): { queryKey: ConnectQueryKey; @@ -99,8 +108,10 @@ export function createQueryOptions< input: SkipToken | MessageInitShape | undefined, { transport, + contextValues, }: { transport: Transport; + contextValues?: SerializableContextValues; }, ): { queryKey: ConnectQueryKey; @@ -112,12 +123,13 @@ export function createQueryOptions< input: input ?? create(schema.input), transport, cardinality: "finite", + contextValues, }); const structuralSharing = createStructuralSharing(schema.output); const queryFn = input === skipToken ? skipToken - : createUnaryQueryFn(transport, schema, input); + : createUnaryQueryFn(transport, schema, input, contextValues); return { queryKey, queryFn, diff --git a/packages/connect-query-core/src/index.ts b/packages/connect-query-core/src/index.ts index 7222a945..5d6a80ae 100644 --- a/packages/connect-query-core/src/index.ts +++ b/packages/connect-query-core/src/index.ts @@ -23,3 +23,4 @@ export { createQueryOptions } from "./create-query-options.js"; export { addStaticKeyToTransport } from "./transport-key.js"; export type { SkipToken } from "@tanstack/query-core"; export { skipToken } from "@tanstack/query-core"; +export type { SerializableContextValues } from "./serializable-context-values.js"; diff --git a/packages/connect-query-core/src/serializable-context-values.ts b/packages/connect-query-core/src/serializable-context-values.ts new file mode 100644 index 00000000..7d5d8bf9 --- /dev/null +++ b/packages/connect-query-core/src/serializable-context-values.ts @@ -0,0 +1,24 @@ +// Copyright 2021-2023 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { type ContextValues } from "@connectrpc/connect"; + +/** + * A version of ContextValues that can be serialized to a string. + * This string will be used to detect if the context has changed + * and the query needs to be invalidated. + */ +export interface SerializableContextValues extends ContextValues { + toString(): string; +} diff --git a/packages/connect-query/src/use-infinite-query.ts b/packages/connect-query/src/use-infinite-query.ts index f3d71ce8..683e7813 100644 --- a/packages/connect-query/src/use-infinite-query.ts +++ b/packages/connect-query/src/use-infinite-query.ts @@ -78,6 +78,7 @@ export function useInfiniteQuery< transport, pageParamKey, getNextPageParam, + contextValues, ...queryOptions }: UseInfiniteQueryOptions, ): UseInfiniteQueryResult>, ConnectError> { @@ -86,6 +87,7 @@ export function useInfiniteQuery< transport: transport ?? transportFromCtx, getNextPageParam, pageParamKey, + contextValues, }); return tsUseInfiniteQuery({ ...baseOptions, diff --git a/packages/connect-query/src/use-mutation.ts b/packages/connect-query/src/use-mutation.ts index c31f93e8..f3430c9b 100644 --- a/packages/connect-query/src/use-mutation.ts +++ b/packages/connect-query/src/use-mutation.ts @@ -19,7 +19,10 @@ import type { MessageShape, } from "@bufbuild/protobuf"; import type { ConnectError, Transport } from "@connectrpc/connect"; -import { callUnaryMethod } from "@connectrpc/connect-query-core"; +import { + callUnaryMethod, + type SerializableContextValues, +} from "@connectrpc/connect-query-core"; import type { UseMutationOptions as TSUseMutationOptions, UseMutationResult, @@ -44,6 +47,7 @@ export type UseMutationOptions< > & { /** The transport to be used for the fetching. */ transport?: Transport; + contextValues?: SerializableContextValues; }; /** @@ -55,14 +59,20 @@ export function useMutation< Ctx = unknown, >( schema: DescMethodUnary, - { transport, ...queryOptions }: UseMutationOptions = {}, + { + transport, + contextValues, + ...queryOptions + }: UseMutationOptions = {}, ): UseMutationResult, ConnectError, MessageInitShape, Ctx> { const transportFromCtx = useTransport(); const transportToUse = transport ?? transportFromCtx; const mutationFn = useCallback( async (input: MessageInitShape) => - callUnaryMethod(transportToUse, schema, input), - [transportToUse, schema], + callUnaryMethod(transportToUse, schema, input, { + contextValues, + }), + [transportToUse, schema, contextValues], ); return tsUseMutation({ ...queryOptions, diff --git a/packages/connect-query/src/use-query.test.ts b/packages/connect-query/src/use-query.test.ts index 3216cf81..493207cd 100644 --- a/packages/connect-query/src/use-query.test.ts +++ b/packages/connect-query/src/use-query.test.ts @@ -16,15 +16,17 @@ import { create } from "@bufbuild/protobuf"; import { createConnectQueryKey, skipToken, + type SerializableContextValues, } from "@connectrpc/connect-query-core"; import { renderHook, waitFor } from "@testing-library/react"; import { mockBigInt, mockEliza } from "test-utils"; import { BigIntService } from "test-utils/gen/bigint_pb.js"; import { ElizaService } from "test-utils/gen/eliza_pb.js"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { wrapper } from "./test/test-wrapper.js"; import { useQuery, useSuspenseQuery } from "./use-query.js"; +import { createContextKey, createContextValues } from "@connectrpc/connect"; // TODO: maybe create a helper to take a service and method and generate this. const sayMethodDescriptor = ElizaService.method.say; @@ -223,6 +225,48 @@ describe("useQuery", () => { ), ).toBe(result.current.data); }); + + it("context values can be passed", async () => { + const contextValueSpy = vi.fn(); + const contextKey = createContextKey("contextKey", { + description: "default", + }); + const baseContextValues = createContextValues().set( + contextKey, + "new value", + ); + const fakeContextValues: SerializableContextValues = { + ...baseContextValues, + toString() { + return "serialized"; + }, + }; + const elizaWithInterceptors = mockEliza(undefined, false, { + interceptors: [ + (next) => (req) => { + contextValueSpy(req.contextValues.get(contextKey)); + return next(req); + }, + ], + }); + const { result } = renderHook( + () => { + return useQuery( + ElizaService.method.say, + {}, + { contextValues: fakeContextValues }, + ); + }, + wrapper({}, elizaWithInterceptors), + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBeTruthy(); + }); + + expect(contextValueSpy).toHaveBeenCalledWith("new value"); + expect(contextValueSpy).toHaveBeenCalledTimes(1); + }); }); describe("useSuspenseQuery", () => { diff --git a/packages/connect-query/src/use-query.ts b/packages/connect-query/src/use-query.ts index 6db782dc..c15944e3 100644 --- a/packages/connect-query/src/use-query.ts +++ b/packages/connect-query/src/use-query.ts @@ -21,6 +21,7 @@ import type { import type { ConnectError, Transport } from "@connectrpc/connect"; import type { ConnectQueryKey, + SerializableContextValues, SkipToken, } from "@connectrpc/connect-query-core"; import { createQueryOptions } from "@connectrpc/connect-query-core"; @@ -54,6 +55,7 @@ export type UseQueryOptions< > & { /** The transport to be used for the fetching. */ transport?: Transport; + contextValues?: SerializableContextValues; }; /** @@ -66,11 +68,16 @@ export function useQuery< >( schema: DescMethodUnary, input?: SkipToken | MessageInitShape, - { transport, ...queryOptions }: UseQueryOptions = {}, + { + transport, + contextValues, + ...queryOptions + }: UseQueryOptions = {}, ): UseQueryResult { const transportFromCtx = useTransport(); const baseOptions = createQueryOptions(schema, input, { transport: transport ?? transportFromCtx, + contextValues, }); return tsUseQuery({ ...baseOptions, @@ -95,6 +102,7 @@ export type UseSuspenseQueryOptions< > & { /** The transport to be used for the fetching. */ transport?: Transport; + contextValues?: SerializableContextValues; }; /** @@ -109,12 +117,14 @@ export function useSuspenseQuery< input?: MessageInitShape, { transport, + contextValues, ...queryOptions }: UseSuspenseQueryOptions = {}, ): UseSuspenseQueryResult { const transportFromCtx = useTransport(); const baseOptions = createQueryOptions(schema, input, { transport: transport ?? transportFromCtx, + contextValues, }); return tsUseSuspenseQuery({ ...baseOptions, diff --git a/packages/test-utils/src/index.tsx b/packages/test-utils/src/index.tsx index a577f9ef..fe3f940e 100644 --- a/packages/test-utils/src/index.tsx +++ b/packages/test-utils/src/index.tsx @@ -14,7 +14,7 @@ import type { MessageInitShape } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf"; -import { createRouterTransport } from "@connectrpc/connect"; +import { createRouterTransport, type Interceptor } from "@connectrpc/connect"; import { BigIntService, @@ -42,20 +42,28 @@ export const sleep = async (timeout: number) => export const mockEliza = ( override?: MessageInitShape, addDelay = false, + options: { + interceptors?: Interceptor[]; + } = {}, ) => - createRouterTransport(({ service }) => { - service(ElizaService, { - say: async (input: SayRequest) => { - if (addDelay) { - await sleep(1000); - } - return create( - SayResponseSchema, - override ?? { sentence: `Hello ${input.sentence}` }, - ); - }, - }); - }); + createRouterTransport( + ({ service }) => { + service(ElizaService, { + say: async (input: SayRequest) => { + if (addDelay) { + await sleep(1000); + } + return create( + SayResponseSchema, + override ?? { sentence: `Hello ${input.sentence}` }, + ); + }, + }); + }, + { + transport: options, + }, + ); /** * a stateless mock for BigIntService