diff --git a/.changeset/add-skip-token.md b/.changeset/add-skip-token.md new file mode 100644 index 0000000..a08f05f --- /dev/null +++ b/.changeset/add-skip-token.md @@ -0,0 +1,19 @@ +--- +"@spiko-tech/effect-react-query": minor +--- + +Add `skipToken` support for conditional query execution + +- Re-export `skipToken` from `@tanstack/react-query` for convenience +- All hooks (`useEffectQuery`, `useInfiniteEffectQuery`, `useEffectQueries`) now accept `skipToken` as `queryFn` +- `toQueryOptions` and `effectQueryOptions` support `skipToken` for use with `useQuery` directly +- When using `skipToken`, the `runtime` option is not required (typed as `never`) + +This enables the idiomatic TanStack Query pattern for conditional queries: + +```ts +const { data } = useEffectQuery({ + queryKey: ["user", userId], + queryFn: userId ? () => Effect.succeed({ id: userId }) : skipToken, +}); +``` diff --git a/src/index.ts b/src/index.ts index 768fb2a..9def448 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export { skipToken } from "@tanstack/react-query"; export { effectQueryOptions } from "./effectQueryOptions"; export { infiniteEffectQueryOptions } from "./infiniteEffectQueryOptions"; export { toQueryOptions } from "./toQueryOptions"; diff --git a/src/toQueryOptions.ts b/src/toQueryOptions.ts index cf6d8bc..1a1c0f1 100644 --- a/src/toQueryOptions.ts +++ b/src/toQueryOptions.ts @@ -1,4 +1,6 @@ -import type { FetchQueryOptions, QueryKey } from "@tanstack/react-query"; +import type { FetchQueryOptions, QueryFunctionContext, QueryKey, SkipToken } from "@tanstack/react-query"; +import { skipToken } from "@tanstack/react-query"; +import type { Effect, ManagedRuntime, Runtime } from "effect"; import { createEffectQueryFn } from "./internal/createEffectQueryFn"; import type { DefinedInitialDataEffectQueryOptionsResult, @@ -52,7 +54,20 @@ export function toQueryOptions( options: UseEffectQueryOptionsResult, ): FetchQueryOptions { - const { queryFn, runtime, select: _select, ...restOptions } = options; + const { queryFn, runtime, select: _select, ...restOptions } = options as { + queryFn: + | ((context: QueryFunctionContext) => Effect.Effect) + | SkipToken; + runtime?: Runtime.Runtime | ManagedRuntime.ManagedRuntime; + select?: unknown; + } & Omit, "queryFn" | "runtime" | "select">; + + if (queryFn === skipToken) { + return { + ...restOptions, + queryFn: skipToken, + }; + } return { ...restOptions, diff --git a/src/types.ts b/src/types.ts index 915c80b..1a14ef6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,7 @@ import type { QueriesPlaceholderDataFunction, QueryFunctionContext, QueryKey, + SkipToken, UseInfiniteQueryOptions, UseInfiniteQueryResult, UseMutationOptions, @@ -78,13 +79,22 @@ export type UseEffectQueryOptions< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, R = never, -> = Omit, "queryFn"> & { - /** - * The query function that returns an Effect. - * Receives the same QueryFunctionContext as standard useQuery. - */ - queryFn: (context: QueryFunctionContext) => Effect.Effect; -} & RuntimeOption; +> = Omit, "queryFn"> & + ( + | ({ + /** + * The query function that returns an Effect. + * Receives the same QueryFunctionContext as standard useQuery. + */ + queryFn: ( + context: QueryFunctionContext, + ) => Effect.Effect; + } & RuntimeOption) + | { + queryFn: SkipToken; + runtime?: never; + } + ); /** * Options for useEffectQuery with defined initial data. @@ -96,9 +106,16 @@ export type DefinedInitialDataEffectQueryOptions< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, R = never, -> = Omit, "initialData"> & { +> = Omit, "queryFn" | "initialData"> & { initialData: NonUndefinedGuard | (() => NonUndefinedGuard); -}; +} & ( + | ({ + queryFn: ( + context: QueryFunctionContext, + ) => Effect.Effect; + } & RuntimeOption) + | { queryFn: SkipToken; runtime?: never } + ); /** * Options for useEffectQuery with undefined initial data. @@ -109,12 +126,19 @@ export type UndefinedInitialDataEffectQueryOptions< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, R = never, -> = Omit, "initialData"> & { +> = Omit, "queryFn" | "initialData"> & { initialData?: | undefined | InitialDataFunction> | NonUndefinedGuard; -}; +} & ( + | ({ + queryFn: ( + context: QueryFunctionContext, + ) => Effect.Effect; + } & RuntimeOption) + | { queryFn: SkipToken; runtime?: never } + ); /** * The result of useEffectQuery hook. @@ -233,26 +257,35 @@ export type UseInfiniteEffectQueryOptions< > = Omit< UseInfiniteQueryOptions, "queryFn" | "getNextPageParam" | "getPreviousPageParam" -> & { - /** - * The query function that returns an Effect. - * Receives the same QueryFunctionContext as standard useInfiniteQuery, - * including pageParam for pagination. - */ - queryFn: ( - context: QueryFunctionContext, - ) => Effect.Effect; - /** - * Function to get the next page parameter. - * Uses NoInfer to ensure TQueryFnData is inferred from queryFn, not from this callback. - */ - getNextPageParam: GetNextPageParamFunction>; - /** - * Optional function to get the previous page parameter. - * Uses NoInfer to ensure TQueryFnData is inferred from queryFn, not from this callback. - */ - getPreviousPageParam?: GetPreviousPageParamFunction>; -} & RuntimeOption; +> & + ( + | ({ + /** + * The query function that returns an Effect. + * Receives the same QueryFunctionContext as standard useInfiniteQuery, + * including pageParam for pagination. + */ + queryFn: ( + context: QueryFunctionContext, + ) => Effect.Effect; + /** + * Function to get the next page parameter. + * Uses NoInfer to ensure TQueryFnData is inferred from queryFn, not from this callback. + */ + getNextPageParam: GetNextPageParamFunction>; + /** + * Optional function to get the previous page parameter. + * Uses NoInfer to ensure TQueryFnData is inferred from queryFn, not from this callback. + */ + getPreviousPageParam?: GetPreviousPageParamFunction>; + } & RuntimeOption) + | { + queryFn: SkipToken; + runtime?: never; + getNextPageParam?: GetNextPageParamFunction>; + getPreviousPageParam?: GetPreviousPageParamFunction>; + } + ); /** * Options for useInfiniteEffectQuery with defined initial data. @@ -272,15 +305,24 @@ export type DefinedInitialDataInfiniteEffectQueryOptions< UseInfiniteQueryOptions, "queryFn" | "getNextPageParam" | "getPreviousPageParam" | "initialData" > & { - queryFn: ( - context: QueryFunctionContext, - ) => Effect.Effect; - getNextPageParam: GetNextPageParamFunction>; - getPreviousPageParam?: GetPreviousPageParamFunction>; initialData: | NonUndefinedGuard> | (() => NonUndefinedGuard>); -} & RuntimeOption; +} & ( + | ({ + queryFn: ( + context: QueryFunctionContext, + ) => Effect.Effect; + getNextPageParam: GetNextPageParamFunction>; + getPreviousPageParam?: GetPreviousPageParamFunction>; + } & RuntimeOption) + | { + queryFn: SkipToken; + runtime?: never; + getNextPageParam?: GetNextPageParamFunction>; + getPreviousPageParam?: GetPreviousPageParamFunction>; + } + ); /** * Options for useInfiniteEffectQuery with undefined initial data. @@ -299,16 +341,25 @@ export type UndefinedInitialDataInfiniteEffectQueryOptions< UseInfiniteQueryOptions, "queryFn" | "getNextPageParam" | "getPreviousPageParam" | "initialData" > & { - queryFn: ( - context: QueryFunctionContext, - ) => Effect.Effect; - getNextPageParam: GetNextPageParamFunction>; - getPreviousPageParam?: GetPreviousPageParamFunction>; initialData?: | undefined | InitialDataFunction>> | NonUndefinedGuard>; -} & RuntimeOption; +} & ( + | ({ + queryFn: ( + context: QueryFunctionContext, + ) => Effect.Effect; + getNextPageParam: GetNextPageParamFunction>; + getPreviousPageParam?: GetPreviousPageParamFunction>; + } & RuntimeOption) + | { + queryFn: SkipToken; + runtime?: never; + getNextPageParam?: GetNextPageParamFunction>; + getPreviousPageParam?: GetPreviousPageParamFunction>; + } + ); /** * The result of useInfiniteEffectQuery hook. @@ -462,15 +513,24 @@ export type UseEffectQueryOptionsForUseQueries< UseQueryOptions, "queryFn" | "placeholderData" | "subscribed" > & { - /** - * The query function that returns an Effect. - */ - queryFn: (context: QueryFunctionContext) => Effect.Effect; /** * Placeholder data for this query. */ placeholderData?: TQueryFnData | QueriesPlaceholderDataFunction; -} & RuntimeOption; +} & ( + | ({ + /** + * The query function that returns an Effect. + */ + queryFn: ( + context: QueryFunctionContext, + ) => Effect.Effect; + } & RuntimeOption) + | { + queryFn: SkipToken; + runtime?: never; + } + ); /** * Maps an array of Effect query options to an array of UseQueryResult. @@ -478,7 +538,9 @@ export type UseEffectQueryOptionsForUseQueries< * Infers data and error types from the queryFn's Effect return type. */ export type EffectQueriesResults< - T extends ReadonlyArray<{ queryFn: (...args: any) => Effect.Effect }>, + T extends ReadonlyArray<{ + queryFn?: ((...args: any) => Effect.Effect) | SkipToken; + }>, > = { -readonly [K in keyof T]: T[K] extends { queryFn: (...args: any) => Effect.Effect; diff --git a/src/useEffectQueries.ts b/src/useEffectQueries.ts index e527617..fc92204 100644 --- a/src/useEffectQueries.ts +++ b/src/useEffectQueries.ts @@ -1,5 +1,5 @@ -import type { QueryKey } from "@tanstack/react-query"; -import { useQueries } from "@tanstack/react-query"; +import type { QueryKey, SkipToken } from "@tanstack/react-query"; +import { skipToken, useQueries } from "@tanstack/react-query"; import type { Effect, ManagedRuntime, Runtime } from "effect"; import { createEffectQueryFn } from "./internal/createEffectQueryFn"; import type { EffectQueriesResults } from "./types"; @@ -11,8 +11,11 @@ import type { EffectQueriesResults } from "./types"; */ type EffectQueryOptionsBase = { queryKey: QueryKey; - queryFn: (...args: any[]) => Effect.Effect; - runtime?: Runtime.Runtime | ManagedRuntime.ManagedRuntime | undefined; + queryFn: ((...args: any[]) => Effect.Effect) | SkipToken; + runtime?: + | Runtime.Runtime + | ManagedRuntime.ManagedRuntime + | undefined; }; /** @@ -69,16 +72,28 @@ export function useEffectQueries< combine?: (result: EffectQueriesResults) => TCombinedResult; }): TCombinedResult { const transformedQueries = options.queries.map((query) => { - const { queryFn, runtime, ...rest } = query as EffectQueryOptionsBase & Record; + const { queryFn, runtime, ...rest } = query as EffectQueryOptionsBase & + Record; + + if (queryFn === skipToken) { + return { + ...rest, + queryFn: skipToken, + }; + } return { ...rest, - queryFn: createEffectQueryFn(queryFn, runtime, (context) => context.signal), + queryFn: createEffectQueryFn( + queryFn, + runtime, + (context) => context.signal, + ), }; }); const result = useQueries({ - queries: transformedQueries, + queries: transformedQueries as any, combine: options.combine as (result: Array) => TCombinedResult, }); diff --git a/src/useEffectQuery.ts b/src/useEffectQuery.ts index 8e34dd0..b1259fc 100644 --- a/src/useEffectQuery.ts +++ b/src/useEffectQuery.ts @@ -1,5 +1,5 @@ import type { QueryFunctionContext, QueryKey } from "@tanstack/react-query"; -import { useQuery } from "@tanstack/react-query"; +import { skipToken, useQuery } from "@tanstack/react-query"; import type { Effect, ManagedRuntime, Runtime } from "effect"; import { createEffectQueryFn } from "./internal/createEffectQueryFn"; import type { @@ -51,7 +51,13 @@ export function useEffectQuery< TQueryKey extends QueryKey = QueryKey, R = never, >( - options: DefinedInitialDataEffectQueryOptions, + options: DefinedInitialDataEffectQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + R + >, ): DefinedUseEffectQueryResult; export function useEffectQuery< @@ -61,7 +67,13 @@ export function useEffectQuery< TQueryKey extends QueryKey = QueryKey, R = never, >( - options: UndefinedInitialDataEffectQueryOptions, + options: UndefinedInitialDataEffectQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + R + >, ): UseEffectQueryResult; export function useEffectQuery< @@ -85,9 +97,23 @@ export function useEffectQuery< options: UseEffectQueryOptions, ): UseEffectQueryResult { const { queryFn, runtime, ...restOptions } = options as { - queryFn: (context: QueryFunctionContext) => Effect.Effect; + queryFn: + | (( + context: QueryFunctionContext, + ) => Effect.Effect) + | typeof skipToken; runtime?: Runtime.Runtime | ManagedRuntime.ManagedRuntime; - } & Omit, "queryFn" | "runtime">; + } & Omit< + UseEffectQueryOptions, + "queryFn" | "runtime" + >; + + if (queryFn === skipToken) { + return useQuery({ + ...restOptions, + queryFn: skipToken, + }); + } return useQuery({ ...restOptions, diff --git a/src/useInfiniteEffectQuery.ts b/src/useInfiniteEffectQuery.ts index d459a0f..c127402 100644 --- a/src/useInfiniteEffectQuery.ts +++ b/src/useInfiniteEffectQuery.ts @@ -1,5 +1,9 @@ -import type { InfiniteData, QueryFunctionContext, QueryKey } from "@tanstack/react-query"; -import { useInfiniteQuery } from "@tanstack/react-query"; +import type { + InfiniteData, + QueryFunctionContext, + QueryKey, +} from "@tanstack/react-query"; +import { skipToken, useInfiniteQuery } from "@tanstack/react-query"; import type { Effect, ManagedRuntime, Runtime } from "effect"; import { createEffectQueryFn } from "./internal/createEffectQueryFn"; import type { @@ -90,7 +94,14 @@ export function useInfiniteEffectQuery< TPageParam = unknown, R = never, >( - options: UseInfiniteEffectQueryOptions, + options: UseInfiniteEffectQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam, + R + >, ): UseInfiniteEffectQueryResult; // Implementation @@ -102,20 +113,52 @@ export function useInfiniteEffectQuery< TPageParam = unknown, R = never, >( - options: UseInfiniteEffectQueryOptions, + options: UseInfiniteEffectQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam, + R + >, ): UseInfiniteEffectQueryResult { - const { queryFn, runtime, ...restOptions } = options as { - queryFn: ( - context: QueryFunctionContext, - ) => Effect.Effect; + const { queryFn, runtime, getNextPageParam, ...restOptions } = options as { + queryFn: + | (( + context: QueryFunctionContext, + ) => Effect.Effect) + | typeof skipToken; runtime?: Runtime.Runtime | ManagedRuntime.ManagedRuntime; + getNextPageParam?: ( + lastPage: TQueryFnData, + allPages: TQueryFnData[], + ) => TPageParam | undefined | null; } & Omit< - UseInfiniteEffectQueryOptions, - "queryFn" | "runtime" + UseInfiniteEffectQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam, + R + >, + "queryFn" | "runtime" | "getNextPageParam" >; + if (queryFn === skipToken) { + return useInfiniteQuery( + { + ...restOptions, + queryFn: skipToken, + // Provide a dummy getNextPageParam when using skipToken (it won't be called) + getNextPageParam: getNextPageParam ?? (() => undefined), + }, + ); + } + return useInfiniteQuery({ ...restOptions, + getNextPageParam: getNextPageParam!, queryFn: createEffectQueryFn(queryFn, runtime, (context) => context.signal), }); } diff --git a/test/effectQueryOptions.test.ts b/test/effectQueryOptions.test.ts index 237dd8e..8cb885e 100644 --- a/test/effectQueryOptions.test.ts +++ b/test/effectQueryOptions.test.ts @@ -4,7 +4,7 @@ import type { DefinedInitialDataEffectQueryOptionsResult, UndefinedInitialDataEffectQueryOptionsResult, } from "../src"; -import { effectQueryOptions } from "../src"; +import { effectQueryOptions, skipToken } from "../src"; // Define errors using Schema.TaggedError class NetworkError extends Schema.TaggedError()("NetworkError", { @@ -181,3 +181,50 @@ describe("effectQueryOptions queryKey type inference", () => { expect(options.queryKey).toEqual(["user", "123", { includeDetails: true }]); }); }); + +// ============================================================================ +// skipToken Tests +// ============================================================================ + +describe("effectQueryOptions with skipToken", () => { + it("should support skipToken as queryFn", () => { + const options = effectQueryOptions({ + queryKey: ["user", "123"] as const, + queryFn: skipToken, + }); + + expect(options.queryKey).toEqual(["user", "123"]); + expect(options.queryFn).toBe(skipToken); + }); + + it("should work in factory pattern with conditional skipToken", () => { + const userQueryOptions = (userId: string | null) => + effectQueryOptions({ + queryKey: ["user", userId] as const, + queryFn: userId ? () => Effect.succeed({ id: userId, name: `User ${userId}` }) : skipToken, + }); + + const optionsWithUser = userQueryOptions("123"); + const optionsSkipped = userQueryOptions(null); + + expect(optionsWithUser.queryKey).toEqual(["user", "123"]); + expect(typeof optionsWithUser.queryFn).toBe("function"); + + expect(optionsSkipped.queryKey).toEqual(["user", null]); + expect(optionsSkipped.queryFn).toBe(skipToken); + }); + + it("should not require runtime when using skipToken", () => { + // This test verifies type constraint: runtime should not be required with skipToken + // even for effects that would normally require a runtime + const options = effectQueryOptions({ + queryKey: ["protected-user", "123"] as const, + queryFn: skipToken, + // No runtime needed with skipToken + }); + + expect(options).toBeDefined(); + expect(options.queryFn).toBe(skipToken); + expect((options as any).runtime).toBeUndefined(); + }); +}); diff --git a/test/useEffectQueries.test.ts b/test/useEffectQueries.test.ts index 5d91e2a..ea4dc5d 100644 --- a/test/useEffectQueries.test.ts +++ b/test/useEffectQueries.test.ts @@ -2,7 +2,7 @@ import { renderHook, waitFor } from "@testing-library/react"; import { Context, Effect, Layer, ManagedRuntime, Match, Schema } from "effect"; import { describe, expect, it, vi } from "vitest"; import type { UseEffectQueryOptionsForUseQueries } from "../src"; -import { useEffectQueries } from "../src"; +import { skipToken, useEffectQueries } from "../src"; import { createWrapper } from "./utils"; // Define errors using Schema.TaggedError @@ -414,3 +414,126 @@ describe("useEffectQueries type-level tests", () => { expect(_options).toBeDefined(); }); }); + +// ============================================================================ +// skipToken Tests +// ============================================================================ + +describe("useEffectQueries with skipToken", () => { + it("should skip individual queries when skipToken is passed", async () => { + const { result } = renderHook( + () => + useEffectQueries({ + queries: [ + { + queryKey: ["user", "1"], + queryFn: () => Effect.succeed({ id: "1", name: "Alice" }), + }, + { + queryKey: ["user", "skipped"], + queryFn: skipToken, + }, + ], + }), + { wrapper: createWrapper() }, + ); + + await waitFor(() => { + expect(result.current[0].isSuccess).toBe(true); + }); + + // First query should succeed + expect(result.current[0].data).toEqual({ id: "1", name: "Alice" }); + + // Second query should be skipped + expect(result.current[1].isPending).toBe(true); + expect(result.current[1].isFetching).toBe(false); + expect(result.current[1].data).toBeUndefined(); + }); + + it("should conditionally skip based on truthy/falsy value", async () => { + const userIds: Array = ["1", null, "3"]; + + const { result } = renderHook( + () => + useEffectQueries({ + queries: userIds.map((userId) => ({ + queryKey: ["user", userId] as const, + queryFn: userId ? () => Effect.succeed({ id: userId, name: `User ${userId}` }) : skipToken, + })), + }), + { wrapper: createWrapper() }, + ); + + await waitFor(() => { + expect(result.current[0].isSuccess).toBe(true); + expect(result.current[2].isSuccess).toBe(true); + }); + + // First and third queries should succeed + expect(result.current[0].data).toEqual({ id: "1", name: "User 1" }); + expect(result.current[2].data).toEqual({ id: "3", name: "User 3" }); + + // Second query should be skipped + expect(result.current[1].isPending).toBe(true); + expect(result.current[1].isFetching).toBe(false); + }); + + it("should work with combine function when some queries are skipped", async () => { + const { result } = renderHook( + () => + useEffectQueries({ + queries: [ + { + queryKey: ["user", "1"], + queryFn: () => Effect.succeed({ id: "1", name: "Alice" }), + }, + { + queryKey: ["user", "skipped"], + queryFn: skipToken, + }, + { + queryKey: ["user", "3"], + queryFn: () => Effect.succeed({ id: "3", name: "Charlie" }), + }, + ], + combine: (results) => ({ + users: results.map((r) => r.data).filter(Boolean), + pendingCount: results.filter((r) => r.isPending && !r.isFetching).length, + successCount: results.filter((r) => r.isSuccess).length, + }), + }), + { wrapper: createWrapper() }, + ); + + await waitFor(() => { + expect(result.current.successCount).toBe(2); + }); + + expect(result.current.users).toEqual([ + { id: "1", name: "Alice" }, + { id: "3", name: "Charlie" }, + ]); + expect(result.current.pendingCount).toBe(1); // The skipped query + }); + + it("should compile: skipToken without runtime option", () => { + // Type-level test: when queryFn is skipToken, runtime should not be required + type Options = UseEffectQueryOptionsForUseQueries< + { id: string; name: string }, + NetworkError, + { id: string; name: string }, + ["user", string], + UserService // Has requirements, but skipToken should not require runtime + >; + + const _options: Options = { + queryKey: ["user", "123"], + queryFn: skipToken, + // runtime is NOT required when using skipToken + }; + + expect(_options).toBeDefined(); + expect(_options.queryFn).toBe(skipToken); + }); +}); diff --git a/test/useEffectQuery.test.ts b/test/useEffectQuery.test.ts index ce97c72..2a82149 100644 --- a/test/useEffectQuery.test.ts +++ b/test/useEffectQuery.test.ts @@ -8,7 +8,7 @@ import type { UseEffectQueryOptions, UseEffectQueryResult, } from "../src"; -import { useEffectQuery } from "../src"; +import { skipToken, useEffectQuery } from "../src"; import { createWrapper } from "./utils"; // Define errors using Schema.TaggedError @@ -407,3 +407,109 @@ describe("useEffectQuery type-level tests", () => { expect(checkDataType).toBeDefined(); }); }); + +// ============================================================================ +// skipToken Tests +// ============================================================================ + +describe("useEffectQuery with skipToken", () => { + it("should skip the query when skipToken is passed", async () => { + const { result } = renderHook( + () => + useEffectQuery({ + queryKey: ["user", "skipped"], + queryFn: skipToken, + }), + { wrapper: createWrapper() }, + ); + + // Query should be in pending state (not loading, not fetched) + expect(result.current.isPending).toBe(true); + expect(result.current.isFetching).toBe(false); + expect(result.current.fetchStatus).toBe("idle"); + expect(result.current.data).toBeUndefined(); + }); + + it("should conditionally skip based on truthy/falsy value", async () => { + const userId: string | null = null; + + const { result } = renderHook( + () => + useEffectQuery({ + queryKey: ["user", userId], + queryFn: userId ? () => Effect.succeed({ id: userId, name: "User" }) : skipToken, + }), + { wrapper: createWrapper() }, + ); + + // Query should be skipped + expect(result.current.isPending).toBe(true); + expect(result.current.isFetching).toBe(false); + }); + + it("should execute query when condition becomes truthy", async () => { + const { result, rerender } = renderHook( + ({ userId }: { userId: string | null }) => + useEffectQuery({ + queryKey: ["user", userId], + queryFn: userId ? () => Effect.succeed({ id: userId, name: "User" }) : skipToken, + }), + { + wrapper: createWrapper(), + initialProps: { userId: null as string | null }, + }, + ); + + // Initially skipped + expect(result.current.isPending).toBe(true); + expect(result.current.isFetching).toBe(false); + + // Re-render with a valid userId + rerender({ userId: "123" }); + + // Now it should fetch + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual({ id: "123", name: "User" }); + }); + + it("should compile: skipToken without runtime option", () => { + // This is a type-level test - when queryFn is skipToken, runtime should not be required + type Options = UseEffectQueryOptions< + { id: string; name: string }, + NetworkError, + { id: string; name: string }, + ["user", string], + UserService // Has requirements, but skipToken should not require runtime + >; + + const _options: Options = { + queryKey: ["user", "123"], + queryFn: skipToken, + // runtime is NOT required when using skipToken + }; + + expect(_options).toBeDefined(); + expect(_options.queryFn).toBe(skipToken); + }); + + it("should not allow runtime with skipToken (type-level test)", () => { + // This test verifies the type constraint - runtime?: never when using skipToken + // The following would cause a TypeScript error: + // const _invalid: UseEffectQueryOptions = { + // queryKey: ["test"], + // queryFn: skipToken, + // runtime: someRuntime, // Error: Type 'Runtime' is not assignable to type 'undefined' + // }; + + // We can only test that skipToken without runtime compiles + const _valid: UseEffectQueryOptions = { + queryKey: ["test"], + queryFn: skipToken, + }; + + expect(_valid).toBeDefined(); + }); +}); diff --git a/test/useInfiniteEffectQuery.test.ts b/test/useInfiniteEffectQuery.test.ts index c0c4a95..609ef30 100644 --- a/test/useInfiniteEffectQuery.test.ts +++ b/test/useInfiniteEffectQuery.test.ts @@ -8,7 +8,7 @@ import type { UseInfiniteEffectQueryOptions, UseInfiniteEffectQueryResult, } from "../src"; -import { useInfiniteEffectQuery } from "../src"; +import { skipToken, useInfiniteEffectQuery } from "../src"; import { createWrapper } from "./utils"; // Define errors using Schema.TaggedError @@ -477,3 +477,110 @@ describe("useInfiniteEffectQuery type-level tests", () => { expect(_options.initialData).toBeDefined(); }); }); + +// ============================================================================ +// skipToken Tests +// ============================================================================ + +describe("useInfiniteEffectQuery with skipToken", () => { + it("should skip the query when skipToken is passed", async () => { + const { result } = renderHook( + () => + useInfiniteEffectQuery({ + queryKey: ["posts", "skipped"], + queryFn: skipToken, + initialPageParam: 0, + }), + { wrapper: createWrapper() }, + ); + + // Query should be in pending state (not loading, not fetched) + expect(result.current.isPending).toBe(true); + expect(result.current.isFetching).toBe(false); + expect(result.current.fetchStatus).toBe("idle"); + expect(result.current.data).toBeUndefined(); + }); + + it("should conditionally skip based on truthy/falsy value", async () => { + const category: string | null = null; + + const { result } = renderHook( + () => + useInfiniteEffectQuery({ + queryKey: ["posts", category], + queryFn: category + ? ({ pageParam }) => + Effect.succeed({ + items: [{ id: String(pageParam), title: `Post in ${category}` }], + nextCursor: null, + }) + : skipToken, + initialPageParam: 0, + }), + { wrapper: createWrapper() }, + ); + + // Query should be skipped + expect(result.current.isPending).toBe(true); + expect(result.current.isFetching).toBe(false); + }); + + it("should execute query when condition becomes truthy", async () => { + const { result, rerender } = renderHook( + ({ category }: { category: string | null }) => + useInfiniteEffectQuery({ + queryKey: ["posts", category], + queryFn: category + ? ({ pageParam }) => + Effect.succeed({ + items: [{ id: String(pageParam), title: `Post in ${category}` }], + nextCursor: null, + }) + : skipToken, + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }), + { + wrapper: createWrapper(), + initialProps: { category: null }, + }, + ); + + // Initially skipped + expect(result.current.isPending).toBe(true); + expect(result.current.isFetching).toBe(false); + + // Re-render with a valid category + rerender({ category: "tech" }); + + // Now it should fetch + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.pages[0].items[0].title).toBe("Post in tech"); + }); + + it("should compile: skipToken without runtime option", () => { + // This is a type-level test - when queryFn is skipToken, runtime should not be required + type Options = UseInfiniteEffectQueryOptions< + PostsPage, + NetworkError, + InfiniteData, + readonly ["posts", string], + number, + PostService // Has requirements, but skipToken should not require runtime + >; + + const _options: Options = { + queryKey: ["posts", "category"] as const, + queryFn: skipToken, + initialPageParam: 0, + // runtime is NOT required when using skipToken + // getNextPageParam is also not required when using skipToken + }; + + expect(_options).toBeDefined(); + expect(_options.queryFn).toBe(skipToken); + }); +});