From cf66d0d27c4f99b2dc00da52a93a8fcaae562fbf Mon Sep 17 00:00:00 2001 From: Joel Arvidsson Date: Wed, 5 Mar 2025 15:03:49 +0100 Subject: [PATCH] feat(react-query): add pause provider --- docs/framework/react/react-native.md | 29 ++++++++ .../react/reference/PauseManagerProvider.md | 22 ++++++ examples/react/pause/.eslintrc | 3 + examples/react/pause/.gitignore | 27 +++++++ examples/react/pause/README.md | 6 ++ examples/react/pause/index.html | 16 +++++ examples/react/pause/package.json | 21 ++++++ examples/react/pause/public/emblem-light.svg | 13 ++++ examples/react/pause/src/index.tsx | 70 +++++++++++++++++++ examples/react/pause/tsconfig.json | 24 +++++++ examples/react/pause/vite.config.ts | 6 ++ packages/query-core/src/index.ts | 1 + packages/query-core/src/pauseManager.ts | 31 ++++++++ .../react-query/src/PauseManagerProvider.tsx | 28 ++++++++ packages/react-query/src/index.ts | 5 ++ packages/react-query/src/useBaseQuery.ts | 32 +++++++-- packages/react-query/src/useQueries.ts | 35 ++++++++-- pnpm-lock.yaml | 25 +++++++ 18 files changed, 384 insertions(+), 10 deletions(-) create mode 100644 docs/framework/react/reference/PauseManagerProvider.md create mode 100644 examples/react/pause/.eslintrc create mode 100644 examples/react/pause/.gitignore create mode 100644 examples/react/pause/README.md create mode 100644 examples/react/pause/index.html create mode 100644 examples/react/pause/package.json create mode 100644 examples/react/pause/public/emblem-light.svg create mode 100644 examples/react/pause/src/index.tsx create mode 100644 examples/react/pause/tsconfig.json create mode 100644 examples/react/pause/vite.config.ts create mode 100644 packages/query-core/src/pauseManager.ts create mode 100644 packages/react-query/src/PauseManagerProvider.tsx diff --git a/docs/framework/react/react-native.md b/docs/framework/react/react-native.md index b920ea54df..3f0e6f8468 100644 --- a/docs/framework/react/react-native.md +++ b/docs/framework/react/react-native.md @@ -95,6 +95,8 @@ In the above code, `refetch` is skipped the first time because `useFocusEffect` ## Disable queries on out of focus screens +### `subscribed` option + If you don’t want certain queries to remain “live” while a screen is out of focus, you can use the subscribed prop on useQuery. This prop lets you control whether a query stays subscribed to updates. Combined with React Navigation’s useIsFocused, it allows you to seamlessly unsubscribe from queries when a screen isn’t in focus: Example usage: @@ -119,3 +121,30 @@ function MyComponent() { ``` When subscribed is false, the query unsubscribes from updates and won’t trigger re-renders or fetch new data for that screen. Once it becomes true again (e.g., when the screen regains focus), the query re-subscribes and stays up to date. + +### `PauseManagerProvider` option + +In case you want to disable updates to _all_ queries in an out of focus screen, one alternative is to control them via `PauseManager`: + +```tsx +import React from 'react' +import { useIsFocused } from '@react-navigation/native' +import { PauseManager, PauseManagerProvider } from 'react-native' + +function MyScreen() { + const isFocused = useIsFocused() + const pauseManager = useRef(null) + if (pauseManager.current === null) { + pauseManager.current = new PauseManager(!isFocused) + } + useEffect(() => { + pauseManager.current?.setPaused(!isFocused) + }, [isFocused]) + + return ( + + + + ) +} +``` diff --git a/docs/framework/react/reference/PauseManagerProvider.md b/docs/framework/react/reference/PauseManagerProvider.md new file mode 100644 index 0000000000..20fe713d12 --- /dev/null +++ b/docs/framework/react/reference/PauseManagerProvider.md @@ -0,0 +1,22 @@ +--- +id: PauseManagerProvider +title: PauseManagerProvider +--- + +Use the `PauseManagerProvider` component to connect and provide a `PauseManager` to your application which is used to selectively disable updates: + +```tsx +import { PauseManager, PauseManagerProvider } from '@tanstack/react-query' + +const pauseManager = new PauseManager() + +function App() { + return ... +} +``` + +**Options** + +- `pauseManager: PauseManager` + - **Required** + - the PauseManager instance to provide diff --git a/examples/react/pause/.eslintrc b/examples/react/pause/.eslintrc new file mode 100644 index 0000000000..4e03b9e10b --- /dev/null +++ b/examples/react/pause/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": ["plugin:react/jsx-runtime", "plugin:react-hooks/recommended"] +} diff --git a/examples/react/pause/.gitignore b/examples/react/pause/.gitignore new file mode 100644 index 0000000000..4673b022e5 --- /dev/null +++ b/examples/react/pause/.gitignore @@ -0,0 +1,27 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +pnpm-lock.yaml +yarn.lock +package-lock.json + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/examples/react/pause/README.md b/examples/react/pause/README.md new file mode 100644 index 0000000000..1cf8892652 --- /dev/null +++ b/examples/react/pause/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `npm install` +- `npm run dev` diff --git a/examples/react/pause/index.html b/examples/react/pause/index.html new file mode 100644 index 0000000000..1e59b6f68f --- /dev/null +++ b/examples/react/pause/index.html @@ -0,0 +1,16 @@ + + + + + + + + + TanStack Query React Pause Example App + + + +
+ + + diff --git a/examples/react/pause/package.json b/examples/react/pause/package.json new file mode 100644 index 0000000000..15ce2dbbc8 --- /dev/null +++ b/examples/react/pause/package.json @@ -0,0 +1,21 @@ +{ + "name": "@tanstack/query-example-react-pause", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.67.1", + "@tanstack/react-query-devtools": "^5.67.1", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.3", + "typescript": "5.8.2", + "vite": "^5.3.5" + } +} diff --git a/examples/react/pause/public/emblem-light.svg b/examples/react/pause/public/emblem-light.svg new file mode 100644 index 0000000000..a58e69ad5e --- /dev/null +++ b/examples/react/pause/public/emblem-light.svg @@ -0,0 +1,13 @@ + + + + emblem-light + Created with Sketch. + + + + + + + + \ No newline at end of file diff --git a/examples/react/pause/src/index.tsx b/examples/react/pause/src/index.tsx new file mode 100644 index 0000000000..bbc4e761c4 --- /dev/null +++ b/examples/react/pause/src/index.tsx @@ -0,0 +1,70 @@ +import ReactDOM from 'react-dom/client' +import { + PauseManager, + PauseManagerProvider, + QueryClient, + QueryClientProvider, + useQuery, +} from '@tanstack/react-query' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +import { memo, useRef, useSyncExternalStore } from 'react' + +const queryClient = new QueryClient() +const pauseManager = new PauseManager(true) + +let counter = 1 + +const useCounter = () => + useQuery({ + queryKey: ['counter'], + refetchOnMount: false, + queryFn: () => counter++, + }) + +export default function App() { + return ( + + + + + ) +} + +function Example() { + const { data, refetch } = useCounter() + const isPaused = useSyncExternalStore( + (onStoreChange) => pauseManager.subscribe(onStoreChange), + () => pauseManager.isPaused(), + ) + + return ( +
+

Parent Counter: {data}

+
+ + + +
+ + +
+ ) +} + +const MemoisedChild = memo(() => { + const { data } = useCounter() + const renders = useRef(0) + renders.current++ + + return ( + <> +

Child counter: {data}

+

Child renders: {renders.current}

+ + ) +}) + +const rootElement = document.getElementById('root') as HTMLElement +ReactDOM.createRoot(rootElement).render() diff --git a/examples/react/pause/tsconfig.json b/examples/react/pause/tsconfig.json new file mode 100644 index 0000000000..23a8707ef4 --- /dev/null +++ b/examples/react/pause/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "eslint.config.js"] +} diff --git a/examples/react/pause/vite.config.ts b/examples/react/pause/vite.config.ts new file mode 100644 index 0000000000..9ffcc67574 --- /dev/null +++ b/examples/react/pause/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], +}) diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index 4e7f141afe..f98c930621 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -13,6 +13,7 @@ export { MutationObserver } from './mutationObserver' export { notifyManager, defaultScheduler } from './notifyManager' export { focusManager } from './focusManager' export { onlineManager } from './onlineManager' +export { PauseManager } from './pauseManager' export { hashKey, replaceEqualDeep, diff --git a/packages/query-core/src/pauseManager.ts b/packages/query-core/src/pauseManager.ts new file mode 100644 index 0000000000..9cfa47d550 --- /dev/null +++ b/packages/query-core/src/pauseManager.ts @@ -0,0 +1,31 @@ +import { Subscribable } from './subscribable' + +type Listener = (paused: boolean) => void + +export class PauseManager extends Subscribable { + #paused: boolean + + constructor(paused = false) { + super() + this.#paused = paused + } + + #onChange(): void { + const isPaused = this.isPaused() + this.listeners.forEach((listener) => { + listener(isPaused) + }) + } + + setPaused(paused: boolean): void { + const changed = this.#paused !== paused + if (changed) { + this.#paused = paused + this.#onChange() + } + } + + isPaused(): boolean { + return this.#paused + } +} diff --git a/packages/react-query/src/PauseManagerProvider.tsx b/packages/react-query/src/PauseManagerProvider.tsx new file mode 100644 index 0000000000..d2932a32ef --- /dev/null +++ b/packages/react-query/src/PauseManagerProvider.tsx @@ -0,0 +1,28 @@ +'use client' +import * as React from 'react' + +import type { PauseManager } from '@tanstack/query-core' + +export const PauseManagerContext = React.createContext< + PauseManager | undefined +>(undefined) + +export const usePauseManager = () => { + return React.useContext(PauseManagerContext) +} + +export type PauseManagerProviderProps = { + pauseManager: PauseManager + children?: React.ReactNode +} + +export const PauseManagerProvider = ({ + pauseManager, + children, +}: PauseManagerProviderProps): React.JSX.Element => { + return ( + + {children} + + ) +} diff --git a/packages/react-query/src/index.ts b/packages/react-query/src/index.ts index 5f372f4195..e2e1509956 100644 --- a/packages/react-query/src/index.ts +++ b/packages/react-query/src/index.ts @@ -29,6 +29,11 @@ export type { UndefinedInitialDataInfiniteOptions, UnusedSkipTokenInfiniteOptions, } from './infiniteQueryOptions' +export { + PauseManagerContext, + PauseManagerProvider, + usePauseManager, +} from './PauseManagerProvider' export { QueryClientContext, QueryClientProvider, diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts index ed731e4309..a1162c2a31 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -17,6 +17,7 @@ import { willFetch, } from './suspense' import { noop } from './utils' +import { usePauseManager } from './PauseManagerProvider' import type { QueryClient, QueryKey, @@ -53,6 +54,7 @@ export function useBaseQuery< const client = useQueryClient(queryClient) const isRestoring = useIsRestoring() const errorResetBoundary = useQueryErrorResetBoundary() + const pauseManager = usePauseManager() const defaultedOptions = client.defaultQueryOptions(options) ;(client.getDefaultOptions().queries as any)?._experimental_beforeQuery?.( @@ -97,17 +99,37 @@ export function useBaseQuery< React.useSyncExternalStore( React.useCallback( (onStoreChange) => { - const unsubscribe = shouldSubscribe - ? observer.subscribe(notifyManager.batchCalls(onStoreChange)) - : noop + if (!shouldSubscribe) { + return noop + } + const notify = notifyManager.batchCalls(onStoreChange) + let isPaused = pauseManager?.isPaused() + let hasPendingChanges = false + const unsubscribeObserver = observer.subscribe(() => { + if (isPaused) { + hasPendingChanges = true + } else { + notify() + } + }) + const unsubscribePaused = pauseManager?.subscribe((paused) => { + isPaused = paused + if (hasPendingChanges && !paused) { + hasPendingChanges = false + notify() + } + }) // Update result to make sure we did not miss any query updates // between creating the observer and subscribing to it. observer.updateResult() - return unsubscribe + return () => { + unsubscribeObserver() + unsubscribePaused?.() + } }, - [observer, shouldSubscribe], + [observer, shouldSubscribe, pauseManager], ), () => observer.getCurrentResult(), () => observer.getCurrentResult(), diff --git a/packages/react-query/src/useQueries.ts b/packages/react-query/src/useQueries.ts index a3b2312897..a107238dfa 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -21,6 +21,7 @@ import { willFetch, } from './suspense' import { noop } from './utils' +import { usePauseManager } from './PauseManagerProvider' import type { DefinedUseQueryResult, UseQueryOptions, @@ -224,6 +225,7 @@ export function useQueries< const client = useQueryClient(queryClient) const isRestoring = useIsRestoring() const errorResetBoundary = useQueryErrorResetBoundary() + const pauseManager = usePauseManager() const defaultedQueries = React.useMemo( () => @@ -268,11 +270,34 @@ export function useQueries< const shouldSubscribe = !isRestoring && options.subscribed !== false React.useSyncExternalStore( React.useCallback( - (onStoreChange) => - shouldSubscribe - ? observer.subscribe(notifyManager.batchCalls(onStoreChange)) - : noop, - [observer, shouldSubscribe], + (onStoreChange) => { + if (!shouldSubscribe) { + return noop + } + const notify = notifyManager.batchCalls(onStoreChange) + let isPaused = pauseManager?.isPaused() + let hasPendingChanges = false + const unsubscribeObserver = observer.subscribe(() => { + if (isPaused) { + hasPendingChanges = true + } else { + notify() + } + }) + const unsubscribePaused = pauseManager?.subscribe((paused) => { + isPaused = paused + if (hasPendingChanges && !paused) { + hasPendingChanges = false + notify() + } + }) + + return () => { + unsubscribeObserver() + unsubscribePaused?.() + } + }, + [observer, shouldSubscribe, pauseManager], ), () => observer.getCurrentResult(), () => observer.getCurrentResult(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36dbf3f68e..5447c35395 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1079,6 +1079,31 @@ importers: specifier: 5.8.2 version: 5.8.2 + examples/react/pause: + dependencies: + '@tanstack/react-query': + specifier: ^5.67.1 + version: link:../../../packages/react-query + '@tanstack/react-query-devtools': + specifier: ^5.67.1 + version: link:../../../packages/react-query-devtools + react: + specifier: ^19.0.0 + version: 19.0.0 + react-dom: + specifier: ^19.0.0 + version: 19.0.0(react@19.0.0) + devDependencies: + '@vitejs/plugin-react': + specifier: ^4.3.3 + version: 4.3.3(vite@5.4.14(@types/node@22.13.5)(less@4.2.2)(lightningcss@1.27.0)(sass@1.85.1)(terser@5.31.6)) + typescript: + specifier: 5.8.2 + version: 5.8.2 + vite: + specifier: ^5.3.5 + version: 5.4.14(@types/node@22.13.5)(less@4.2.2)(lightningcss@1.27.0)(sass@1.85.1)(terser@5.31.6) + examples/react/playground: dependencies: '@tanstack/react-query':