Repository: reduxjs/redux-toolkit

Problem statement:
Option to pause Polling when window Unfocused
Sorry if this is a silly question, but is it possible to pause polling on a query if the window loses focus.

I currently have a situation where i need to manually `initiate` a query due to some other bad code where i have used prefetch in a horrible hacky way to bypass cache timeout (it's a pagination/inifinate scroll issue that is first on my list for a refactor!!).

```typescript
  useEffect(() => {
    let unsubscribe: () => void;

    if (id) {
      const result = dispatch(
        getChatHistory.initiate(
          { id, page: 1 },
          {
            subscribe: true,
            subscriptionOptions: {
              pollingInterval: 10000,
            },
          }
        )
      );

      unsubscribe = result.unsubscribe;
    }

    return () => {
      unsubscribe && unsubscribe();
    };
  }, [id, dispatch]);
```

If there is no "native" way to pause polling, I think it should be possible to create a listener for the focus/unfocus action dispatches and set some state value accordingly.

I have posted here instead of SO in case the functionality could be useful (please close/delete the issue if there is no merit in the idea and I will ask on SO instead).


Hints:
Hmm. ~~I don't _think_ there's a way to pause polling atm~~, but I'm also not familiar with that portion of the codebase.

actually, looking at the code...

```ts
        if (
          api.internalActions.updateSubscriptionOptions.match(action) ||
          api.internalActions.unsubscribeQueryResult.match(action)
        ) {
          updatePollingInterval(action.payload, mwApi)
        }

// later

      const lowestPollingInterval = findLowestPollingInterval(subscriptions)

      if (!Number.isFinite(lowestPollingInterval)) {
        cleanupPollForKey(queryCacheKey)
        return
      }

```

so maybe dispatching `api.internalActions.updateSubscriptionOptions` would work here?
That would get it out of sync with the hook. It's currently not supported, but a well thought-out PR would be welcome :)
I would love to be able to contribute in some way, but this one might be out of my wheelhouse. I have solved my issue in the short term by creating actions for the Focus events and then setting state in `extraReducers`.

```typescript
const onFocus = createAction("__rtkq/focused");
const onFocusLost = createAction("__rtkq/unfocused");
```

I see these actions are exported here:

https://github.com/reduxjs/redux-toolkit/blob/adf7d01e350daa1f6480eab86a19424ef6a5b6a1/packages/toolkit/src/query/core/setupListeners.ts#L7

But I was unable to import them and use them in my project (NextJS).

VSCode is happy to import them like this

```typescript
import {
  onFocus,
  onFocusLost,
} from "@reduxjs/toolkit/dist/query/core/setupListeners";
```

Typescript is happy with it but NextJS isn't. Running `dev` or `build` results in

```
Module not found: Can't resolve '@reduxjs/toolkit/dist/query/core/setupListeners' in 'C:\Code\project\store\features\messages'
```

While creating my own actions has worked, i'm not a fan of the idea long term as it depends on RTK not changing the ActionType at some point in the future.

Is it possible to get these Actions exported so they can be used by projects as needed?

Now this is something that I could create a PR for if you agree it is a worthy exercise :-).

Thanks for your help.
Chiming in to say I have similar issues with the imports of `onFocus` and it not being liked, in this case by Vite.

Also pausing polling seems like a really good feature as browsers have said they aren't happy if apps keep doing it whilst unfocused.
I am interested in contributing here, but I want to clarify what the preferred behavior by the team is first. These are my initial thoughts on it, but I may be off the mark and missing something crucial about RTKQuery as this is my first real dive into the source.

## Options considered:

### specific option for polling only to check if the configSlice considers the document focused or not before sending a refetch:
If the intention is to maintain the actual polling hooks functionality and interval, but just to block any actual refetch happening while the window is unfocused. Then I think simply adding a check before the actual refetch is dispatched could act as a pause while keeping the poll synced with the hook.

I am unsure if the best way to pass this option would be through the createAPI definition or through the hook as it is only impacting polling in this implementation so I am just showing a snippet of the implementation idea rather than the actual option handling

```cjs
 function startNextPoll({
    queryCacheKey
  }, api2) {
    const state = api2.getState()[reducerPath];
    const querySubState = state.queries[queryCacheKey];
    // added boolean that takes from the config slice already tracking when the window is focused or unfocused
    const isFocused = state.config.focused;
    const subscriptions = internalState.currentSubscriptions[queryCacheKey];
    if (!querySubState || querySubState.status === "uninitialized" /* uninitialized */)
      return;
    const lowestPollingInterval = findLowestPollingInterval(subscriptions);
    if (!Number.isFinite(lowestPollingInterval))
      return;
    const currentPoll = currentPolls[queryCacheKey];
    if (currentPoll?.timeout) {
      clearTimeout(currentPoll.timeout);
      currentPoll.timeout = void 0;
    }
    const nextPollTimestamp = Date.now() + lowestPollingInterval;
    const currentInterval = currentPolls[queryCacheKey] = {
      nextPollTimestamp,
      pollingInterval: lowestPollingInterval,
      timeout: setTimeout(() => {
        currentInterval.timeout = void 0;
        // Added conditional to not dispatch when unfocused
        if (isFocused) { 
          api2.dispatch(refetchQuery(querySubState, queryCacheKey));
        }
      }, lowestPollingInterval)
    };
  }
```

### Similar concept to the first but probably requires more rewrite but might be safer to avoid unintended side-effects

Having the same code as above but instead of just ignoring the dispatch, it can be added to the functionality that refetches a query when `onFocus` happens, effectively pausing the actual fetching but keeping it in sync and having the data returned to the user still


### Other option but not necessarily in this scope
 Pass an option to all hooks to not refetch server requests when unfocused and can handle it at the `executeEndpoint` and `cacheLifecycle` level where the endpoint actually decides whether a server request is necessary
 
 
 First time looking into RTKQ Code so very possible I'm missing something but thats my thoughts to begin

It might be worth noting that a similar option was added to Apollo Client recently in https://github.com/apollographql/apollo-client/pull/11397, so I suggest also reading the discussion there.
Thanks, that was helpful read and I took a look at react-query's. That PR in apollo follows a very similar implementation to the first one listed above. Normal polling behaviour continues but the query doesn't dispatch if the flag is set either in the createApi declaration or in the hooks query options.

Only difference is I planned on using the already existing focus state from the configSlice, but I think that's inconsequential, unless that handler has some behaviour im unaware of.

I'll spin up a PR when I get up in the morning that aims at a query hook option and pollHandler logic and has some tests.

I haven't actually looked at what is involved in adding the option to createApi or the actual endpoint definition yet if we want to involve that in this feature too.

