-
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
Changes from 15 commits
8ecd36f
b8c51af
79577b5
1510019
532505a
50c2149
6dd943a
c9c3177
4a5c3e2
083af14
3da56f0
2cad580
346e5e8
fc66f12
1e3297e
a6e421a
53871a1
82bd4ae
f8e0e05
10175b5
b248d6c
4a413c3
8010b0c
c150398
d07aa6a
1eef04f
4bde5a0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,28 +7,48 @@ import type { | |
WatchQueryOptions, | ||
} from "../../core/index.js"; | ||
import { isNetworkRequestSettled } from "../../core/index.js"; | ||
import type { ObservableSubscription } from "../../utilities/index.js"; | ||
import type { | ||
ObservableSubscription, | ||
PromiseWithState, | ||
WithSequence, | ||
} from "../../utilities/index.js"; | ||
import { | ||
createFulfilledPromise, | ||
createRejectedPromise, | ||
} from "../../utilities/index.js"; | ||
import type { CacheKey } from "./types.js"; | ||
import type { CacheKey, QueryKey } from "./types.js"; | ||
import type { useBackgroundQuery, useReadQuery } from "../hooks/index.js"; | ||
import { withSequence, wrapPromiseWithState } from "../../utilities/index.js"; | ||
import { secondIfNewerFulfilledOrFirst } from "../../utilities/promises/decoration.js"; | ||
|
||
type QueryRefPromise<TData> = PromiseWithState<ApolloQueryResult<TData>> & | ||
WithSequence; | ||
|
||
type Listener<TData> = (promise: Promise<ApolloQueryResult<TData>>) => void; | ||
type Listener<TData> = (promise: QueryRefPromise<TData>) => void; | ||
|
||
type FetchMoreOptions<TData> = Parameters< | ||
ObservableQuery<TData>["fetchMore"] | ||
>[0]; | ||
|
||
const QUERY_REFERENCE_SYMBOL: unique symbol = Symbol(); | ||
const PROMISE_SYMBOL: unique symbol = Symbol(); | ||
|
||
function createSequencedFulfilledPromise<TValue>(value: TValue) { | ||
return withSequence(createFulfilledPromise(value)); | ||
} | ||
|
||
function createSequencedRejectedPromise<TValue = unknown>(reason: unknown) { | ||
return withSequence(createRejectedPromise<TValue>(reason)); | ||
} | ||
|
||
/** | ||
* A `QueryReference` is an opaque object returned by {@link useBackgroundQuery}. | ||
* A child component reading the `QueryReference` via {@link useReadQuery} will | ||
* suspend until the promise resolves. | ||
*/ | ||
export interface QueryReference<TData = unknown> { | ||
[QUERY_REFERENCE_SYMBOL]: InternalQueryReference<TData>; | ||
readonly [QUERY_REFERENCE_SYMBOL]: InternalQueryReference<TData>; | ||
[PROMISE_SYMBOL]: QueryRefPromise<TData>; | ||
} | ||
|
||
interface InternalQueryReferenceOptions { | ||
|
@@ -38,15 +58,34 @@ interface InternalQueryReferenceOptions { | |
} | ||
|
||
export function wrapQueryRef<TData>( | ||
internalQueryRef: InternalQueryReference<TData> | ||
internalQueryRef: InternalQueryReference<TData>, | ||
promise: QueryRefPromise<TData> | ||
): QueryReference<TData> { | ||
return { [QUERY_REFERENCE_SYMBOL]: internalQueryRef }; | ||
return { | ||
[QUERY_REFERENCE_SYMBOL]: internalQueryRef, | ||
[PROMISE_SYMBOL]: promise, | ||
}; | ||
} | ||
|
||
export function unwrapQueryRef<TData>( | ||
queryRef: QueryReference<TData> | ||
): InternalQueryReference<TData> { | ||
return queryRef[QUERY_REFERENCE_SYMBOL]; | ||
): [InternalQueryReference<TData>, () => QueryRefPromise<TData>] { | ||
const reference = queryRef[QUERY_REFERENCE_SYMBOL]; | ||
return [ | ||
reference, | ||
() => | ||
secondIfNewerFulfilledOrFirst( | ||
queryRef[PROMISE_SYMBOL], | ||
reference.promise | ||
), | ||
]; | ||
} | ||
|
||
export function updateWrappedQueryRef<TData>( | ||
queryRef: QueryReference<TData>, | ||
promise: QueryRefPromise<TData> | ||
) { | ||
queryRef[PROMISE_SYMBOL] = promise; | ||
} | ||
|
||
const OBSERVED_CHANGED_OPTIONS = [ | ||
|
@@ -65,11 +104,10 @@ type ObservedOptions = Pick< | |
|
||
export class InternalQueryReference<TData = unknown> { | ||
public result: ApolloQueryResult<TData>; | ||
public readonly key: CacheKey; | ||
public readonly key: QueryKey = {}; | ||
public readonly observable: ObservableQuery<TData>; | ||
|
||
public promiseCache?: Map<CacheKey, Promise<ApolloQueryResult<TData>>>; | ||
public promise: Promise<ApolloQueryResult<TData>>; | ||
public promise: QueryRefPromise<TData>; | ||
|
||
private subscription: ObservableSubscription; | ||
private listeners = new Set<Listener<TData>>(); | ||
|
@@ -92,7 +130,6 @@ export class InternalQueryReference<TData = unknown> { | |
// Don't save this result as last result to prevent delivery of last result | ||
// when first subscribing | ||
this.result = observable.getCurrentResult(false); | ||
this.key = options.key; | ||
|
||
if (options.onDispose) { | ||
this.onDispose = options.onDispose; | ||
|
@@ -103,13 +140,17 @@ export class InternalQueryReference<TData = unknown> { | |
(this.result.data && | ||
(!this.result.partial || this.watchQueryOptions.returnPartialData)) | ||
) { | ||
this.promise = createFulfilledPromise(this.result); | ||
this.promise = createSequencedFulfilledPromise(this.result); | ||
this.status = "idle"; | ||
} else { | ||
this.promise = new Promise((resolve, reject) => { | ||
this.resolve = resolve; | ||
this.reject = reject; | ||
}); | ||
this.promise = withSequence( | ||
wrapPromiseWithState( | ||
new Promise((resolve, reject) => { | ||
this.resolve = resolve; | ||
this.reject = reject; | ||
}) | ||
) | ||
); | ||
} | ||
|
||
this.subscription = observable | ||
|
@@ -189,7 +230,7 @@ export class InternalQueryReference<TData = unknown> { | |
|
||
if (currentCanonizeResults !== watchQueryOptions.canonizeResults) { | ||
this.result = { ...this.result, ...this.observable.getCurrentResult() }; | ||
this.promise = createFulfilledPromise(this.result); | ||
this.promise = createSequencedFulfilledPromise(this.result); | ||
} | ||
} | ||
|
||
|
@@ -249,7 +290,7 @@ export class InternalQueryReference<TData = unknown> { | |
} | ||
|
||
this.result = result; | ||
this.promise = createFulfilledPromise(result); | ||
this.promise = createSequencedFulfilledPromise(result); | ||
this.deliver(this.promise); | ||
break; | ||
} | ||
|
@@ -270,23 +311,28 @@ export class InternalQueryReference<TData = unknown> { | |
break; | ||
} | ||
case "idle": { | ||
this.promise = createRejectedPromise(error); | ||
this.promise = | ||
createSequencedRejectedPromise<ApolloQueryResult<TData>>(error); | ||
this.deliver(this.promise); | ||
} | ||
} | ||
} | ||
|
||
private deliver(promise: Promise<ApolloQueryResult<TData>>) { | ||
private deliver(promise: QueryRefPromise<TData>) { | ||
this.listeners.forEach((listener) => listener(promise)); | ||
} | ||
|
||
private initiateFetch(returnedPromise: Promise<ApolloQueryResult<TData>>) { | ||
this.status = "loading"; | ||
|
||
this.promise = new Promise((resolve, reject) => { | ||
this.resolve = resolve; | ||
this.reject = reject; | ||
}); | ||
this.promise = withSequence( | ||
wrapPromiseWithState( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We need to wrap them here, so 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 commentThe reason will be displayed to describe this comment to others. Learn more. ya good call! |
||
new Promise((resolve, reject) => { | ||
this.resolve = resolve; | ||
this.reject = reject; | ||
}) | ||
) | ||
); | ||
|
||
this.promise.catch(() => {}); | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -216,6 +216,7 @@ function renderVariablesIntegrationTest({ | |
}, | ||
}, | ||
}, | ||
delay: 200, | ||
}; | ||
} | ||
); | ||
|
@@ -641,7 +642,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 commentThe 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! |
||
|
||
expect(_result).toEqual({ | ||
data: { hello: "world 1" }, | ||
|
@@ -678,7 +679,7 @@ describe("useBackgroundQuery", () => { | |
|
||
const [queryRef] = result.current; | ||
|
||
const _result = await unwrapQueryRef(queryRef).promise; | ||
const _result = await unwrapQueryRef(queryRef)[0].promise; | ||
|
||
await waitFor(() => { | ||
expect(_result).toEqual({ | ||
|
@@ -719,7 +720,7 @@ describe("useBackgroundQuery", () => { | |
|
||
const [queryRef] = result.current; | ||
|
||
const _result = await unwrapQueryRef(queryRef).promise; | ||
const _result = await unwrapQueryRef(queryRef)[0].promise; | ||
|
||
await waitFor(() => { | ||
expect(_result).toMatchObject({ | ||
|
@@ -779,7 +780,7 @@ describe("useBackgroundQuery", () => { | |
|
||
const [queryRef] = result.current; | ||
|
||
const _result = await unwrapQueryRef(queryRef).promise; | ||
const _result = await unwrapQueryRef(queryRef)[0].promise; | ||
const resultSet = new Set(_result.data.results); | ||
const values = Array.from(resultSet).map((item) => item.value); | ||
|
||
|
@@ -840,7 +841,7 @@ describe("useBackgroundQuery", () => { | |
|
||
const [queryRef] = result.current; | ||
|
||
const _result = await unwrapQueryRef(queryRef).promise; | ||
const _result = await unwrapQueryRef(queryRef)[0].promise; | ||
const resultSet = new Set(_result.data.results); | ||
const values = Array.from(resultSet).map((item) => item.value); | ||
|
||
|
@@ -882,7 +883,7 @@ describe("useBackgroundQuery", () => { | |
|
||
const [queryRef] = result.current; | ||
|
||
const _result = await unwrapQueryRef(queryRef).promise; | ||
const _result = await unwrapQueryRef(queryRef)[0].promise; | ||
|
||
expect(_result).toEqual({ | ||
data: { hello: "from link" }, | ||
|
@@ -922,7 +923,7 @@ describe("useBackgroundQuery", () => { | |
|
||
const [queryRef] = result.current; | ||
|
||
const _result = await unwrapQueryRef(queryRef).promise; | ||
const _result = await unwrapQueryRef(queryRef)[0].promise; | ||
|
||
expect(_result).toEqual({ | ||
data: { hello: "from cache" }, | ||
|
@@ -969,7 +970,7 @@ describe("useBackgroundQuery", () => { | |
|
||
const [queryRef] = result.current; | ||
|
||
const _result = await unwrapQueryRef(queryRef).promise; | ||
const _result = await unwrapQueryRef(queryRef)[0].promise; | ||
|
||
expect(_result).toEqual({ | ||
data: { foo: "bar", hello: "from link" }, | ||
|
@@ -1009,7 +1010,7 @@ describe("useBackgroundQuery", () => { | |
|
||
const [queryRef] = result.current; | ||
|
||
const _result = await unwrapQueryRef(queryRef).promise; | ||
const _result = await unwrapQueryRef(queryRef)[0].promise; | ||
|
||
expect(_result).toEqual({ | ||
data: { hello: "from link" }, | ||
|
@@ -1052,7 +1053,7 @@ describe("useBackgroundQuery", () => { | |
|
||
const [queryRef] = result.current; | ||
|
||
const _result = await unwrapQueryRef(queryRef).promise; | ||
const _result = await unwrapQueryRef(queryRef)[0].promise; | ||
|
||
expect(_result).toEqual({ | ||
data: { hello: "from link" }, | ||
|
@@ -3227,12 +3228,14 @@ describe("useBackgroundQuery", () => { | |
result: { | ||
data: { character: { id: "1", name: "Captain Marvel" } }, | ||
}, | ||
delay: 200, | ||
}, | ||
{ | ||
request: { query, variables: { id: "2" } }, | ||
result: { | ||
data: { character: { id: "2", name: "Captain America" } }, | ||
}, | ||
delay: 200, | ||
}, | ||
]; | ||
|
||
|
@@ -3297,7 +3300,7 @@ describe("useBackgroundQuery", () => { | |
|
||
// parent component re-suspends | ||
expect(renders.suspenseCount).toBe(2); | ||
expect(renders.count).toBe(2); | ||
expect(renders.count).toBe(1); | ||
|
||
expect( | ||
await screen.findByText("1 - Spider-Man (updated)") | ||
|
@@ -3307,11 +3310,13 @@ describe("useBackgroundQuery", () => { | |
|
||
// parent component re-suspends | ||
expect(renders.suspenseCount).toBe(3); | ||
expect(renders.count).toBe(3); | ||
expect(renders.count).toBe(2); | ||
|
||
expect( | ||
await screen.findByText("1 - Spider-Man (updated again)") | ||
).toBeInTheDocument(); | ||
|
||
expect(renders.count).toBe(3); | ||
}); | ||
it("throws errors when errors are returned after calling `refetch`", async () => { | ||
using _consoleSpy = spyOnConsole("error"); | ||
|
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:
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
?