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

feat: add support for React.use() #7988

Open
wants to merge 100 commits into
base: main
Choose a base branch
from

Conversation

KATT
Copy link
Contributor

@KATT KATT commented Aug 30, 2024

Closes #7980


Progress

  • add basic functionality for useQuery()
  • make other tests pass (👋 help wanted!)
  • add test for useInfiniteQuery()
  • do more tests with some example
  • update docs?

Copy link

nx-cloud bot commented Aug 30, 2024

☁️ Nx Cloud Report

CI is running/has finished running commands for commit 60f830b. As they complete they will appear below. Click to see the status, the terminal output, and the build insights.

📂 See all runs for this CI Pipeline Execution


✅ Successfully ran 2 targets

Sent with 💌 from NxCloud.

Copy link

pkg-pr-new bot commented Aug 30, 2024

Open in Stackblitz

More templates

@tanstack/angular-query-devtools-experimental

pnpm add https://pkg.pr.new/@tanstack/angular-query-devtools-experimental@7988

@tanstack/angular-query-experimental

pnpm add https://pkg.pr.new/@tanstack/angular-query-experimental@7988

@tanstack/eslint-plugin-query

pnpm add https://pkg.pr.new/@tanstack/eslint-plugin-query@7988

@tanstack/query-broadcast-client-experimental

pnpm add https://pkg.pr.new/@tanstack/query-broadcast-client-experimental@7988

@tanstack/query-async-storage-persister

pnpm add https://pkg.pr.new/@tanstack/query-async-storage-persister@7988

@tanstack/query-core

pnpm add https://pkg.pr.new/@tanstack/query-core@7988

@tanstack/query-devtools

pnpm add https://pkg.pr.new/@tanstack/query-devtools@7988

@tanstack/query-persist-client-core

pnpm add https://pkg.pr.new/@tanstack/query-persist-client-core@7988

@tanstack/query-sync-storage-persister

pnpm add https://pkg.pr.new/@tanstack/query-sync-storage-persister@7988

@tanstack/react-query

pnpm add https://pkg.pr.new/@tanstack/react-query@7988

@tanstack/react-query-devtools

pnpm add https://pkg.pr.new/@tanstack/react-query-devtools@7988

@tanstack/react-query-next-experimental

pnpm add https://pkg.pr.new/@tanstack/react-query-next-experimental@7988

@tanstack/react-query-persist-client

pnpm add https://pkg.pr.new/@tanstack/react-query-persist-client@7988

@tanstack/solid-query-devtools

pnpm add https://pkg.pr.new/@tanstack/solid-query-devtools@7988

@tanstack/solid-query-persist-client

pnpm add https://pkg.pr.new/@tanstack/solid-query-persist-client@7988

@tanstack/svelte-query

pnpm add https://pkg.pr.new/@tanstack/svelte-query@7988

@tanstack/svelte-query-devtools

pnpm add https://pkg.pr.new/@tanstack/svelte-query-devtools@7988

@tanstack/svelte-query-persist-client

pnpm add https://pkg.pr.new/@tanstack/svelte-query-persist-client@7988

@tanstack/vue-query-devtools

pnpm add https://pkg.pr.new/@tanstack/vue-query-devtools@7988

@tanstack/vue-query

pnpm add https://pkg.pr.new/@tanstack/vue-query@7988

@tanstack/solid-query

pnpm add https://pkg.pr.new/@tanstack/solid-query@7988

commit: 60f830b

@KATT

This comment was marked as outdated.

@KATT

This comment was marked as outdated.

@KATT KATT requested a review from TkDodo September 13, 2024 10:09
@TkDodo
Copy link
Collaborator

TkDodo commented Sep 14, 2024

I added some more tests where I wasn't sure if that will work, but it does 👏 .

I will discuss this PR with @Ephem next week but I think we can ship it then. I might want to rename the feature flag to experimental_autoPrefetching or something like that, because you can theoretically turn it on without ever using the promise, and it will give you auto prefetching during rendering.

@TkDodo
Copy link
Collaborator

