diff --git a/.size-limits.json b/.size-limits.json index b2800c3acfe..7eaebd5dffd 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 41255, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33943 + "dist/apollo-client.min.cjs": 41282, + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33969 } diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index 3eaa1bdb5cb..27063e7be1d 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -56,6 +56,40 @@ describe("maskOperation", () => { ); }); + test("returns null when data is null", () => { + const query = gql` + query { + foo + ...QueryFields + } + + fragment QueryFields on Query { + bar + } + `; + + const data = maskOperation(null, query, new InMemoryCache()); + + expect(data).toBe(null); + }); + + test("returns undefined when data is undefined", () => { + const query = gql` + query { + foo + ...QueryFields + } + + fragment QueryFields on Query { + bar + } + `; + + const data = maskOperation(undefined, query, new InMemoryCache()); + + expect(data).toBe(undefined); + }); + test("strips top-level fragment data from query", () => { const query = gql` query { @@ -177,6 +211,83 @@ describe("maskOperation", () => { }); }); + test("handles nulls in child selection sets", () => { + const query = gql` + query { + user { + profile { + id + } + ...UserFields + } + } + fragment UserFields on User { + profile { + id + fullName + } + } + `; + + const nullUser = maskOperation( + deepFreeze({ user: null }), + query, + new InMemoryCache() + ); + const nullProfile = maskOperation( + deepFreeze({ user: { __typename: "User", profile: null } }), + query, + new InMemoryCache() + ); + + expect(nullUser).toEqual({ user: null }); + expect(nullProfile).toEqual({ + user: { __typename: "User", profile: null }, + }); + }); + + test("handles nulls in arrays", () => { + const query = gql` + query { + users { + profile { + id + } + ...UserFields + } + } + fragment UserFields on User { + profile { + id + fullName + } + } + `; + + const data = maskOperation( + deepFreeze({ + users: [ + null, + { __typename: "User", profile: null }, + { + __typename: "User", + profile: { __typename: "Profile", id: "1", fullName: "Test User" }, + }, + ], + }), + query, + new InMemoryCache() + ); + + expect(data).toEqual({ + users: [ + null, + { __typename: "User", profile: null }, + { __typename: "User", profile: { __typename: "Profile", id: "1" } }, + ], + }); + }); + test("deep freezes the masked result if the original data is frozen", () => { const query = gql` query { @@ -1710,6 +1821,39 @@ describe("maskOperation", () => { }); describe("maskFragment", () => { + test("returns null when data is null", () => { + const fragment = gql` + fragment Foo on Query { + foo + ...QueryFields + } + + fragment QueryFields on Query { + bar + } + `; + + const data = maskFragment(null, fragment, new InMemoryCache(), "Foo"); + + expect(data).toBe(null); + }); + + test("returns undefined when data is undefined", () => { + const fragment = gql` + fragment Foo on Query { + foo + ...QueryFields + } + + fragment QueryFields on Query { + bar + } + `; + + const data = maskFragment(undefined, fragment, new InMemoryCache(), "Foo"); + + expect(data).toBe(undefined); + }); test("masks named fragments in fragment documents", () => { const fragment = gql` fragment UserFields on User { @@ -1764,6 +1908,75 @@ describe("maskFragment", () => { }); }); + test("handles nulls in child selection sets", () => { + const fragment = gql` + fragment UserFields on User { + profile { + id + } + ...ProfileFields + } + fragment ProfileFields on User { + profile { + id + fullName + } + } + `; + + const data = maskFragment( + deepFreeze({ __typename: "User", profile: null }), + fragment, + new InMemoryCache(), + "UserFields" + ); + + expect(data).toEqual({ __typename: "User", profile: null }); + }); + + test("handles nulls in arrays", () => { + const fragment = gql` + fragment UserFields on Query { + users { + profile { + id + } + ...ProfileFields + } + } + fragment ProfileFields on User { + profile { + id + fullName + } + } + `; + + const data = maskFragment( + deepFreeze({ + users: [ + null, + { __typename: "User", profile: null }, + { + __typename: "User", + profile: { __typename: "Profile", id: "1", fullName: "Test User" }, + }, + ], + }), + fragment, + new InMemoryCache(), + "UserFields" + ); + + expect(data).toEqual({ + users: [ + null, + { __typename: "User", profile: null }, + { __typename: "User", profile: { __typename: "Profile", id: "1" } }, + ], + }); + }); + test("deep freezes the masked result if the original data is frozen", () => { const fragment = gql` fragment UserFields on User { diff --git a/src/core/masking.ts b/src/core/masking.ts index ee82723e6b3..226750cceb8 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -40,6 +40,11 @@ export function maskOperation( "Expected a parsed GraphQL document with a query, mutation, or subscription." ); + if (data == null) { + // Maintain the original `null` or `undefined` value + return data; + } + const context: MaskingContext = { operationType: definition.operation, operationName: definition.name?.value, @@ -100,6 +105,11 @@ export function maskFragment( fragmentName ); + if (data == null) { + // Maintain the original `null` or `undefined` value + return data; + } + const context: MaskingContext = { operationType: "fragment", operationName: fragment.name.value, @@ -132,6 +142,10 @@ function maskSelectionSet( let changed = false; const masked = data.map((item, index) => { + if (item === null) { + return null; + } + const [masked, itemChanged] = maskSelectionSet( item, selectionSet, @@ -155,7 +169,7 @@ function maskSelectionSet( memo[keyName] = data[keyName]; - if (childSelectionSet) { + if (childSelectionSet && data[keyName] !== null) { const [masked, childChanged] = maskSelectionSet( data[keyName], childSelectionSet,