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
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
100 commits
Select commit Hold shift + click to select a range
8cbd6a0
let’s do it again
KATT Sep 1, 2024
739d023
fix test group
KATT Sep 1, 2024
7fe1d33
maybe
KATT Sep 1, 2024
757aeab
mkay
KATT Sep 1, 2024
7c88325
cool
KATT Sep 1, 2024
c2a2cb5
rm console.logs
KATT Sep 1, 2024
d290fca
mkay
KATT Sep 1, 2024
da88d68
mkay
KATT Sep 1, 2024
5e2767b
fix(vue-query): invalidate queries immediately after calling `invalid…
Mini-ghost Sep 1, 2024
5ec8342
release: v5.53.2
tannerlinsley Sep 1, 2024
1cae4fe
docs(vue-query): update SSR guide for nuxt2 (#8001)
DamianOsipiuk Sep 1, 2024
dc2fad6
thenable
KATT Sep 2, 2024
c4a1f9e
mkay
KATT Sep 2, 2024
4530246
Merge remote-tracking branch 'origin/main' into discussions-7980-prom…
KATT Sep 2, 2024
7faa2fa
Update packages/react-query/src/__tests__/useQuery.test.tsx
KATT Sep 2, 2024
e6583df
mkay
KATT Sep 2, 2024
4270093
mkay
KATT Sep 2, 2024
9cdc99d
faster and more consistent
KATT Sep 2, 2024
e704e3d
mkay
KATT Sep 2, 2024
e5cf4ee
mkay
KATT Sep 2, 2024
a3ea13e
mkay
KATT Sep 2, 2024
306bb1f
mkay
KATT Sep 2, 2024
ef3926a
mkay
KATT Sep 2, 2024
8993e37
fix unhandled rejections
KATT Sep 2, 2024
df946eb
more
KATT Sep 2, 2024
091a1b4
more
KATT Sep 2, 2024
05fc6c6
mkay
KATT Sep 2, 2024
2ecd43f
fix more
KATT Sep 2, 2024
b6b2d45
fixy
KATT Sep 2, 2024
34bb74e
cool
KATT Sep 2, 2024
739b66a
Merge branch 'main' into discussions-7980-promises
KATT Sep 2, 2024
36d367a
Update packages/react-query/package.json
KATT Sep 2, 2024
d1184ba
fix: track data property if `promise` is tracked
TkDodo Sep 2, 2024
dc85450
Revert "fix: track data property if `promise` is tracked"
KATT Sep 2, 2024
0d32020
add test case that @tkdodo was concerned about
KATT Sep 2, 2024
26d9067
tweak
KATT Sep 2, 2024
e1c2394
mkay
KATT Sep 2, 2024
6435af3
add `useInfiniteQuery()` test
KATT Sep 2, 2024
d4a2822
consistent testing
KATT Sep 2, 2024
88db1b9
better test
KATT Sep 2, 2024
cb0fa35
rm comment
KATT Sep 2, 2024
608dc61
test resetting errror boundary
KATT Sep 2, 2024
608e476
better test
KATT Sep 2, 2024
e419146
cool
KATT Sep 2, 2024
c7781ee
cool
KATT Sep 2, 2024
8491a96
more test
KATT Sep 3, 2024
9d0d7fa
Merge branch 'main' into discussions-7980-promises
KATT Sep 3, 2024
97aec78
mv cleanup
KATT Sep 3, 2024
b09263c
mkay
KATT Sep 3, 2024
f7b0359
some more things
KATT Sep 3, 2024
6356ab4
add fixme
KATT Sep 3, 2024
9d3b8bc
fix types
KATT Sep 3, 2024
d9cf744
wat
KATT Sep 3, 2024
001377d
Merge remote-tracking branch 'origin/main' into discussions-7980-prom…
KATT Sep 6, 2024
17b4743
fixes
KATT Sep 6, 2024
55b6c19
revert
KATT Sep 6, 2024
4bfa39d
fix
KATT Sep 6, 2024
f27f30d
colocating doesn’t workkk
KATT Sep 6, 2024
79ec573
mkay
KATT Sep 6, 2024
05de0fb
mkay
KATT Sep 6, 2024
25a5957
might work
KATT Sep 6, 2024
543f337
more test
KATT Sep 6, 2024
9e2147d
cool
KATT Sep 6, 2024
648acc2
i don’t know hwat i’m doing
KATT Sep 6, 2024
23ae156
mocky
KATT Sep 6, 2024
8967bee
lint
KATT Sep 6, 2024
7c7ab77
space
KATT Sep 6, 2024
1b24dcb
rm log
KATT Sep 6, 2024
7ad4230
setIsServer
KATT Sep 6, 2024
f1248f2
mkay
KATT Sep 6, 2024
8461f2a
ffs
KATT Sep 6, 2024
0275b3d
remove unnecessary stufffff
KATT Sep 6, 2024
8a64488
tweak more
KATT Sep 6, 2024
27996a4
just naming and comments
KATT Sep 6, 2024
011e373
tweak
KATT Sep 6, 2024
e217605
fix: use fetchOptimistic util instead of observer.fetchOptimistic
TkDodo Sep 9, 2024
be6c1f0
refactor: make sure to only trigger fetching during render if we real…
TkDodo Sep 9, 2024
6f8b777
Merge branch 'main' into discussions-7980-promises
TkDodo Sep 9, 2024
9b4e38b
fix: move the `isNewCacheEntry` check before observer creation
TkDodo Sep 9, 2024
b8cb318
chore: avoid rect key warnings
TkDodo Sep 9, 2024
3b33b03
fix: add an `updateResult` for all observers to finalize currentThenable
TkDodo Sep 9, 2024
86b8b03
chore: logs during suspense errors
TkDodo Sep 9, 2024
9df70ac
fix: empty catch
TkDodo Sep 9, 2024
ac8754a
feature flag
KATT Sep 13, 2024
1b7f5d4
Merge branch 'main' into discussions-7980-promises
KATT Sep 13, 2024
1e4f719
add comment
KATT Sep 13, 2024
4ee2728
simplify
KATT Sep 13, 2024
2fa83df
omit from suspense
KATT Sep 13, 2024
9f281f4
feat flag
KATT Sep 13, 2024
540ddd5
more tests
TkDodo Sep 14, 2024
b573cc6
test: scope experimental_promise to useQuery().promise tests
TkDodo Sep 14, 2024
c617a08
refactor: rename to experimental_prefetchInRender
TkDodo Sep 18, 2024
ae93100
test: more tests
TkDodo Sep 18, 2024
138b126
test: more cancelation
TkDodo Sep 18, 2024
d3bd66c
fix cancellation
KATT Sep 18, 2024
7941921
make it work
KATT Sep 19, 2024
a47709a
tweak comment
KATT Sep 19, 2024
0df1dc3
Update packages/react-query/src/useBaseQuery.ts
KATT Sep 19, 2024
2cbe35a
simplify code a bit
KATT Sep 19, 2024
60f830b
Update packages/query-core/src/queryObserver.ts
KATT Sep 19, 2024
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
102 changes: 101 additions & 1 deletion packages/query-core/src/__tests__/queryObserver.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ describe('queryObserver', () => {
let queryClient: QueryClient

beforeEach(() => {
queryClient = createQueryClient()
queryClient = createQueryClient({
defaultOptions: {
queries: {
experimental_prefetchInRender: true,
},
},
})
queryClient.mount()
})

Expand Down Expand Up @@ -1133,4 +1139,98 @@ describe('queryObserver', () => {

unsubscribe()
})

test('should return a promise that resolves when data is present', async () => {
const results: Array<QueryObserverResult> = []
const key = queryKey()
let count = 0
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: () => {
if (++count > 9) {
return Promise.resolve('data')
}
throw new Error('rejected')
},
retry: 10,
retryDelay: 0,
})
const unsubscribe = observer.subscribe(() => {
results.push(observer.getCurrentResult())
})

await waitFor(() => {
expect(results.at(-1)?.data).toBe('data')
})

const numberOfUniquePromises = new Set(
results.map((result) => result.promise),
).size
expect(numberOfUniquePromises).toBe(1)

unsubscribe()
})

test('should return a new promise after recovering from an error', async () => {
const results: Array<QueryObserverResult> = []
const key = queryKey()

let succeeds = false
let idx = 0
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: () => {
if (succeeds) {
return Promise.resolve('data')
}
throw new Error(`rejected #${++idx}`)
},
retry: 5,
retryDelay: 0,
})
const unsubscribe = observer.subscribe(() => {
results.push(observer.getCurrentResult())
})

