diff --git a/src/react/hooks/__tests__/compareResults.test.tsx b/src/react/hooks/__tests__/compareResults.test.tsx new file mode 100644 index 00000000000..abf22e2d9b7 --- /dev/null +++ b/src/react/hooks/__tests__/compareResults.test.tsx @@ -0,0 +1,360 @@ +import { gql } from "../../../core"; +import { compareResultsUsingQuery } from "../compareResults"; + +describe("compareResultsUsingQuery", () => { + it("is importable and a function", () => { + expect(typeof compareResultsUsingQuery).toBe("function"); + }); + + it("works with a basic single-field query", () => { + const query = gql` + query { + hello + } + `; + + expect(compareResultsUsingQuery( + query, + { hello: "hi" }, + { hello: "hi" }, + )).toBe(true); + + expect(compareResultsUsingQuery( + query, + { hello: "hi", unrelated: 1 }, + { hello: "hi", unrelated: 100 }, + )).toBe(true); + + expect(compareResultsUsingQuery( + query, + { hello: "hi" }, + { hello: "hey" }, + )).toBe(false); + + expect(compareResultsUsingQuery( + query, + {}, + { hello: "hi" }, + )).toBe(false); + + expect(compareResultsUsingQuery( + query, + { hello: "hi" }, + {}, + )).toBe(false); + + expect(compareResultsUsingQuery( + query, + { hello: "hi" }, + null, + )).toBe(false); + + expect(compareResultsUsingQuery( + query, + null, + { hello: "hi" }, + )).toBe(false); + + expect(compareResultsUsingQuery( + query, + null, + null, + )).toBe(true); + + expect(compareResultsUsingQuery( + query, + {}, + {}, + )).toBe(true); + + expect(compareResultsUsingQuery( + query, + { unrelated: "whatever" }, + { unrelated: "no matter" }, + )).toBe(true); + }); + + it("is not confused by properties in different orders", () => { + const query = gql` + query { + a + b + c + } + `; + + expect(compareResultsUsingQuery( + query, + { a: 1, b: 2, c: 3 }, + { b: 2, c: 3, a: 1 }, + )).toBe(true); + + expect(compareResultsUsingQuery( + query, + { d: "bogus", a: 1, b: 2, c: 3 }, + { b: 2, c: 3, a: 1, d: "also bogus" }, + )).toBe(true); + }); + + it("respects the @nonreactive directive on fields", () => { + const query = gql` + query { + a + b + c @nonreactive + } + `; + + expect(compareResultsUsingQuery( + query, + { a: 1, b: 2, c: 3 }, + { a: 1, b: 2, c: "different" }, + )).toBe(true); + + expect(compareResultsUsingQuery( + query, + { a: 1, b: 2, c: 3 }, + { a: "different", b: 2, c: 4 }, + )).toBe(false); + }); + + it("respects the @nonreactive directive on inline fragments", () => { + const query = gql` + query { + a + ... @nonreactive { + b + c + } + } + `; + + expect(compareResultsUsingQuery( + query, + { a: 1, b: 2, c: 3 }, + { a: 1, b: 20, c: 30 }, + )).toBe(true); + + expect(compareResultsUsingQuery( + query, + { a: 1, b: 2, c: 3 }, + { a: 10, b: 20, c: 30 }, + )).toBe(false); + }); + + it("respects the @nonreactive directive on named fragment ...spreads", () => { + const query = gql` + query { + a + ...BCFragment @nonreactive + } + + fragment BCFragment on Query { + b + c + } + `; + + expect(compareResultsUsingQuery( + query, + { a: 1, b: 2, c: 3 }, + { a: 1, b: 2, c: 30 }, + )).toBe(true); + + expect(compareResultsUsingQuery( + query, + { a: 1, b: 2, c: 3 }, + { a: 1, b: 20, c: 3 }, + )).toBe(true); + + expect(compareResultsUsingQuery( + query, + { a: 1, b: 2, c: 3 }, + { a: 1, b: 20, c: 30 }, + )).toBe(true); + + expect(compareResultsUsingQuery( + query, + { a: 1, b: 2, c: 3 }, + { a: 10, b: 20, c: 30 }, + )).toBe(false); + }); + + it("respects the @nonreactive directive on named fragment definitions", () => { + const query = gql` + query { + a + ...BCFragment + } + + fragment BCFragment on Query @nonreactive { + b + c + } + `; + + expect(compareResultsUsingQuery( + query, + { a: 1, b: 2, c: 3 }, + { a: 1, b: 2, c: 30 }, + )).toBe(true); + + expect(compareResultsUsingQuery( + query, + { a: 1, b: 2, c: 3 }, + { a: 1, b: 20, c: 3 }, + )).toBe(true); + + expect(compareResultsUsingQuery( + query, + { a: 1, b: 2, c: 3 }, + { a: 1, b: 20, c: 30 }, + )).toBe(true); + + expect(compareResultsUsingQuery( + query, + { a: 1, b: 2, c: 3 }, + { a: 10, b: 20, c: 30 }, + )).toBe(false); + }); + + it("traverses fragments without @nonreactive", () => { + const query = gql` + query { + a + ...BCFragment + } + + fragment BCFragment on Query { + b + c + } + `; + + expect(compareResultsUsingQuery( + query, + { a: 1, b: 2, c: 3 }, + { a: 1, b: 2, c: 3 }, + )).toBe(true); + + expect(compareResultsUsingQuery( + query, + { a: 1, b: 2, c: 3 }, + { c: 3, a: 1, b: 2 }, + )).toBe(true); + + expect(compareResultsUsingQuery( + query, + { a: 1, b: 2, c: 3 }, + { a: 1, b: 2, c: 30 }, + )).toBe(false); + + expect(compareResultsUsingQuery( + query, + { a: 1, b: 2, c: 3 }, + { a: 1, b: 20, c: 3 }, + )).toBe(false); + + expect(compareResultsUsingQuery( + query, + { a: 1, b: 2, c: 3 }, + { a: 1, b: 20, c: 30 }, + )).toBe(false); + + expect(compareResultsUsingQuery( + query, + { a: 1, b: 2, c: 3 }, + { a: 10, b: 20, c: 30 }, + )).toBe(false); + }); + + it("iterates over array-valued result fields", () => { + const query = gql` + query { + things { + __typename + id + ...ThingDetails + } + } + + fragment ThingDetails on Thing { + stable + volatile @nonreactive + } + `; + + const makeThing = (id: string, stable = 1234) => ({ + __typename: "Thing", + id, + stable, + volatile: Math.random(), + }); + + expect(compareResultsUsingQuery( + query, + { things: "abc".split("").map(id => makeThing(id)) }, + { things: [makeThing("a"), makeThing("b"), makeThing("c")] }, + )).toBe(true); + + expect(compareResultsUsingQuery( + query, + { things: "abc".split("").map(id => makeThing(id)) }, + { things: "not an array" }, + )).toBe(false); + + expect(compareResultsUsingQuery( + query, + { things: {} }, + { things: [] }, + )).toBe(false); + + expect(compareResultsUsingQuery( + query, + { things: [] }, + { things: {} }, + )).toBe(false); + + expect(compareResultsUsingQuery( + query, + { things: [] }, + { things: [] }, + )).toBe(true); + + expect(compareResultsUsingQuery( + query, + { things: {} }, + { things: {} }, + )).toBe(true); + + expect(compareResultsUsingQuery( + query, + { things: "ab".split("").map(id => makeThing(id)) }, + { things: [makeThing("a"), makeThing("b")] }, + )).toBe(true); + + expect(compareResultsUsingQuery( + query, + { things: "ab".split("").map(id => makeThing(id)) }, + { things: [makeThing("b"), makeThing("a")] }, + )).toBe(false); + + expect(compareResultsUsingQuery( + query, + { things: "ab".split("").map(id => makeThing(id)) }, + { things: [makeThing("a"), makeThing("b", 2345)] }, + )).toBe(false); + + expect(compareResultsUsingQuery( + query, + { things: "ab".split("").map(id => makeThing(id)) }, + { things: [makeThing("a", 3456), makeThing("b")] }, + )).toBe(false); + + expect(compareResultsUsingQuery( + query, + { things: "ab".split("").map(id => makeThing(id)) }, + { things: [makeThing("b"), makeThing("a")] }, + )).toBe(false); + }); +}); diff --git a/src/react/hooks/compareResults.ts b/src/react/hooks/compareResults.ts new file mode 100644 index 00000000000..b2653b06cb6 --- /dev/null +++ b/src/react/hooks/compareResults.ts @@ -0,0 +1,149 @@ +import equal from "@wry/equality"; + +import { + DirectiveNode, + DocumentNode, + FieldNode, + FragmentDefinitionNode, + FragmentSpreadNode, + InlineFragmentNode, + SelectionNode, + SelectionSetNode, +} from "graphql"; + +import { + createFragmentMap, + FragmentMap, + getFragmentDefinitions, + getFragmentFromSelection, + getMainDefinition, + isField, + resultKeyNameFromField, + shouldInclude, +} from "../../utilities"; + +// Returns true if aResult and bResult are deeply equal according to the fields +// selected by the given query, ignoring any fields marked as @nonreactive. +export function compareResultsUsingQuery( + query: DocumentNode, + aResult: any, + bResult: any, + variables?: Record | undefined, +): boolean { + if (aResult === bResult) return true; + return compareResultsUsingSelectionSet( + getMainDefinition(query).selectionSet, + aResult, + bResult, + { + fragmentMap: createFragmentMap(getFragmentDefinitions(query)), + variables, + }, + ); +} + +// Encapsulates the information used by compareResultsUsingSelectionSet that +// does not change during the recursion. +interface CompareContext { + fragmentMap: FragmentMap; + variables: Record | undefined; +} + +function compareResultsUsingSelectionSet( + selectionSet: SelectionSetNode, + aResult: any, + bResult: any, + context: CompareContext, +): boolean { + const seenSelections = new Set(); + + // Returning true from this Array.prototype.every callback function skips the + // current field/subtree. Returning false aborts the entire traversal + // immediately, causing compareResultsUsingSelectionSet to return false. + return selectionSet.selections.every(selection => { + // Avoid re-processing the same selection at the same level of recursion, in + // case the same field gets included via multiple indirect fragment spreads. + if (seenSelections.has(selection)) return true; + seenSelections.add(selection); + + // Ignore @skip(if: true) and @include(if: false) fields. + if (!shouldInclude(selection, context.variables)) return true; + + // If the field or (named) fragment spread has a @nonreactive directive on + // it, we don't care if it's different, so we pretend it's the same. + if (selectionHasNonreactiveDirective(selection)) return true; + + if (isField(selection)) { + const resultKey = resultKeyNameFromField(selection); + const aResultChild = aResult && aResult[resultKey]; + const bResultChild = bResult && bResult[resultKey]; + const childSelectionSet = selection.selectionSet; + + if (!childSelectionSet) { + // These are scalar values, so we can compare them with deep equal + // without redoing the main recursive work. + return equal(aResultChild, bResultChild); + } + + const aChildIsArray = Array.isArray(aResultChild); + const bChildIsArray = Array.isArray(bResultChild); + if (aChildIsArray !== bChildIsArray) return false; + if (aChildIsArray && bChildIsArray) { + const length = aResultChild.length; + if (bResultChild.length !== length) { + return false; + } + for (let i = 0; i < length; ++i) { + if (!compareResultsUsingSelectionSet( + childSelectionSet, + aResultChild[i], + bResultChild[i], + context, + )) { + return false; + } + } + return true; + } + + return compareResultsUsingSelectionSet( + childSelectionSet, + aResultChild, + bResultChild, + context, + ); + + } else { + const fragment = getFragmentFromSelection(selection, context.fragmentMap); + if (fragment) { + // The fragment might === selection if it's an inline fragment, but + // could be !== if it's a named fragment ...spread. + if (selectionHasNonreactiveDirective(fragment)) return true; + + return compareResultsUsingSelectionSet( + fragment.selectionSet, + // Notice that we reuse the same aResult and bResult values here, + // since the fragment ...spread does not specify a field name, but + // consists of multiple fields (within the fragment's selection set) + // that should be applied to the current result value(s). + aResult, + bResult, + context, + ); + } + } + }); +} + +function selectionHasNonreactiveDirective(selection: + | FieldNode + | InlineFragmentNode + | FragmentSpreadNode + | FragmentDefinitionNode, +): boolean { + return !!selection.directives && selection.directives.some(directiveIsNonreactive); +} + +function directiveIsNonreactive(dir: DirectiveNode): boolean { + return dir.name.value === "nonreactive"; +}