-
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
Preserve referential equality from previous result in the store #1136
Changes from 15 commits
a062622
8738b44
bae06ed
6468846
a4931fd
190a86a
5833dc4
8476608
e1eae36
08b69dd
249c552
a1ae3b6
d314e2f
ff1af8d
c1a0b09
c97b0f8
d74149f
af36e8e
ec2163e
f127dbb
24a1a32
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 |
---|---|---|
|
@@ -5,6 +5,7 @@ import { | |
import graphqlAnywhere, { | ||
Resolver, | ||
FragmentMatcher, | ||
ExecInfo, | ||
} from 'graphql-anywhere'; | ||
|
||
import { | ||
|
@@ -26,6 +27,18 @@ import { | |
ApolloReducerConfig, | ||
} from '../store'; | ||
|
||
import { isEqual } from '../util/isEqual'; | ||
|
||
/** | ||
* The key which the cache id for a given value is stored in the result object. This key is private | ||
* and should not be used by Apollo client users. | ||
* | ||
* Uses a symbol if available in the environment. | ||
* | ||
* @private | ||
*/ | ||
export const ID_KEY = typeof Symbol !== 'undefined' ? Symbol('id') : '@@id'; | ||
|
||
export type DiffResult = { | ||
result?: any; | ||
isMissing?: boolean; | ||
|
@@ -36,6 +49,7 @@ export type ReadQueryOptions = { | |
query: DocumentNode, | ||
variables?: Object, | ||
returnPartialData?: boolean, | ||
previousResult?: any, | ||
config?: ApolloReducerConfig, | ||
}; | ||
|
||
|
@@ -47,37 +61,50 @@ export type CustomResolverMap = { | |
}, | ||
}; | ||
|
||
/** | ||
* This code needs an optional `previousResult` property on `IdValue` so that when the results | ||
* returned from the store are the same, we can just return the `previousResult` and not a new | ||
* value thus preserving referential equality. | ||
* | ||
* The `previousResult` property is added to our `IdValue`s in the `graphql-anywhere` resolver so | ||
* that they can be in the right position for `resultMapper` to test equality and return whichever | ||
* result is appropriate. | ||
* | ||
* `resultMapper` takes the `previousResult`s and performs a shallow referential equality check. If | ||
* that passes then instead of returning the object created by `graphql-anywhere` the | ||
* `resultMapper` function will instead return the `previousResult`. This process is bottom-up so | ||
* we start at the leaf results and swap them for `previousResult`s all the way up until we get to | ||
* the root object. | ||
*/ | ||
interface IdValueWithPreviousResult extends IdValue { | ||
previousResult?: any; | ||
} | ||
|
||
/** | ||
* Resolves the result of a query solely from the store (i.e. never hits the server). | ||
* | ||
* @param store The {@link NormalizedCache} used by Apollo for the `data` portion of the store. | ||
* @param {Store} store The {@link NormalizedCache} used by Apollo for the `data` portion of the | ||
* store. | ||
* | ||
* @param query The query document to resolve from the data available in the store. | ||
* @param {DocumentNode} query The query document to resolve from the data available in the store. | ||
* | ||
* @param variables A map from the name of a variable to its value. These variables can be | ||
* referenced by the query document. | ||
* @param {Object} [variables] A map from the name of a variable to its value. These variables can | ||
* be referenced by the query document. | ||
* | ||
* @param returnPartialData If set to true, the query will be resolved even if all of the data | ||
* needed to resolve the query is not found in the store. The data keys that are not found will not | ||
* be present in the returned object. If set to false, an error will be thrown if there are fields | ||
* that cannot be resolved from the store. | ||
* @param {boolean} [returnPartialData] If set to true, the query will be resolved even if all of | ||
* the data needed to resolve the query is not found in the store. The data keys that are not found | ||
* will not be present in the returned object. If set to false, an error will be thrown if there | ||
* are fields that cannot be resolved from the store. | ||
* | ||
* @param {any} previousResult The previous result returned by this function for the same query. | ||
* If nothing in the store changed since that previous result then values from the previous result | ||
* will be returned to preserve referential equality. | ||
*/ | ||
export function readQueryFromStore<QueryType>({ | ||
store, | ||
query, | ||
variables, | ||
returnPartialData = false, | ||
config, | ||
}: ReadQueryOptions): QueryType { | ||
const { result } = diffQueryAgainstStore({ | ||
query, | ||
store, | ||
export function readQueryFromStore<QueryType>({ returnPartialData = false, ...options }: ReadQueryOptions): QueryType { | ||
return diffQueryAgainstStore({ | ||
...options, | ||
returnPartialData, | ||
variables, | ||
config, | ||
}); | ||
|
||
return result; | ||
}).result; | ||
} | ||
|
||
type ReadStoreContext = { | ||
|
@@ -134,16 +161,17 @@ match fragments.`); | |
|
||
const readStoreResolver: Resolver = ( | ||
fieldName: string, | ||
idValue: IdValue, | ||
idValue: IdValueWithPreviousResult, | ||
args: any, | ||
context: ReadStoreContext, | ||
{ resultKey }: ExecInfo, | ||
) => { | ||
assertIdValue(idValue); | ||
|
||
const objId = idValue.id; | ||
const obj = context.store[objId]; | ||
const storeKeyName = storeKeyNameFromFieldNameAndArgs(fieldName, args); | ||
const fieldValue = (obj || {})[storeKeyName]; | ||
let fieldValue = (obj || {})[storeKeyName]; | ||
|
||
if (typeof fieldValue === 'undefined') { | ||
if (context.customResolvers && obj && (obj.__typename || objId === 'ROOT_QUERY')) { | ||
|
@@ -170,11 +198,26 @@ Perhaps you want to use the \`returnPartialData\` option?`); | |
return fieldValue; | ||
} | ||
|
||
// if this is an object scalar, it must be a json blob and we have to unescape it | ||
if (isJsonValue(fieldValue)) { | ||
// 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. | ||
// | ||
// `isEqual` will first perform a referential equality check (with `===`) in case the JSON | ||
// value has not changed in the store, and then a deep equality check if that fails in case a | ||
// new JSON object was returned by the API but that object may still be the same. | ||
if (idValue.previousResult && isEqual(idValue.previousResult[resultKey], fieldValue.json)) { | ||
return idValue.previousResult[resultKey]; | ||
} | ||
return fieldValue.json; | ||
} | ||
|
||
// If we had a previous result, try adding that previous result value for this field to our field | ||
// value. This will create a new value without mutating the old one. | ||
if (idValue.previousResult) { | ||
fieldValue = addPreviousResultToIdValues(fieldValue, idValue.previousResult[resultKey]); | ||
} | ||
|
||
return fieldValue; | ||
}; | ||
|
||
|
@@ -184,13 +227,15 @@ Perhaps you want to use the \`returnPartialData\` option?`); | |
* @param {DocumentNode} query A parsed GraphQL query document | ||
* @param {Store} store The Apollo Client store object | ||
* @param {boolean} [returnPartialData] Whether to throw an error if any fields are missing | ||
* @param {any} previousResult The previous result returned by this function for the same query | ||
* @return {result: Object, isMissing: [boolean]} | ||
*/ | ||
export function diffQueryAgainstStore({ | ||
store, | ||
query, | ||
variables, | ||
returnPartialData = true, | ||
previousResult, | ||
config, | ||
}: ReadQueryOptions): DiffResult { | ||
// Throw the right validation error by trying to find a query in the document | ||
|
@@ -209,10 +254,12 @@ export function diffQueryAgainstStore({ | |
const rootIdValue = { | ||
type: 'id', | ||
id: 'ROOT_QUERY', | ||
previousResult, | ||
}; | ||
|
||
const result = graphqlAnywhere(readStoreResolver, query, rootIdValue, context, variables, { | ||
fragmentMatcher, | ||
resultMapper, | ||
}); | ||
|
||
return { | ||
|
@@ -228,3 +275,126 @@ an object reference. This should never happen during normal use unless you have | |
that is directly manipulating the store; please file an issue.`); | ||
} | ||
} | ||
|
||
/** | ||
* Adds a previous result value to id values in a nested array. For a single id value and a single | ||
* previous result then the previous value is added directly. | ||
* | ||
* For arrays we put all of the ids from the previous result array in a map and add them to id | ||
* values with the same id. | ||
* | ||
* This function does not mutate. Instead it returns new instances of modified values. | ||
* | ||
* @private | ||
*/ | ||
function addPreviousResultToIdValues (value: any, previousResult: any): any { | ||
// If the value is an `IdValue`, add the previous result to it whether or not that | ||
// `previousResult` is undefined. | ||
// | ||
// If the value is an array, recurse over each item trying to add the `previousResult` for that | ||
// item. | ||
if (isIdValue(value)) { | ||
return { | ||
...value, | ||
previousResult, | ||
}; | ||
} else if (Array.isArray(value)) { | ||
const idToPreviousResult: { [id: string]: any } = {}; | ||
|
||
// If the previous result was an array, we want to build up our map of ids to previous results | ||
// using the private `ID_KEY` property that is added in `resultMapper`. | ||
if (Array.isArray(previousResult)) { | ||
previousResult.forEach(item => { | ||
if (item[ID_KEY]) { | ||
idToPreviousResult[item[ID_KEY]] = item; | ||
} | ||
}); | ||
} | ||
|
||
// For every value we want to add the previous result. | ||
return value.map((item, i) => { | ||
// By default the previous result for this item will be in the same array position as this | ||
// item. | ||
let itemPreviousResult = previousResult && previousResult[i]; | ||
|
||
// If the item is an id value, we should check to see if there is a previous result for this | ||
// specific id. If there is, that will be the value for `itemPreviousResult`. | ||
if (isIdValue(item)) { | ||
itemPreviousResult = idToPreviousResult[item.id] || itemPreviousResult; | ||
} | ||
|
||
return addPreviousResultToIdValues(item, itemPreviousResult); | ||
}); | ||
} | ||
// Return the value, nothing changed. | ||
return value; | ||
} | ||
|
||
/** | ||
* Maps a result from `graphql-anywhere` to a final result value. | ||
* | ||
* If the result and the previous result from the `idValue` pass a shallow equality test, we just | ||
* return the `previousResult` to maintain referential equality. | ||
* | ||
* We also add a private id property to the result that we can use later on. | ||
* | ||
* @private | ||
*/ | ||
function resultMapper (resultFields: any, idValue: IdValueWithPreviousResult) { | ||
// If we had a previous result, we may be able to return that and preserve referential equality | ||
if (idValue.previousResult) { | ||
const currentResultKeys = Object.keys(resultFields); | ||
|
||
const sameAsPreviousResult = | ||
// Confirm that we have the same keys in both the current result and the previous result. | ||
Object.keys(idValue.previousResult) | ||
.reduce((sameKeys, key) => sameKeys && currentResultKeys.indexOf(key) > -1, true) && | ||
|
||
// Perform a shallow comparison of the result fields with the previous result. If all of | ||
// the shallow fields are referentially equal to the fields of the previous result we can | ||
// just return the previous result. | ||
// | ||
// While we do a shallow comparison of objects, but we do a deep comparison of arrays. | ||
currentResultKeys.reduce((same, key) => ( | ||
same && areNestedArrayItemsStrictlyEqual(resultFields[key], idValue.previousResult[key]) | ||
), true); | ||
|
||
if (sameAsPreviousResult) { | ||
return idValue.previousResult; | ||
} | ||
} | ||
|
||
// Add the id to the result fields. It should be non-enumerable so users can’t see it without | ||
// trying very hard. | ||
Object.defineProperty(resultFields, ID_KEY, { | ||
enumerable: false, | ||
configurable: false, | ||
writable: false, | ||
value: idValue.id, | ||
}); | ||
|
||
return resultFields; | ||
} | ||
|
||
type NestedArray<T> = T | Array<T | Array<T | Array<T>>>; | ||
|
||
/** | ||
* Compare all the items to see if they are all referentially equal in two arrays no matter how | ||
* deeply nested the arrays are. | ||
* | ||
* @private | ||
*/ | ||
function areNestedArrayItemsStrictlyEqual (a: NestedArray<any>, b: NestedArray<any>): boolean { | ||
// If `a` and `b` are referentially equal, return true. | ||
if (a === b) { | ||
return true; | ||
} | ||
// If either `a` or `b` are not an array return false. `a` and `b` are known to not be equal | ||
// here, we checked above. | ||
if (!Array.isArray(a) || !Array.isArray(b)) { | ||
return false; | ||
} | ||
// 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); | ||
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. With this comparison we'd end up with 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. Yep, that’s my bad. Comparing length up front is probably good hygiene anyway. |
||
} |
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.
This misses the important point that this applies to sub-objects in the result, not just the whole result (which was true before!)
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.
Fixed 👍