From 36b25dc8e123fdabdacb93fee55124dcccdf9599 Mon Sep 17 00:00:00 2001 From: David Condrey Date: Thu, 17 Apr 2025 05:58:09 -0700 Subject: [PATCH 1/6] Update web-preset.ts Enhanced visibility check. Resolves #3039 --- src/_internal/utils/web-preset.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/_internal/utils/web-preset.ts b/src/_internal/utils/web-preset.ts index 7637e450e..fdfc871df 100644 --- a/src/_internal/utils/web-preset.ts +++ b/src/_internal/utils/web-preset.ts @@ -22,8 +22,18 @@ const [onWindowEvent, offWindowEvent] = : [noop, noop] const isVisible = () => { - const visibilityState = isDocumentDefined && document.visibilityState - return isUndefined(visibilityState) || visibilityState !== 'hidden' + if (!isDocumentDefined) return true + + try { + const isDocVisible = document.visibilityState !== 'hidden' + + const isDocFocused = + typeof document.hasFocus === 'function' ? document.hasFocus() : true + + return isDocVisible && isDocFocused + } catch (err) { + return true + } } const initFocus = (callback: () => void) => { From 64a27cc7c18f8098f087af1872d7ef75cc748c1a Mon Sep 17 00:00:00 2001 From: David Condrey Date: Thu, 17 Apr 2025 06:46:29 -0700 Subject: [PATCH 2/6] Update web-preset.ts no longer using isUndefined --- src/_internal/utils/web-preset.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_internal/utils/web-preset.ts b/src/_internal/utils/web-preset.ts index fdfc871df..39535e91c 100644 --- a/src/_internal/utils/web-preset.ts +++ b/src/_internal/utils/web-preset.ts @@ -1,6 +1,6 @@ import type { ProviderConfiguration } from '../types' import { isWindowDefined, isDocumentDefined } from './helper' -import { isUndefined, noop } from './shared' +import { noop } from './shared' /** * Due to the bug https://bugs.chromium.org/p/chromium/issues/detail?id=678075, From 00b1d850a060bcef35086a14eec2b4fe6481a2ac Mon Sep 17 00:00:00 2001 From: David Condrey Date: Sat, 24 May 2025 02:41:15 -0700 Subject: [PATCH 3/6] Add unit tests for isVisible in web-preset --- test/unit/web-preset-visible.test.ts | 56 ++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 test/unit/web-preset-visible.test.ts diff --git a/test/unit/web-preset-visible.test.ts b/test/unit/web-preset-visible.test.ts new file mode 100644 index 000000000..487e9da09 --- /dev/null +++ b/test/unit/web-preset-visible.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { preset } from '../../src/_internal/utils/web-preset' + +describe('web-preset isVisible', () => { + let originalDocument: any + + beforeEach(() => { + originalDocument = global.document + global.document = { + visibilityState: 'visible', + hasFocus: () => true + } + }) + + afterEach(() => { + global.document = originalDocument + }) + + it('returns true when document is visible and focused', () => { + global.document.visibilityState = 'visible' + global.document.hasFocus = () => true + expect(preset.isVisible()).toBe(true) + }) + + it('returns false when document is hidden and not focused', () => { + global.document.visibilityState = 'hidden' + global.document.hasFocus = () => false + expect(preset.isVisible()).toBe(false) + }) + + it('returns false when document is hidden but focused', () => { + global.document.visibilityState = 'hidden' + global.document.hasFocus = () => true + expect(preset.isVisible()).toBe(false) + }) + + it('returns false when document is visible but not focused', () => { + global.document.visibilityState = 'visible' + global.document.hasFocus = () => false + expect(preset.isVisible()).toBe(false) + }) + + it('returns true if hasFocus is not a function', () => { + global.document.visibilityState = 'visible' + // @ts-expect-error + global.document.hasFocus = undefined + expect(preset.isVisible()).toBe(true) + }) + + it('returns true if there is an exception', () => { + Object.defineProperty(global, 'document', { + get() { throw new Error('Simulated error') } + }) + expect(preset.isVisible()).toBe(true) + }) +}) \ No newline at end of file From 1036902f946025f6357f97daecb8fb56b6814862 Mon Sep 17 00:00:00 2001 From: David Condrey Date: Tue, 23 Dec 2025 23:11:26 -0800 Subject: [PATCH 4/6] refactor: separate focus state from visibility in SWR preset --- src/_internal/types.ts | 10 +++++ src/_internal/utils/config.ts | 1 + src/_internal/utils/web-preset.ts | 17 ++++---- src/index/use-swr.ts | 16 +++++-- test/unit/web-preset-visible.test.ts | 64 +++++++++------------------- 5 files changed, 52 insertions(+), 56 deletions(-) diff --git a/src/_internal/types.ts b/src/_internal/types.ts index c5dee2f2e..f7a5a6eaa 100644 --- a/src/_internal/types.ts +++ b/src/_internal/types.ts @@ -184,6 +184,11 @@ export interface PublicConfiguration< * @defaultValue false */ refreshWhenOffline?: boolean + /** + * polling when the window is not focused (if `refreshInterval` is enabled) + * @defaultValue true + */ + refreshWhenUnfocused?: boolean /** * automatically revalidate when window gets focused * @@ -317,6 +322,11 @@ export interface PublicConfiguration< * @see {@link https://swr.vercel.app/docs/advanced/react-native#customize-focus-and-reconnect-events} */ isVisible: () => boolean + /** + * hasFocus is a function that returns a boolean, to determine if the window has focus. Used for controlling polling behavior via refreshWhenUnfocused. + * @see {@link https://swr.vercel.app/docs/advanced/react-native#customize-focus-and-reconnect-events} + */ + hasFocus: () => boolean } export type FullConfiguration< diff --git a/src/_internal/utils/config.ts b/src/_internal/utils/config.ts index ce05caea2..91412613b 100644 --- a/src/_internal/utils/config.ts +++ b/src/_internal/utils/config.ts @@ -60,6 +60,7 @@ export const defaultConfig: FullConfiguration = mergeObjects( revalidateOnReconnect: true, revalidateIfStale: true, shouldRetryOnError: true, + refreshWhenUnfocused: true, // timeouts errorRetryInterval: slowConnection ? 10000 : 5000, diff --git a/src/_internal/utils/web-preset.ts b/src/_internal/utils/web-preset.ts index 39535e91c..a2a637483 100644 --- a/src/_internal/utils/web-preset.ts +++ b/src/_internal/utils/web-preset.ts @@ -1,6 +1,6 @@ import type { ProviderConfiguration } from '../types' import { isWindowDefined, isDocumentDefined } from './helper' -import { noop } from './shared' +import { isUndefined, noop } from './shared' /** * Due to the bug https://bugs.chromium.org/p/chromium/issues/detail?id=678075, @@ -22,15 +22,15 @@ const [onWindowEvent, offWindowEvent] = : [noop, noop] const isVisible = () => { + const visibilityState = isDocumentDefined && document.visibilityState + return isUndefined(visibilityState) || visibilityState !== 'hidden' +} + +const hasFocus = () => { if (!isDocumentDefined) return true try { - const isDocVisible = document.visibilityState !== 'hidden' - - const isDocFocused = - typeof document.hasFocus === 'function' ? document.hasFocus() : true - - return isDocVisible && isDocFocused + return typeof document.hasFocus === 'function' ? document.hasFocus() : true } catch (err) { return true } @@ -70,7 +70,8 @@ const initReconnect = (callback: () => void) => { export const preset = { isOnline, - isVisible + isVisible, + hasFocus } as const export const defaultConfigOptions: ProviderConfiguration = { diff --git a/src/index/use-swr.ts b/src/index/use-swr.ts index bb9b011af..903af5bb7 100644 --- a/src/index/use-swr.ts +++ b/src/index/use-swr.ts @@ -134,6 +134,7 @@ export const useSWRHandler = ( refreshInterval, refreshWhenHidden, refreshWhenOffline, + refreshWhenUnfocused, keepPreviousData, strictServerPrefetchWarning } = config @@ -671,7 +672,7 @@ export const useSWRHandler = ( if ( getConfig().revalidateOnFocus && now > nextFocusRevalidatedAt && - isActive() + getConfig().isOnline() ) { nextFocusRevalidatedAt = now + getConfig().focusThrottleInterval softRevalidate() @@ -743,11 +744,12 @@ export const useSWRHandler = ( function execute() { // Check if it's OK to execute: - // Only revalidate when the page is visible, online, and not errored. + // Only revalidate when the page is visible, online, focused, and not errored. if ( !getCache().error && (refreshWhenHidden || getConfig().isVisible()) && - (refreshWhenOffline || getConfig().isOnline()) + (refreshWhenOffline || getConfig().isOnline()) && + (refreshWhenUnfocused || getConfig().hasFocus()) ) { revalidate(WITH_DEDUPE).then(next) } else { @@ -764,7 +766,13 @@ export const useSWRHandler = ( timer = -1 } } - }, [refreshInterval, refreshWhenHidden, refreshWhenOffline, key]) + }, [ + refreshInterval, + refreshWhenHidden, + refreshWhenOffline, + refreshWhenUnfocused, + key + ]) // Display debug info in React DevTools. useDebugValue(returnedData) diff --git a/test/unit/web-preset-visible.test.ts b/test/unit/web-preset-visible.test.ts index 487e9da09..56d7baba3 100644 --- a/test/unit/web-preset-visible.test.ts +++ b/test/unit/web-preset-visible.test.ts @@ -1,56 +1,32 @@ -import { describe, expect, it, beforeEach, afterEach } from 'vitest' import { preset } from '../../src/_internal/utils/web-preset' describe('web-preset isVisible', () => { - let originalDocument: any - - beforeEach(() => { - originalDocument = global.document - global.document = { - visibilityState: 'visible', - hasFocus: () => true - } - }) - - afterEach(() => { - global.document = originalDocument - }) - - it('returns true when document is visible and focused', () => { - global.document.visibilityState = 'visible' - global.document.hasFocus = () => true - expect(preset.isVisible()).toBe(true) - }) - - it('returns false when document is hidden and not focused', () => { - global.document.visibilityState = 'hidden' - global.document.hasFocus = () => false - expect(preset.isVisible()).toBe(false) + it('returns a boolean', () => { + expect(typeof preset.isVisible()).toBe('boolean') }) - it('returns false when document is hidden but focused', () => { - global.document.visibilityState = 'hidden' - global.document.hasFocus = () => true - expect(preset.isVisible()).toBe(false) + it('checks document.visibilityState', () => { + // In a real browser, this would return true when visible + // In jsdom, it returns true by default + const result = preset.isVisible() + expect(typeof result).toBe('boolean') }) +}) - it('returns false when document is visible but not focused', () => { - global.document.visibilityState = 'visible' - global.document.hasFocus = () => false - expect(preset.isVisible()).toBe(false) +describe('web-preset hasFocus', () => { + it('returns a boolean', () => { + expect(typeof preset.hasFocus()).toBe('boolean') }) - it('returns true if hasFocus is not a function', () => { - global.document.visibilityState = 'visible' - // @ts-expect-error - global.document.hasFocus = undefined - expect(preset.isVisible()).toBe(true) + it('checks document.hasFocus()', () => { + // In jsdom, hasFocus() should be available on document + const result = preset.hasFocus() + expect(typeof result).toBe('boolean') }) - it('returns true if there is an exception', () => { - Object.defineProperty(global, 'document', { - get() { throw new Error('Simulated error') } - }) - expect(preset.isVisible()).toBe(true) + it('handles missing hasFocus gracefully', () => { + // hasFocus should return a boolean + const result = preset.hasFocus() + expect(typeof result).toBe('boolean') }) -}) \ No newline at end of file +}) From dea54ff18f6a71fac87cdd86a01ac4f6ad5efe82 Mon Sep 17 00:00:00 2001 From: David Condrey Date: Tue, 3 Mar 2026 10:59:00 -0800 Subject: [PATCH 5/6] fix: use hasFocus() fallback for Chrome visibility quirks - Change isActive() to accept hasFocus() as alternative to isVisible(), fixing revalidateOnFocus in Chrome where visibilityState can be stale - Default refreshWhenUnfocused to falsy (consistent with refreshWhenHidden and refreshWhenOffline), so polling pauses on alt-tab via hasFocus() - Restore isActive() in FOCUS_EVENT handler (was incorrectly changed to isOnline() only) - Rewrite unit tests to verify actual behavior with mocked state - Update mockVisibilityHidden() to also mock hasFocus() for accuracy Fixes #3039, #4130 --- src/_internal/types.ts | 2 +- src/_internal/utils/config.ts | 1 - src/index/use-swr.ts | 6 +++-- test/jest-setup.ts | 6 +++++ test/unit/web-preset-visible.test.ts | 36 +++++++++++++--------------- test/utils.tsx | 7 +++++- 6 files changed, 34 insertions(+), 24 deletions(-) diff --git a/src/_internal/types.ts b/src/_internal/types.ts index f7a5a6eaa..4ed962e15 100644 --- a/src/_internal/types.ts +++ b/src/_internal/types.ts @@ -186,7 +186,7 @@ export interface PublicConfiguration< refreshWhenOffline?: boolean /** * polling when the window is not focused (if `refreshInterval` is enabled) - * @defaultValue true + * @defaultValue false */ refreshWhenUnfocused?: boolean /** diff --git a/src/_internal/utils/config.ts b/src/_internal/utils/config.ts index 91412613b..ce05caea2 100644 --- a/src/_internal/utils/config.ts +++ b/src/_internal/utils/config.ts @@ -60,7 +60,6 @@ export const defaultConfig: FullConfiguration = mergeObjects( revalidateOnReconnect: true, revalidateIfStale: true, shouldRetryOnError: true, - refreshWhenUnfocused: true, // timeouts errorRetryInterval: slowConnection ? 10000 : 5000, diff --git a/src/index/use-swr.ts b/src/index/use-swr.ts index 903af5bb7..e2e77a25c 100644 --- a/src/index/use-swr.ts +++ b/src/index/use-swr.ts @@ -161,7 +161,9 @@ export const useSWRHandler = ( const fetcherRef = useRef(fetcher) const configRef = useRef(config) const getConfig = () => configRef.current - const isActive = () => getConfig().isVisible() && getConfig().isOnline() + const isActive = () => + (getConfig().isVisible() || getConfig().hasFocus()) && + getConfig().isOnline() const [getCache, setCache, subscribeCache, getInitialCache] = createCacheHelper< @@ -672,7 +674,7 @@ export const useSWRHandler = ( if ( getConfig().revalidateOnFocus && now > nextFocusRevalidatedAt && - getConfig().isOnline() + isActive() ) { nextFocusRevalidatedAt = now + getConfig().focusThrottleInterval softRevalidate() diff --git a/test/jest-setup.ts b/test/jest-setup.ts index c44951a68..1e67b9cf0 100644 --- a/test/jest-setup.ts +++ b/test/jest-setup.ts @@ -1 +1,7 @@ import '@testing-library/jest-dom' + +// jsdom's document.hasFocus() returns false by default (no real window). +// Mock it to return true to match the real browser default state. +if (typeof document !== 'undefined') { + jest.spyOn(document, 'hasFocus').mockReturnValue(true) +} diff --git a/test/unit/web-preset-visible.test.ts b/test/unit/web-preset-visible.test.ts index 56d7baba3..dfa8b2cf6 100644 --- a/test/unit/web-preset-visible.test.ts +++ b/test/unit/web-preset-visible.test.ts @@ -1,32 +1,30 @@ import { preset } from '../../src/_internal/utils/web-preset' describe('web-preset isVisible', () => { - it('returns a boolean', () => { - expect(typeof preset.isVisible()).toBe('boolean') + it('returns true when visibilityState is visible', () => { + expect(preset.isVisible()).toBe(true) }) - it('checks document.visibilityState', () => { - // In a real browser, this would return true when visible - // In jsdom, it returns true by default - const result = preset.isVisible() - expect(typeof result).toBe('boolean') + it('returns false when visibilityState is hidden', () => { + const spy = jest.spyOn(document, 'visibilityState', 'get') + spy.mockReturnValue('hidden') + expect(preset.isVisible()).toBe(false) + spy.mockRestore() }) }) describe('web-preset hasFocus', () => { - it('returns a boolean', () => { - expect(typeof preset.hasFocus()).toBe('boolean') + it('returns true when document.hasFocus() is true', () => { + const spy = jest.spyOn(document, 'hasFocus') + spy.mockReturnValue(true) + expect(preset.hasFocus()).toBe(true) + spy.mockRestore() }) - it('checks document.hasFocus()', () => { - // In jsdom, hasFocus() should be available on document - const result = preset.hasFocus() - expect(typeof result).toBe('boolean') - }) - - it('handles missing hasFocus gracefully', () => { - // hasFocus should return a boolean - const result = preset.hasFocus() - expect(typeof result).toBe('boolean') + it('returns false when document.hasFocus() is false', () => { + const spy = jest.spyOn(document, 'hasFocus') + spy.mockReturnValue(false) + expect(preset.hasFocus()).toBe(false) + spy.mockRestore() }) }) diff --git a/test/utils.tsx b/test/utils.tsx index aa0542fdd..727de97c9 100644 --- a/test/utils.tsx +++ b/test/utils.tsx @@ -73,7 +73,12 @@ export const hydrateWithConfig = ( export const mockVisibilityHidden = () => { const mockVisibilityState = jest.spyOn(document, 'visibilityState', 'get') mockVisibilityState.mockImplementation(() => 'hidden') - return () => mockVisibilityState.mockRestore() + const mockHasFocus = jest.spyOn(document, 'hasFocus') + mockHasFocus.mockReturnValue(false) + return () => { + mockVisibilityState.mockRestore() + mockHasFocus.mockRestore() + } } // Using `act()` will cause React 18 to batch updates. From d6cef4b575e3e4060db97e2feb298ee3ec119408 Mon Sep 17 00:00:00 2001 From: David Condrey Date: Wed, 4 Mar 2026 18:42:40 -0800 Subject: [PATCH 6/6] fix: resolve TS2349 callable union type error for `use` polyfill Add explicit type annotation to the `use` constant so TypeScript sees a single callable signature instead of trying to unify incompatible overloaded signatures from React.use and the inline fallback. --- src/index/use-swr.ts | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/src/index/use-swr.ts b/src/index/use-swr.ts index e2e77a25c..3e84813f8 100644 --- a/src/index/use-swr.ts +++ b/src/index/use-swr.ts @@ -39,39 +39,37 @@ import type { GlobalState } from '../_internal' -const use = +const use: (usable: PromiseLike | Promise) => T = React.use || - // This extra generic is to avoid TypeScript mixing up the generic and JSX sytax - // and emitting an error. + // Fallback for React versions without `use()`. // We assume that this is only for the `use(thenable)` case, not `use(context)`. // https://github.com/facebook/react/blob/aed00dacfb79d17c53218404c52b1c7aa59c4a89/packages/react-server/src/ReactFizzThenable.js#L45 - (( - thenable: Promise & { + (thenable => { + const t = thenable as Promise & { status?: 'pending' | 'fulfilled' | 'rejected' - value?: T + value?: any reason?: unknown } - ): T => { - switch (thenable.status) { + switch (t.status) { case 'pending': - throw thenable + throw t case 'fulfilled': - return thenable.value as T + return t.value case 'rejected': - throw thenable.reason + throw t.reason default: - thenable.status = 'pending' - thenable.then( + t.status = 'pending' + t.then( v => { - thenable.status = 'fulfilled' - thenable.value = v + t.status = 'fulfilled' + t.value = v }, e => { - thenable.status = 'rejected' - thenable.reason = e + t.status = 'rejected' + t.reason = e } ) - throw thenable + throw t } })