Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(react-query): ensureSuspenseTimers should ALWAYS set staleTime to 1000 when it is below 1000. #8398

Merged
merged 11 commits into from
Jan 3, 2025
231 changes: 231 additions & 0 deletions packages/react-query/src/__tests__/suspense.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { act, render, waitFor } from '@testing-library/react'
import { Suspense } from 'react'
import { beforeEach, describe, expect, it } from 'vitest'
import { QueryClient, QueryClientProvider, useSuspenseQuery } from '..'
import { queryKey, renderWithClient, sleep } from './utils'

describe('Suspense Timer Tests', () => {
let queryClient: QueryClient

beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
})

it('should enforce minimum staleTime of 1000ms when using suspense with number', async () => {
const shortStaleTime = 10
let fetchCount = 0

function TestComponent() {
const { data } = useSuspenseQuery({
queryKey: ['test'],
queryFn: () => {
fetchCount++
return 'data'
},
staleTime: shortStaleTime,
})
return <div>{data}</div>
}

const wrapper = render(
<QueryClientProvider client={queryClient}>
<Suspense fallback="loading">
<TestComponent />
</Suspense>
</QueryClientProvider>,
)

await waitFor(() => {
expect(wrapper.getByText('data')).toBeInTheDocument()
})

wrapper.rerender(
<QueryClientProvider client={queryClient}>
<Suspense fallback="loading">
<TestComponent />
</Suspense>
</QueryClientProvider>,
)

await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100))
})

expect(fetchCount).toBe(1)
})

it('should enforce minimum staleTime of 1000ms when using suspense with function', async () => {
let fetchCount = 0
const staleTimeFunc = () => 10

function TestComponent() {
const { data } = useSuspenseQuery({
queryKey: ['test-func'],
queryFn: () => {
fetchCount++
return 'data'
},
staleTime: staleTimeFunc,
})
return <div>{data}</div>
}

const wrapper = render(
<QueryClientProvider client={queryClient}>
<Suspense fallback="loading">
<TestComponent />
</Suspense>
</QueryClientProvider>,
)

await waitFor(() => {
expect(wrapper.getByText('data')).toBeInTheDocument()
})

wrapper.rerender(
<QueryClientProvider client={queryClient}>
<Suspense fallback="loading">
<TestComponent />
</Suspense>
</QueryClientProvider>,
)

await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100))
})

expect(fetchCount).toBe(1)
})

it('should respect staleTime when value is greater than 1000ms', async () => {
const key = queryKey()
const staleTime = 2000
let fetchCount = 0

function Component() {
const result = useSuspenseQuery({
queryKey: key,
queryFn: async () => {
fetchCount++
await sleep(10)
return 'data'
},
staleTime,
})
return <div>data: {result.data}</div>
}

const rendered = renderWithClient(
queryClient,
<Suspense fallback="loading">
<Component />
</Suspense>,
)

await waitFor(() => rendered.getByText('data: data'))

// Manually trigger a remount
rendered.rerender(
<Suspense fallback="loading">
<Component />
</Suspense>,
)

// Wait longer than 1000ms but less than staleTime
await act(async () => {
await sleep(1500)
})

// Should not refetch as we're still within the staleTime
expect(fetchCount).toBe(1)
})

it('should enforce minimum staleTime when undefined is provided', async () => {
const key = queryKey()
let fetchCount = 0

function Component() {
const result = useSuspenseQuery({
queryKey: key,
queryFn: async () => {
fetchCount++
await sleep(10)
return 'data'
},
// Explicitly set staleTime as undefined
staleTime: undefined,
})
return <div>data: {result.data}</div>
}

const rendered = renderWithClient(
queryClient,
<Suspense fallback="loading">
<Component />
</Suspense>,
)

await waitFor(() => rendered.getByText('data: data'))

rendered.rerender(
<Suspense fallback="loading">
<Component />
</Suspense>,
)

// Wait less than enforced 1000ms
await act(async () => {
await sleep(500)
})

// Should not refetch as minimum staleTime of 1000ms is enforced
expect(fetchCount).toBe(1)
})

it('should respect staleTime when function returns value greater than 1000ms', async () => {
const key = queryKey()
let fetchCount = 0

function Component() {
const result = useSuspenseQuery({
queryKey: key,
queryFn: async () => {
fetchCount++
await sleep(10)
return 'data'
},
staleTime: () => 3000,
})
return <div>data: {result.data}</div>
}

const rendered = renderWithClient(
queryClient,
<Suspense fallback="loading">
<Component />
</Suspense>,
)

await waitFor(() => rendered.getByText('data: data'))

rendered.rerender(
<Suspense fallback="loading">
<Component />
</Suspense>,
)

// Wait longer than 1000ms but less than returned staleTime
await act(async () => {
await sleep(2000)
})
TkDodo marked this conversation as resolved.
Show resolved Hide resolved

// Should not refetch as we're still within the returned staleTime
expect(fetchCount).toBe(1)
})
})
55 changes: 0 additions & 55 deletions packages/react-query/src/__tests__/useSuspenseQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -327,61 +327,6 @@ describe('useSuspenseQuery', () => {
consoleMock.mockRestore()
})

it('should refetch when re-mounting', async () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why was this test removed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we now enforce a minimum staleTime of 1000ms in Suspense mode for better performance and consistency, the "should refetch when re-mounting" test is no longer valid. This test expected immediate refetching on remount with staleTime: 0, but this behavior would conflict with our implementation that ensures queries remain fresh for at least 1000ms when using Suspense.

The test was designed to verify a behavior that we've intentionally changed to prevent unnecessary refetches and provide a more stable user experience in Suspense mode. Removing this test ensures our test suite accurately reflects our current design decision of enforcing minimum staleTime.

const key = queryKey()
let count = 0

function Component() {
const result = useSuspenseQuery({
queryKey: key,
queryFn: async () => {
await sleep(100)
count++
return count
},
retry: false,
staleTime: 0,
})
return (
<div>
<span>data: {result.data}</span>
<span>fetching: {result.isFetching ? 'true' : 'false'}</span>
</div>
)
}

function Page() {
const [show, setShow] = React.useState(true)
return (
<div>
<button
onClick={() => {
setShow(!show)
}}
>
{show ? 'hide' : 'show'}
</button>
<React.Suspense fallback="Loading...">
{show && <Component />}
</React.Suspense>
</div>
)
}

const rendered = renderWithClient(queryClient, <Page />)

await waitFor(() => rendered.getByText('Loading...'))
await waitFor(() => rendered.getByText('data: 1'))
await waitFor(() => rendered.getByText('fetching: false'))
await waitFor(() => rendered.getByText('hide'))
fireEvent.click(rendered.getByText('hide'))
await waitFor(() => rendered.getByText('show'))
fireEvent.click(rendered.getByText('show'))
await waitFor(() => rendered.getByText('fetching: true'))
await waitFor(() => rendered.getByText('data: 2'))
await waitFor(() => rendered.getByText('fetching: false'))
})

it('should set staleTime when having passed a function', async () => {
const key = queryKey()
let count = 0
Expand Down
14 changes: 9 additions & 5 deletions packages/react-query/src/suspense.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,16 @@ export const defaultThrowOnError = <
export const ensureSuspenseTimers = (
defaultedOptions: DefaultedQueryObserverOptions<any, any, any, any, any>,
) => {
const originalStaleTime = defaultedOptions.staleTime

if (defaultedOptions.suspense) {
// Always set stale time when using suspense to prevent
// fetching again when directly mounting after suspending
if (defaultedOptions.staleTime === undefined) {
defaultedOptions.staleTime = 1000
}
// Handle staleTime to ensure minimum 1000ms in Suspense mode
// This prevents unnecessary refetching when components remount after suspending
defaultedOptions.staleTime =
typeof originalStaleTime === 'function'
? (...args) => Math.max(originalStaleTime(...args), 1000)
: Math.max(originalStaleTime ?? 1000, 1000)

if (typeof defaultedOptions.gcTime === 'number') {
defaultedOptions.gcTime = Math.max(defaultedOptions.gcTime, 1000)
}
Expand Down
Loading