Skip to content

Commit 3f0068e

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 3f0068e

3 files changed

Lines changed: 255 additions & 2 deletions

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: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1723,16 +1723,54 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`,
17231723
return this.queryManager.transform(document);
17241724
}
17251725

1726+
/**
1727+
* The query document used for the last `maskResult` call, used to
1728+
* invalidate the cache when the query changes via reobserve.
1729+
*/
1730+
private lastMaskedQuery: DocumentNode | undefined = undefined;
1731+
/**
1732+
* The `result.data` input from the last `maskResult` call, used to
1733+
* short-circuit re-masking when the cache returns the same reference.
1734+
*/
1735+
private lastMaskedInput: any = undefined;
1736+
/**
1737+
* The masked output from the last `maskResult` call, reused when the new
1738+
* masked result is deeply equal to preserve referential equality.
1739+
*/
1740+
private lastMaskedResult: any = undefined;
1741+
17261742
private maskResult<T extends { data: any }>(result: T): T {
1743+
const query = this.query;
17271744
const masked = this.queryManager.maskOperation({
1728-
document: this.query,
1745+
document: query,
17291746
data: result.data,
17301747
fetchPolicy: this.options.fetchPolicy,
17311748
cause: this,
17321749
});
17331750

17341751
// Maintain object identity as much as possible
1735-
return masked === result.data ? result : { ...result, data: masked };
1752+
if (masked === result.data) return result;
1753+
1754+
// Preserve referential equality of masked data when the underlying
1755+
// cache data hasn't changed (same reference) or when the masked
1756+
// output is deeply equal to the previous one. The query identity
1757+
// check ensures we invalidate when the query changes via reobserve.
1758+
if (
1759+
this.lastMaskedResult !== undefined &&
1760+
this.lastMaskedQuery === query &&
1761+
(result.data === this.lastMaskedInput ||
1762+
equal(masked, this.lastMaskedResult))
1763+
) {
1764+
// Update lastMaskedInput so subsequent calls with the same new
1765+
// input reference short-circuit on the fast path above.
1766+
this.lastMaskedInput = result.data;
1767+
return { ...result, data: this.lastMaskedResult };
1768+
}
1769+
1770+
this.lastMaskedQuery = query;
1771+
this.lastMaskedInput = result.data;
1772+
this.lastMaskedResult = masked;
1773+
return { ...result, data: masked };
17361774
}
17371775

17381776
private dirty: boolean = false;

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

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11871,6 +11871,216 @@ 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: useQuery.Result<Query, Record<string, never>>;
11976+
do {
11977+
({ snapshot: refetchSnapshot } = await renderStream.takeRender());
11978+
} while (refetchSnapshot.networkStatus !== NetworkStatus.ready);
11979+
11980+
// The masked data should be the same reference since the underlying
11981+
// data hasn't changed
11982+
expect(refetchSnapshot.data).toBe(initialSnapshot.data);
11983+
11984+
await expect(renderStream).not.toRerender();
11985+
});
11986+
11987+
// https://github.com/apollographql/apollo-client/issues/13181
11988+
it("preserves referential equality of masked array data on refetch with identical results", async () => {
11989+
type UserFieldsFragment = {
11990+
__typename: "User";
11991+
age: number;
11992+
} & { " $fragmentName"?: "UserFieldsFragment" };
11993+
11994+
interface Query {
11995+
currentUsers: Array<
11996+
{
11997+
__typename: "User";
11998+
id: number;
11999+
name: string;
12000+
} & {
12001+
" $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment };
12002+
}
12003+
>;
12004+
}
12005+
12006+
const query: TypedDocumentNode<Query, Record<string, never>> = gql`
12007+
query MaskedArrayQuery {
12008+
currentUsers {
12009+
id
12010+
name
12011+
...UserFields
12012+
}
12013+
}
12014+
12015+
fragment UserFields on User {
12016+
age
12017+
}
12018+
`;
12019+
12020+
const mockData = {
12021+
currentUsers: [
12022+
{ __typename: "User" as const, id: 1, name: "User One", age: 25 },
12023+
{ __typename: "User" as const, id: 2, name: "User Two", age: 30 },
12024+
],
12025+
};
12026+
12027+
const mocks = [
12028+
{ request: { query }, result: { data: mockData } },
12029+
{ request: { query }, result: { data: mockData } },
12030+
];
12031+
12032+
const client = new ApolloClient({
12033+
dataMasking: true,
12034+
cache: new InMemoryCache(),
12035+
link: new MockLink(mocks),
12036+
});
12037+
12038+
const renderStream =
12039+
createRenderStream<useQuery.Result<Query, Record<string, never>>>();
12040+
12041+
function App() {
12042+
const result = useQuery(query);
12043+
12044+
renderStream.replaceSnapshot(result);
12045+
12046+
return null;
12047+
}
12048+
12049+
using _disabledAct = disableActEnvironment();
12050+
await renderStream.render(<App />, {
12051+
wrapper: ({ children }) => (
12052+
<ApolloProvider client={client}>{children}</ApolloProvider>
12053+
),
12054+
});
12055+
12056+
// loading
12057+
await renderStream.takeRender();
12058+
12059+
const { snapshot: initialSnapshot } = await renderStream.takeRender();
12060+
12061+
expect(initialSnapshot.data).toStrictEqual({
12062+
currentUsers: [
12063+
{ __typename: "User", id: 1, name: "User One" },
12064+
{ __typename: "User", id: 2, name: "User Two" },
12065+
],
12066+
});
12067+
12068+
// Trigger refetch with identical result
12069+
await initialSnapshot.refetch();
12070+
12071+
// Skip intermediate renders (e.g. NetworkStatus.refetch) and find
12072+
// the settled result
12073+
let refetchSnapshot: useQuery.Result<Query, Record<string, never>>;
12074+
do {
12075+
({ snapshot: refetchSnapshot } = await renderStream.takeRender());
12076+
} while (refetchSnapshot.networkStatus !== NetworkStatus.ready);
12077+
12078+
// The masked data should be the same reference since the underlying
12079+
// data hasn't changed
12080+
expect(refetchSnapshot.data).toBe(initialSnapshot.data);
12081+
12082+
await expect(renderStream).not.toRerender();
12083+
});
1187412084
});
1187512085

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

0 commit comments

Comments
 (0)