Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-masked-data-referential-equality.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apollo/client": patch
---

fix(masking): preserve referential equality of masked data on refetch with identical results
Comment thread
jerelmiller marked this conversation as resolved.
Outdated
11 changes: 11 additions & 0 deletions src/core/ObservableQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
jerelmiller marked this conversation as resolved.
Outdated
if (
previous.result.data !== undefined &&
result.data !== previous.result.data &&
equal(result.data, previous.result.data)
) {
(result as { data: unknown }).data = previous.result.data;
Comment thread
jerelmiller marked this conversation as resolved.
Outdated
}

return { query, variables, result, meta };
});

Expand Down
210 changes: 210 additions & 0 deletions src/react/hooks/__tests__/useQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Query, Record<string, never>> = 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,
},
},
},
},
];
Comment thread
jerelmiller marked this conversation as resolved.

const client = new ApolloClient({
dataMasking: true,
cache: new InMemoryCache(),
link: new MockLink(mocks),
});

const renderStream =
createRenderStream<useQuery.Result<Query, Record<string, never>>>();

function App() {
const result = useQuery(query);

renderStream.replaceSnapshot(result);

return null;
}

using _disabledAct = disableActEnvironment();
await renderStream.render(<App />, {
wrapper: ({ children }) => (
<ApolloProvider client={client}>{children}</ApolloProvider>
),
});
Comment thread
jerelmiller marked this conversation as resolved.
Outdated

// loading
await renderStream.takeRender();

const { snapshot: initialSnapshot } = await renderStream.takeRender();

expect(initialSnapshot.data).toStrictEqual({
currentUser: {
__typename: "User",
id: 1,
name: "Test User",
},
});
Comment thread
jerelmiller marked this conversation as resolved.
Outdated

// Trigger refetch with identical result
Comment thread
jerelmiller marked this conversation as resolved.
Outdated
await initialSnapshot.refetch();

// Skip intermediate renders (e.g. NetworkStatus.refetch) and find
// the settled result
let refetchSnapshot: useQuery.Result<Query, Record<string, never>>;
do {
({ snapshot: refetchSnapshot } = await renderStream.takeRender());
} while (refetchSnapshot.networkStatus !== NetworkStatus.ready);
Comment thread
jerelmiller marked this conversation as resolved.
Outdated

// The masked data should be the same reference since the underlying
// data hasn't changed
Comment thread
jerelmiller marked this conversation as resolved.
Outdated
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 () => {
Comment thread
jerelmiller marked this conversation as resolved.
type UserFieldsFragment = {
__typename: "User";
age: number;
} & { " $fragmentName"?: "UserFieldsFragment" };

interface Query {
currentUsers: Array<
{
__typename: "User";
id: number;
name: string;
} & {
" $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment };
}
>;
}

const query: TypedDocumentNode<Query, Record<string, never>> = 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<useQuery.Result<Query, Record<string, never>>>();

function App() {
const result = useQuery(query);

renderStream.replaceSnapshot(result);

return null;
}

using _disabledAct = disableActEnvironment();
await renderStream.render(<App />, {
wrapper: ({ children }) => (
<ApolloProvider client={client}>{children}</ApolloProvider>
),
});

// 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<Query, Record<string, never>>;
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();
});
});
Comment thread
jerelmiller marked this conversation as resolved.

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