From 6287871a3cfe10d9a7d6d14b2f7bae765e646e36 Mon Sep 17 00:00:00 2001 From: Olexii Kyrychenko Date: Sun, 4 Oct 2026 12:27:00 +0300 Subject: [PATCH] fix(tanstack): block only active initial query loading --- .changeset/active-query-work.md | 5 + packages/tanstack/README.md | 16 +- .../src/hooks/__tests__/queryPolicy.test.tsx | 144 ++++++++++++++++++ .../hooks/__tests__/queryPolicy.test.types.ts | 21 +++ .../__tests__/queryPolicy.test.utils.tsx | 64 ++++++++ .../src/hooks/useBlockingInfiniteQuery.ts | 2 +- .../tanstack/src/hooks/useBlockingQueries.ts | 2 +- .../tanstack/src/hooks/useBlockingQuery.ts | 2 +- 8 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 .changeset/active-query-work.md create mode 100644 packages/tanstack/src/hooks/__tests__/queryPolicy.test.tsx create mode 100644 packages/tanstack/src/hooks/__tests__/queryPolicy.test.types.ts create mode 100644 packages/tanstack/src/hooks/__tests__/queryPolicy.test.utils.tsx diff --git a/.changeset/active-query-work.md b/.changeset/active-query-work.md new file mode 100644 index 0000000..eeef2b2 --- /dev/null +++ b/.changeset/active-query-work.md @@ -0,0 +1,5 @@ +--- +"@okyrychenko-dev/react-action-guard-tanstack": minor +--- + +Block query initial loading only while pending and actively fetching. Disabled idle and offline paused queries no longer block by default across single, infinite, and collection hooks. Background refetch and pagination blocking remain opt-in, and mutation pending behavior is unchanged. See the TanStack README migration guidance for workflows that previously relied on pending queries without active work. diff --git a/packages/tanstack/README.md b/packages/tanstack/README.md index a5f345f..78bdb05 100644 --- a/packages/tanstack/README.md +++ b/packages/tanstack/README.md @@ -101,7 +101,7 @@ All four hooks accept the native TanStack Query options and return the correspon | `priority?: number` | Priority used by React Action Guard when several blockers apply | Hook-specific value above | | `timeout?: number` | Milliseconds before the Blocking lifecycle removes the blocker | No timeout | | `onTimeout?: (blockerId: string) => void` | Called when the blocker times out; treat the ID as opaque | None | -| `onLoading?: boolean` | Block while loading; mutation pending always blocks | `true` for queries; always enabled for mutation | +| `onLoading?: boolean` | Block active initial fetching; mutation pending always blocks | `true` for queries; always enabled for mutation | | `onFetching?: boolean` | Block while fetching after initial load; not available for mutation | `false` | | `onError?: boolean` | Keep blocking in an error state | `false` | | `reasonOnLoading?: string` | Loading message for query and multi-query hooks | Not set | @@ -111,14 +111,22 @@ All four hooks accept the native TanStack Query options and return the correspon `onLoading` and `onFetching` are available on query, infinite-query, and multi-query configurations. Mutations always block while pending and do not have a fetching state. +### Migration: active loading by default + +Query loading now means `isPending && isFetching` (TanStack's `isLoading`). Disabled queries without data and offline paused queries leave their scopes available, even though their status is pending. Initial requests block when they actually start and release after settlement or while paused. This applies to query, infinite-query, and query collections; an idle member does not keep a collection blocked. + +Cached background requests do not block by default. Set `onFetching: true` to block refetches and infinite next/previous-page requests; paused fetches do not qualify. `reasonOnLoading` describes active initial requests, and `reasonOnFetching` describes background or pagination work. Error blocking still requires `onError: true`; mutation pending behavior is unchanged. + +If your application previously relied on a disabled or paused pending query to lock a workflow, register that workflow condition separately with core's `useActionBlocker`. There is no query option for blocking solely because data is absent. + ### State mapping and reason precedence | Hook | Loading | Fetching | Error | | -------------------------- | --------------------- | ----------------------------------------------------------------- | ----------------------- | -| `useBlockingQuery` | `isPending` | `isRefetching` | `isError` | -| `useBlockingInfiniteQuery` | `isPending` | `isRefetching`, `isFetchingNextPage`, or `isFetchingPreviousPage` | `isError` | +| `useBlockingQuery` | `isLoading` | `isRefetching` | `isError` | +| `useBlockingInfiniteQuery` | `isLoading` | `isRefetching`, `isFetchingNextPage`, or `isFetchingPreviousPage` | `isError` | | `useBlockingMutation` | `isPending` | Not applicable | `isError` | -| `useBlockingQueries` | Any result is pending | Any result is refetching | Any result has an error | +| `useBlockingQueries` | Any result is loading | Any result is refetching | Any result has an error | The enabled `onLoading`, `onFetching`, and `onError` options decide whether a blocker exists. When states overlap, the reason is selected in **loading → fetching → error** order from the first defined state-specific message; otherwise it falls back to `reason`. This reason precedence is independent of which blocking option is enabled. An empty string is a defined message. diff --git a/packages/tanstack/src/hooks/__tests__/queryPolicy.test.tsx b/packages/tanstack/src/hooks/__tests__/queryPolicy.test.tsx new file mode 100644 index 0000000..3a1f166 --- /dev/null +++ b/packages/tanstack/src/hooks/__tests__/queryPolicy.test.tsx @@ -0,0 +1,144 @@ +import { QueryClient, onlineManager } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { createPolicyWrapper, usePolicyFixture } from "./queryPolicy.test.utils"; + +const available = [[], [], []]; +const loading = [["Loading"], ["Loading"], ["Loading"]]; +const fetching = [["Fetching"], ["Fetching"], ["Fetching"]]; +const config = { reasonOnLoading: "Loading", reasonOnFetching: "Fetching" }; + +describe("active query policy across public hooks", () => { + afterEach(() => { + onlineManager.setOnline(true); + }); + + it("should leave disabled pending queries available and block when enabled", async () => { + const client = new QueryClient(); + const queryFn = () => new Promise(() => undefined); + const { result, rerender } = renderHook(usePolicyFixture, { + wrapper: createPolicyWrapper(client), + initialProps: { client, queryFn, config, enabled: false }, + }); + + expect(result.current.query.fetchStatus).toBe("idle"); + expect(result.current.infinite.fetchStatus).toBe("idle"); + expect(result.current.collection[0].fetchStatus).toBe("idle"); + expect(result.current.reasons).toEqual(available); + + rerender({ client, queryFn, config, enabled: true }); + + await waitFor(() => expect(result.current.reasons).toEqual(loading)); + }); + it("should leave paused initial queries available and block after reconnect", async () => { + onlineManager.setOnline(false); + + const client = new QueryClient(); + const { result } = renderHook(usePolicyFixture, { + wrapper: createPolicyWrapper(client), + initialProps: { client, queryFn: () => new Promise(() => undefined), config }, + }); + + expect(result.current.query.fetchStatus).toBe("paused"); + expect(result.current.infinite.fetchStatus).toBe("paused"); + expect(result.current.collection[0].fetchStatus).toBe("paused"); + expect(result.current.reasons).toEqual(available); + act(() => { + onlineManager.setOnline(true); + }); + await waitFor(() => expect(result.current.reasons).toEqual(loading)); + }); + + it.each([false, true])( + "should block cached refetches only with onFetching=%s", + async (onFetching) => { + const client = new QueryClient(); + + client.setQueryData(["single"], "cached"); + client.setQueryData(["infinite"], { pages: ["cached"], pageParams: [0] }); + client.setQueryData(["collection"], "cached"); + + const resolvers: Array<(value: string) => void> = []; + const queryFn = () => + new Promise((resolve) => { + resolvers.push(resolve); + }); + const { result } = renderHook(usePolicyFixture, { + wrapper: createPolicyWrapper(client), + initialProps: { client, queryFn, config: { ...config, onFetching }, enabled: false }, + }); + + expect(result.current.reasons).toEqual(available); + act(() => { + void result.current.query.refetch(); + void result.current.infinite.refetch(); + void result.current.collection[0].refetch(); + }); + await waitFor(() => { + expect(result.current.query.isRefetching).toBe(true); + expect(result.current.infinite.isRefetching).toBe(true); + expect(result.current.collection[0].isRefetching).toBe(true); + }); + expect(result.current.reasons).toEqual(onFetching ? fetching : available); + await act(async () => { + resolvers.forEach((resolve) => resolve("fresh")); + }); + await waitFor(() => { + expect(result.current.query.data).toBe("fresh"); + expect(result.current.infinite.data?.pages).toEqual(["fresh"]); + expect(result.current.collection[0].data).toBe("fresh"); + expect(result.current.reasons).toEqual(available); + }); + } + ); + + it.each([false, true])("should block pagination only with onFetching=%s", async (onFetching) => { + const client = new QueryClient(); + const resolvers: Array<(value: string) => void> = []; + const queryFn = () => + new Promise((resolve) => { + resolvers.push(resolve); + }); + const { result } = renderHook(usePolicyFixture, { + wrapper: createPolicyWrapper(client), + initialProps: { client, queryFn, config: { ...config, onFetching } }, + }); + + expect(result.current.reasons).toEqual(loading); + await act(async () => { + resolvers.splice(0).forEach((resolve) => resolve("first")); + }); + await waitFor(() => expect(result.current.reasons).toEqual(available)); + act(() => { + void result.current.infinite.fetchNextPage(); + }); + await waitFor(() => expect(result.current.infinite.isFetchingNextPage).toBe(true)); + expect(result.current.reasons).toEqual(onFetching ? [[], ["Fetching"], []] : available); + await act(async () => { + resolvers.forEach((resolve) => resolve("second")); + }); + await waitFor(() => { + expect(result.current.infinite.data?.pages).toEqual(["first", "second"]); + expect(result.current.reasons).toEqual(available); + }); + }); + + it.each([false, true])("should block errors only with onError=%s", async (onError) => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(usePolicyFixture, { + wrapper: createPolicyWrapper(client), + initialProps: { + client, + queryFn: () => Promise.reject(new Error("Failed")), + config: { ...config, onError, reasonOnError: "Error" }, + }, + }); + + await waitFor(() => { + expect(result.current.query.isError).toBe(true); + expect(result.current.infinite.isError).toBe(true); + expect(result.current.collection[0].isError).toBe(true); + }); + expect(result.current.reasons).toEqual(onError ? [["Error"], ["Error"], ["Error"]] : available); + }); +}); diff --git a/packages/tanstack/src/hooks/__tests__/queryPolicy.test.types.ts b/packages/tanstack/src/hooks/__tests__/queryPolicy.test.types.ts new file mode 100644 index 0000000..30b9972 --- /dev/null +++ b/packages/tanstack/src/hooks/__tests__/queryPolicy.test.types.ts @@ -0,0 +1,21 @@ +import type { + InfiniteData, + QueryClient, + UseInfiniteQueryResult, + UseQueryResult, +} from "@tanstack/react-query"; +import type { QueryBlockingConfig } from "../useBlockingQuery.types"; + +export interface PolicyFixtureOptions { + client: QueryClient; + enabled?: boolean; + queryFn: () => Promise; + config?: QueryBlockingConfig; +} + +export interface PolicyFixtureResult { + query: UseQueryResult; + infinite: UseInfiniteQueryResult>; + collection: [UseQueryResult, UseQueryResult]; + reasons: Array>; +} diff --git a/packages/tanstack/src/hooks/__tests__/queryPolicy.test.utils.tsx b/packages/tanstack/src/hooks/__tests__/queryPolicy.test.utils.tsx new file mode 100644 index 0000000..e80ebac --- /dev/null +++ b/packages/tanstack/src/hooks/__tests__/queryPolicy.test.utils.tsx @@ -0,0 +1,64 @@ +import { UIBlockingProvider, useBlockingInfo } from "@okyrychenko-dev/react-action-guard"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useBlockingInfiniteQuery, useBlockingQueries, useBlockingQuery } from "../../hooks"; +import type { ReactElement, ReactNode } from "react"; +import type { PolicyFixtureOptions, PolicyFixtureResult } from "./queryPolicy.test.types"; + +export function usePolicyFixture({ + client, + enabled = true, + queryFn, + config = {}, +}: PolicyFixtureOptions): PolicyFixtureResult { + const query = useBlockingQuery( + { + queryKey: ["single"], + queryFn, + enabled, + blockingConfig: { ...config, scope: "single" }, + }, + client + ); + const infinite = useBlockingInfiniteQuery( + { + queryKey: ["infinite"], + queryFn, + enabled, + initialPageParam: 0, + getNextPageParam: (_lastPage, pages) => pages.length, + blockingConfig: { ...config, scope: "infinite" }, + }, + client + ); + const collection = useBlockingQueries( + [ + { queryKey: ["collection"], queryFn, enabled }, + { queryKey: ["idle"], queryFn, enabled: false }, + ], + { ...config, scope: "collection" }, + client + ); + + return { + query, + infinite, + collection, + reasons: [ + useBlockingInfo("single"), + useBlockingInfo("infinite"), + useBlockingInfo("collection"), + ].map((blockers) => blockers.map(({ reason }) => reason)), + }; +} + +export function createPolicyWrapper( + client: QueryClient +): ({ children }: { children: ReactNode }) => ReactElement { + return function PolicyWrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} diff --git a/packages/tanstack/src/hooks/useBlockingInfiniteQuery.ts b/packages/tanstack/src/hooks/useBlockingInfiniteQuery.ts index b5d79c2..ff789d0 100644 --- a/packages/tanstack/src/hooks/useBlockingInfiniteQuery.ts +++ b/packages/tanstack/src/hooks/useBlockingInfiniteQuery.ts @@ -66,7 +66,7 @@ export function useBlockingInfiniteQuery< kind: "infinite-query", key: options.queryKey, state: { - loading: query.isPending, + loading: query.isLoading, fetching: query.isRefetching || query.isFetchingNextPage || query.isFetchingPreviousPage, error: query.isError, }, diff --git a/packages/tanstack/src/hooks/useBlockingQueries.ts b/packages/tanstack/src/hooks/useBlockingQueries.ts index ca4742b..7f6778c 100644 --- a/packages/tanstack/src/hooks/useBlockingQueries.ts +++ b/packages/tanstack/src/hooks/useBlockingQueries.ts @@ -18,7 +18,7 @@ export function useBlockingQueries>( useBlockingCoordination({ kind: "queries", state: { - loading: results.some((result) => result.isPending), + loading: results.some((result) => result.isLoading), fetching: results.some((result) => result.isRefetching), error: results.some((result) => result.isError), }, diff --git a/packages/tanstack/src/hooks/useBlockingQuery.ts b/packages/tanstack/src/hooks/useBlockingQuery.ts index 215431b..ca386e9 100644 --- a/packages/tanstack/src/hooks/useBlockingQuery.ts +++ b/packages/tanstack/src/hooks/useBlockingQuery.ts @@ -50,7 +50,7 @@ export function useBlockingQuery< useBlockingCoordination({ kind: "query", key: options.queryKey, - state: { loading: query.isPending, fetching: query.isRefetching, error: query.isError }, + state: { loading: query.isLoading, fetching: query.isRefetching, error: query.isError }, config: blockingConfig, defaultReason: "Loading data...", defaultPriority: 10,