-
Notifications
You must be signed in to change notification settings - Fork 2.7k
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
useBackgroundQuery
: remove promiseCache
, work around race condition
#11366
useBackgroundQuery
: remove promiseCache
, work around race condition
#11366
Conversation
|
79577b5
to
b8c51af
Compare
size-limit report 📦
|
useSuspenseQuery
: remove promiseCache
, work around race conditionuseBackgroundQuery
: remove promiseCache
, work around race condition
src/react/cache/QueryReference.ts
Outdated
this.reject = reject; | ||
}); | ||
this.promise = withSequence( | ||
wrapPromiseWithState( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need to wrap them here, so secondIfNewerFulfilledOrFirst
can know if the promise is already fulfilled.
That has a neat side effect:
Wrapping the promises with state here will save React a cycle of "start rendering" -> "suspend to wait for thenable because we don't know that it's already fulfilled" -> "resume rendering".
Async work becomes sync.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ya good call!
src/react/hooks/useReadQuery.ts
Outdated
); | ||
promise = secondIfNewerFulfilledOrFirst(promise, internalQueryRef.promise); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are situations where the parent never re-rendered, and the above useSyncExternalStore
listener was only active for the foreground render, not the background render.
In that case, the promise
of this lane's QueryRef might still point towards an outdated promise. If the internalQueryRef.promise
is fulfilled, and newer than promise
, we want to priorize that one.
src/utilities/promises/decoration.ts
Outdated
export type PromiseWithState<TValue> = | ||
| PendingPromise<TValue> | ||
| FulfilledPromise<TValue> | ||
| RejectedPromise<TValue>; | ||
|
||
export function withSequence<T extends Promise<any>>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not happy about the new export, maybe we could also just move the sequence stuff into the other helpers here?
Generally, there's probably a good opportunity to make things nicer here now :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Meh not the end of the world. I think we should probably mark this as internal though:
/* @internal */
Thoughts?
we only care about identity, not about contents
…-useBackgroundQuery-promiseCache
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm mostly ok with the changes here, but the one that concerns me the most is the change in the suspense count for the test that is meant to re-suspend multiple times on refetch. Would you mind taking a look at that and providing an explanation as to why that change is necessary? Perhaps I'm missing something obvious.
if (unwrapQueryRef(wrapped.current)[0] !== queryRef) { | ||
setWrappedQueryRef({ current: wrapQueryRef(queryRef, queryRef.promise) }); | ||
} | ||
let wrappedQueryRef = wrapped.current; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let wrappedQueryRef = wrapped.current; | |
const wrappedQueryRef = wrapped.current; |
Sorry 😅.
Better yet, since you're not reading this value anywhere but the return from this hook, perhaps you can just use wrapped.current
there?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, the differentiation between wrapped
and wrappedQueryRef
shouldn't be necessary at all anymore - removing it :)
const [wrapped, setWrappedQueryRef] = React.useState({ | ||
current: wrapQueryRef(queryRef, queryRef.promise), | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm curious about the decision to create this as an object with a current
property. It doesn't look like you do any mutable update to the current
property and exclusively rely on setWrappedQueryRef
.
Since wrapQueryRef
will return a new object each time, that should be sufficient enough to ensure React won't bail on the render when comparing old and new values. Could you consider simplifying this to just set the wrapped query ref directly?
const [wrapped, setWrappedQueryRef] = React.useState({ | |
current: wrapQueryRef(queryRef, queryRef.promise), | |
}); | |
const [wrappedQueryRef, setWrappedQueryRef] = React.useState(() => | |
wrapQueryRef(queryRef, queryRef.promise) | |
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this might be a relic from the time before we had updateWrappedQueryRef
and had to create a new one everytime. Removed it and all tests are still green.
src/utilities/promises/decoration.ts
Outdated
|
||
// prevent potential edge cases leaking unhandled error rejections | ||
promise.catch(() => {}); | ||
|
||
promise.status = "rejected"; | ||
promise.reason = reason; | ||
|
||
return promise; | ||
return withSequence(promise); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd prefer to keep these methods free of the withSequence
decoration and instead decorate promises that need sequences at the call sites that need them.
return withSequence(promise); | |
return promise; |
@@ -641,7 +641,7 @@ describe("useBackgroundQuery", () => { | |||
|
|||
const [queryRef] = result.current; | |||
|
|||
const _result = await unwrapQueryRef(queryRef).promise; | |||
const _result = await unwrapQueryRef(queryRef)[0].promise; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oof I really gotta update these tests to use the new profiler helpers 😄. Would love to avoid testing against the query refs directly, but understand the change here!
src/utilities/promises/decoration.ts
Outdated
[sequence]: number; | ||
} | ||
|
||
export function secondIfNewerFulfilledOrFirst<TValue>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's still not clear to me if this does or doesn't solve the issue you mentioned on how we could show incorrect data if the child component rerenders in the "foreground" render while the background is suspended. Would you mind adding a test case for that in this PR so we can verify this does indeed work as we expect it to? I'd love to see that test fail when not using this helper, then pass as soon as its added back.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have a test covering that - if you replace this function with just return first
, the test
useBackgroundQuery › fetchMore › properly uses
updateQuery
when callingfetchMore
will fail.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ha, I should have been more diligent about trying this. Awesome to hear!
src/react/hooks/useReadQuery.ts
Outdated
} | ||
|
||
const promise = useSyncExternalStore( | ||
let promise = useSyncExternalStore( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let promise = useSyncExternalStore( | |
const promise = useSyncExternalStore( |
Perhaps I'm missing where its reassigned, but if not, could we use a const
here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It was before, but isn't anymore :) Good catch!
@@ -36,32 +38,23 @@ export interface UseReadQueryResult<TData = unknown> { | |||
export function useReadQuery<TData>( | |||
queryRef: QueryReference<TData> | |||
): UseReadQueryResult<TData> { | |||
const internalQueryRef = unwrapQueryRef(queryRef); | |||
invariant( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
// parent component re-suspends | ||
expect(renders.suspenseCount).toBe(2); | ||
// parent component didn't re-suspend | ||
expect(renders.suspenseCount).toBe(1); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This doesn't seem quite right to me. Refetches should cause the component to re-suspend again unless the refetch
is wrapped with a startTransition
, so I would expect that clicking refetch twice after the initial load, would cause the suspense boundary to be shown 3 times.
src/react/cache/QueryReference.ts
Outdated
this.reject = reject; | ||
}); | ||
this.promise = withSequence( | ||
wrapPromiseWithState( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ya good call!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll let you decide on the internal designation or not, but this looks great to me!
src/react/cache/QueryReference.ts
Outdated
return [ | ||
reference, | ||
() => | ||
secondIfNewerFulfilledOrFirst( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know I approved this already, but wanted to throw an alternate idea out here.
I think we can avoid the sequencing altogether by just checking to see if the promises are different from each other. If the reference.promise
has changed, it means its been updated to a newer promise. In that way, you can then compare to see if the second has been fulfilled or not.
I just tried this change out with these tests and all continue to pass:
return [
reference,
() => {
const first = queryRef[PROMISE_SYMBOL];
const second = reference.promise;
if (first === second) {
return first;
}
return second.status === "fulfilled" ? second : first;
},
];
Returning first
makes the tests fail like you described in my other comment.
How do you feel about this and removing the need for withSequence
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎉 Nice work here! Great to see a potential bug caught before anyone else does 😆
…ackgroundQuery-promiseCache
to account for "empty" renders that don't actually render any components we're interested in testing
This one moves quite a lot of parts around - let's best have a call at some point to talk about it @jerelmiller :)
Checklist: