-
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
Prevent QueryInfo#markResult
mutation of result.data
and return cache data consistently whether complete or incomplete
#11202
Changes from all commits
5e8b5e0
7f9c5ac
a201111
7c2bc08
a031860
d8c6072
2ad8485
ec5d86c
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 |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@apollo/client": minor | ||
--- | ||
|
||
Prevent `QueryInfo#markResult` mutation of `result.data` and return cache data consistently whether complete or incomplete. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,171 @@ | ||
// This file contains InMemoryCache-specific tests that exercise the | ||
// ApolloClient class. Other test modules in this directory only test | ||
// InMemoryCache and related utilities, without involving ApolloClient. | ||
|
||
import { ApolloClient, WatchQueryFetchPolicy, gql } from "../../../core"; | ||
import { ApolloLink } from "../../../link/core"; | ||
import { Observable } from "../../../utilities"; | ||
import { InMemoryCache } from "../.."; | ||
import { subscribeAndCount } from "../../../testing"; | ||
|
||
describe("InMemoryCache tests exercising ApolloClient", () => { | ||
it.each<WatchQueryFetchPolicy>([ | ||
"cache-first", | ||
"network-only", | ||
"cache-and-network", | ||
"cache-only", | ||
"no-cache", | ||
])( | ||
"results should be read from cache even when incomplete (fetchPolicy %s)", | ||
(fetchPolicy) => { | ||
const dateFromCache = "2023-09-14T13:03:22.616Z"; | ||
const dateFromNetwork = "2023-09-15T13:03:22.616Z"; | ||
|
||
const cache = new InMemoryCache({ | ||
typePolicies: { | ||
Query: { | ||
fields: { | ||
date: { | ||
read(existing) { | ||
return new Date(existing || dateFromCache); | ||
}, | ||
}, | ||
}, | ||
}, | ||
}, | ||
}); | ||
|
||
const client = new ApolloClient({ | ||
link: new ApolloLink( | ||
(operation) => | ||
new Observable((observer) => { | ||
observer.next({ | ||
data: { | ||
// This raw string should be converted to a Date by the Query.date | ||
// read function passed to InMemoryCache below. | ||
date: dateFromNetwork, | ||
// Make sure we don't accidentally return fields not mentioned in | ||
// the query just because the result is incomplete. | ||
ignored: "irrelevant to the subscribed query", | ||
// Note the Query.missing field is, well, missing. | ||
}, | ||
}); | ||
setTimeout(() => { | ||
observer.complete(); | ||
}, 10); | ||
}) | ||
), | ||
cache, | ||
}); | ||
|
||
const query = gql` | ||
query { | ||
date | ||
missing | ||
} | ||
`; | ||
|
||
const observable = client.watchQuery({ | ||
query, | ||
fetchPolicy, // Varies with each test iteration | ||
returnPartialData: true, | ||
}); | ||
|
||
return new Promise<void>((resolve, reject) => { | ||
subscribeAndCount(reject, observable, (handleCount, result) => { | ||
let adjustedCount = handleCount; | ||
if ( | ||
fetchPolicy === "network-only" || | ||
fetchPolicy === "no-cache" || | ||
fetchPolicy === "cache-only" | ||
) { | ||
// The network-only, no-cache, and cache-only fetch policies do not | ||
// deliver a loading:true result initially, so we adjust the | ||
// handleCount to skip that case. | ||
++adjustedCount; | ||
} | ||
|
||
// The only fetch policy that does not re-read results from the cache is | ||
// the "no-cache" policy. In this test, that means the Query.date field | ||
// will remain as a raw string rather than being converted to a Date by | ||
// the read function. | ||
const expectedDateAfterResult = | ||
fetchPolicy === "cache-only" | ||
? new Date(dateFromCache) | ||
: fetchPolicy === "no-cache" | ||
? dateFromNetwork | ||
: new Date(dateFromNetwork); | ||
|
||
if (adjustedCount === 1) { | ||
expect(result.loading).toBe(true); | ||
expect(result.data).toEqual({ | ||
date: new Date(dateFromCache), | ||
}); | ||
} else if (adjustedCount === 2) { | ||
expect(result.loading).toBe(false); | ||
expect(result.data).toEqual({ | ||
date: expectedDateAfterResult, | ||
// The no-cache fetch policy does return extraneous fields from the | ||
// raw network result that were not requested in the query, since | ||
// the cache is not consulted. | ||
...(fetchPolicy === "no-cache" | ||
? { | ||
ignored: "irrelevant to the subscribed query", | ||
} | ||
: null), | ||
}); | ||
|
||
if (fetchPolicy === "no-cache") { | ||
// The "no-cache" fetch policy does not receive updates from the | ||
// cache, so we finish the test early (passing). | ||
setTimeout(() => resolve(), 20); | ||
} else { | ||
cache.writeQuery({ | ||
query: gql` | ||
query { | ||
missing | ||
} | ||
`, | ||
data: { | ||
missing: "not missing anymore", | ||
}, | ||
}); | ||
} | ||
} else if (adjustedCount === 3) { | ||
expect(result.loading).toBe(false); | ||
expect(result.data).toEqual({ | ||
date: expectedDateAfterResult, | ||
missing: "not missing anymore", | ||
}); | ||
|
||
expect(cache.extract()).toEqual({ | ||
ROOT_QUERY: { | ||
__typename: "Query", | ||
// The cache-only fetch policy does not receive updates from the | ||
// network, so it never ends up writing the date field into the | ||
// cache explicitly, though Query.date can still be synthesized by | ||
// the read function. | ||
...(fetchPolicy === "cache-only" | ||
? null | ||
: { | ||
// Make sure this field is stored internally as a raw string. | ||
date: dateFromNetwork, | ||
}), | ||
// Written explicitly with cache.writeQuery above. | ||
missing: "not missing anymore", | ||
// The ignored field is never written to the cache, because it is | ||
// not included in the query. | ||
}, | ||
}); | ||
|
||
// Wait 20ms to give the test a chance to fail if there are unexpected | ||
// additional results. | ||
setTimeout(() => resolve(), 20); | ||
} else { | ||
reject(new Error(`Unexpected count ${adjustedCount}`)); | ||
} | ||
}); | ||
}); | ||
} | ||
); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -66,14 +66,6 @@ export function resetStore(qm: QueryManager<any>) { | |
} | ||
|
||
describe("QueryManager", () => { | ||
// Standard "get id from object" method. | ||
const dataIdFromObject = (object: any) => { | ||
if (object.__typename && object.id) { | ||
return object.__typename + "__" + object.id; | ||
} | ||
return undefined; | ||
}; | ||
|
||
// Helper method that serves as the constructor method for | ||
// QueryManager but has defaults that make sense for these | ||
// tests. | ||
|
@@ -2224,107 +2216,6 @@ describe("QueryManager", () => { | |
} | ||
); | ||
|
||
itAsync( | ||
"should not return stale data when we orphan a real-id node in the store with a real-id node", | ||
(resolve, reject) => { | ||
const query1 = gql` | ||
query { | ||
author { | ||
name { | ||
firstName | ||
lastName | ||
} | ||
age | ||
id | ||
__typename | ||
} | ||
} | ||
`; | ||
const query2 = gql` | ||
query { | ||
author { | ||
name { | ||
firstName | ||
} | ||
id | ||
__typename | ||
} | ||
} | ||
`; | ||
const data1 = { | ||
author: { | ||
name: { | ||
firstName: "John", | ||
lastName: "Smith", | ||
}, | ||
age: 18, | ||
id: "187", | ||
__typename: "Author", | ||
}, | ||
}; | ||
const data2 = { | ||
author: { | ||
name: { | ||
firstName: "John", | ||
}, | ||
id: "197", | ||
__typename: "Author", | ||
}, | ||
}; | ||
const reducerConfig = { dataIdFromObject }; | ||
const queryManager = createQueryManager({ | ||
link: mockSingleLink( | ||
{ | ||
request: { query: query1 }, | ||
result: { data: data1 }, | ||
}, | ||
{ | ||
request: { query: query2 }, | ||
result: { data: data2 }, | ||
}, | ||
{ | ||
request: { query: query1 }, | ||
result: { data: data1 }, | ||
} | ||
).setOnError(reject), | ||
config: reducerConfig, | ||
}); | ||
|
||
const observable1 = queryManager.watchQuery<any>({ query: query1 }); | ||
const observable2 = queryManager.watchQuery<any>({ query: query2 }); | ||
|
||
// I'm not sure the waiting 60 here really is required, but the test used to do it | ||
return Promise.all([ | ||
observableToPromise( | ||
{ | ||
observable: observable1, | ||
wait: 60, | ||
}, | ||
(result) => { | ||
expect(result).toEqual({ | ||
data: data1, | ||
loading: false, | ||
networkStatus: NetworkStatus.ready, | ||
}); | ||
} | ||
), | ||
observableToPromise( | ||
{ | ||
observable: observable2, | ||
wait: 60, | ||
}, | ||
(result) => { | ||
expect(result).toEqual({ | ||
data: data2, | ||
loading: false, | ||
networkStatus: NetworkStatus.ready, | ||
}); | ||
} | ||
), | ||
]).then(resolve, reject); | ||
} | ||
); | ||
|
||
itAsync( | ||
"should return partial data when configured when we orphan a real-id node in the store with a real-id node", | ||
(resolve, reject) => { | ||
|
@@ -2519,9 +2410,7 @@ describe("QueryManager", () => { | |
loading: false, | ||
networkStatus: NetworkStatus.ready, | ||
data: { | ||
info: { | ||
a: "ay", | ||
}, | ||
info: {}, | ||
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. I'm not sure why this result disappeared here? 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. This is a somewhat weird test that exercises the feud-stopping logic described by the long comment in Specifically, the What changed with this PR is that we used to return the raw network result, which does have If we allowed these two queries to keep clobbering each other's properties (because we can't merge the new InMemoryCache({
typePolicies: {
Query: {
fields: {
info: { merge: true }
}
}
}
}) 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. Makes sense, thanks for the explanation! |
||
}, | ||
}); | ||
setTimeout(resolve, 100); | ||
|
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.
Just wanted to add: I'm definitely not challenging this defensive copy - 💯 agree!