Patch:
diff --git a/packages/toolkit/src/query/core/apiState.ts b/packages/toolkit/src/query/core/apiState.ts
--- a/packages/toolkit/src/query/core/apiState.ts
+++ b/packages/toolkit/src/query/core/apiState.ts
@@ -83,6 +83,14 @@ export type SubscriptionOptions = {
    * How frequently to automatically re-fetch data (in milliseconds). Defaults to `0` (off).
    */
   pollingInterval?: number
+  /**
+   *  Defaults to 'false'. This setting allows you to control whether RTK Query will continue polling if the window is not focused.
+   *
+   *  If pollingInterval is not set or set to 0, this **will not be evaluated** until pollingInterval is greater than 0.
+   *
+   *  Note: requires [`setupListeners`](./setupListeners) to have been called.
+   */
+  skipPollingIfUnfocused?: boolean
   /**
    * Defaults to `false`. This setting allows you to control whether RTK Query will try to refetch all subscribed queries after regaining a network connection.
    *
diff --git a/packages/toolkit/src/query/core/buildMiddleware/polling.ts b/packages/toolkit/src/query/core/buildMiddleware/polling.ts
--- a/packages/toolkit/src/query/core/buildMiddleware/polling.ts
+++ b/packages/toolkit/src/query/core/buildMiddleware/polling.ts
@@ -60,7 +60,8 @@ export const buildPollingHandler: InternalHandlerBuilder = ({
     if (!querySubState || querySubState.status === QueryStatus.uninitialized)
       return
 
-    const lowestPollingInterval = findLowestPollingInterval(subscriptions)
+    const { lowestPollingInterval, skipPollingIfUnfocused } =
+      findLowestPollingInterval(subscriptions)
     if (!Number.isFinite(lowestPollingInterval)) return
 
     const currentPoll = currentPolls[queryCacheKey]
@@ -72,16 +73,16 @@ export const buildPollingHandler: InternalHandlerBuilder = ({
 
     const nextPollTimestamp = Date.now() + lowestPollingInterval
 
-    const currentInterval: typeof currentPolls[number] = (currentPolls[
-      queryCacheKey
-    ] = {
+    currentPolls[queryCacheKey] = {
       nextPollTimestamp,
       pollingInterval: lowestPollingInterval,
       timeout: setTimeout(() => {
-        currentInterval!.timeout = undefined
-        api.dispatch(refetchQuery(querySubState, queryCacheKey))
+        if (state.config.focused || !skipPollingIfUnfocused) {
+          api.dispatch(refetchQuery(querySubState, queryCacheKey))
+        }
+        startNextPoll({ queryCacheKey }, api)
       }, lowestPollingInterval),
-    })
+    }
   }
 
   function updatePollingInterval(
@@ -96,7 +97,7 @@ export const buildPollingHandler: InternalHandlerBuilder = ({
       return
     }
 
-    const lowestPollingInterval = findLowestPollingInterval(subscriptions)
+    const { lowestPollingInterval } = findLowestPollingInterval(subscriptions)
 
     if (!Number.isFinite(lowestPollingInterval)) {
       cleanupPollForKey(queryCacheKey)
@@ -126,6 +127,7 @@ export const buildPollingHandler: InternalHandlerBuilder = ({
   }
 
   function findLowestPollingInterval(subscribers: Subscribers = {}) {
+    let skipPollingIfUnfocused: boolean | undefined = false
     let lowestPollingInterval = Number.POSITIVE_INFINITY
     for (let key in subscribers) {
       if (!!subscribers[key].pollingInterval) {
@@ -133,10 +135,16 @@ export const buildPollingHandler: InternalHandlerBuilder = ({
           subscribers[key].pollingInterval!,
           lowestPollingInterval
         )
+        skipPollingIfUnfocused =
+          subscribers[key].skipPollingIfUnfocused || skipPollingIfUnfocused
       }
     }
 
-    return lowestPollingInterval
+    return {
+      lowestPollingInterval,
+      skipPollingIfUnfocused,
+    }
   }
+
   return handler
 }
diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts
--- a/packages/toolkit/src/query/react/buildHooks.ts
+++ b/packages/toolkit/src/query/react/buildHooks.ts
@@ -672,6 +672,7 @@ export function buildHooks<Definitions extends EndpointDefinitions>({
         refetchOnMountOrArgChange,
         skip = false,
         pollingInterval = 0,
+        skipPollingIfUnfocused = false,
       } = {}
     ) => {
       const { initiate } = api.endpoints[name] as ApiEndpointQuery<
@@ -715,6 +716,7 @@ export function buildHooks<Definitions extends EndpointDefinitions>({
         refetchOnReconnect,
         refetchOnFocus,
         pollingInterval,
+        skipPollingIfUnfocused,
       })
 
       const lastRenderHadSubscription = useRef(false)
@@ -815,6 +817,7 @@ export function buildHooks<Definitions extends EndpointDefinitions>({
       refetchOnReconnect,
       refetchOnFocus,
       pollingInterval = 0,
+      skipPollingIfUnfocused = false,
     } = {}) => {
       const { initiate } = api.endpoints[name] as ApiEndpointQuery<
         QueryDefinition<any, any, any, any, any>,
@@ -829,6 +832,7 @@ export function buildHooks<Definitions extends EndpointDefinitions>({
         refetchOnReconnect,
         refetchOnFocus,
         pollingInterval,
+        skipPollingIfUnfocused,
       })
 
       usePossiblyImmediateEffect(() => {


Test patch:
diff --git a/packages/toolkit/src/query/tests/polling.test.tsx b/packages/toolkit/src/query/tests/polling.test.tsx
--- a/packages/toolkit/src/query/tests/polling.test.tsx
+++ b/packages/toolkit/src/query/tests/polling.test.tsx
@@ -122,4 +122,112 @@ describe('polling tests', () => {
 
     expect(mockBaseQuery.mock.calls.length).toBeGreaterThanOrEqual(2)
   })
+
+  it('respects skipPollingIfUnfocused', async () => {
+    mockBaseQuery.mockClear()
+    storeRef.store.dispatch(
+      getPosts.initiate(2, {
+        subscriptionOptions: {
+          pollingInterval: 10,
+          skipPollingIfUnfocused: true,
+        },
+        subscribe: true,
+      })
+    )
+    storeRef.store.dispatch(api.internalActions?.onFocusLost())
+
+    await delay(50)
+    const callsWithSkip = mockBaseQuery.mock.calls.length
+
+    storeRef.store.dispatch(
+      getPosts.initiate(2, {
+        subscriptionOptions: {
+          pollingInterval: 10,
+          skipPollingIfUnfocused: false,
+        },
+        subscribe: true,
+      })
+    )
+
+    storeRef.store.dispatch(api.internalActions?.onFocus())
+
+    await delay(50)
+    const callsWithoutSkip = mockBaseQuery.mock.calls.length
+
+    expect(callsWithSkip).toBe(1)
+    expect(callsWithoutSkip).toBeGreaterThan(2)
+
+    storeRef.store.dispatch(api.util.resetApiState())
+  })
+
+  it('respects skipPollingIfUnfocused if at least one subscription has it', async () => {
+    storeRef.store.dispatch(
+      getPosts.initiate(3, {
+        subscriptionOptions: {
+          pollingInterval: 10,
+          skipPollingIfUnfocused: false,
+        },
+        subscribe: true,
+      })
+    )
+
+    await delay(50)
+    const callsWithoutSkip = mockBaseQuery.mock.calls.length
+
+    storeRef.store.dispatch(
+      getPosts.initiate(3, {
+        subscriptionOptions: {
+          pollingInterval: 15,
+          skipPollingIfUnfocused: true,
+        },
+        subscribe: true,
+      })
+    )
+
+    storeRef.store.dispatch(
+      getPosts.initiate(3, {
+        subscriptionOptions: {
+          pollingInterval: 20,
+          skipPollingIfUnfocused: false,
+        },
+        subscribe: true,
+      })
+    )
+
+    storeRef.store.dispatch(api.internalActions?.onFocusLost())
+
+    await delay(50)
+    const callsWithSkip = mockBaseQuery.mock.calls.length
+
+    expect(callsWithoutSkip).toBeGreaterThan(2)
+    expect(callsWithSkip).toBe(callsWithoutSkip + 1)
+  })
+
+  it('replaces skipPollingIfUnfocused when the subscription options are updated', async () => {
+    const { requestId, queryCacheKey, ...subscription } =
+      storeRef.store.dispatch(
+        getPosts.initiate(1, {
+          subscriptionOptions: {
+            pollingInterval: 10,
+            skipPollingIfUnfocused: false,
+          },
+          subscribe: true,
+        })
+      )
+
+    const getSubs = createSubscriptionGetter(queryCacheKey)
+
+    await delay(1)
+    expect(Object.keys(getSubs())).toHaveLength(1)
+    expect(getSubs()[requestId].skipPollingIfUnfocused).toBe(false)
+
+    subscription.updateSubscriptionOptions({
+      pollingInterval: 20,
+      skipPollingIfUnfocused: true,
+    })
+
+    await delay(1)
+    expect(Object.keys(getSubs())).toHaveLength(1)
+    expect(getSubs()[requestId].skipPollingIfUnfocused).toBe(true)
+  })
 })