Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/active-query-work.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 12 additions & 4 deletions packages/tanstack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.

Expand Down
144 changes: 144 additions & 0 deletions packages/tanstack/src/hooks/__tests__/queryPolicy.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string>(() => 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<string>(() => 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<string>((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<string>((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);
});
});
21 changes: 21 additions & 0 deletions packages/tanstack/src/hooks/__tests__/queryPolicy.test.types.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
config?: QueryBlockingConfig;
}

export interface PolicyFixtureResult {
query: UseQueryResult<string>;
infinite: UseInfiniteQueryResult<InfiniteData<string>>;
collection: [UseQueryResult<string>, UseQueryResult<string>];
reasons: Array<Array<string>>;
}
64 changes: 64 additions & 0 deletions packages/tanstack/src/hooks/__tests__/queryPolicy.test.utils.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<QueryClientProvider client={client}>
<UIBlockingProvider>{children}</UIBlockingProvider>
</QueryClientProvider>
);
};
}
2 changes: 1 addition & 1 deletion packages/tanstack/src/hooks/useBlockingInfiniteQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
2 changes: 1 addition & 1 deletion packages/tanstack/src/hooks/useBlockingQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function useBlockingQueries<T extends Array<unknown>>(
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),
},
Expand Down
2 changes: 1 addition & 1 deletion packages/tanstack/src/hooks/useBlockingQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading