Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions src/core/queriesObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,20 +64,24 @@ export class QueriesObserver extends Subscribable<QueriesObserverListener> {
}

getOptimisticResult(queries: QueryObserverOptions[]): QueryObserverResult[] {
return queries.map(options => {
return queries.map((options, index) => {
const defaultedOptions = this.client.defaultQueryObserverOptions(options)
return this.getObserver(defaultedOptions).getOptimisticResult(
return this.getObserver(defaultedOptions, index).getOptimisticResult(
defaultedOptions
)
})
}

private getObserver(options: QueryObserverOptions): QueryObserver {
private getObserver(
options: QueryObserverOptions,
index: number
): QueryObserver {
const defaultedOptions = this.client.defaultQueryObserverOptions(options)
return (
this.observersMap[defaultedOptions.queryHash!] ||
new QueryObserver(this.client, defaultedOptions)
)
let currentObserver = this.observersMap[defaultedOptions.queryHash!]
if (!currentObserver && defaultedOptions.keepPreviousData) {
currentObserver = this.observers[index]
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

please let me know if this is a bad idea. Generally, I was thinking that if we have keepPreviousData on, we would like to keep the data of the query that lies at the index. This likely won't work if the lengths are completely different, and we might "keep" wrong data then. But by just going by the queryHash like before, we're never able to make keepPreviousData work because we'll get a new Observer every time as the hash changes ...

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

One way to limit this would be to only do the index access if the length hasn’t changed. That would make keepPreviousData only work on equal lengths though and we should likely document that

}
return currentObserver ?? new QueryObserver(this.client, defaultedOptions)
}

private updateObservers(notifyOptions?: NotifyOptions): void {
Expand All @@ -96,9 +100,9 @@ export class QueriesObserver extends Subscribable<QueriesObserverListener> {
options
)
const queryHash = defaultedOptions.queryHash!
const observer = this.getObserver(defaultedOptions)
const observer = this.getObserver(defaultedOptions, i)

if (prevObserversMap[queryHash]) {
if (prevObserversMap[queryHash] || defaultedOptions.keepPreviousData) {
observer.setOptions(defaultedOptions, notifyOptions)
}

Expand Down
195 changes: 194 additions & 1 deletion src/react/tests/useQueries.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { waitFor } from '@testing-library/react'
import React from 'react'

import { queryKey, renderWithClient, sleep } from './utils'
import { queryKey, renderWithClient, setActTimeout, sleep } from './utils'
import { useQueries, QueryClient, UseQueryResult, QueryCache } from '../..'

describe('useQueries', () => {
Expand Down Expand Up @@ -42,4 +43,196 @@ describe('useQueries', () => {
expect(results[1]).toMatchObject([{ data: 1 }, { data: undefined }])
expect(results[2]).toMatchObject([{ data: 1 }, { data: 2 }])
})

it('should keep previous data if amount of queries is the same', async () => {
const key1 = queryKey()
const key2 = queryKey()
const states: UseQueryResult[][] = []

function Page() {
const [count, setCount] = React.useState(1)
const result = useQueries([
{
queryKey: [key1, count],
keepPreviousData: true,
queryFn: async () => {
await sleep(5)
return count * 2
},
},
{
queryKey: [key2, count],
keepPreviousData: true,
queryFn: async () => {
await sleep(10)
return count * 5
},
},
])
states.push(result)

React.useEffect(() => {
setActTimeout(() => {
setCount(prev => prev + 1)
}, 20)
}, [])

return null
}

renderWithClient(queryClient, <Page />)

await waitFor(() => expect(states.length).toBe(7))

expect(states[0]).toMatchObject([
{
status: 'loading',
data: undefined,
isPreviousData: false,
isFetching: true,
},
{
status: 'loading',
data: undefined,
isPreviousData: false,
isFetching: true,
},
])
expect(states[1]).toMatchObject([
{ status: 'success', data: 2, isPreviousData: false, isFetching: false },
{
status: 'loading',
data: undefined,
isPreviousData: false,
isFetching: true,
},
])
expect(states[2]).toMatchObject([
{ status: 'success', data: 2, isPreviousData: false, isFetching: false },
{ status: 'success', data: 5, isPreviousData: false, isFetching: false },
])
expect(states[3]).toMatchObject([
{ status: 'success', data: 2, isPreviousData: true, isFetching: true },
{ status: 'success', data: 5, isPreviousData: true, isFetching: true },
])
expect(states[4]).toMatchObject([
{ status: 'success', data: 2, isPreviousData: true, isFetching: true },
{ status: 'success', data: 5, isPreviousData: true, isFetching: true },
])
Comment on lines +114 to +121
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

not sure why I'm getting two re-renders here with the same results, I think it's because each of the two queries transitions to isPreviousData separately ... ?

expect(states[5]).toMatchObject([
{ status: 'success', data: 4, isPreviousData: false, isFetching: false },
{ status: 'success', data: 5, isPreviousData: true, isFetching: true },
])
expect(states[6]).toMatchObject([
{ status: 'success', data: 4, isPreviousData: false, isFetching: false },
{ status: 'success', data: 10, isPreviousData: false, isFetching: false },
])
})

Choose a reason for hiding this comment

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

I created two more test cases, tell me what you think:

  it('should keep previous data if amount of queries is the same when has 3 elements', async () => {
    const key1 = queryKey()
    const key2 = queryKey()
    const key3 = queryKey()
    const states: UseQueryResult[][] = []

    function Page() {
      const [count, setCount] = React.useState(1)

      const result = useQueries([
        {
          queryKey: [key1, count],
          keepPreviousData: true,
          queryFn: async () => {
            await sleep(5)
            return 1 * count
          },
        },
        {
          queryKey: [key2, count],
          keepPreviousData: true,
          queryFn: async () => {
            await sleep(10)
            return 2 * count
          },
        },
        {
          queryKey: [key3, count],
          keepPreviousData: true,
          queryFn: async () => {
            await sleep(15)
            return 3 * count
          },
        },
      ])
      states.push(result)

      React.useEffect(() => {
        setActTimeout(() => {
          setCount(prev => prev + 1)
        }, 20)
      }, [])

      return null
    }

    renderWithClient(queryClient, <Page />)

    await waitFor(() => expect(states.length).toBe(9))

    expect(states[0]).toMatchObject([
      {
        status: 'loading',
        data: undefined,
        isPreviousData: false,
        isFetching: true,
      },
      {
        status: 'loading',
        data: undefined,
        isPreviousData: false,
        isFetching: true,
      },
      {
        status: 'loading',
        data: undefined,
        isPreviousData: false,
        isFetching: true,
      },
    ])

    expect(states[1]).toMatchObject([
      { status: 'success', data: 1, isPreviousData: false, isFetching: false },
      {
        status: 'loading',
        data: undefined,
        isPreviousData: false,
        isFetching: true,
      },
      {
        status: 'loading',
        data: undefined,
        isPreviousData: false,
        isFetching: true,
      },
    ])

    expect(states[2]).toMatchObject([
      { status: 'success', data: 1, isPreviousData: false, isFetching: false },
      { status: 'success', data: 2, isPreviousData: false, isFetching: false },
      {
        status: 'loading',
        data: undefined,
        isPreviousData: false,
        isFetching: true,
      },
    ])

    expect(states[3]).toMatchObject([
      { status: 'success', data: 1, isPreviousData: false, isFetching: false },
      { status: 'success', data: 2, isPreviousData: false, isFetching: false },
      { status: 'success', data: 3, isPreviousData: false, isFetching: false },
    ])

    expect(states[4]).toMatchObject([
      { status: 'success', data: 1, isPreviousData: true, isFetching: true },
      { status: 'success', data: 2, isPreviousData: true, isFetching: true },
      { status: 'success', data: 3, isPreviousData: true, isFetching: true },
    ])

    expect(states[5]).toMatchObject([
      { status: 'success', data: 1, isPreviousData: true, isFetching: true },
      { status: 'success', data: 2, isPreviousData: true, isFetching: true },
      { status: 'success', data: 3, isPreviousData: true, isFetching: true },
    ])

    expect(states[6]).toMatchObject([
      { status: 'success', data: 2, isPreviousData: false, isFetching: false },
      { status: 'success', data: 2, isPreviousData: true, isFetching: true },
      { status: 'success', data: 3, isPreviousData: true, isFetching: true },
    ])

    expect(states[7]).toMatchObject([
      { status: 'success', data: 2, isPreviousData: false, isFetching: false },
      { status: 'success', data: 4, isPreviousData: false, isFetching: false },
      { status: 'success', data: 3, isPreviousData: true, isFetching: true },
    ])

    expect(states[8]).toMatchObject([
      { status: 'success', data: 2, isPreviousData: false, isFetching: false },
      { status: 'success', data: 4, isPreviousData: false, isFetching: false },
      { status: 'success', data: 6, isPreviousData: false, isFetching: false },
    ])
  })

  it('should not keep previous data', async () => {
    const key1 = queryKey()
    const key2 = queryKey()
    const key3 = queryKey()
    const states: UseQueryResult[][] = []

    function Page() {
      const [count, setCount] = React.useState(1)

      const result = useQueries([
        {
          queryKey: [key1, count],
          queryFn: async () => {
            await sleep(5)
            return 1 * count
          },
        },
        {
          queryKey: [key2, count],
          queryFn: async () => {
            await sleep(10)
            return 2 * count
          },
        },
        {
          queryKey: [key3, count],
          queryFn: async () => {
            await sleep(15)
            return 3 * count
          },
        },
      ])
      states.push(result)

      React.useEffect(() => {
        setActTimeout(() => {
          setCount(prev => prev + 1)
        }, 20)
      }, [])

      return null
    }

    renderWithClient(queryClient, <Page />)

    await waitFor(() => expect(states.length).toBe(9))

    expect(states[0]).toMatchObject([
      {
        status: 'loading',
        data: undefined,
        isPreviousData: false,
        isFetching: true,
      },
      {
        status: 'loading',
        data: undefined,
        isPreviousData: false,
        isFetching: true,
      },
      {
        status: 'loading',
        data: undefined,
        isPreviousData: false,
        isFetching: true,
      },
    ])

    expect(states[1]).toMatchObject([
      { status: 'success', data: 1, isPreviousData: false, isFetching: false },
      {
        status: 'loading',
        data: undefined,
        isPreviousData: false,
        isFetching: true,
      },
      {
        status: 'loading',
        data: undefined,
        isPreviousData: false,
        isFetching: true,
      },
    ])

    expect(states[2]).toMatchObject([
      { status: 'success', data: 1, isPreviousData: false, isFetching: false },
      { status: 'success', data: 2, isPreviousData: false, isFetching: false },
      {
        status: 'loading',
        data: undefined,
        isPreviousData: false,
        isFetching: true,
      },
    ])

    expect(states[3]).toMatchObject([
      { status: 'success', data: 1, isPreviousData: false, isFetching: false },
      { status: 'success', data: 2, isPreviousData: false, isFetching: false },
      { status: 'success', data: 3, isPreviousData: false, isFetching: false },
    ])

    expect(states[4]).toMatchObject([
      {
        status: 'loading',
        data: undefined,
        isPreviousData: false,
        isFetching: true,
      },
      {
        status: 'loading',
        data: undefined,
        isPreviousData: false,
        isFetching: true,
      },
      {
        status: 'loading',
        data: undefined,
        isPreviousData: false,
        isFetching: true,
      },
    ])

    expect(states[5]).toMatchObject([
      {
        status: 'loading',
        data: undefined,
        isPreviousData: false,
        isFetching: true,
      },
      {
        status: 'loading',
        data: undefined,
        isPreviousData: false,
        isFetching: true,
      },
      {
        status: 'loading',
        data: undefined,
        isPreviousData: false,
        isFetching: true,
      },
    ])

    expect(states[6]).toMatchObject([
      { status: 'success', data: 2, isPreviousData: false, isFetching: false },
      {
        status: 'loading',
        data: undefined,
        isPreviousData: false,
        isFetching: true,
      },
      {
        status: 'loading',
        data: undefined,
        isPreviousData: false,
        isFetching: true,
      },
    ])

    expect(states[7]).toMatchObject([
      { status: 'success', data: 2, isPreviousData: false, isFetching: false },
      { status: 'success', data: 4, isPreviousData: false, isFetching: false },
      {
        status: 'loading',
        data: undefined,
        isPreviousData: false,
        isFetching: true,
      },
    ])

    expect(states[8]).toMatchObject([
      { status: 'success', data: 2, isPreviousData: false, isFetching: false },
      { status: 'success', data: 4, isPreviousData: false, isFetching: false },
      { status: 'success', data: 6, isPreviousData: false, isFetching: false },
    ])
  })

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

when has 3 elements

thanks, but your test has 3 static elements. My claim is that it might not work with dynamic elements, so 2 elements on the first render, followed by 3 elements on the next render. I added a test for this here: 62ce4ff and it seems to work okay.

I'm still positive that if you remove one element in the middle of the list, you will keep the previous data from this item. not sure if this will be perceived as intended behaviour, which is why I've suggested a different solution here: #2340 (comment), which would limit keepPreviousData to arrays of the same length...


it('should keep previous data for variable amounts of useQueries', async () => {
const key = queryKey()
const states: UseQueryResult[][] = []

function Page() {
const [count, setCount] = React.useState(2)
const result = useQueries(
Array.from({ length: count }, (_, i) => ({
queryKey: [key, count, i + 1],
keepPreviousData: true,
queryFn: async () => {
await sleep(5 * (i + 1))
return (i + 1) * count * 2
},
}))
)

states.push(result)

React.useEffect(() => {
setActTimeout(() => {
setCount(prev => prev + 1)
}, 20)
}, [])

return null
}

renderWithClient(queryClient, <Page />)

await waitFor(() => expect(states.length).toBe(8))

expect(states[0]).toMatchObject([
{
status: 'loading',
data: undefined,
isPreviousData: false,
isFetching: true,
},
{
status: 'loading',
data: undefined,
isPreviousData: false,
isFetching: true,
},
])
expect(states[1]).toMatchObject([
{ status: 'success', data: 4, isPreviousData: false, isFetching: false },
{
status: 'loading',
data: undefined,
isPreviousData: false,
isFetching: true,
},
])
expect(states[2]).toMatchObject([
{ status: 'success', data: 4, isPreviousData: false, isFetching: false },
{ status: 'success', data: 8, isPreviousData: false, isFetching: false },
])

expect(states[3]).toMatchObject([
{ status: 'success', data: 4, isPreviousData: true, isFetching: true },
{ status: 'success', data: 8, isPreviousData: true, isFetching: true },
{
status: 'loading',
data: undefined,
isPreviousData: false,
isFetching: true,
},
])
expect(states[4]).toMatchObject([
{ status: 'success', data: 4, isPreviousData: true, isFetching: true },
{ status: 'success', data: 8, isPreviousData: true, isFetching: true },
{
status: 'loading',
data: undefined,
isPreviousData: false,
isFetching: true,
},
])
expect(states[5]).toMatchObject([
{ status: 'success', data: 6, isPreviousData: false, isFetching: false },
{ status: 'success', data: 8, isPreviousData: true, isFetching: true },
{
status: 'loading',
data: undefined,
isPreviousData: false,
isFetching: true,
},
])
expect(states[6]).toMatchObject([
{ status: 'success', data: 6, isPreviousData: false, isFetching: false },
{ status: 'success', data: 12, isPreviousData: false, isFetching: false },
{
status: 'loading',
data: undefined,
isPreviousData: false,
isFetching: true,
},
])
expect(states[7]).toMatchObject([
{ status: 'success', data: 6, isPreviousData: false, isFetching: false },
{ status: 'success', data: 12, isPreviousData: false, isFetching: false },
{ status: 'success', data: 18, isPreviousData: false, isFetching: false },
])
})
})