From 1e4f9e34c5d92f59f50fc9abb4a5a6ee69a9eae0 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 27 Aug 2024 09:56:53 -0600 Subject: [PATCH] Add data masking to fragments read by `useFragment`/`watchFragment` (#12018) --- .api-reports/api-report-cache.api.md | 9 + .api-reports/api-report-core.api.md | 17 +- .api-reports/api-report-react.api.md | 17 +- .../api-report-react_components.api.md | 17 +- .api-reports/api-report-react_context.api.md | 17 +- .api-reports/api-report-react_hoc.api.md | 17 +- .api-reports/api-report-react_hooks.api.md | 17 +- .api-reports/api-report-react_internal.api.md | 17 +- .api-reports/api-report-react_ssr.api.md | 17 +- .api-reports/api-report-testing.api.md | 17 +- .api-reports/api-report-testing_core.api.md | 17 +- .api-reports/api-report-utilities.api.md | 17 +- .api-reports/api-report.api.md | 17 +- .size-limits.json | 4 +- src/__tests__/dataMasking.ts | 876 ++++++++++++++++++ src/cache/core/__tests__/cache.ts | 70 ++ src/cache/core/cache.ts | 48 +- src/cache/index.ts | 1 + src/core/ApolloClient.ts | 39 +- src/core/QueryManager.ts | 10 +- .../hooks/__tests__/useFragment.test.tsx | 304 +++++- src/react/hooks/useFragment.ts | 34 +- 22 files changed, 1554 insertions(+), 45 deletions(-) diff --git a/.api-reports/api-report-cache.api.md b/.api-reports/api-report-cache.api.md index f7723ea6c0d..452c2e2e068 100644 --- a/.api-reports/api-report-cache.api.md +++ b/.api-reports/api-report-cache.api.md @@ -40,6 +40,8 @@ export abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; + // (undocumented) maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; @@ -702,6 +704,13 @@ export function makeReference(id: string): Reference; // @public (undocumented) export function makeVar(value: T): ReactiveVar; +// @public (undocumented) +export interface MaskFragmentOptions { + data: TData; + fragment: DocumentNode | TypedDocumentNode; + fragmentName?: string; +} + // @public (undocumented) export interface MergeInfo { // (undocumented) diff --git a/.api-reports/api-report-core.api.md b/.api-reports/api-report-core.api.md index 916ddeae548..3771973942a 100644 --- a/.api-reports/api-report-core.api.md +++ b/.api-reports/api-report-core.api.md @@ -54,6 +54,10 @@ export abstract class ApolloCache implements DataProxy { getMemoryInternals?: typeof getApolloCacheMemoryInternals; // (undocumented) identify(object: StoreObject | Reference): string | undefined; + // Warning: (ae-forgotten-export) The symbol "MaskFragmentOptions" needs to be exported by the entry point index.d.ts + // + // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; // (undocumented) maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) @@ -1311,6 +1315,13 @@ export function makeReference(id: string): Reference; // @public (undocumented) export function makeVar(value: T): ReactiveVar; +// @public (undocumented) +interface MaskFragmentOptions { + data: TData; + fragment: DocumentNode | TypedDocumentNode; + fragmentName?: string; +} + // @public (undocumented) type MaybeAsync = T | PromiseLike; @@ -1847,6 +1858,8 @@ class QueryManager { keepRootFields?: boolean; }, cache?: ApolloCache): Promise>; // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; + // (undocumented) mutate, TCache extends ApolloCache>({ mutation, variables, optimisticResponse, updateQueries, refetchQueries, awaitRefetchQueries, update: updateWithProxyFn, onQueryUpdated, fetchPolicy, errorPolicy, keepRootFields, context, }: MutationOptions): Promise>; // (undocumented) mutationStore?: { @@ -2330,8 +2343,8 @@ interface WriteContext extends ReadMergeModifyContext { // src/cache/inmemory/types.ts:139:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:116:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:117:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:140:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:385:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:144:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:389:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:275:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts // src/link/http/selectHttpOptionsAndBody.ts:128:32 - (ae-forgotten-export) The symbol "HttpQueryOptions" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-react.api.md b/.api-reports/api-report-react.api.md index 1c9c4fd40d2..b1029cefff5 100644 --- a/.api-reports/api-report-react.api.md +++ b/.api-reports/api-report-react.api.md @@ -52,6 +52,10 @@ abstract class ApolloCache implements DataProxy { // // (undocumented) identify(object: StoreObject | Reference): string | undefined; + // Warning: (ae-forgotten-export) The symbol "MaskFragmentOptions" needs to be exported by the entry point index.d.ts + // + // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; // (undocumented) maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) @@ -1068,6 +1072,13 @@ type LocalStateOptions = { fragmentMatcher?: FragmentMatcher; }; +// @public (undocumented) +interface MaskFragmentOptions { + data: TData; + fragment: DocumentNode | TypedDocumentNode; + fragmentName?: string; +} + // @public (undocumented) type MaybeAsync = T | PromiseLike; @@ -1625,6 +1636,8 @@ class QueryManager { keepRootFields?: boolean; }, cache?: ApolloCache): Promise>; // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; + // (undocumented) mutate, TCache extends ApolloCache>({ mutation, variables, optimisticResponse, updateQueries, refetchQueries, awaitRefetchQueries, update: updateWithProxyFn, onQueryUpdated, fetchPolicy, errorPolicy, keepRootFields, context, }: MutationOptions): Promise>; // (undocumented) mutationStore?: { @@ -2354,8 +2367,8 @@ interface WatchQueryOptions implements DataProxy { // // (undocumented) identify(object: StoreObject | Reference): string | undefined; + // Warning: (ae-forgotten-export) The symbol "MaskFragmentOptions" needs to be exported by the entry point index.d.ts + // + // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; // (undocumented) maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) @@ -929,6 +933,13 @@ type LocalStateOptions = { fragmentMatcher?: FragmentMatcher; }; +// @public (undocumented) +interface MaskFragmentOptions { + data: TData; + fragment: DocumentNode | TypedDocumentNode; + fragmentName?: string; +} + // @public (undocumented) type MaybeAsync = T | PromiseLike; @@ -1439,6 +1450,8 @@ class QueryManager { keepRootFields?: boolean; }, cache?: ApolloCache): Promise>; // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; + // (undocumented) mutate, TCache extends ApolloCache>({ mutation, variables, optimisticResponse, updateQueries, refetchQueries, awaitRefetchQueries, update: updateWithProxyFn, onQueryUpdated, fetchPolicy, errorPolicy, keepRootFields, context, }: MutationOptions): Promise>; // (undocumented) mutationStore?: { @@ -1833,8 +1846,8 @@ interface WatchQueryOptions implements DataProxy { // // (undocumented) identify(object: StoreObject | Reference): string | undefined; + // Warning: (ae-forgotten-export) The symbol "MaskFragmentOptions" needs to be exported by the entry point index.d.ts + // + // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; // (undocumented) maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) @@ -926,6 +930,13 @@ type LocalStateOptions = { fragmentMatcher?: FragmentMatcher; }; +// @public (undocumented) +interface MaskFragmentOptions { + data: TData; + fragment: DocumentNode | TypedDocumentNode; + fragmentName?: string; +} + // @public (undocumented) type MaybeAsync = T | PromiseLike; @@ -1367,6 +1378,8 @@ class QueryManager { keepRootFields?: boolean; }, cache?: ApolloCache): Promise>; // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; + // (undocumented) mutate, TCache extends ApolloCache>({ mutation, variables, optimisticResponse, updateQueries, refetchQueries, awaitRefetchQueries, update: updateWithProxyFn, onQueryUpdated, fetchPolicy, errorPolicy, keepRootFields, context, }: MutationOptions): Promise>; // (undocumented) mutationStore?: { @@ -1753,8 +1766,8 @@ interface WatchQueryOptions implements DataProxy { // // (undocumented) identify(object: StoreObject | Reference): string | undefined; + // Warning: (ae-forgotten-export) The symbol "MaskFragmentOptions" needs to be exported by the entry point index.d.ts + // + // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; // (undocumented) maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) @@ -933,6 +937,13 @@ type LocalStateOptions = { fragmentMatcher?: FragmentMatcher; }; +// @public (undocumented) +interface MaskFragmentOptions { + data: TData; + fragment: DocumentNode | TypedDocumentNode; + fragmentName?: string; +} + // @public (undocumented) type MaybeAsync = T | PromiseLike; @@ -1412,6 +1423,8 @@ class QueryManager { keepRootFields?: boolean; }, cache?: ApolloCache): Promise>; // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; + // (undocumented) mutate, TCache extends ApolloCache>({ mutation, variables, optimisticResponse, updateQueries, refetchQueries, awaitRefetchQueries, update: updateWithProxyFn, onQueryUpdated, fetchPolicy, errorPolicy, keepRootFields, context, }: MutationOptions): Promise>; // (undocumented) mutationStore?: { @@ -1780,8 +1793,8 @@ export function withSubscription implements DataProxy { // // (undocumented) identify(object: StoreObject | Reference): string | undefined; + // Warning: (ae-forgotten-export) The symbol "MaskFragmentOptions" needs to be exported by the entry point index.d.ts + // + // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; // (undocumented) maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) @@ -1017,6 +1021,13 @@ type LocalStateOptions = { fragmentMatcher?: FragmentMatcher; }; +// @public (undocumented) +interface MaskFragmentOptions { + data: TData; + fragment: DocumentNode | TypedDocumentNode; + fragmentName?: string; +} + // @public (undocumented) type MaybeAsync = T | PromiseLike; @@ -1494,6 +1505,8 @@ class QueryManager { keepRootFields?: boolean; }, cache?: ApolloCache): Promise>; // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; + // (undocumented) mutate, TCache extends ApolloCache>({ mutation, variables, optimisticResponse, updateQueries, refetchQueries, awaitRefetchQueries, update: updateWithProxyFn, onQueryUpdated, fetchPolicy, errorPolicy, keepRootFields, context, }: MutationOptions): Promise>; // (undocumented) mutationStore?: { @@ -2178,8 +2191,8 @@ interface WatchQueryOptions implements DataProxy { // // (undocumented) identify(object: StoreObject | Reference): string | undefined; + // Warning: (ae-forgotten-export) The symbol "MaskFragmentOptions" needs to be exported by the entry point index.d.ts + // + // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; // (undocumented) maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) @@ -1027,6 +1031,13 @@ type LocalStateOptions = { fragmentMatcher?: FragmentMatcher; }; +// @public (undocumented) +interface MaskFragmentOptions { + data: TData; + fragment: DocumentNode | TypedDocumentNode; + fragmentName?: string; +} + // @public (undocumented) type MaybeAsync = T | PromiseLike; @@ -1545,6 +1556,8 @@ class QueryManager { keepRootFields?: boolean; }, cache?: ApolloCache): Promise>; // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; + // (undocumented) mutate, TCache extends ApolloCache>({ mutation, variables, optimisticResponse, updateQueries, refetchQueries, awaitRefetchQueries, update: updateWithProxyFn, onQueryUpdated, fetchPolicy, errorPolicy, keepRootFields, context, }: MutationOptions): Promise>; // (undocumented) mutationStore?: { @@ -2241,8 +2254,8 @@ export function wrapQueryRef(inter // src/core/LocalState.ts:46:5 - (ae-forgotten-export) The symbol "FragmentMap" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:116:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:117:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:140:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:385:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:144:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:389:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/types.ts:174:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts // src/core/types.ts:203:5 - (ae-forgotten-export) The symbol "Resolver" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:275:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-react_ssr.api.md b/.api-reports/api-report-react_ssr.api.md index 4f8c6a0b87d..1d6c2847a90 100644 --- a/.api-reports/api-report-react_ssr.api.md +++ b/.api-reports/api-report-react_ssr.api.md @@ -51,6 +51,10 @@ abstract class ApolloCache implements DataProxy { // // (undocumented) identify(object: StoreObject | Reference): string | undefined; + // Warning: (ae-forgotten-export) The symbol "MaskFragmentOptions" needs to be exported by the entry point index.d.ts + // + // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; // (undocumented) maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) @@ -911,6 +915,13 @@ type LocalStateOptions = { fragmentMatcher?: FragmentMatcher; }; +// @public (undocumented) +interface MaskFragmentOptions { + data: TData; + fragment: DocumentNode | TypedDocumentNode; + fragmentName?: string; +} + // @public (undocumented) type MaybeAsync = T | PromiseLike; @@ -1352,6 +1363,8 @@ class QueryManager { keepRootFields?: boolean; }, cache?: ApolloCache): Promise>; // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; + // (undocumented) mutate, TCache extends ApolloCache>({ mutation, variables, optimisticResponse, updateQueries, refetchQueries, awaitRefetchQueries, update: updateWithProxyFn, onQueryUpdated, fetchPolicy, errorPolicy, keepRootFields, context, }: MutationOptions): Promise>; // (undocumented) mutationStore?: { @@ -1738,8 +1751,8 @@ interface WatchQueryOptions implements DataProxy { // // (undocumented) identify(object: StoreObject | Reference): string | undefined; + // Warning: (ae-forgotten-export) The symbol "MaskFragmentOptions" needs to be exported by the entry point index.d.ts + // + // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; // (undocumented) maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) @@ -900,6 +904,13 @@ type LocalStateOptions = { fragmentMatcher?: FragmentMatcher; }; +// @public (undocumented) +interface MaskFragmentOptions { + data: TData; + fragment: DocumentNode | TypedDocumentNode; + fragmentName?: string; +} + // @public (undocumented) type MaybeAsync = T | PromiseLike; @@ -1433,6 +1444,8 @@ class QueryManager { keepRootFields?: boolean; }, cache?: ApolloCache): Promise>; // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; + // (undocumented) mutate, TCache extends ApolloCache>({ mutation, variables, optimisticResponse, updateQueries, refetchQueries, awaitRefetchQueries, update: updateWithProxyFn, onQueryUpdated, fetchPolicy, errorPolicy, keepRootFields, context, }: MutationOptions): Promise>; // (undocumented) mutationStore?: { @@ -1806,8 +1819,8 @@ export function withWarningSpy(it: (...args: TArgs // src/core/LocalState.ts:46:5 - (ae-forgotten-export) The symbol "FragmentMap" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:116:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:117:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:140:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:385:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:144:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:389:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/types.ts:174:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts // src/core/types.ts:203:5 - (ae-forgotten-export) The symbol "Resolver" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:275:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-testing_core.api.md b/.api-reports/api-report-testing_core.api.md index 8477b69c386..1d58d41f20e 100644 --- a/.api-reports/api-report-testing_core.api.md +++ b/.api-reports/api-report-testing_core.api.md @@ -50,6 +50,10 @@ abstract class ApolloCache implements DataProxy { // // (undocumented) identify(object: StoreObject | Reference): string | undefined; + // Warning: (ae-forgotten-export) The symbol "MaskFragmentOptions" needs to be exported by the entry point index.d.ts + // + // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; // (undocumented) maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) @@ -899,6 +903,13 @@ type LocalStateOptions = { fragmentMatcher?: FragmentMatcher; }; +// @public (undocumented) +interface MaskFragmentOptions { + data: TData; + fragment: DocumentNode | TypedDocumentNode; + fragmentName?: string; +} + // @public (undocumented) type MaybeAsync = T | PromiseLike; @@ -1390,6 +1401,8 @@ class QueryManager { keepRootFields?: boolean; }, cache?: ApolloCache): Promise>; // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; + // (undocumented) mutate, TCache extends ApolloCache>({ mutation, variables, optimisticResponse, updateQueries, refetchQueries, awaitRefetchQueries, update: updateWithProxyFn, onQueryUpdated, fetchPolicy, errorPolicy, keepRootFields, context, }: MutationOptions): Promise>; // (undocumented) mutationStore?: { @@ -1763,8 +1776,8 @@ export function withWarningSpy(it: (...args: TArgs // src/core/LocalState.ts:46:5 - (ae-forgotten-export) The symbol "FragmentMap" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:116:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:117:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:140:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:385:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:144:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:389:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/types.ts:174:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts // src/core/types.ts:203:5 - (ae-forgotten-export) The symbol "Resolver" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:275:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-utilities.api.md b/.api-reports/api-report-utilities.api.md index d56b5c19ec6..df1df1fb86e 100644 --- a/.api-reports/api-report-utilities.api.md +++ b/.api-reports/api-report-utilities.api.md @@ -65,6 +65,10 @@ abstract class ApolloCache implements DataProxy { getMemoryInternals?: typeof getApolloCacheMemoryInternals; // (undocumented) identify(object: StoreObject | Reference): string | undefined; + // Warning: (ae-forgotten-export) The symbol "MaskFragmentOptions" needs to be exported by the entry point index.d.ts + // + // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; // (undocumented) maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) @@ -1596,6 +1600,13 @@ export function makeUniqueId(prefix: string): string; // @public (undocumented) function makeVar(value: T): ReactiveVar; +// @public (undocumented) +interface MaskFragmentOptions { + data: TData; + fragment: DocumentNode | TypedDocumentNode; + fragmentName?: string; +} + // @public (undocumented) export function maybe(thunk: () => T): T | undefined; @@ -2158,6 +2169,8 @@ class QueryManager { keepRootFields?: boolean; }, cache?: ApolloCache): Promise>; // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; + // (undocumented) mutate, TCache extends ApolloCache>({ mutation, variables, optimisticResponse, updateQueries, refetchQueries, awaitRefetchQueries, update: updateWithProxyFn, onQueryUpdated, fetchPolicy, errorPolicy, keepRootFields, context, }: MutationOptions): Promise>; // (undocumented) mutationStore?: { @@ -2698,8 +2711,8 @@ interface WriteContext extends ReadMergeModifyContext { // src/core/LocalState.ts:71:3 - (ae-forgotten-export) The symbol "ApolloClient" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:116:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:117:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:140:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:385:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:144:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:389:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/types.ts:174:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts // src/core/types.ts:203:5 - (ae-forgotten-export) The symbol "Resolver" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:275:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index 7feb432835a..450330093e4 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -56,6 +56,10 @@ export abstract class ApolloCache implements DataProxy { getMemoryInternals?: typeof getApolloCacheMemoryInternals; // (undocumented) identify(object: StoreObject | Reference): string | undefined; + // Warning: (ae-forgotten-export) The symbol "MaskFragmentOptions" needs to be exported by the entry point index.d.ts + // + // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; // (undocumented) maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) @@ -1492,6 +1496,13 @@ export function makeReference(id: string): Reference; // @public (undocumented) export function makeVar(value: T): ReactiveVar; +// @public (undocumented) +interface MaskFragmentOptions { + data: TData; + fragment: DocumentNode | TypedDocumentNode; + fragmentName?: string; +} + // @public (undocumented) type MaybeAsync = T | PromiseLike; @@ -2198,6 +2209,8 @@ class QueryManager { keepRootFields?: boolean; }, cache?: ApolloCache): Promise>; // (undocumented) + maskFragment(options: MaskFragmentOptions): TData; + // (undocumented) mutate, TCache extends ApolloCache>({ mutation, variables, optimisticResponse, updateQueries, refetchQueries, awaitRefetchQueries, update: updateWithProxyFn, onQueryUpdated, fetchPolicy, errorPolicy, keepRootFields, context, }: MutationOptions): Promise>; // (undocumented) mutationStore?: { @@ -3045,8 +3058,8 @@ interface WriteContext extends ReadMergeModifyContext { // src/cache/inmemory/types.ts:139:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:116:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:117:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:140:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:385:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:144:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:389:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:275:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts // src/link/http/selectHttpOptionsAndBody.ts:128:32 - (ae-forgotten-export) The symbol "HttpQueryOptions" needs to be exported by the entry point index.d.ts // src/react/hooks/useBackgroundQuery.ts:38:3 - (ae-forgotten-export) The symbol "SubscribeToMoreFunction" needs to be exported by the entry point index.d.ts diff --git a/.size-limits.json b/.size-limits.json index 7c04a30b992..ff40a03e55a 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 40870, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33688 + "dist/apollo-client.min.cjs": 41174, + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33943 } diff --git a/src/__tests__/dataMasking.ts b/src/__tests__/dataMasking.ts index b114cad5c7d..27e9f19ebfd 100644 --- a/src/__tests__/dataMasking.ts +++ b/src/__tests__/dataMasking.ts @@ -13,6 +13,7 @@ import { } from "../core"; import { MockLink } from "../testing"; import { ObservableStream, spyOnConsole } from "../testing/internal"; +import { invariant } from "../utilities/globals"; test("masks queries when dataMasking is `true`", async () => { interface Query { @@ -1299,6 +1300,881 @@ test("warns when passing parent object to `from` that is non-normalized", async } }); +test("masks watched fragments when dataMasking is `true`", async () => { + type UserFieldsFragment = { + __typename: "User"; + id: number; + age: number; + }; + + type NameFieldsFragment = { + __typename: "User"; + firstName: string; + lastName: string; + }; + + const nameFieldsFragment: TypedDocumentNode = gql` + fragment NameFields on User { + firstName + lastName + } + `; + + const userFieldsFragment: TypedDocumentNode = gql` + fragment UserFields on User { + id + age + ...NameFields + } + + ${nameFieldsFragment} + `; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); + + client.writeFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + age: 30, + // @ts-expect-error Need to determine types when writing data for masked fragment types + firstName: "Test", + lastName: "User", + }, + }); + + const fragmentStream = new ObservableStream( + client.watchFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + from: { __typename: "User", id: 1 }, + }) + ); + + const { data, complete } = await fragmentStream.takeNext(); + + expect(data).toEqual({ __typename: "User", id: 1, age: 30 }); + expect(complete).toBe(true); + invariant(complete, "Should never be incomplete"); + + const nestedFragmentStream = new ObservableStream( + client.watchFragment({ fragment: nameFieldsFragment, from: data }) + ); + + { + const { data, complete } = await nestedFragmentStream.takeNext(); + + expect(complete).toBe(true); + expect(data).toEqual({ + __typename: "User", + firstName: "Test", + lastName: "User", + }); + } +}); + +test("does not mask watched fragments when dataMasking is disabled", async () => { + type UserFieldsFragment = { + __typename: "User"; + id: number; + age: number; + firstName: string; + lastName: string; + }; + + type NameFieldsFragment = { + __typename: "User"; + firstName: string; + lastName: string; + }; + + const nameFieldsFragment: TypedDocumentNode = gql` + fragment NameFields on User { + __typename + firstName + lastName + } + `; + + const userFieldsFragment: TypedDocumentNode = gql` + fragment UserFields on User { + __typename + id + age + ...NameFields + } + + ${nameFieldsFragment} + `; + + const client = new ApolloClient({ + dataMasking: false, + cache: new InMemoryCache(), + }); + + client.writeFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + age: 30, + firstName: "Test", + lastName: "User", + }, + }); + + const fragmentStream = new ObservableStream( + client.watchFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + from: { __typename: "User", id: 1 }, + }) + ); + + const { data, complete } = await fragmentStream.takeNext(); + + expect(data).toEqual({ + __typename: "User", + id: 1, + age: 30, + firstName: "Test", + lastName: "User", + }); + expect(complete).toBe(true); + invariant(complete, "Should never be incomplete"); + + const nestedFragmentStream = new ObservableStream( + client.watchFragment({ fragment: nameFieldsFragment, from: data }) + ); + + { + const { data, complete } = await nestedFragmentStream.takeNext(); + + expect(complete).toBe(true); + expect(data).toEqual({ + __typename: "User", + firstName: "Test", + lastName: "User", + }); + } +}); + +test("does not mask watched fragments by default", async () => { + type UserFieldsFragment = { + __typename: "User"; + id: number; + age: number; + firstName: string; + lastName: string; + }; + + type NameFieldsFragment = { + __typename: "User"; + firstName: string; + lastName: string; + }; + + const nameFieldsFragment: TypedDocumentNode = gql` + fragment NameFields on User { + __typename + firstName + lastName + } + `; + + const userFieldsFragment: TypedDocumentNode = gql` + fragment UserFields on User { + __typename + id + age + ...NameFields + } + + ${nameFieldsFragment} + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + }); + + client.writeFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + age: 30, + firstName: "Test", + lastName: "User", + }, + }); + + const fragmentStream = new ObservableStream( + client.watchFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + from: { __typename: "User", id: 1 }, + }) + ); + + const { data, complete } = await fragmentStream.takeNext(); + + expect(data).toEqual({ + __typename: "User", + id: 1, + age: 30, + firstName: "Test", + lastName: "User", + }); + expect(complete).toBe(true); + invariant(complete, "Should never be incomplete"); + + const nestedFragmentStream = new ObservableStream( + client.watchFragment({ fragment: nameFieldsFragment, from: data }) + ); + + { + const { data, complete } = await nestedFragmentStream.takeNext(); + + expect(complete).toBe(true); + expect(data).toEqual({ + __typename: "User", + firstName: "Test", + lastName: "User", + }); + } +}); + +test("does not mask watched fragments marked with @unmask", async () => { + interface Fragment { + __typename: "User"; + id: number; + name: string; + age: number; + } + + const fragment: TypedDocumentNode = gql` + fragment UserFields on User { + id + name + ...ProfileFields @unmask + } + + fragment ProfileFields on User { + age + } + `; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); + + client.writeFragment({ + fragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); + + const observable = client.watchFragment({ + fragment, + fragmentName: "UserFields", + from: { __typename: "User", id: 1 }, + }); + + const stream = new ObservableStream(observable); + + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }); + } +}); + +test("masks watched fragments updated by the cache", async () => { + interface Fragment { + __typename: "User"; + id: number; + name: string; + } + + const fragment: TypedDocumentNode = gql` + fragment UserFields on User { + id + name + ...ProfileFields + } + + fragment ProfileFields on User { + age + } + `; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); + + client.writeFragment({ + fragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + name: "Test User", + // @ts-expect-error Need to determine how to handle masked fragment types with writes + age: 30, + }, + }); + + const observable = client.watchFragment({ + fragment, + fragmentName: "UserFields", + from: { __typename: "User", id: 1 }, + }); + + const stream = new ObservableStream(observable); + + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + __typename: "User", + id: 1, + name: "Test User", + }); + } + + client.writeFragment({ + fragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + name: "Test User (updated)", + // @ts-ignore TODO: Determine how to handle cache writes with masked + // query type + age: 35, + }, + }); + + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + __typename: "User", + id: 1, + name: "Test User (updated)", + }); + } +}); + +test("does not trigger update on watched fragment when updating field in named fragment", async () => { + interface Fragment { + __typename: "User"; + id: number; + name: string; + } + + const fragment: TypedDocumentNode = gql` + fragment UserFields on User { + id + name + ...ProfileFields + } + + fragment ProfileFields on User { + age + } + `; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); + + client.writeFragment({ + fragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + name: "Test User", + // @ts-ignore TODO: Determine how to handle cache writes with masking + age: 30, + }, + }); + + const observable = client.watchFragment({ + fragment, + fragmentName: "UserFields", + from: { __typename: "User", id: 1 }, + }); + const stream = new ObservableStream(observable); + + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + __typename: "User", + id: 1, + name: "Test User", + }); + } + + client.writeFragment({ + fragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + name: "Test User", + // @ts-ignore TODO: Determine how to handle cache writes with masking + age: 35, + }, + }); + + await expect(stream.takeNext()).rejects.toThrow( + new Error("Timeout waiting for next event") + ); + + expect( + client.readFragment({ fragment, fragmentName: "UserFields", id: "User:1" }) + ).toEqual({ + __typename: "User", + id: 1, + name: "Test User", + age: 35, + }); +}); + +test("triggers update to child watched fragment when updating field in named fragment", async () => { + interface UserFieldsFragment { + __typename: "User"; + id: number; + name: string; + } + + interface ProfileFieldsFragment { + __typename: "User"; + age: number; + } + + const profileFieldsFragment: TypedDocumentNode = + gql` + fragment ProfileFields on User { + age + } + `; + + const userFieldsFragment: TypedDocumentNode = gql` + fragment UserFields on User { + id + name + ...ProfileFields + } + + ${profileFieldsFragment} + `; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); + + client.writeFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + name: "Test User", + // @ts-ignore TODO: Determine how to handle cache writes with masking + age: 30, + }, + }); + + const userFieldsObservable = client.watchFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + from: { __typename: "User", id: 1 }, + }); + + const nameFieldsObservable = client.watchFragment({ + fragment: profileFieldsFragment, + from: { __typename: "User", id: 1 }, + }); + + const userFieldsStream = new ObservableStream(userFieldsObservable); + const nameFieldsStream = new ObservableStream(nameFieldsObservable); + + { + const { data } = await userFieldsStream.takeNext(); + + expect(data).toEqual({ + __typename: "User", + id: 1, + name: "Test User", + }); + } + + { + const { data } = await nameFieldsStream.takeNext(); + + expect(data).toEqual({ + __typename: "User", + age: 30, + }); + } + + client.writeFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + name: "Test User", + // @ts-ignore TODO: Determine how to handle cache writes with masking + age: 35, + }, + }); + + { + const { data } = await nameFieldsStream.takeNext(); + + expect(data).toEqual({ + __typename: "User", + age: 35, + }); + } + + await expect(userFieldsStream.takeNext()).rejects.toThrow( + new Error("Timeout waiting for next event") + ); + + expect( + client.readFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + id: "User:1", + }) + ).toEqual({ + __typename: "User", + id: 1, + name: "Test User", + age: 35, + }); +}); + +test("does not trigger update to watched fragments when updating field in named fragment with @nonreactive", async () => { + interface UserFieldsFragment { + __typename: "User"; + id: number; + lastUpdatedAt: string; + } + + interface ProfileFieldsFragment { + __typename: "User"; + lastUpdatedAt: string; + } + + const profileFieldsFragment: TypedDocumentNode = + gql` + fragment ProfileFields on User { + age + lastUpdatedAt @nonreactive + } + `; + + const userFieldsFragment: TypedDocumentNode = gql` + fragment UserFields on User { + id + lastUpdatedAt @nonreactive + ...ProfileFields + } + + ${profileFieldsFragment} + `; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); + + client.writeFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + lastUpdatedAt: "2024-01-01", + // @ts-ignore TODO: Determine how to handle cache writes with masking + age: 30, + }, + }); + + const userFieldsObservable = client.watchFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + from: { __typename: "User", id: 1 }, + }); + + const profileFieldsObservable = client.watchFragment({ + fragment: profileFieldsFragment, + from: { __typename: "User", id: 1 }, + }); + + const userFieldsStream = new ObservableStream(userFieldsObservable); + const profileFieldsStream = new ObservableStream(profileFieldsObservable); + + { + const { data } = await userFieldsStream.takeNext(); + + expect(data).toEqual({ + __typename: "User", + id: 1, + lastUpdatedAt: "2024-01-01", + }); + } + + { + const { data } = await profileFieldsStream.takeNext(); + + expect(data).toEqual({ + __typename: "User", + age: 30, + lastUpdatedAt: "2024-01-01", + }); + } + + client.writeFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + lastUpdatedAt: "2024-01-02", + // @ts-ignore TODO: Determine how to handle cache writes with masking + age: 30, + }, + }); + + await expect(userFieldsStream.takeNext()).rejects.toThrow( + new Error("Timeout waiting for next event") + ); + await expect(profileFieldsStream.takeNext()).rejects.toThrow( + new Error("Timeout waiting for next event") + ); + + expect( + client.readFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + id: "User:1", + }) + ).toEqual({ + __typename: "User", + id: 1, + lastUpdatedAt: "2024-01-02", + age: 30, + }); +}); + +test("does not trigger update to watched fragments when updating parent field with @nonreactive and child field", async () => { + interface UserFieldsFragment { + __typename: "User"; + id: number; + lastUpdatedAt: string; + } + + interface ProfileFieldsFragment { + __typename: "User"; + lastUpdatedAt: string; + } + + const profileFieldsFragment: TypedDocumentNode = + gql` + fragment ProfileFields on User { + age + lastUpdatedAt @nonreactive + } + `; + + const userFieldsFragment: TypedDocumentNode = gql` + fragment UserFields on User { + id + lastUpdatedAt @nonreactive + ...ProfileFields + } + + ${profileFieldsFragment} + `; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); + + client.writeFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + lastUpdatedAt: "2024-01-01", + // @ts-ignore TODO: Determine how to handle cache writes with masking + age: 30, + }, + }); + + const userFieldsObservable = client.watchFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + from: { __typename: "User", id: 1 }, + }); + + const profileFieldsObservable = client.watchFragment({ + fragment: profileFieldsFragment, + from: { __typename: "User", id: 1 }, + }); + + const userFieldsStream = new ObservableStream(userFieldsObservable); + const profileFieldsStream = new ObservableStream(profileFieldsObservable); + + { + const { data } = await userFieldsStream.takeNext(); + + expect(data).toEqual({ + __typename: "User", + id: 1, + lastUpdatedAt: "2024-01-01", + }); + } + + { + const { data } = await profileFieldsStream.takeNext(); + + expect(data).toEqual({ + __typename: "User", + age: 30, + lastUpdatedAt: "2024-01-01", + }); + } + + client.writeFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + lastUpdatedAt: "2024-01-02", + // @ts-ignore TODO: Determine how to handle cache writes with masking + age: 31, + }, + }); + + { + const { data } = await profileFieldsStream.takeNext(); + + expect(data).toEqual({ + __typename: "User", + age: 31, + lastUpdatedAt: "2024-01-02", + }); + } + + await expect(userFieldsStream.takeNext()).rejects.toThrow( + new Error("Timeout waiting for next event") + ); + + expect( + client.readFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + id: "User:1", + }) + ).toEqual({ + __typename: "User", + id: 1, + lastUpdatedAt: "2024-01-02", + age: 31, + }); +}); + +test("warns when accessing an unmasked field on a watched fragment while using @unmask with mode: 'migrate'", async () => { + using consoleSpy = spyOnConsole("warn"); + + interface Fragment { + __typename: "User"; + id: number; + name: string; + age: number; + } + + const fragment: TypedDocumentNode = gql` + fragment UserFields on User { + id + name + ...ProfileFields @unmask(mode: "migrate") + } + + fragment ProfileFields on User { + age + name + } + `; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); + + const observable = client.watchFragment({ + fragment, + fragmentName: "UserFields", + from: { __typename: "User", id: 1 }, + }); + const stream = new ObservableStream(observable); + + { + const { data } = await stream.takeNext(); + data.__typename; + data.id; + data.name; + + expect(consoleSpy.warn).not.toHaveBeenCalled(); + + data.age; + + expect(consoleSpy.warn).toHaveBeenCalledTimes(1); + expect(consoleSpy.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "fragment 'UserFields'", + "age" + ); + + // Ensure we only warn once + data.age; + expect(consoleSpy.warn).toHaveBeenCalledTimes(1); + } +}); + class TestCache extends ApolloCache { public diff(query: Cache.DiffOptions): DataProxy.DiffResult { return {}; diff --git a/src/cache/core/__tests__/cache.ts b/src/cache/core/__tests__/cache.ts index 38ee5c9f5db..5732cc14d5e 100644 --- a/src/cache/core/__tests__/cache.ts +++ b/src/cache/core/__tests__/cache.ts @@ -231,6 +231,76 @@ describe("abstract cache", () => { }); }); + describe("maskFragment", () => { + it("returns original data and warns on caches that don't implement the required interface", () => { + using consoleSpy = spyOnConsole("warn"); + + const fragment = gql` + fragment UserFields on User { + id + ...NameFields + } + + fragment NameFields on User { + name + } + `; + const data = { + __typename: "User", + id: 1, + name: "Mister Masked", + }; + const cache = new TestCache(); + + const result = cache.maskFragment({ fragment, data }); + + expect(result).toBe(data); + expect(consoleSpy.warn).toHaveBeenCalledTimes(1); + expect(consoleSpy.warn).toHaveBeenCalledWith( + expect.stringContaining("does not support data masking") + ); + }); + + it("returns masked fragment for caches that implement required interface", () => { + class MaskingCache extends TestCache { + protected fragmentMatches( + _fragment: InlineFragmentNode, + _typename: string + ): boolean { + return true; + } + } + + const cache = new MaskingCache(); + + const fragment = gql` + fragment UserFields on User { + __typename + id + ...NameFields + } + + fragment NameFields on User { + name + } + `; + + const data = { + __typename: "User", + id: 1, + name: "Mister Masked", + }; + + const result = cache.maskFragment({ + fragment, + data, + fragmentName: "UserFields", + }); + + expect(result).toEqual({ __typename: "User", id: 1 }); + }); + }); + describe("updateQuery", () => { it("runs the readQuery & writeQuery methods", () => { const test = new TestCache(); diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index d0f921b6e7e..a67a54d62da 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -24,7 +24,7 @@ import type { } from "../../core/types.js"; import type { MissingTree } from "./types/common.js"; import { equalByQuery } from "../../core/equalByQuery.js"; -import { maskOperation } from "../../core/masking.js"; +import { maskFragment, maskOperation } from "../../core/masking.js"; import { invariant } from "../../utilities/globals/index.js"; export type Transaction = (c: ApolloCache) => void; @@ -74,6 +74,31 @@ export interface WatchFragmentOptions { optimistic?: boolean; } +export interface MaskFragmentOptions { + /** + * A GraphQL fragment document parsed into an AST with the `gql` + * template literal. + * + * @docGroup 1. Required options + */ + fragment: DocumentNode | TypedDocumentNode; + /** + * The raw, unmasked data that should be masked. + * + * @docGroup 1. Required options + */ + data: TData; + /** + * The name of the fragment defined in the fragment document. + * + * Required if the fragment document includes more than one fragment, + * optional otherwise. + * + * @docGroup 2. Cache options + */ + fragmentName?: string; +} + /** * Watched fragment results. */ @@ -403,6 +428,27 @@ export abstract class ApolloCache implements DataProxy { return maskOperation(data, document, this.fragmentMatches.bind(this)); } + public maskFragment(options: MaskFragmentOptions) { + const { data, fragment, fragmentName } = options; + + if (!this.fragmentMatches) { + if (__DEV__) { + invariant.warn( + "This cache does not support data masking which effectively disables it. Please use a cache that supports data masking or disable data masking to silence this warning." + ); + } + + return data; + } + + return maskFragment( + data, + fragment, + this.fragmentMatches.bind(this), + fragmentName + ); + } + /** * @experimental * @internal diff --git a/src/cache/index.ts b/src/cache/index.ts index 1f55d8c16df..92a5315b6b7 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -4,6 +4,7 @@ export type { Transaction, WatchFragmentOptions, WatchFragmentResult, + MaskFragmentOptions, } from "./core/cache.js"; export { ApolloCache } from "./core/cache.js"; export { Cache } from "./core/types/Cache.js"; diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index 307852ef930..fd6127e930b 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -5,7 +5,8 @@ import type { DocumentNode, FormattedExecutionResult } from "graphql"; import type { FetchResult, GraphQLRequest } from "../link/core/index.js"; import { ApolloLink, execute } from "../link/core/index.js"; import type { ApolloCache, DataProxy, Reference } from "../cache/index.js"; -import type { DocumentTransform, Observable } from "../utilities/index.js"; +import type { DocumentTransform } from "../utilities/index.js"; +import { Observable } from "../utilities/index.js"; import { version } from "../version.js"; import type { UriFunction } from "../link/http/index.js"; import { HttpLink } from "../link/http/index.js"; @@ -163,6 +164,7 @@ import type { WatchFragmentOptions, WatchFragmentResult, } from "../cache/core/cache.js"; +import { equalByQuery } from "./equalByQuery.js"; export { mergeOptions }; /** @@ -546,7 +548,40 @@ export class ApolloClient implements DataProxy { >( options: WatchFragmentOptions ): Observable> { - return this.cache.watchFragment(options); + const { fragment, fragmentName } = options; + + const observable = this.cache.watchFragment(options); + let latestResult: WatchFragmentResult | undefined; + + return new Observable((observer) => { + const subscription = observable.subscribe({ + next: (result) => { + result.data = this.queryManager.maskFragment({ + fragment, + fragmentName, + data: result.data, + }); + + if ( + latestResult && + equalByQuery( + this.cache["getFragmentDoc"](fragment, fragmentName), + { data: latestResult.data }, + { data: result.data } + ) + ) { + return; + } + + latestResult = result; + observer.next(result); + }, + complete: observer.complete.bind(observer), + error: observer.error.bind(observer), + }); + + return () => subscription.unsubscribe(); + }); } /** diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 519b341bf60..450a72fdd6a 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -14,7 +14,11 @@ import { isExecutionPatchResult, removeDirectivesFromDocument, } from "../utilities/index.js"; -import type { Cache, ApolloCache } from "../cache/index.js"; +import type { + Cache, + ApolloCache, + MaskFragmentOptions, +} from "../cache/index.js"; import { canonicalStringify } from "../cache/index.js"; import type { @@ -1520,6 +1524,10 @@ export class QueryManager { return results; } + public maskFragment(options: MaskFragmentOptions) { + return this.dataMasking ? this.cache.maskFragment(options) : options.data; + } + private fetchQueryByPolicy( queryInfo: QueryInfo, { diff --git a/src/react/hooks/__tests__/useFragment.test.tsx b/src/react/hooks/__tests__/useFragment.test.tsx index 665adb09492..95714080d38 100644 --- a/src/react/hooks/__tests__/useFragment.test.tsx +++ b/src/react/hooks/__tests__/useFragment.test.tsx @@ -9,7 +9,11 @@ import { import userEvent from "@testing-library/user-event"; import { act } from "@testing-library/react"; -import { UseFragmentOptions, useFragment } from "../useFragment"; +import { + UseFragmentOptions, + UseFragmentResult, + useFragment, +} from "../useFragment"; import { MockedProvider } from "../../../testing"; import { ApolloProvider } from "../../context"; import { @@ -29,7 +33,13 @@ import { concatPagination } from "../../../utilities"; import assert from "assert"; import { expectTypeOf } from "expect-type"; import { SubscriptionObserver } from "zen-observable-ts"; -import { profile, profileHook, spyOnConsole } from "../../../testing/internal"; +import { + createProfiler, + profile, + profileHook, + spyOnConsole, + useTrackRenders, +} from "../../../testing/internal"; describe("useFragment", () => { it("is importable and callable", () => { @@ -1795,6 +1805,296 @@ describe("useFragment", () => { }); }); +describe("data masking", () => { + it("returns masked fragment when data masking is enabled", async () => { + type Post = { + __typename: "Post"; + id: number; + title: string; + }; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); + + const fragment: TypedDocumentNode = gql` + fragment PostFragment on Post { + id + title + ...PostFields + } + + fragment PostFields on Post { + updatedAt + } + `; + + client.writeFragment({ + fragment, + fragmentName: "PostFragment", + data: { + __typename: "Post", + id: 1, + title: "Blog post", + // @ts-expect-error Need to determine how to work with masked types + updatedAt: "2024-01-01", + }, + }); + + const ProfiledHook = profileHook(() => + useFragment({ + fragment, + fragmentName: "PostFragment", + from: { __typename: "Post", id: 1 }, + }) + ); + + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + { + const snapshot = await ProfiledHook.takeSnapshot(); + + expect(snapshot).toEqual({ + complete: true, + data: { + __typename: "Post", + id: 1, + title: "Blog post", + }, + }); + } + + await expect(ProfiledHook).not.toRerender(); + }); + + it("does not rerender for cache writes to masked fields", async () => { + type Post = { + __typename: "Post"; + id: number; + title: string; + }; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); + + const fragment: TypedDocumentNode = gql` + fragment PostFragment on Post { + id + title + ...PostFields + } + + fragment PostFields on Post { + updatedAt + } + `; + + client.writeFragment({ + fragment, + fragmentName: "PostFragment", + data: { + __typename: "Post", + id: 1, + title: "Blog post", + // @ts-expect-error Need to determine how to work with masked types + updatedAt: "2024-01-01", + }, + }); + + const ProfiledHook = profileHook(() => + useFragment({ + fragment, + fragmentName: "PostFragment", + from: { __typename: "Post", id: 1 }, + }) + ); + + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + { + const snapshot = await ProfiledHook.takeSnapshot(); + + expect(snapshot).toEqual({ + complete: true, + data: { + __typename: "Post", + id: 1, + title: "Blog post", + }, + }); + } + + client.writeFragment({ + fragment, + fragmentName: "PostFragment", + data: { + __typename: "Post", + id: 1, + title: "Blog post", + // @ts-expect-error Need to determine how to work with masked types + updatedAt: "2024-02-01", + }, + }); + + await expect(ProfiledHook).not.toRerender(); + }); + + it("updates child fragments for cache updates to masked fields", async () => { + type ParentFragment = { + __typename: "Post"; + id: number; + title: string; + }; + + type ChildFragment = { + __typename: "Post"; + id: number; + title: string; + }; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); + + const childFragment: TypedDocumentNode = gql` + fragment PostFields on Post { + updatedAt + } + `; + + const parentFragment: TypedDocumentNode = gql` + fragment PostFragment on Post { + id + title + ...PostFields + } + + ${childFragment} + `; + + client.writeFragment({ + fragment: parentFragment, + fragmentName: "PostFragment", + data: { + __typename: "Post", + id: 1, + title: "Blog post", + // @ts-expect-error Need to determine how to work with masked types + updatedAt: "2024-01-01", + }, + }); + + const Profiler = createProfiler({ + initialSnapshot: { + parent: null as UseFragmentResult | null, + child: null as UseFragmentResult | null, + }, + }); + + function Parent() { + useTrackRenders(); + const parent = useFragment({ + fragment: parentFragment, + fragmentName: "PostFragment", + from: { __typename: "Post", id: 1 }, + }); + + Profiler.mergeSnapshot({ parent }); + + return parent.complete ? : null; + } + + function Child({ parent }: { parent: ParentFragment }) { + useTrackRenders(); + const child = useFragment({ fragment: childFragment, from: parent }); + + Profiler.mergeSnapshot({ child }); + + return null; + } + + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([Parent, Child]); + expect(snapshot).toEqual({ + parent: { + complete: true, + data: { + __typename: "Post", + id: 1, + title: "Blog post", + }, + }, + child: { + complete: true, + data: { + __typename: "Post", + updatedAt: "2024-01-01", + }, + }, + }); + } + + client.writeFragment({ + fragment: parentFragment, + fragmentName: "PostFragment", + data: { + __typename: "Post", + id: 1, + title: "Blog post", + // @ts-expect-error Need to determine how to work with masked types + updatedAt: "2024-02-01", + }, + }); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([Child]); + expect(snapshot).toEqual({ + parent: { + complete: true, + data: { + __typename: "Post", + id: 1, + title: "Blog post", + }, + }, + child: { + complete: true, + data: { + __typename: "Post", + updatedAt: "2024-02-01", + }, + }, + }); + } + + await expect(Profiler).not.toRerender(); + }); +}); + describe("has the same timing as `useQuery`", () => { const itemFragment = gql` fragment ItemFragment on Item { diff --git a/src/react/hooks/useFragment.ts b/src/react/hooks/useFragment.ts index 89ce1603a00..65e8dd64a32 100644 --- a/src/react/hooks/useFragment.ts +++ b/src/react/hooks/useFragment.ts @@ -63,7 +63,8 @@ export function useFragment( function _useFragment( options: UseFragmentOptions ): UseFragmentResult { - const { cache } = useApolloClient(options.client); + const client = useApolloClient(options.client); + const { cache } = client; const { from, ...rest } = options; // We calculate the cache id seperately from `stableOptions` because we don't @@ -81,19 +82,26 @@ function _useFragment( // get the correct diff on the next render given new diffOptions const diff = React.useMemo(() => { const { fragment, fragmentName, from, optimistic = true } = stableOptions; + const { cache } = client; + const diff = cache.diff({ + ...stableOptions, + returnPartialData: true, + id: from, + query: cache["getFragmentDoc"](fragment, fragmentName), + optimistic, + }); return { - result: diffToResult( - cache.diff({ - ...stableOptions, - returnPartialData: true, - id: from, - query: cache["getFragmentDoc"](fragment, fragmentName), - optimistic, - }) - ), + result: diffToResult({ + ...diff, + result: client["queryManager"].maskFragment({ + fragment, + fragmentName, + data: diff.result, + }), + }), }; - }, [stableOptions, cache]); + }, [client, stableOptions]); // Used for both getSnapshot and getServerSnapshot const getSnapshot = React.useCallback(() => diff.result, [diff]); @@ -102,7 +110,7 @@ function _useFragment( React.useCallback( (forceUpdate) => { let lastTimeout = 0; - const subscription = cache.watchFragment(stableOptions).subscribe({ + const subscription = client.watchFragment(stableOptions).subscribe({ next: (result) => { // Since `next` is called async by zen-observable, we want to avoid // unnecessarily rerendering this hook for the initial result @@ -123,7 +131,7 @@ function _useFragment( clearTimeout(lastTimeout); }; }, - [cache, stableOptions, diff] + [client, stableOptions, diff] ), getSnapshot, getSnapshot