TkDodo commented Sep 14, 2024

So I made some more tests for normal useQuery with auto-prefetching on and off, on a simple component:

it('test', async () => {
  function Page() {
    console.log('render', Date.now())
    const { data } = useQuery({
      queryKey: ['test'],
      queryFn: () => {
        console.log('queryFn', Date.now())
        return 'test'
      },
    })

    return (
      <div>
        <h1>{data}</h1>
      </div>
    )
  }

  renderWithClient(queryClient, <Page />)
})

with the flag on, the two logged timestamps consistently differ by 2ms, because we start the fetch immediately.

with the flag off, we start the fetch in the uSES subscription / an effect, which can be later, depending on how intensive rendering is. In this little example, it was usually 4-5ms. However, if we add a bit of cpu intensive work to our component, like rendering 1k divs:

{Array.from({ length: 1000 }, (_, i) => String(i)).map((v) => (
  <div key={v}>{v}</div>
))}

the delay was around 30ms, but obviously still a constant 2ms with the flag on.

This probably doesn't matter if your fetch takes 500ms, but I've just had a discussion with someone who said their fetches were delayed by 1-3 seconds, and it turns out it was something in their component rendering.

So the question is: is this actually a good optimization for normal useQuery users too? Even if they don't suspend, it should be safe to trigger the fetch during render because there are no active observers (as we trigger the fetch conditionally). It's similar to how our hydration writes to the cache during render for seeding it, or how usePrefetchQuery can fetch during render. Thoughts @Ephem ?

Copy link
Collaborator

@Ephem Ephem left a comment

Choose a reason for hiding this comment

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

Fantastic! 👏👏 This is pretty complex, so I'll need to some more time to really wrap my head around things.

So many great tests here! Do you think it would make sense to also add some tests with <HydrationBoundary>, both when we de/rehydrate already awaited data, and when we de/rehydrate a promise?

packages/react-query/src/useBaseQuery.ts Outdated Show resolved Hide resolved
client.getQueryCache().get(defaultedOptions.queryHash)?.promise

promise?.catch(noop).finally(() => {
if (!observer.hasListeners()) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm trying to wrap my head around this all. Why are we checking for !observer.hasListeners() here?

Copy link
Contributor Author

@KATT KATT Sep 17, 2024

Choose a reason for hiding this comment

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

Just to prevent updateResult() to be called twice - if it has listeners, updateResult will be called

(It will work without this check too 🤷‍♂️)

Copy link
Collaborator

Choose a reason for hiding this comment

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

yeah I just checked, if we remove both checks for !observer.hasListeners(), all the tests still pass. if we want to keep these checks, we should add tests where this fails @KATT. I know that calling updateResult multiple times might e.g. re-run inline select functions, but I'm not sure if that's worth optimizing for ...

packages/query-core/src/retryer.ts Outdated Show resolved Hide resolved
packages/query-core/src/queryObserver.ts Show resolved Hide resolved
@Ephem
Copy link
Collaborator

Ephem commented Sep 16, 2024

Thoughts @Ephem ?

I think I need to marinate this a bit. I mean, on one hand it should be "safe" to do. On the other, is it always desirable? I'm thinking about (future) cases like pre-rendering, the <Offscreen> API etc. Probably? But maybe not if a router prerenders all possible routes based on Links on a page? Will devs need some more control (prefetch, but not if this is a prerender).

If there are any issues there, I fully expect usePrefetchQuery to have the same ones, but at least that's more explicit. I think that's mostly an argument for not aiming to make this the "default" behaviour though and a big part of me wants to say this is a nice little optimisation and we can tackle any challenges when they come up. 😄

@TkDodo
Copy link
Collaborator

TkDodo commented Sep 16, 2024

If there are any issues there, I fully expect usePrefetchQuery to have the same ones

yes, this auto_prefetching was pretty much taken from the usePrefetchQuery implementation. I think we are aligned that we could keep it as an experimental flag though, and that you need to turn it on to be able to use(promise), which makes the whole promise feature experimental, too :)

@KATT
Copy link
Contributor Author

KATT commented Sep 17, 2024

Ping me know if you need anything on my end here or have questions that I can help answer, I don't see much tangible feedback as of yet ☺

@TkDodo
Copy link
Collaborator

TkDodo commented Sep 18, 2024

This user-land implementation got me thinking: why don't we also do the promise handling in the react layer rather than the observer 🤔. It looks way simplified. What am I missing 😂?

https://github.com/nucleartux/react-query-read

@TkDodo
Copy link
Collaborator

TkDodo commented Sep 18, 2024

@KATT I added more tests, and one scenario is failing (this one): if we cancel the query while we are suspending, we stay in a forever pending state.

I think the problem is that we just revert to the previous state if we get a query cancelation, and state happens to be status: 'pending' because we didn't have any previous data:

if (isCancelledError(error) && error.revert && this.#revertState) {
return { ...this.#revertState, fetchStatus: 'idle' }
}

I'm not even sure rejecting the promise the right expectation here, as the query isn't in error state. Honestly not sure what should happen here - maybe staying in pending is the right call, idk ...

@KATT
Copy link
Contributor Author

KATT commented Sep 18, 2024

@KATT I added more tests, and one scenario is failing (this one): if we cancel the query while we are suspending, we stay in a forever pending state.

I think the problem is that we just revert to the previous state if we get a query cancelation, and state happens to be status: 'pending' because we didn't have any previous data:

if (isCancelledError(error) && error.revert && this.#revertState) {
return { ...this.#revertState, fetchStatus: 'idle' }
}

I'm not even sure rejecting the promise the right expectation here, as the query isn't in error state. Honestly not sure what should happen here - maybe staying in pending is the right call, idk ...

Good catch. Not sure what the right approach here is. I've never cancelled a query and don't see why I would ever do that 😅, but I understand people will use it and break it.

Hm.

I thiiiiiink rejecting it with an AbortError would make sense to me?

@KATT

This comment was marked as outdated.

@KATT

This comment was marked as outdated.

Comment on lines +613 to +645
const nextThenable = (() => {
const thenable = pendingThenable<TData>()

if (nextResult.status === 'error') {
thenable.reject(nextResult.error)
} else if (nextResult.data !== undefined) {
thenable.resolve(nextResult.data)
} else if (!nextResult.isFetching) {
thenable.reject(cancellationError)
}
return thenable as Thenable<TData>
})()

const prevThenable = this.#currentThenable

switch (prevThenable.status) {
case 'pending':
if (nextThenable.status === 'fulfilled') {
prevThenable.resolve(nextThenable.value)
} else if (nextThenable.status === 'rejected') {
prevThenable.reject(nextThenable.reason)
}
break

case 'fulfilled':
case 'rejected':
if (!isThenableEqual(prevThenable, nextThenable)) {
// Replace the thenable when the results have changed
this.#currentThenable = nextResult.promise = nextThenable
}
break
}
}
Copy link
Contributor Author

@KATT KATT Sep 19, 2024

Choose a reason for hiding this comment

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

cc @TkDodo

This code has updated a bit to support for cancellation - since the query client itself doesn't have the concept of "abort errors" (AFAICT?) we instead:

  1. Eagerly create a new thenable of what the "next" thenable should represent
    • If the previous thenable was pending, we finalize it if the next reason or value
    • If the previous wasn't pending, we do an equal-check to see if we should replace it (since the cancellation error is a constant, the equal-check will work as intended)

@KATT KATT requested a review from TkDodo September 19, 2024 09:30
@@ -593,6 +608,42 @@ export class QueryObserver<
| undefined

const nextResult = this.createResult(this.#currentQuery, this.options)

if (this.options.experimental_prefetchInRender) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

all tests still pass if we move this block until after the early return:

https://github.com/TanStack/react-query/blob/60f830bcfa9b8b223565a6cee9a9bcb79d4cab32/packages/query-core/src/queryObserver.ts#L654-L657

I guess we should do that ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good to me, I don't think the positioning was deliberate; probably just put it first to avoid premature debugging

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants