Skip to content

Commit fcea3af

Browse files
audrius-savclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 98439d7 commit fcea3af

3 files changed

Lines changed: 144 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@apollo/client": patch
3+
---
4+
5+
fix(masking): preserve referential equality of masked data on refetch with identical results

src/core/ObservableQuery.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1723,6 +1723,13 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`,
17231723
return this.queryManager.transform(document);
17241724
}
17251725

1726+
/** The `result.data` input from the last `maskResult` call, used to
1727+
* short-circuit re-masking when the cache returns the same reference. */
1728+
private lastMaskedInput: any = undefined;
1729+
/** The masked output from the last `maskResult` call, reused when the new
1730+
* masked result is deeply equal to preserve referential equality. */
1731+
private lastMaskedResult: any = undefined;
1732+
17261733
private maskResult<T extends { data: any }>(result: T): T {
17271734
const masked = this.queryManager.maskOperation({
17281735
document: this.query,
@@ -1732,7 +1739,22 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`,
17321739
});
17331740

17341741
// Maintain object identity as much as possible
1735-
return masked === result.data ? result : { ...result, data: masked };
1742+
if (masked === result.data) return result;
1743+
1744+
// Preserve referential equality of masked data when the underlying
1745+
// cache data hasn't changed (same reference) or when the masked
1746+
// output is deeply equal to the previous one.
1747+
if (
1748+
this.lastMaskedResult !== undefined &&
1749+
(result.data === this.lastMaskedInput ||
1750+
equal(masked, this.lastMaskedResult))
1751+
) {
1752+
return { ...result, data: this.lastMaskedResult };
1753+
}
1754+
1755+
this.lastMaskedInput = result.data;
1756+
this.lastMaskedResult = masked;
1757+
return { ...result, data: masked };
17361758
}
17371759

17381760
private dirty: boolean = false;

src/react/hooks/__tests__/useQuery.test.tsx

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11871,6 +11871,122 @@ describe("useQuery Hook", () => {
1187111871
});
1187211872
}
1187311873
});
11874+
11875+
// https://github.com/apollographql/apollo-client/issues/13181
11876+
it("preserves referential equality of masked data on refetch with identical results", async () => {
11877+
type UserFieldsFragment = {
11878+
__typename: "User";
11879+
age: number;
11880+
} & { " $fragmentName"?: "UserFieldsFragment" };
11881+
11882+
interface Query {
11883+
currentUser: {
11884+
__typename: "User";
11885+
id: number;
11886+
name: string;
11887+
} & { " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment } };
11888+
}
11889+
11890+
const query: TypedDocumentNode<Query, Record<string, never>> = gql`
11891+
query MaskedQuery {
11892+
currentUser {
11893+
id
11894+
name
11895+
...UserFields
11896+
}
11897+
}
11898+
11899+
fragment UserFields on User {
11900+
age
11901+
}
11902+
`;
11903+
11904+
const mocks = [
11905+
{
11906+
request: { query },
11907+
result: {
11908+
data: {
11909+
currentUser: {
11910+
__typename: "User",
11911+
id: 1,
11912+
name: "Test User",
11913+
age: 30,
11914+
},
11915+
},
11916+
},
11917+
},
11918+
{
11919+
request: { query },
11920+
result: {
11921+
data: {
11922+
currentUser: {
11923+
__typename: "User",
11924+
id: 1,
11925+
name: "Test User",
11926+
age: 30,
11927+
},
11928+
},
11929+
},
11930+
},
11931+
];
11932+
11933+
const client = new ApolloClient({
11934+
dataMasking: true,
11935+
cache: new InMemoryCache(),
11936+
link: new MockLink(mocks),
11937+
});
11938+
11939+
const renderStream =
11940+
createRenderStream<useQuery.Result<Query, Record<string, never>>>();
11941+
11942+
function App() {
11943+
const result = useQuery(query);
11944+
11945+
renderStream.replaceSnapshot(result);
11946+
11947+
return null;
11948+
}
11949+
11950+
using _disabledAct = disableActEnvironment();
11951+
await renderStream.render(<App />, {
11952+
wrapper: ({ children }) => (
11953+
<ApolloProvider client={client}>{children}</ApolloProvider>
11954+
),
11955+
});
11956+
11957+
// loading
11958+
await renderStream.takeRender();
11959+
11960+
const { snapshot: initialSnapshot } = await renderStream.takeRender();
11961+
11962+
expect(initialSnapshot.data).toStrictEqual({
11963+
currentUser: {
11964+
__typename: "User",
11965+
id: 1,
11966+
name: "Test User",
11967+
},
11968+
});
11969+
11970+
// Trigger refetch with identical result
11971+
await initialSnapshot.refetch();
11972+
11973+
// Skip intermediate renders (e.g. NetworkStatus.refetch) and find
11974+
// the settled result
11975+
let refetchSnapshot;
11976+
while (true) {
11977+
const { snapshot } = await renderStream.takeRender();
11978+
if (snapshot.networkStatus === NetworkStatus.ready) {
11979+
refetchSnapshot = snapshot;
11980+
break;
11981+
}
11982+
}
11983+
11984+
// The masked data should be the same reference since the underlying
11985+
// data hasn't changed
11986+
expect(refetchSnapshot.data).toBe(initialSnapshot.data);
11987+
11988+
await expect(renderStream).not.toRerender();
11989+
});
1187411990
});
1187511991

1187611992
// https://github.com/apollographql/apollo-client/issues/12229

0 commit comments

Comments
 (0)