From d4a0cf8f3e858182b4639f4d435f23605291648e Mon Sep 17 00:00:00 2001 From: "audrius.sav" Date: Wed, 25 Mar 2026 15:38:20 +0200 Subject: [PATCH 1/7] fix(masking): preserve referential equality of masked data on refetch When `dataMasking: true` is configured, `maskOperation` always creates new object references for masked data (since fragment fields are stripped). This breaks referential equality on refetch with identical results, causing unnecessary useEffect callbacks and re-renders. Memoize the masked output in `ObservableQuery.maskResult` so that if the input data reference is unchanged or the masked result is deeply equal to the previous one, the previous masked reference is reused. Fixes #13181 Co-Authored-By: Claude Opus 4.6 --- .../fix-masked-data-referential-equality.md | 5 + src/core/ObservableQuery.ts | 11 + src/react/hooks/__tests__/useQuery.test.tsx | 210 ++++++++++++++++++ 3 files changed, 226 insertions(+) create mode 100644 .changeset/fix-masked-data-referential-equality.md diff --git a/.changeset/fix-masked-data-referential-equality.md b/.changeset/fix-masked-data-referential-equality.md new file mode 100644 index 00000000000..8d487aa35a0 --- /dev/null +++ b/.changeset/fix-masked-data-referential-equality.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +fix(masking): preserve referential equality of masked data on refetch with identical results diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 551b1cfb05c..c3a77df08ed 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -2000,6 +2000,17 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, result.loading = isNetworkRequestInFlight(result.networkStatus); result = this.maskResult(result); + // Preserve referential equality of masked data when the new masked + // result is deeply equal to the previous one. This prevents + // unnecessary re-renders when refetching returns identical data. + if ( + previous.result.data !== undefined && + result.data !== previous.result.data && + equal(result.data, previous.result.data) + ) { + (result as { data: unknown }).data = previous.result.data; + } + return { query, variables, result, meta }; }); diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 0982d4dadd4..b73ff9b9c8e 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -11871,6 +11871,216 @@ describe("useQuery Hook", () => { }); } }); + + // https://github.com/apollographql/apollo-client/issues/13181 + it("preserves referential equality of masked data on refetch with identical results", async () => { + type UserFieldsFragment = { + __typename: "User"; + age: number; + } & { " $fragmentName"?: "UserFieldsFragment" }; + + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + } & { " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment } }; + } + + const query: TypedDocumentNode> = gql` + query MaskedQuery { + currentUser { + id + name + ...UserFields + } + } + + fragment UserFields on User { + age + } + `; + + const mocks = [ + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, + }, + }, + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, + }, + }, + ]; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const renderStream = + createRenderStream>>(); + + function App() { + const result = useQuery(query); + + renderStream.replaceSnapshot(result); + + return null; + } + + using _disabledAct = disableActEnvironment(); + await renderStream.render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + // loading + await renderStream.takeRender(); + + const { snapshot: initialSnapshot } = await renderStream.takeRender(); + + expect(initialSnapshot.data).toStrictEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + }, + }); + + // Trigger refetch with identical result + await initialSnapshot.refetch(); + + // Skip intermediate renders (e.g. NetworkStatus.refetch) and find + // the settled result + let refetchSnapshot: useQuery.Result>; + do { + ({ snapshot: refetchSnapshot } = await renderStream.takeRender()); + } while (refetchSnapshot.networkStatus !== NetworkStatus.ready); + + // The masked data should be the same reference since the underlying + // data hasn't changed + expect(refetchSnapshot.data).toBe(initialSnapshot.data); + + await expect(renderStream).not.toRerender(); + }); + + // https://github.com/apollographql/apollo-client/issues/13181 + it("preserves referential equality of masked array data on refetch with identical results", async () => { + type UserFieldsFragment = { + __typename: "User"; + age: number; + } & { " $fragmentName"?: "UserFieldsFragment" }; + + interface Query { + currentUsers: Array< + { + __typename: "User"; + id: number; + name: string; + } & { + " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment }; + } + >; + } + + const query: TypedDocumentNode> = gql` + query MaskedArrayQuery { + currentUsers { + id + name + ...UserFields + } + } + + fragment UserFields on User { + age + } + `; + + const mockData = { + currentUsers: [ + { __typename: "User" as const, id: 1, name: "User One", age: 25 }, + { __typename: "User" as const, id: 2, name: "User Two", age: 30 }, + ], + }; + + const mocks = [ + { request: { query }, result: { data: mockData } }, + { request: { query }, result: { data: mockData } }, + ]; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const renderStream = + createRenderStream>>(); + + function App() { + const result = useQuery(query); + + renderStream.replaceSnapshot(result); + + return null; + } + + using _disabledAct = disableActEnvironment(); + await renderStream.render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + // loading + await renderStream.takeRender(); + + const { snapshot: initialSnapshot } = await renderStream.takeRender(); + + expect(initialSnapshot.data).toStrictEqual({ + currentUsers: [ + { __typename: "User", id: 1, name: "User One" }, + { __typename: "User", id: 2, name: "User Two" }, + ], + }); + + // Trigger refetch with identical result + await initialSnapshot.refetch(); + + // Skip intermediate renders (e.g. NetworkStatus.refetch) and find + // the settled result + let refetchSnapshot: useQuery.Result>; + do { + ({ snapshot: refetchSnapshot } = await renderStream.takeRender()); + } while (refetchSnapshot.networkStatus !== NetworkStatus.ready); + + // The masked data should be the same reference since the underlying + // data hasn't changed + expect(refetchSnapshot.data).toBe(initialSnapshot.data); + + await expect(renderStream).not.toRerender(); + }); }); // https://github.com/apollographql/apollo-client/issues/12229 From 6ccfb22403b6b4dd7e061c128177c1a006ad0b9b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 3 Jun 2026 15:22:27 -0600 Subject: [PATCH 2/7] Use maxUsageCount instead of repeating mocks --- src/react/hooks/__tests__/useQuery.test.tsx | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index fd255590b5b..89a8d89216a 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -11912,19 +11912,7 @@ describe("useQuery Hook", () => { }, }, }, - }, - { - request: { query }, - result: { - data: { - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 30, - }, - }, - }, + maxUsageCount: 2, }, ]; @@ -12023,8 +12011,7 @@ describe("useQuery Hook", () => { }; const mocks = [ - { request: { query }, result: { data: mockData } }, - { request: { query }, result: { data: mockData } }, + { request: { query }, result: { data: mockData }, maxUsageCount: 2 }, ]; const client = new ApolloClient({ From 5db32a753fa3c6934588378e13f514a8473b3b03 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 3 Jun 2026 15:31:05 -0600 Subject: [PATCH 3/7] Rewrite test to use renderHookToSnapshotStream --- src/react/hooks/__tests__/useQuery.test.tsx | 159 +++++++++++--------- 1 file changed, 90 insertions(+), 69 deletions(-) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 89a8d89216a..ec0b5b3c23c 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -11922,52 +11922,57 @@ describe("useQuery Hook", () => { link: new MockLink(mocks), }); - const renderStream = - createRenderStream>>(); - - function App() { - const result = useQuery(query); - - renderStream.replaceSnapshot(result); + using _disabledAct = disableActEnvironment(); + const renderStream = await renderHookToSnapshotStream( + () => useQuery(query), + { wrapper: createClientWrapper(client) } + ); - return null; - } + const { takeSnapshot, getCurrentSnapshot } = renderStream; - using _disabledAct = disableActEnvironment(); - await renderStream.render(, { - wrapper: ({ children }) => ( - {children} - ), + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, }); - // loading - await renderStream.takeRender(); + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { currentUser: { __typename: "User", id: 1, name: "Test User" } }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); - const { snapshot: initialSnapshot } = await renderStream.takeRender(); + const { refetch, data: initialData } = getCurrentSnapshot(); - expect(initialSnapshot.data).toStrictEqual({ - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - }, + await expect(refetch()).resolves.toStrictEqualTyped({ + data: { currentUser: { __typename: "User", id: 1, name: "Test User" } }, }); - // Trigger refetch with identical result - await initialSnapshot.refetch(); - - // Skip intermediate renders (e.g. NetworkStatus.refetch) and find - // the settled result - let refetchSnapshot: useQuery.Result>; - do { - ({ snapshot: refetchSnapshot } = await renderStream.takeRender()); - } while (refetchSnapshot.networkStatus !== NetworkStatus.ready); + await expect(renderStream).toRerenderWithSimilarSnapshot({ + expected: (previous) => ({ + ...previous, + loading: true, + networkStatus: NetworkStatus.refetch, + }), + }); - // The masked data should be the same reference since the underlying - // data hasn't changed - expect(refetchSnapshot.data).toBe(initialSnapshot.data); + await expect(renderStream).toRerenderWithSimilarSnapshot({ + expected: (previous) => ({ + ...previous, + loading: false, + networkStatus: NetworkStatus.ready, + }), + }); await expect(renderStream).not.toRerender(); + + expect(getCurrentSnapshot().data).toBe(initialData); }); // https://github.com/apollographql/apollo-client/issues/13181 @@ -12020,51 +12025,67 @@ describe("useQuery Hook", () => { link: new MockLink(mocks), }); - const renderStream = - createRenderStream>>(); - - function App() { - const result = useQuery(query); - - renderStream.replaceSnapshot(result); + using _disabledAct = disableActEnvironment(); + const renderStream = await renderHookToSnapshotStream( + () => useQuery(query), + { wrapper: createClientWrapper(client) } + ); - return null; - } + const { takeSnapshot, getCurrentSnapshot } = renderStream; - using _disabledAct = disableActEnvironment(); - await renderStream.render(, { - wrapper: ({ children }) => ( - {children} - ), + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, }); - // loading - await renderStream.takeRender(); + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + currentUsers: [ + { __typename: "User", id: 1, name: "User One" }, + { __typename: "User", id: 2, name: "User Two" }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); - const { snapshot: initialSnapshot } = await renderStream.takeRender(); + const { refetch, data: initialData } = getCurrentSnapshot(); - expect(initialSnapshot.data).toStrictEqual({ - currentUsers: [ - { __typename: "User", id: 1, name: "User One" }, - { __typename: "User", id: 2, name: "User Two" }, - ], + await expect(refetch()).resolves.toStrictEqualTyped({ + data: { + currentUsers: [ + { __typename: "User", id: 1, name: "User One" }, + { __typename: "User", id: 2, name: "User Two" }, + ], + }, }); - // Trigger refetch with identical result - await initialSnapshot.refetch(); - - // Skip intermediate renders (e.g. NetworkStatus.refetch) and find - // the settled result - let refetchSnapshot: useQuery.Result>; - do { - ({ snapshot: refetchSnapshot } = await renderStream.takeRender()); - } while (refetchSnapshot.networkStatus !== NetworkStatus.ready); + await expect(renderStream).toRerenderWithSimilarSnapshot({ + expected: (previous) => ({ + ...previous, + loading: true, + networkStatus: NetworkStatus.refetch, + }), + }); - // The masked data should be the same reference since the underlying - // data hasn't changed - expect(refetchSnapshot.data).toBe(initialSnapshot.data); + await expect(renderStream).toRerenderWithSimilarSnapshot({ + expected: (previous) => ({ + ...previous, + loading: false, + networkStatus: NetworkStatus.ready, + }), + }); await expect(renderStream).not.toRerender(); + + expect(getCurrentSnapshot().data).toBe(initialData); }); }); From 222cd7147fd36170c3c39116fb59c935110402e3 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 3 Jun 2026 15:38:00 -0600 Subject: [PATCH 4/7] Remove unneeded type cast --- src/core/ObservableQuery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 48607d34712..7c7e48151d0 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -2012,7 +2012,7 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, result.data !== previous.result.data && equal(result.data, previous.result.data) ) { - (result as { data: unknown }).data = previous.result.data; + result.data = previous.result.data; } return { query, variables, result, meta }; From fa67188ec4d1e85a0ea1a5e1764bd7cbc25857d2 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 3 Jun 2026 15:38:47 -0600 Subject: [PATCH 5/7] Update comments --- src/core/ObservableQuery.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 7c7e48151d0..97825dabc90 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -2005,8 +2005,8 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, result = this.maskResult(result); // Preserve referential equality of masked data when the new masked - // result is deeply equal to the previous one. This prevents - // unnecessary re-renders when refetching returns identical data. + // result is deeply equal to the previous one. This prevents React hooks + // like `useMemo` or `useEffect` from firing unnecessarily. if ( previous.result.data !== undefined && result.data !== previous.result.data && From 83e8965262a37b433a1abc1fbeef41211376f53b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 3 Jun 2026 15:39:51 -0600 Subject: [PATCH 6/7] Update changeset --- .changeset/fix-masked-data-referential-equality.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-masked-data-referential-equality.md b/.changeset/fix-masked-data-referential-equality.md index 8d487aa35a0..294da5b6b59 100644 --- a/.changeset/fix-masked-data-referential-equality.md +++ b/.changeset/fix-masked-data-referential-equality.md @@ -2,4 +2,4 @@ "@apollo/client": patch --- -fix(masking): preserve referential equality of masked data on refetch with identical results +Preserve referential equality of masked data on refetch when the result is deeply equal to the previous result. From 08a741b9b84e39746bd8ce46a64f2334e46179d0 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 3 Jun 2026 15:41:56 -0600 Subject: [PATCH 7/7] Add test to validate masked field changing keeps reference to old object --- src/react/hooks/__tests__/useQuery.test.tsx | 117 ++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index ec0b5b3c23c..4ddbc3e8f24 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -11975,6 +11975,123 @@ describe("useQuery Hook", () => { expect(getCurrentSnapshot().data).toBe(initialData); }); + // https://github.com/apollographql/apollo-client/issues/13181 + it("preserves referential equality of masked data on refetch when only masked field changes on refetch", async () => { + type UserFieldsFragment = { + __typename: "User"; + age: number; + } & { " $fragmentName"?: "UserFieldsFragment" }; + + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + } & { " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment } }; + } + + const query: TypedDocumentNode> = gql` + query MaskedQuery { + currentUser { + id + name + ...UserFields + } + } + + fragment UserFields on User { + age + } + `; + + const mocks = [ + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, + }, + }, + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 31, + }, + }, + }, + }, + ]; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + using _disabledAct = disableActEnvironment(); + const renderStream = await renderHookToSnapshotStream( + () => useQuery(query), + { wrapper: createClientWrapper(client) } + ); + + const { takeSnapshot, getCurrentSnapshot } = renderStream; + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { currentUser: { __typename: "User", id: 1, name: "Test User" } }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + + const { refetch, data: initialData } = getCurrentSnapshot(); + + await expect(refetch()).resolves.toStrictEqualTyped({ + data: { currentUser: { __typename: "User", id: 1, name: "Test User" } }, + }); + + await expect(renderStream).toRerenderWithSimilarSnapshot({ + expected: (previous) => ({ + ...previous, + loading: true, + networkStatus: NetworkStatus.refetch, + }), + }); + + await expect(renderStream).toRerenderWithSimilarSnapshot({ + expected: (previous) => ({ + ...previous, + loading: false, + networkStatus: NetworkStatus.ready, + }), + }); + + await expect(renderStream).not.toRerender(); + + expect(getCurrentSnapshot().data).toBe(initialData); + }); + // https://github.com/apollographql/apollo-client/issues/13181 it("preserves referential equality of masked array data on refetch with identical results", async () => { type UserFieldsFragment = {