-
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
Merged
benjamn
merged 8 commits into
release-3.9
from
issue-9293-fix-QueryInfo-markResult-result.data-mutation
Sep 14, 2023
Merged
Changes from 7 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
5e8b5e0
Avoid destructively modifying result.data in QueryInfo#markResult.
benjamn 7f9c5ac
Test adjustments after fixing QueryInfo#markResult.
benjamn a201111
New test of re-reading incomplete network results from cache.
benjamn 7c2bc08
Run 'npx changeset' for PR #11202.
benjamn a031860
Run 'npm run format' for code modified in PR #11202.
benjamn d8c6072
Bump size-limit +8 bytes.
benjamn 2ad8485
extend test to distinguish between cache & network
phryneas ec5d86c
defensive copy of `result` at the top of `markResult`
phryneas File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`)); | ||
} | ||
}); | ||
}); | ||
} | ||
); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change | ||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -361,7 +361,7 @@ export class QueryInfo { | |||||||||||
"variables" | "fetchPolicy" | "errorPolicy" | ||||||||||||
>, | ||||||||||||
cacheWriteBehavior: CacheWriteBehavior | ||||||||||||
) { | ||||||||||||
): typeof result { | ||||||||||||
const merger = new DeepMerger(); | ||||||||||||
const graphQLErrors = isNonEmptyArray(result.errors) | ||||||||||||
? result.errors.slice(0) | ||||||||||||
|
@@ -408,7 +408,10 @@ export class QueryInfo { | |||||||||||
}); | ||||||||||||
|
||||||||||||
this.lastWrite = { | ||||||||||||
result, | ||||||||||||
// Make a shallow defensive copy of the result object, in case we | ||||||||||||
// later later modify result.data in place, since we don't want | ||||||||||||
// that mutation affecting the saved lastWrite.result.data. | ||||||||||||
result: { ...result }, | ||||||||||||
variables: options.variables, | ||||||||||||
dmCount: destructiveMethodCounts.get(this.cache), | ||||||||||||
}; | ||||||||||||
|
@@ -448,7 +451,10 @@ export class QueryInfo { | |||||||||||
if (this.lastDiff && this.lastDiff.diff.complete) { | ||||||||||||
// Reuse data from the last good (complete) diff that we | ||||||||||||
// received, when possible. | ||||||||||||
result.data = this.lastDiff.diff.result; | ||||||||||||
result = { | ||||||||||||
...result, | ||||||||||||
data: this.lastDiff.diff.result, | ||||||||||||
}; | ||||||||||||
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.
Suggested change
See other comment. |
||||||||||||
return; | ||||||||||||
} | ||||||||||||
// If the previous this.diff was incomplete, fall through to | ||||||||||||
|
@@ -470,20 +476,23 @@ export class QueryInfo { | |||||||||||
this.updateWatch(options.variables); | ||||||||||||
} | ||||||||||||
|
||||||||||||
// If we're allowed to write to the cache, and we can read a | ||||||||||||
// complete result from the cache, update result.data to be the | ||||||||||||
// result from the cache, rather than the raw network result. | ||||||||||||
// Set without setDiff to avoid triggering a notify call, since | ||||||||||||
// we have other ways of notifying for this result. | ||||||||||||
// If we're allowed to write to the cache, update result.data to be | ||||||||||||
// the result as re-read from the cache, rather than the raw network | ||||||||||||
// result. Set without setDiff to avoid triggering a notify call, | ||||||||||||
// since we have other ways of notifying for this result. | ||||||||||||
this.updateLastDiff(diff, diffOptions); | ||||||||||||
if (diff.complete) { | ||||||||||||
result.data = diff.result; | ||||||||||||
} | ||||||||||||
result = { | ||||||||||||
...result, | ||||||||||||
// TODO Improve types so we don't need this cast. | ||||||||||||
data: diff.result as any, | ||||||||||||
}; | ||||||||||||
benjamn marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||
}); | ||||||||||||
} else { | ||||||||||||
this.lastWrite = void 0; | ||||||||||||
} | ||||||||||||
} | ||||||||||||
|
||||||||||||
return result; | ||||||||||||
} | ||||||||||||
|
||||||||||||
public markReady() { | ||||||||||||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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!