await waitFor(() => {
expect(results.at(-1)?.status).toBe('error')
})

expect(
results.every((result) => result.promise === results[0]!.promise),
).toBe(true)

{
// fail again
const lengthBefore = results.length
observer.refetch()
await waitFor(() => {
expect(results.length).toBeGreaterThan(lengthBefore)
expect(results.at(-1)?.status).toBe('error')
})

const numberOfUniquePromises = new Set(
results.map((result) => result.promise),
).size

expect(numberOfUniquePromises).toBe(2)
}
{
// succeed
succeeds = true
observer.refetch()

await waitFor(() => {
results.at(-1)?.status === 'success'
})

const numberOfUniquePromises = new Set(
results.map((result) => result.promise),
).size

expect(numberOfUniquePromises).toBe(3)
}

unsubscribe()
})
})
60 changes: 56 additions & 4 deletions packages/query-core/src/queryObserver.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import { focusManager } from './focusManager'
import { notifyManager } from './notifyManager'
import { fetchState } from './query'
import { Subscribable } from './subscribable'
import { isThenableEqual, pendingThenable } from './thenable'
import {
isServer,
isValidTimeout,
Expand All @@ -8,12 +13,9 @@ import {
shallowEqualObjects,
timeUntilStale,
} from './utils'
import { notifyManager } from './notifyManager'
import { focusManager } from './focusManager'
import { Subscribable } from './subscribable'
import { fetchState } from './query'
import type { FetchOptions, Query, QueryState } from './query'
import type { QueryClient } from './queryClient'
import type { Thenable } from './thenable'
import type {
DefaultError,
DefaultedQueryObserverOptions,
Expand All @@ -38,6 +40,10 @@ interface ObserverFetchOptions extends FetchOptions {
throwOnError?: boolean
}

const cancellationError = new Error(
'No data or error and the query is not fetching - query was most likely cancelled',
)

export class QueryObserver<
TQueryFnData = unknown,
TError = DefaultError,
Expand All @@ -57,6 +63,7 @@ export class QueryObserver<
TQueryData,
TQueryKey
>
#currentThenable: Thenable<TData>
#selectError: TError | null
#selectFn?: (data: TQueryData) => TData
#selectResult?: TData
Expand All @@ -82,6 +89,13 @@ export class QueryObserver<

this.#client = client
this.#selectError = null
this.#currentThenable = pendingThenable()
TkDodo marked this conversation as resolved.
Show resolved Hide resolved
if (!this.options.experimental_prefetchInRender) {
this.#currentThenable.reject(
new Error('experimental_prefetchInRender feature flag is not enabled'),
)
}

this.bindMethods()
this.setOptions(options)
}
Expand Down Expand Up @@ -582,6 +596,7 @@ export class QueryObserver<
isRefetchError: isError && hasData,
isStale: isStale(query, options),
refetch: this.refetch,
promise: this.#currentThenable,
}

return result as QueryObserverResult<TData, TError>
Expand All @@ -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

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
}
}
Comment on lines +613 to +645
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)


this.#currentResultState = this.#currentQuery.state
this.#currentResultOptions = this.options

Expand Down Expand Up @@ -639,6 +690,7 @@ export class QueryObserver<
return Object.keys(this.#currentResult).some((key) => {
const typedKey = key as keyof QueryObserverResult
const changed = this.#currentResult[typedKey] !== prevResult[typedKey]

return changed && includedProps.has(typedKey)
})
}
Expand Down
18 changes: 7 additions & 11 deletions packages/query-core/src/retryer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { focusManager } from './focusManager'
import { onlineManager } from './onlineManager'
import { pendingThenable } from './thenable'
import { isServer, sleep } from './utils'
import type { CancelOptions, DefaultError, NetworkMode } from './types'

Expand Down Expand Up @@ -75,13 +76,8 @@ export function createRetryer<TData = unknown, TError = DefaultError>(
let failureCount = 0
let isResolved = false
let continueFn: ((value?: unknown) => void) | undefined
let promiseResolve: (data: TData) => void
let promiseReject: (error: TError) => void

const promise = new Promise<TData>((outerResolve, outerReject) => {
promiseResolve = outerResolve
promiseReject = outerReject
})
const thenable = pendingThenable<TData>()

const cancel = (cancelOptions?: CancelOptions): void => {
if (!isResolved) {
Expand Down Expand Up @@ -110,7 +106,7 @@ export function createRetryer<TData = unknown, TError = DefaultError>(
isResolved = true
config.onSuccess?.(value)
continueFn?.()
promiseResolve(value)
thenable.resolve(value)
}
}

Expand All @@ -119,7 +115,7 @@ export function createRetryer<TData = unknown, TError = DefaultError>(
isResolved = true
config.onError?.(value)
continueFn?.()
promiseReject(value)
thenable.reject(value)
}
}

Expand Down Expand Up @@ -207,11 +203,11 @@ export function createRetryer<TData = unknown, TError = DefaultError>(
}

return {
promise,
promise: thenable,
cancel,
continue: () => {
continueFn?.()
return promise
return thenable
},
cancelRetry,
continueRetry,
Expand All @@ -223,7 +219,7 @@ export function createRetryer<TData = unknown, TError = DefaultError>(
} else {
pause().then(run)
}
return promise
return thenable
},
}
}
92 changes: 92 additions & 0 deletions packages/query-core/src/thenable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Thenable types which matches React's types for promises
*
* React seemingly uses `.status`, `.value` and `.reason` properties on a promises to optimistically unwrap data from promises
*
* @see https://github.com/facebook/react/blob/main/packages/shared/ReactTypes.js#L112-L138
* @see https://github.com/facebook/react/blob/4f604941569d2e8947ce1460a0b2997e835f37b9/packages/react-debug-tools/src/ReactDebugHooks.js#L224-L227
*/

interface Fulfilled<T> {
status: 'fulfilled'
value: T

reason?: never
}
interface Rejected {
status: 'rejected'
reason: unknown

value?: never
}
interface Pending<T> {
status: 'pending'

reason?: never
value?: never
/**
* Resolve the promise with a value.
* Will remove the `resolve` and `reject` properties from the promise.
*/
resolve: (value: T) => void
/**
* Reject the promise with a reason.
* Will remove the `resolve` and `reject` properties from the promise.
*/
reject: (reason: unknown) => void
}

export type FulfilledThenable<T> = Promise<T> & Fulfilled<T>
export type RejectedThenable<T> = Promise<T> & Rejected
export type PendingThenable<T> = Promise<T> & Pending<T>

export type Thenable<T> =
| FulfilledThenable<T>
| RejectedThenable<T>
| PendingThenable<T>

export function pendingThenable<T>(): PendingThenable<T> {
let resolve: Pending<T>['resolve']
let reject: Pending<T>['reject']
// this could use `Promise.withResolvers()` in the future
const thenable = new Promise((_resolve, _reject) => {
resolve = _resolve
reject = _reject
}) as PendingThenable<T>

thenable.status = 'pending'
thenable.catch(() => {
// prevent unhandled rejection errors
})

function finalize(data: Fulfilled<T> | Rejected) {
Object.assign(thenable, data)

// clear pending props props to avoid calling them twice
delete (thenable as Partial<PendingThenable<T>>).resolve
delete (thenable as Partial<PendingThenable<T>>).reject
}

thenable.resolve = (value) => {
finalize({
status: 'fulfilled',
value,
})

resolve(value)
}
thenable.reject = (reason) => {
finalize({
status: 'rejected',
reason,
})

reject(reason)
}

return thenable
}

export function isThenableEqual<T>(a: Thenable<T>, b: Thenable<T>): boolean {
return a.status === b.status && a.value === b.value && a.reason === b.reason
}
Loading
Loading