diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index d3ce1568654..797a7b9331b 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -490,6 +490,7 @@ Array [ "removeFragmentSpreadFromDocument", "resultKeyNameFromField", "shouldInclude", + "shouldMask", "storeKeyNameFromField", "stringifyForDisplay", "stripTypename", diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index d89876d85c9..df1d9579771 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -17,6 +17,7 @@ import { isReference, makeReference, shouldInclude, + shouldMask, addTypenameToDocument, getDefaultValues, getMainDefinition, @@ -377,7 +378,7 @@ export class StoreReader { workSet.forEach((selection) => { // Omit fields with directives @skip(if: ) or // @include(if: ). - if (!shouldInclude(selection, variables)) return; + if (shouldMask(selection) || !shouldInclude(selection, variables)) return; if (isField(selection)) { let fieldValue = policies.readField( diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 92029d9a6f1..729788b8a93 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -690,6 +690,7 @@ export class QueryManager { { name: "client", remove: true }, { name: "connection" }, { name: "nonreactive" }, + { name: 'mask' }, ], document ), diff --git a/src/react/hooks/__tests__/useFragmentWithMask.test.tsx b/src/react/hooks/__tests__/useFragmentWithMask.test.tsx new file mode 100644 index 00000000000..ba9792d7d2c --- /dev/null +++ b/src/react/hooks/__tests__/useFragmentWithMask.test.tsx @@ -0,0 +1,673 @@ +import * as React from "react"; +import { render, waitFor, screen } from "@testing-library/react"; +import { act } from "react-dom/test-utils"; + +import { useFragment } from "../useFragment"; +import { ApolloProvider } from "../../context"; +import { + InMemoryCache, + gql, + TypedDocumentNode, + ApolloClient, + ApolloLink, +} from "../../../core"; +import { useQuery } from "../useQuery"; +import { concatPagination } from "../../../utilities"; + +describe("useFragment", () => { + it("is importable and callable", () => { + expect(typeof useFragment).toBe("function"); + }); + + type SubItem = { + __typename: string; + id: number; + text?: string; + }; + + type Item = { + __typename: string; + id: number; + text?: string; + subItem: SubItem; + }; + + const SubItemFragment: TypedDocumentNode = gql` + fragment SubItemFragment on SubItem { + text + } + `; + + const ItemFragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + text + subItem { + id + ...SubItemFragment @mask + } + } + ${SubItemFragment} + `; + + it.each>([ + // This query uses a basic field-level directive. + gql` + query GetItems { + list { + id + text @mask + subItem { + id + text @mask + } + } + } + `, + // // This query uses on an anonymous/inline ...spread directive. + gql` + query GetItems { + list { + id + ... @mask { + text + subItem { + id + ... @mask { + text + } + } + } + } + } + `, + // This query uses on a ...spread with a type condition. + gql` + query GetItems { + list { + id + ... on Item @mask { + text + subItem { + id + ... on SubItem @mask { + text + } + } + } + } + } + `, + // This query uses directive on a named fragment ...spread. + gql` + query GetItems { + list { + id + ...ItemFragment @mask + } + } + + ${ItemFragment} + `, + ])( + "Parent list component can use to avoid rerendering", + async (query) => { + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + list: concatPagination(), + }, + }, + Item: { + keyFields: ["id"], + // Configuring keyArgs:false for Item.text is one way to prevent field + // keys like text, but it's not the only way. Since + // is now in the KNOWN_DIRECTIVES array defined in + // utilities/graphql/storeUtils.ts, the '' suffix won't be + // automatically appended to field keys by default. + // fields: { + // text: { + // keyArgs: false, + // }, + // }, + }, + SubItem: { + keyFields: ["id"], + }, + }, + }); + + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + }); + + const renders: string[] = []; + + function List() { + const response = useQuery(query); + + const { data } = response; + renders.push("list"); + + return ( +
    + {data?.list.map((item) => ( + + ))} +
+ ); + } + + function Item({ itemId }: { itemId: number }) { + const { data } = useFragment({ + fragment: ItemFragment, + fragmentName: "ItemFragment", + from: { + __typename: "Item", + id: itemId, + }, + }); + + renders.push(`item ${itemId}`); + + if (!data || !data.subItem?.id) return null; + return ( +
  • + {`Item #${itemId}: ${data.text}`} + +
  • + ); + } + + function SubItem({ subItemId }: { subItemId: number }) { + const { data } = useFragment({ + fragment: SubItemFragment, + fragmentName: "SubItemFragment", + from: { + __typename: "SubItem", + id: subItemId, + }, + }); + + renders.push(`subItem ${subItemId}`); + + if (!data) return null; + + return {` Sub #${subItemId}: ${data.text}`}; + } + + act(() => { + cache.writeQuery({ + query, + data: { + list: [ + { + __typename: "Item", + id: 1, + text: "first", + subItem: { __typename: "SubItem", id: 1, text: "first Sub" }, + }, + { + __typename: "Item", + id: 2, + text: "second", + subItem: { __typename: "SubItem", id: 2, text: "second Sub" }, + }, + { + __typename: "Item", + id: 3, + text: "third", + subItem: { __typename: "SubItem", id: 3, text: "third Sub" }, + }, + ], + }, + }); + }); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + list: [ + { __ref: 'Item:{"id":1}' }, + { __ref: 'Item:{"id":2}' }, + { __ref: 'Item:{"id":3}' }, + ], + }, + 'Item:{"id":1}': { + __typename: "Item", + id: 1, + text: "first", + subItem: { + __ref: 'SubItem:{"id":1}', + }, + }, + 'Item:{"id":2}': { + __typename: "Item", + id: 2, + text: "second", + subItem: { + __ref: 'SubItem:{"id":2}', + }, + }, + 'Item:{"id":3}': { + __typename: "Item", + id: 3, + text: "third", + subItem: { + __ref: 'SubItem:{"id":3}', + }, + }, + 'SubItem:{"id":1}': { + __typename: "SubItem", + id: 1, + text: "first Sub", + }, + 'SubItem:{"id":2}': { + __typename: "SubItem", + id: 2, + text: "second Sub", + }, + 'SubItem:{"id":3}': { + __typename: "SubItem", + id: 3, + text: "third Sub", + }, + }); + + render( + + + + ); + + function getItemTexts() { + return screen.getAllByText(/Item #\d+/).map((el) => el.textContent); + } + + await waitFor(() => { + expect(getItemTexts()).toEqual([ + "Item #1: first Sub #1: first Sub", + "Item #2: second Sub #2: second Sub", + "Item #3: third Sub #3: third Sub", + ]); + }); + + expect(renders).toEqual([ + "list", + "item 1", + "subItem 1", + "item 2", + "subItem 2", + "item 3", + "subItem 3", + ]); + + function appendLyToText(id: number) { + act(() => { + cache.modify({ + id: cache.identify({ __typename: "Item", id })!, + fields: { + text(existing) { + return existing + "ly"; + }, + }, + }); + }); + } + + function appendLyToSubText(id: number) { + act(() => { + cache.modify({ + id: cache.identify({ __typename: "SubItem", id })!, + fields: { + text(existing) { + return existing + "ly"; + }, + }, + }); + }); + } + + appendLyToText(2); + + await waitFor(() => { + expect(renders).toEqual([ + "list", + "item 1", + "subItem 1", + "item 2", + "subItem 2", + "item 3", + "subItem 3", + "item 2", + "subItem 2", + ]); + + expect(getItemTexts()).toEqual([ + "Item #1: first Sub #1: first Sub", + "Item #2: secondly Sub #2: second Sub", + "Item #3: third Sub #3: third Sub", + ]); + }); + + appendLyToText(1); + + await waitFor(() => { + expect(renders).toEqual([ + "list", + "item 1", + "subItem 1", + "item 2", + "subItem 2", + "item 3", + "subItem 3", + "item 2", + "subItem 2", + "item 1", + "subItem 1", + ]); + + expect(getItemTexts()).toEqual([ + "Item #1: firstly Sub #1: first Sub", + "Item #2: secondly Sub #2: second Sub", + "Item #3: third Sub #3: third Sub", + ]); + }); + + appendLyToText(3); + + await waitFor(() => { + expect(renders).toEqual([ + "list", + "item 1", + "subItem 1", + "item 2", + "subItem 2", + "item 3", + "subItem 3", + "item 2", + "subItem 2", + "item 1", + "subItem 1", + "item 3", + "subItem 3", + ]); + + expect(getItemTexts()).toEqual([ + "Item #1: firstly Sub #1: first Sub", + "Item #2: secondly Sub #2: second Sub", + "Item #3: thirdly Sub #3: third Sub", + ]); + }); + + act(() => { + cache.writeQuery({ + query, + data: { + list: [ + { + __typename: "Item", + id: 4, + text: "fourth", + subItem: { __typename: "SubItem", id: 4, text: "fourthSub" }, + }, + { + __typename: "Item", + id: 5, + text: "fifth", + subItem: { __typename: "SubItem", id: 5, text: "fifthSub" }, + }, + ], + }, + }); + }); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + list: [ + { __ref: 'Item:{"id":1}' }, + { __ref: 'Item:{"id":2}' }, + { __ref: 'Item:{"id":3}' }, + { __ref: 'Item:{"id":4}' }, + { __ref: 'Item:{"id":5}' }, + ], + }, + 'Item:{"id":1}': { + __typename: "Item", + id: 1, + text: "firstly", + subItem: { + __ref: 'SubItem:{"id":1}', + }, + }, + 'Item:{"id":2}': { + __typename: "Item", + id: 2, + text: "secondly", + subItem: { + __ref: 'SubItem:{"id":2}', + }, + }, + 'Item:{"id":3}': { + __typename: "Item", + id: 3, + text: "thirdly", + subItem: { + __ref: 'SubItem:{"id":3}', + }, + }, + 'Item:{"id":4}': { + __typename: "Item", + id: 4, + text: "fourth", + subItem: { + __ref: 'SubItem:{"id":4}', + }, + }, + 'Item:{"id":5}': { + __typename: "Item", + id: 5, + text: "fifth", + subItem: { + __ref: 'SubItem:{"id":5}', + }, + }, + 'SubItem:{"id":1}': { + __typename: "SubItem", + id: 1, + text: "first Sub", + }, + 'SubItem:{"id":2}': { + __typename: "SubItem", + id: 2, + text: "second Sub", + }, + 'SubItem:{"id":3}': { + __typename: "SubItem", + id: 3, + text: "third Sub", + }, + 'SubItem:{"id":4}': { + __typename: "SubItem", + id: 4, + text: "fourthSub", + }, + 'SubItem:{"id":5}': { + __typename: "SubItem", + id: 5, + text: "fifthSub", + }, + }); + + await waitFor(() => { + expect(renders).toEqual([ + "list", + "item 1", + "subItem 1", + "item 2", + "subItem 2", + "item 3", + "subItem 3", + "item 2", + "subItem 2", + "item 1", + "subItem 1", + "item 3", + "subItem 3", + "list", + "item 1", + "subItem 1", + "item 2", + "subItem 2", + "item 3", + "subItem 3", + "item 4", + "subItem 4", + "item 5", + "subItem 5", + ]); + + expect(getItemTexts()).toEqual([ + "Item #1: firstly Sub #1: first Sub", + "Item #2: secondly Sub #2: second Sub", + "Item #3: thirdly Sub #3: third Sub", + "Item #4: fourth Sub #4: fourthSub", + "Item #5: fifth Sub #5: fifthSub", + ]); + }); + + appendLyToText(5); + + await waitFor(() => { + expect(renders).toEqual([ + "list", + "item 1", + "subItem 1", + "item 2", + "subItem 2", + "item 3", + "subItem 3", + "item 2", + "subItem 2", + "item 1", + "subItem 1", + "item 3", + "subItem 3", + "list", + "item 1", + "subItem 1", + "item 2", + "subItem 2", + "item 3", + "subItem 3", + "item 4", + "subItem 4", + "item 5", + "subItem 5", + // A single new render: + "item 5", + "subItem 5", + ]); + + expect(getItemTexts()).toEqual([ + "Item #1: firstly Sub #1: first Sub", + "Item #2: secondly Sub #2: second Sub", + "Item #3: thirdly Sub #3: third Sub", + "Item #4: fourth Sub #4: fourthSub", + "Item #5: fifthly Sub #5: fifthSub", + ]); + }); + + appendLyToText(4); + + await waitFor(() => { + expect(renders).toEqual([ + "list", + "item 1", + "subItem 1", + "item 2", + "subItem 2", + "item 3", + "subItem 3", + "item 2", + "subItem 2", + "item 1", + "subItem 1", + "item 3", + "subItem 3", + "list", + "item 1", + "subItem 1", + "item 2", + "subItem 2", + "item 3", + "subItem 3", + "item 4", + "subItem 4", + "item 5", + "subItem 5", + "item 5", + "subItem 5", + // A single new render: + "item 4", + "subItem 4", + ]); + + expect(getItemTexts()).toEqual([ + "Item #1: firstly Sub #1: first Sub", + "Item #2: secondly Sub #2: second Sub", + "Item #3: thirdly Sub #3: third Sub", + "Item #4: fourthly Sub #4: fourthSub", + "Item #5: fifthly Sub #5: fifthSub", + ]); + }); + + appendLyToSubText(1); + + await waitFor(() => { + expect(renders).toEqual([ + "list", + "item 1", + "subItem 1", + "item 2", + "subItem 2", + "item 3", + "subItem 3", + "item 2", + "subItem 2", + "item 1", + "subItem 1", + "item 3", + "subItem 3", + "list", + "item 1", + "subItem 1", + "item 2", + "subItem 2", + "item 3", + "subItem 3", + "item 4", + "subItem 4", + "item 5", + "subItem 5", + "item 5", + "subItem 5", + "item 4", + "subItem 4", + // A single new render: + "subItem 1", + ]); + + expect(getItemTexts()).toEqual([ + "Item #1: firstly Sub #1: first Subly", + "Item #2: secondly Sub #2: second Sub", + "Item #3: thirdly Sub #3: third Sub", + "Item #4: fourthly Sub #4: fourthSub", + "Item #5: fifthly Sub #5: fifthSub", + ]); + }); + } + ); +}); diff --git a/src/utilities/graphql/directives.ts b/src/utilities/graphql/directives.ts index 797823f00f1..f2b6bccc482 100644 --- a/src/utilities/graphql/directives.ts +++ b/src/utilities/graphql/directives.ts @@ -18,6 +18,18 @@ export type DirectiveInfo = { [fieldName: string]: { [argName: string]: any }; }; +export function shouldMask(selection: SelectionNode) { + + const { directives } = selection + if (!directives || !directives.length) { + return false; + } + + return directives.some(directive => { + return directive.name.value === "mask" + }) +} + export function shouldInclude( { directives }: SelectionNode, variables?: Record diff --git a/src/utilities/graphql/storeUtils.ts b/src/utilities/graphql/storeUtils.ts index c96e0628eb8..cfd050b787e 100644 --- a/src/utilities/graphql/storeUtils.ts +++ b/src/utilities/graphql/storeUtils.ts @@ -211,6 +211,7 @@ const KNOWN_DIRECTIVES: string[] = [ "rest", "export", "nonreactive", + 'mask' ]; // Default stable JSON.stringify implementation used by getStoreKeyName. Can be diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 637ae100af7..4cebdd64a5a 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -6,6 +6,7 @@ export type { } from "./graphql/directives.js"; export { shouldInclude, + shouldMask, hasDirectives, hasAnyDirectives, hasAllDirectives,