diff --git a/docs/config.json b/docs/config.json index 5ff8e8f240..8666e2fabe 100644 --- a/docs/config.json +++ b/docs/config.json @@ -909,6 +909,10 @@ "label": "onlineManager", "to": "reference/onlineManager" }, + { + "label": "environmentManager", + "to": "reference/environmentManager" + }, { "label": "notifyManager", "to": "reference/notifyManager" diff --git a/docs/reference/environmentManager.md b/docs/reference/environmentManager.md new file mode 100644 index 0000000000..2295a76463 --- /dev/null +++ b/docs/reference/environmentManager.md @@ -0,0 +1,50 @@ +--- +id: EnvironmentManager +title: environmentManager +--- + +The `environmentManager` manages how TanStack Query detects whether the current runtime should be treated as server-side. + +By default, it uses the same server detection as the exported `isServer` utility from query-core. + +Use this manager to override server detection globally for runtimes that are not traditional browser/server environments (for example, extension workers). + +Its available methods are: + +- [`isServer`](#environmentmanagerisserver) +- [`setIsServer`](#environmentmanagersetisserver) + +## `environmentManager.isServer` + +Returns whether the current runtime is treated as a server environment. + +```tsx +import { environmentManager } from '@tanstack/react-query' + +const server = environmentManager.isServer() +``` + +## `environmentManager.setIsServer` + +Overrides the server check globally. + +```tsx +import { environmentManager } from '@tanstack/react-query' + +// Override +environmentManager.setIsServer(() => { + return typeof window === 'undefined' && !('chrome' in globalThis) +}) +``` + +**Options** + +- `isServerValue: () => boolean` + +To restore the default behavior, set the function back to query-core's `isServer` utility: + +```tsx +import { environmentManager, isServer } from '@tanstack/react-query' + +environmentManager.setIsServer(() => isServer) +``` diff --git a/packages/preact-query/src/__tests__/utils.tsx b/packages/preact-query/src/__tests__/utils.tsx index d4f1dba05e..b37adc318b 100644 --- a/packages/preact-query/src/__tests__/utils.tsx +++ b/packages/preact-query/src/__tests__/utils.tsx @@ -1,4 +1,4 @@ -import * as utils from '@tanstack/query-core' +import { environmentManager, isServer } from '@tanstack/query-core' import { act, render } from '@testing-library/preact' import type { ComponentChildren, VNode } from 'preact' import { useEffect, useState } from 'preact/hooks' @@ -58,17 +58,9 @@ export function setActTimeout(fn: () => void, ms?: number) { }, ms) } -// This monkey-patches the isServer-value from utils, -// so that we can pretend to be in a server environment -export function setIsServer(isServer: boolean) { - const original = utils.isServer - Object.defineProperty(utils, 'isServer', { - get: () => isServer, - }) - +export function setIsServer(value: boolean) { + environmentManager.setIsServer(() => value) return () => { - Object.defineProperty(utils, 'isServer', { - get: () => original, - }) + environmentManager.setIsServer(() => isServer) } } diff --git a/packages/preact-query/src/useBaseQuery.ts b/packages/preact-query/src/useBaseQuery.ts index 84d7567916..05ce46e7f6 100644 --- a/packages/preact-query/src/useBaseQuery.ts +++ b/packages/preact-query/src/useBaseQuery.ts @@ -1,4 +1,4 @@ -import { isServer, noop, notifyManager } from '@tanstack/query-core' +import { environmentManager, noop, notifyManager } from '@tanstack/query-core' import type { QueryClient, QueryKey, @@ -147,7 +147,7 @@ export function useBaseQuery< if ( defaultedOptions.experimental_prefetchInRender && - !isServer && + !environmentManager.isServer() && willFetch(result, isRestoring) ) { const promise = isNewCacheEntry diff --git a/packages/query-core/src/__tests__/environmentManager.test.tsx b/packages/query-core/src/__tests__/environmentManager.test.tsx new file mode 100644 index 0000000000..8d428d7be0 --- /dev/null +++ b/packages/query-core/src/__tests__/environmentManager.test.tsx @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, test } from 'vitest' +import { environmentManager, isServer } from '..' + +describe('environmentManager', () => { + afterEach(() => { + environmentManager.setIsServer(() => isServer) + }) + + test('should use the default isServer detection', () => { + expect(environmentManager.isServer()).toBe(isServer) + }) + + test('should allow overriding isServer globally', () => { + environmentManager.setIsServer(() => true) + expect(environmentManager.isServer()).toBe(true) + + environmentManager.setIsServer(() => false) + expect(environmentManager.isServer()).toBe(false) + }) + + test('should allow overriding isServer with a function', () => { + let server = true + environmentManager.setIsServer(() => server) + expect(environmentManager.isServer()).toBe(true) + + server = false + expect(environmentManager.isServer()).toBe(false) + }) +}) diff --git a/packages/query-core/src/__tests__/focusManager.test.tsx b/packages/query-core/src/__tests__/focusManager.test.tsx index 3783361fb0..5900b9b202 100644 --- a/packages/query-core/src/__tests__/focusManager.test.tsx +++ b/packages/query-core/src/__tests__/focusManager.test.tsx @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest' import { FocusManager } from '../focusManager' -import { setIsServer } from './utils' describe('focusManager', () => { let focusManager: FocusManager @@ -55,17 +54,26 @@ describe('focusManager', () => { }) test('cleanup (removeEventListener) should not be called if window is not defined', () => { - const restoreIsServer = setIsServer(true) - + const windowSpy = vi.spyOn(globalThis, 'window', 'get') + windowSpy.mockImplementation( + () => undefined as unknown as Window & typeof globalThis, + ) const removeEventListenerSpy = vi.spyOn(globalThis, 'removeEventListener') - const unsubscribe = focusManager.subscribe(() => undefined) + const subscribe = () => focusManager.subscribe(() => undefined) + let firstUnsubscribe: (() => void) | undefined - unsubscribe() + expect(() => { + firstUnsubscribe = subscribe() + }).not.toThrow() + const secondUnsubscribe = subscribe() + + firstUnsubscribe?.() + secondUnsubscribe() expect(removeEventListenerSpy).not.toHaveBeenCalled() - restoreIsServer() + windowSpy.mockRestore() }) test('cleanup (removeEventListener) should not be called if window.addEventListener is not defined', () => { diff --git a/packages/query-core/src/__tests__/onlineManager.test.tsx b/packages/query-core/src/__tests__/onlineManager.test.tsx index 20a9439de9..79fbb697ee 100644 --- a/packages/query-core/src/__tests__/onlineManager.test.tsx +++ b/packages/query-core/src/__tests__/onlineManager.test.tsx @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { OnlineManager } from '../onlineManager' -import { setIsServer } from './utils' describe('onlineManager', () => { let onlineManager: OnlineManager @@ -64,17 +63,20 @@ describe('onlineManager', () => { }) test('cleanup (removeEventListener) should not be called if window is not defined', () => { - const restoreIsServer = setIsServer(true) - + const windowSpy = vi.spyOn(globalThis, 'window', 'get') + windowSpy.mockImplementation( + () => undefined as unknown as Window & typeof globalThis, + ) const removeEventListenerSpy = vi.spyOn(globalThis, 'removeEventListener') const unsubscribe = onlineManager.subscribe(() => undefined) + expect(unsubscribe).toBeInstanceOf(Function) unsubscribe() expect(removeEventListenerSpy).not.toHaveBeenCalled() - restoreIsServer() + windowSpy.mockRestore() }) test('cleanup (removeEventListener) should not be called if window.addEventListener is not defined', () => { diff --git a/packages/query-core/src/__tests__/utils.ts b/packages/query-core/src/__tests__/utils.ts index f9ef89d765..6396d341ea 100644 --- a/packages/query-core/src/__tests__/utils.ts +++ b/packages/query-core/src/__tests__/utils.ts @@ -1,6 +1,5 @@ import { vi } from 'vitest' -import { onlineManager } from '..' -import * as utils from '../utils' +import { environmentManager, isServer, onlineManager } from '..' import type { MockInstance } from 'vitest' import type { MutationOptions, QueryClient } from '..' @@ -21,17 +20,9 @@ export function executeMutation( .execute(variables) } -// This monkey-patches the isServer-value from utils, -// so that we can pretend to be in a server environment -export function setIsServer(isServer: boolean) { - const original = utils.isServer - Object.defineProperty(utils, 'isServer', { - get: () => isServer, - }) - +export function setIsServer(value: boolean) { + environmentManager.setIsServer(() => value) return () => { - Object.defineProperty(utils, 'isServer', { - get: () => original, - }) + environmentManager.setIsServer(() => isServer) } } diff --git a/packages/query-core/src/environmentManager.ts b/packages/query-core/src/environmentManager.ts new file mode 100644 index 0000000000..9da5caafd0 --- /dev/null +++ b/packages/query-core/src/environmentManager.ts @@ -0,0 +1,25 @@ +import { isServer } from './utils' + +export type IsServerValue = () => boolean + +/** + * Manages environment detection used by TanStack Query internals. + */ +export const environmentManager = (() => { + let isServerFn: IsServerValue = () => isServer + + return { + /** + * Returns whether the current runtime should be treated as a server environment. + */ + isServer(): boolean { + return isServerFn() + }, + /** + * Overrides the server check globally. + */ + setIsServer(isServerValue: IsServerValue): void { + isServerFn = isServerValue + }, + } +})() diff --git a/packages/query-core/src/focusManager.ts b/packages/query-core/src/focusManager.ts index cb0d859871..21be2612d3 100644 --- a/packages/query-core/src/focusManager.ts +++ b/packages/query-core/src/focusManager.ts @@ -1,5 +1,4 @@ import { Subscribable } from './subscribable' -import { isServer } from './utils' type Listener = (focused: boolean) => void @@ -18,7 +17,7 @@ export class FocusManager extends Subscribable { this.#setup = (onFocus) => { // addEventListener does not exist in React Native, but window does // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!isServer && window.addEventListener) { + if (typeof window !== 'undefined' && window.addEventListener) { const listener = () => onFocus() // Listen to visibilitychange window.addEventListener('visibilitychange', listener, false) diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index a7763cf648..a4267aabc9 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -1,6 +1,7 @@ /* istanbul ignore file */ export { focusManager } from './focusManager' +export { environmentManager } from './environmentManager' export { defaultShouldDehydrateMutation, defaultShouldDehydrateQuery, diff --git a/packages/query-core/src/onlineManager.ts b/packages/query-core/src/onlineManager.ts index daf77d5a4c..6c59a14b90 100644 --- a/packages/query-core/src/onlineManager.ts +++ b/packages/query-core/src/onlineManager.ts @@ -1,5 +1,4 @@ import { Subscribable } from './subscribable' -import { isServer } from './utils' type Listener = (online: boolean) => void type SetupFn = (setOnline: Listener) => (() => void) | undefined @@ -15,7 +14,7 @@ export class OnlineManager extends Subscribable { this.#setup = (onOnline) => { // addEventListener does not exist in React Native, but window does // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!isServer && window.addEventListener) { + if (typeof window !== 'undefined' && window.addEventListener) { const onlineListener = () => onOnline(true) const offlineListener = () => onOnline(false) // Listen to online diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 463407a073..d6b3817b82 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -1,10 +1,10 @@ import { focusManager } from './focusManager' +import { environmentManager } from './environmentManager' import { notifyManager } from './notifyManager' import { fetchState } from './query' import { Subscribable } from './subscribable' import { pendingThenable } from './thenable' import { - isServer, isValidTimeout, noop, replaceData, @@ -358,7 +358,11 @@ export class QueryObserver< this.#currentQuery, ) - if (isServer || this.#currentResult.isStale || !isValidTimeout(staleTime)) { + if ( + environmentManager.isServer() || + this.#currentResult.isStale || + !isValidTimeout(staleTime) + ) { return } @@ -389,7 +393,7 @@ export class QueryObserver< this.#currentRefetchInterval = nextInterval if ( - isServer || + environmentManager.isServer() || resolveEnabled(this.options.enabled, this.#currentQuery) === false || !isValidTimeout(this.#currentRefetchInterval) || this.#currentRefetchInterval === 0 diff --git a/packages/query-core/src/removable.ts b/packages/query-core/src/removable.ts index 8642ab36ec..68545f7438 100644 --- a/packages/query-core/src/removable.ts +++ b/packages/query-core/src/removable.ts @@ -1,5 +1,6 @@ import { timeoutManager } from './timeoutManager' -import { isServer, isValidTimeout } from './utils' +import { environmentManager } from './environmentManager' +import { isValidTimeout } from './utils' import type { ManagedTimerId } from './timeoutManager' export abstract class Removable { @@ -24,7 +25,7 @@ export abstract class Removable { // Default to 5 minutes (Infinity for server-side) if no gcTime is set this.gcTime = Math.max( this.gcTime || 0, - newGcTime ?? (isServer ? Infinity : 5 * 60 * 1000), + newGcTime ?? (environmentManager.isServer() ? Infinity : 5 * 60 * 1000), ) } diff --git a/packages/query-core/src/retryer.ts b/packages/query-core/src/retryer.ts index f4ada851c9..0b1bb1833a 100644 --- a/packages/query-core/src/retryer.ts +++ b/packages/query-core/src/retryer.ts @@ -1,7 +1,8 @@ import { focusManager } from './focusManager' import { onlineManager } from './onlineManager' import { pendingThenable } from './thenable' -import { isServer, sleep } from './utils' +import { environmentManager } from './environmentManager' +import { sleep } from './utils' import type { Thenable } from './thenable' import type { CancelOptions, DefaultError, NetworkMode } from './types' @@ -166,7 +167,7 @@ export function createRetryer( } // Do we need to retry the request? - const retry = config.retry ?? (isServer ? 0 : 3) + const retry = config.retry ?? (environmentManager.isServer() ? 0 : 3) const retryDelay = config.retryDelay ?? defaultRetryDelay const delay = typeof retryDelay === 'function' diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index 27d44bc98b..b29e8ded45 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -86,6 +86,9 @@ export type QueryTypeFilter = 'all' | 'active' | 'inactive' // UTILS +/** @deprecated + * use `environmentManager.isServer()` instead. + */ export const isServer = typeof window === 'undefined' || 'Deno' in globalThis export function noop(): void diff --git a/packages/react-query/src/__tests__/utils.tsx b/packages/react-query/src/__tests__/utils.tsx index 3734c1caf6..ae8e2fb23f 100644 --- a/packages/react-query/src/__tests__/utils.tsx +++ b/packages/react-query/src/__tests__/utils.tsx @@ -1,7 +1,7 @@ import { vi } from 'vitest' import * as React from 'react' import { act, render } from '@testing-library/react' -import * as utils from '@tanstack/query-core' +import { environmentManager, isServer } from '@tanstack/query-core' import { QueryClientProvider, onlineManager } from '..' import type { QueryClient } from '..' import type { MockInstance } from 'vitest' @@ -56,17 +56,9 @@ export function setActTimeout(fn: () => void, ms?: number) { }, ms) } -// This monkey-patches the isServer-value from utils, -// so that we can pretend to be in a server environment -export function setIsServer(isServer: boolean) { - const original = utils.isServer - Object.defineProperty(utils, 'isServer', { - get: () => isServer, - }) - +export function setIsServer(value: boolean) { + environmentManager.setIsServer(() => value) return () => { - Object.defineProperty(utils, 'isServer', { - get: () => original, - }) + environmentManager.setIsServer(() => isServer) } } diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts index 2a151fe113..a88f7d40fb 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { isServer, noop, notifyManager } from '@tanstack/query-core' +import { environmentManager, noop, notifyManager } from '@tanstack/query-core' import { useQueryClient } from './QueryClientProvider' import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary' import { @@ -148,7 +148,7 @@ export function useBaseQuery< if ( defaultedOptions.experimental_prefetchInRender && - !isServer && + !environmentManager.isServer() && willFetch(result, isRestoring) ) { const promise = isNewCacheEntry