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

Prevent QueryInfo#markResult mutation of result.data and return cache data consistently whether complete or incomplete #11202

Merged

Conversation

benjamn
Copy link
Member

@benjamn benjamn commented Sep 8, 2023

This PR fixes issue #9293 by ensuring all cache-friendly fetch policies (including cache-{first,only,and-network} and network-only but excluding no-cache) return result data after re-reading the data from the cache, even when the data are incomplete (fixing #9293), so any custom read functions (or other cache logic) can be applied consistently whether or not the cache data are complete. I've added a new test that exercises all the fetch policies, since test coverage was previously inadequate in this area.

While diagnosing this problem, I noticed QueryInfo#markResult previously updated result.data using a destructive mutation, which could easily go unnoticed by the caller of markResult, so I refactored that code a bit to return a new result object instead of modifying the one provided.

As @CarsonF's reproduction from #9293 suggests, consistently returning cache data is important when using a read function to implement a scalar wrapper like Date for an otherwise string-valued field. I tested these changes against the reproduction repo, and while there's still some weirdness related to the use of offsetLimitPagination for the Query.people list field, the problem of the disappearing Date wrappers seems to be solved. More generally, I believe this change allows a more reliable implementation our most popular feature request (by 👍 reactions), apollographql/apollo-feature-requests#368.

While I stand behind the logic of this change for consistency's sake, it's worth mentioning the old behavior allowed returning raw network results with extraneous fields not present in the query, in cases where the server violates GraphQL validation rules by returning more properties than requested, but also does not return all the requested properties. See my test changes involving todoWithoutPrice and carDataWithoutVine for examples of this edge case. Now that we always re-read results from the cache using the provided query, you should always receive only the queried fields, so any extraneous properties will (correctly) disappear.

@changeset-bot

This comment was marked as resolved.

@github-actions
Copy link
Contributor

github-actions bot commented Sep 8, 2023

size-limit report 📦

Path Size
dist/apollo-client.min.cjs 37.29 KB (+0.03% 🔺)
import { ApolloClient, InMemoryCache, HttpLink } from "dist/main.cjs" 43.76 KB (+0.03% 🔺)
import { ApolloClient, InMemoryCache, HttpLink } from "dist/main.cjs" (production) 42.34 KB (+0.04% 🔺)
import { ApolloClient, InMemoryCache, HttpLink } from "dist/index.js" 32.54 KB (+0.04% 🔺)
import { ApolloClient, InMemoryCache, HttpLink } from "dist/index.js" (production) 31.28 KB (+0.04% 🔺)
import { ApolloProvider } from "dist/react/index.js" 1.21 KB (0%)
import { ApolloProvider } from "dist/react/index.js" (production) 1.2 KB (0%)
import { useQuery } from "dist/react/index.js" 4.27 KB (0%)
import { useQuery } from "dist/react/index.js" (production) 4.08 KB (0%)
import { useLazyQuery } from "dist/react/index.js" 4.58 KB (0%)
import { useLazyQuery } from "dist/react/index.js" (production) 4.4 KB (0%)
import { useMutation } from "dist/react/index.js" 2.52 KB (0%)
import { useMutation } from "dist/react/index.js" (production) 2.51 KB (0%)
import { useSubscription } from "dist/react/index.js" 2.24 KB (0%)
import { useSubscription } from "dist/react/index.js" (production) 2.2 KB (0%)
import { useSuspenseQuery } from "dist/react/index.js" 4.76 KB (0%)
import { useSuspenseQuery } from "dist/react/index.js" (production) 4.19 KB (0%)
import { useBackgroundQuery } from "dist/react/index.js" 4.28 KB (0%)
import { useBackgroundQuery } from "dist/react/index.js" (production) 3.7 KB (0%)
import { useReadQuery } from "dist/react/index.js" 2.96 KB (0%)
import { useReadQuery } from "dist/react/index.js" (production) 2.91 KB (0%)
import { useFragment } from "dist/react/index.js" 2.08 KB (0%)
import { useFragment } from "dist/react/index.js" (production) 2.03 KB (0%)

benjamn added a commit that referenced this pull request Sep 8, 2023
@benjamn
Copy link
Member Author

benjamn commented Sep 8, 2023

@jerelmiller @phryneas @alessbell I guess I'm not sure how to update the size-limit limits. It looks like npx size-limit --json gets run twice, and passes the first time but not the second.

Most of these test tweaks are reasonable improvements necessitated by
fixing a bug that allowed queries to receive raw network results with
extraneous fields when the results were incomplete. Now, the extraneous
fields are no longer delivered, since they were not requested.

The test I removed completely does not make sense, and was only passing
previously because of the mock link running out of results.
@benjamn benjamn force-pushed the issue-9293-fix-QueryInfo-markResult-result.data-mutation branch from fc09281 to d8c6072 Compare September 13, 2023 20:54
@benjamn
Copy link
Member Author

benjamn commented Sep 13, 2023

Ran npm run format and rebased against @alessbell's .size-limit.cjs changes from the base release-3.9 branch, with a small additional size tweak, and it looks like all tests/checks are passing now, so I think y'all can review this PR now without having to see any red ❌s. Thanks for your help behind the scenes (on Slack).

Copy link
Member

@phryneas phryneas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've adjusted the test a bit ( see 2ad8485 ) and have some questions on this - let's talk about it later!

info: {
a: "ay",
},
info: {},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why this result disappeared here?

Copy link
Member Author

Choose a reason for hiding this comment

The 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 QueryInfo#markResult. I ultimately want to remove that logic and this test, but the test currently captures expected behavior.

Specifically, the data.info.a property already disappeared in the previous case (count === 3) because of writing the { info: { b: "bee" }} result into the cache, clobbering the info.a field. Because the most recent { info: { a: "ay" }} result is the same as before, markResult skips writing it to the cache (not happy with this compromise, but see the long comment for rationale), so it does not reappear in the cache in the count === 4 case.

What changed with this PR is that we used to return the raw network result, which does have { a: "ay" }, because the result from the cache would be incomplete. Now, we always return what we get from the cache, which is incomplete (missing the a: "ay" property).

If we allowed these two queries to keep clobbering each other's properties (because we can't merge the Query.info object, because it has no identity and hasn't been configured with merge: true), then we'd get an infinite request loop, which is arguably worse than the weird behavior of this test. Ultimately, I think we should be able to make it easier (even default) for these two queries to have their own separate views of the Query.info object, so they don't keep stepping on each other's data, but the standard current solution is to mark the Query.info field as mergeable, so info.a and info.b can coexist:

new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        info: { merge: true }
      }
    }
  }
})

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, thanks for the explanation!

src/core/QueryInfo.ts Outdated Show resolved Hide resolved
Comment on lines 454 to 457
result = {
...result,
data: this.lastDiff.diff.result,
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
result = {
...result,
data: this.lastDiff.diff.result,
};
result.data = this.lastDiff.diff.result;

See other comment.

Comment on lines +411 to +414
// 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 },
Copy link
Member

@phryneas phryneas Sep 14, 2023

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!

Copy link
Member

@phryneas phryneas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved!

@benjamn we already talked about this - I changed the defensive copy logic in this commit: ec5d86c (#11202)

Could you take another look at that before merging?

@benjamn benjamn merged commit 8b6e10b into release-3.9 Sep 14, 2023
4 checks passed
@benjamn benjamn deleted the issue-9293-fix-QueryInfo-markResult-result.data-mutation branch September 14, 2023 21:18
@benjamn
Copy link
Member Author

benjamn commented Sep 14, 2023

Your changes look good to me. Thanks for jumping in and reviewing!

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Oct 15, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants