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

Preserve referential equality from previous result in the store #1136

Merged
merged 21 commits into from
Jan 9, 2017

Conversation

calebmer
Copy link
Contributor

@calebmer calebmer commented Jan 5, 2017

This PR implements the preservation of referential equality across reads from the store for data that has not changed. So say you have an array of [a, b, c] and a mutation adds a new item, d, to make [a, b, c, d]. Then the following should hold last[0] === next[0] && last[1] === next[1] && last[2] === next[2] while the array itself is not necessarily referentially equal (last !== next). This makes it easier to create optimizations in the UI layer to only re-render data which changed. Specifically this would make the implementation of shouldComponentUpdate in React apps much cleaner.

This PR is not yet finished, but I’d like an initial review of what’s there. I’m going to work on some other issues for a bit then come back to this. The implementation is a little messy as the graphql-anywhere resolver and resultMapper need to work together to both identify which values have previous results and if those previous results equal the new result. The implementation gets a little cleaner if we remove support for arrays whose order changes (e.g. [a, b, c] -> [b, c, a]), but that means we wouldn’t support the case where an item is added to the beginning of an array which is pretty important (e.g. [a, b, c] -> [d, a, b, c]).

What’s left to do is wiring this up with wherever it is called so that previousResult values actually get passed down into diffQueryAgainstStore.

Closes #695

  • If this PR is a new feature, reference an issue where a consensus about the design was reached (not necessary for small changes)
  • Make sure all of the significant new logic is covered by tests
  • Rebase your changes on master so that they can be merged easily
  • Make sure all tests and linter rules pass
  • Update CHANGELOG.md with your change

Copy link
Contributor

@stubailo stubailo left a comment

Choose a reason for hiding this comment

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

Minor comments

// if this is an object scalar, it must be a json blob and we have to unescape it
// If the JSON blob is the same now as in the previous result, return the previous result to
// maintain referential equality.
if (idValue.previousResult && isEqual(idValue.previousResult[fieldName], fieldValue.json)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we can use === here instead of isEqual

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Test fails when I do that. It’s because two requests will never return the exact same object (when compared with ===), so we have to do a deep equals.

Copy link
Contributor

Choose a reason for hiding this comment

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

OK I'm fine with this for now - let's do some benchmarks later and see if it is ever a problem!

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure I follow here. Surely once written to the store the object doesn't change, right? It's only when another request fetches the same object that the reference changes. If that's the case, then we could first check referential equality and only if that fails apply isEqual.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@helfer isEqual always does a referential check first. If you’d like we could do another one here to make the intention explicit.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If you don’t care about referential equality when a new JSON object comes back from the API (which when a deep comparison is performed is actually the same object) we could just remove this entirely as the edge case may not be worth supporting at all. I only really added it to get this test to pass, otherwise it’s a safe removal.

//
// While we do a shallow comparison of objects, we do a deep comparison of arrays.
const sameAsPreviousResult = Object.keys(resultFields).reduce(
(same, key) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Code style - I'd put these args on the previous line.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, this line went through multiple iterations.

// Flatten out the field values before comparing them. Non-arrays will turn into singleton
// arrays and multi-dimensional arrays will be flattened out. Depth doesn’t matter in this
// case, we just need to check that all items are equal.
const next = flattenArray(resultFields[key]);
Copy link
Contributor

Choose a reason for hiding this comment

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

This would definitely pass through a false positive if the arrays had the same items in flattened form but had different shapes.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You’re right. I initially patched isEqual to do shallow object checks when a config flag was passed, but that felt wrong so I changed it to this. There is probably a better solution.

Copy link
Contributor

@helfer helfer left a comment

Choose a reason for hiding this comment

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

This is really awesome! 👍 I'm especially liking the thorough yet relatively succinct and clean tests.

I agree that it may feel a bit contrived, but it fits the graphql-anywhere pattern and I don't see how we could improve much on it while still using graphql-anywhere. I believe it will take a moment to understand what's going on for folks who aren't familiar with graphql-anywhere (eg. practically everyone outside the apollo team) so I do think it would help to add a comment at the top that explains how the resolver and resultMapper play together to make things work.

Actually, that reminds me that we should still write a blog post at some time that explains what graphql-anywhere is and does!

// Flatten out the field values before comparing them. Non-arrays will turn into singleton
// arrays and multi-dimensional arrays will be flattened out. Depth doesn’t matter in this
// case, we just need to check that all items are equal.
const next = flattenArray(resultFields[key]);
Copy link
Contributor

Choose a reason for hiding this comment

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

What are the performance implications of this? I'm not too worried about deeply nested arrays at this point in time, but what about one-dimensional arrays? If we're copying every array every time to make a comparison that could amount to a fair bit of copying.

Although... I once said never optimize something you don't already know needs optimizing. So that might count here as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My guess would be it isn’t a huge deal, but I’m going to change it anyway. @stubailo pointed out it could be a source of bugs by creating false positives.

// if this is an object scalar, it must be a json blob and we have to unescape it
// If the JSON blob is the same now as in the previous result, return the previous result to
// maintain referential equality.
if (idValue.previousResult && isEqual(idValue.previousResult[fieldName], fieldValue.json)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure I follow here. Surely once written to the store the object doesn't change, right? It's only when another request fetches the same object that the reference changes. If that's the case, then we could first check referential equality and only if that fails apply isEqual.

previousResult,
});

assert.deepEqual(result, queryResult);
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess this assertion wouldn't strictly be necessary, but you put it in to speed up debugging if things are still deepEqual but not strictly equal?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, it’s more of a quick sanity check then anything else. For instance result might not deep equal queryResult if we wrote result.a.b = 10 on line 432 but would still strict equal previousResult.

@@ -346,4 +347,347 @@ describe('diffing queries against the store', () => {
});
});
});

Copy link
Contributor

Choose a reason for hiding this comment

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

These tests look really neat! For some reason that makes me really happy.

@stubailo
Copy link
Contributor

stubailo commented Jan 6, 2017

@helfer when I talked to Caleb he mentioned that isEqual checks referential equality first anyway for performance.

@calebmer
Copy link
Contributor Author

calebmer commented Jan 6, 2017

Added some commits to address comments and improve file legibility. I’m going to work on getting the previousResult passed into the appropriate call sites for diffQueryAgainstStore now. I’ll ping the thread again once that’s done 👍

@calebmer
Copy link
Contributor Author

calebmer commented Jan 6, 2017

Ok, so I added the previousResult option here which I think is all that really matters? Because it appears that is the code path which creates results for watchQuery which is what React uses. Whenever the store changes this code path generates a new result for the watchQuery observers.

Other locations which we could add a previousResult to is as follows. Let me know if a previousResult should be added to any of these locations:

  1. https://github.com/apollostack/apollo-client/blob/d314e2f1ee3617d9a5715a677b1c17da77701288/src/core/QueryManager.ts#L477-L483
  2. https://github.com/apollostack/apollo-client/blob/d314e2f1ee3617d9a5715a677b1c17da77701288/src/core/QueryManager.ts#L755
  3. https://github.com/apollostack/apollo-client/blob/d314e2f1ee3617d9a5715a677b1c17da77701288/src/core/QueryManager.ts#L762
  4. https://github.com/apollostack/apollo-client/blob/d314e2f1ee3617d9a5715a677b1c17da77701288/src/core/QueryManager.ts#L964-L970
  5. https://github.com/apollostack/apollo-client/blob/d314e2f1ee3617d9a5715a677b1c17da77701288/src/data/resultReducers.ts#L44-L50

@@ -15,6 +15,7 @@ Expect active development and potentially significant breaking changes in the `0
- Remove lodash as a production dependency [PR #1122](https://github.com/apollostack/apollo-client/pull/1122)
- Minor fix to write to `ROOT_SUBSCRIPTION` ID in the store for subscription results. [PR #1122](https://github.com/apollostack/apollo-client/pull/1127)
- Remove `whatwg-fetch` polyfill dependency and instead warn when a global `fetch` implementation is not found. [PR #1134](https://github.com/apollostack/apollo-client/pull/1134)
- New results from `watchQuery` are referentially equal (so `a === b`) to previous results if nothing changed in the store for a better UI integration experience when determining what changed. [PR #1136](https://github.com/apollostack/apollo-client/pull/1136)
Copy link
Contributor

Choose a reason for hiding this comment

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

This misses the important point that this applies to sub-objects in the result, not just the whole result (which was true before!)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed 👍

@stubailo
Copy link
Contributor

stubailo commented Jan 6, 2017

We should write a blog post about this when it ships. I think it will do a lot to demonstrate the attention to detail of what we're doing.

}
// Otherwise let us compare all of the array items (which are potentially nested arrays!) to see
// if they are equal.
return a.reduce((same, item, i) => same && areNestedArrayItemsStrictlyEqual(item, b[i]), true);
Copy link
Contributor

Choose a reason for hiding this comment

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

With this comparison we'd end up with [1,2] === [1,2,3] because we're only comparing up to the number of items in array a.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, that’s my bad. Comparing length up front is probably good hygiene anyway.

@helfer
Copy link
Contributor

helfer commented Jan 9, 2017

Sweet, that's an awesome PR @calebmer!

I added previousResult to getCurrentResult as well because React Apollo uses that in a bunch of places, and I think we need referential equality there as well. It definitely doesn't hurt.

Going to merge this now.

@helfer helfer merged commit aa06fda into zero-decimal-six Jan 9, 2017
@helfer helfer removed the in progress label Jan 9, 2017
@calebmer calebmer deleted the refactor/preserve-referential-equality branch January 9, 2017 16:06
@danielgriese
Copy link

WUAT? This bugged me for a long time. Awesome! Thank you very much and sorry for the spam... just wanted to show my gratitude! :D

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants